Microsoft 365 actions use this account for manual and scheduled runs. The selected person - must connect Microsoft 365 and approve this workflow revision. Selecting an account does not grant - consent. Changes to instructions, capabilities, or destinations require approval again. + must connect Microsoft 365. Selecting an account does not grant consent: when someone else saves + the workflow or changes an agent or action it uses, the selected person approves that revision + before it runs as them. A revision they saved themselves needs no separate approval.
); diff --git a/application/v2_ui/src/lib/workflowChangeTracking.ts b/application/v2_ui/src/lib/workflowChangeTracking.ts index 775af4de4..b186dbc2d 100644 --- a/application/v2_ui/src/lib/workflowChangeTracking.ts +++ b/application/v2_ui/src/lib/workflowChangeTracking.ts @@ -1237,13 +1237,18 @@ export function workflowSaveNeedsConfirmation(changes: readonly WorkflowChange[] /** * Whether saving changes the Microsoft 365 execution fingerprint of a workflow that runs as an * account, so the account holder has to approve Run as again. Mirrors the server's fingerprint keys. + * A save by the Run as account itself is that person's own revision, so it asks nothing; pass the + * signed-in user as `currentUserId`. */ export function workflowRunAsConsequence( baseline: WorkflowDefinition, draft: WorkflowDefinition, changes: readonly WorkflowChange[], + currentUserId?: string | null, ): boolean { - if (!textOf(baseline.m365_run_as_user_id) || !textOf(draft.m365_run_as_user_id)) return false; + const runAs = textOf(draft.m365_run_as_user_id).trim(); + if (!textOf(baseline.m365_run_as_user_id) || !runAs) return false; + if (currentUserId && runAs === currentUserId.trim()) return false; return changes.some((change) => workflowChangeFields(change.key).some((field) => isWorkflowRunAsFingerprintField(field))); } diff --git a/application/v2_ui/src/lib/workflowProposals.ts b/application/v2_ui/src/lib/workflowProposals.ts index 711635a29..2e2302df0 100644 --- a/application/v2_ui/src/lib/workflowProposals.ts +++ b/application/v2_ui/src/lib/workflowProposals.ts @@ -25,7 +25,7 @@ const RUNS_KINDS = ['manual', 'count', 'on_change'] as const; const ALERT_MODES = ['every_run', 'failures_only'] as const; const ALERT_SEVERITIES = ['info', 'low'] as const; const SIMILAR_REASONS = ['same_sources', 'same_schedule', 'similar_name'] as const; -const APPROVAL_STATES = ['waiting', 'approved'] as const; +const APPROVAL_STATES = ['waiting', 'approved', 'self_authored'] as const; export type WorkflowProposalTriggerType = typeof TRIGGER_TYPES[number]; @@ -67,6 +67,10 @@ export interface WorkflowProposalM365 { sources: string[]; /** From the stored connection record only; null when it was not needed or could not be read. */ connected: boolean | null; + /** + * Where Run as approval stands. `self_authored`: the requester saved (or, before Create, will + * save) the revision that runs as them, so it needs no separate approval. + */ approval_state: typeof APPROVAL_STATES[number] | null; } diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md index 556008f10..8161fe654 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md @@ -1067,7 +1067,9 @@ Phase 3 ships as three PRs. 3a needs nothing else, 3b needs Phase 2 and A2, and File Sync now, including on structured (v3) drafts (gotcha 58). - **Follow-ups** from #1580: - URL Access for workflows created from chat (v1 refuses it and explains), and a pre-run Run as approval route, so - the card can approve before the first run. + the card can approve before the first run. Since 0.261.229 a workflow the requester creates runs as them without + a separate approval ([Run as approval for a revision you saved](M365_RUN_AS_SELF_AUTHORED_APPROVAL.md)), so the + route matters only when someone else later changes the workflow or an agent it uses. - The per-user cap is soft when different proposals are accepted at the same time. - A conversation-level status route, if per-run reads prove chatty. - V2-native Microsoft 365 connection and approvals pages, to replace the card's classic links. diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md index 28be3cf9f..c790ef1b6 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md @@ -232,7 +232,9 @@ requester of a run in their own private conversation. Anyone else gets 404 An accept needs `mode`, `workflow`, or both. With a `workflow` draft, the workflow is enabled when the draft enables it, unless `mode` says otherwise. -Accept records the creation in the activity log; Run as is never approved here. +Accept records the creation in the activity log. It records no Run as approval: +the requester saves the workflow it creates, so its own revision runs as them +without one (since 0.261.229). ### States and actions @@ -303,10 +305,20 @@ A task on an agent with Microsoft 365 actions runs as the requester. The card says which Microsoft 365 data the workflow uses and whether it can send email or calendar invitations. Whether Microsoft 365 is connected comes from the stored connection record only, with no token refresh or Graph call. When it isn't -connected, the card links to the profile's Microsoft 365 connection. Accepting -never approves Run as: the workflow's first run waits for the requester's -approval, as any workflow's does, and the card links to Approvals while a run is -waiting. +connected, the card links to the profile's Microsoft 365 connection. + +Since 0.261.229, Run as follows the server's `approval_state`: + +| `approval_state` | When | The card says | +| --- | --- | --- | +| `self_authored` | Before Create, when the workflow runs as the requester; after Create, when its stored `modified_by` and Run as account are both the requester | It needs no separate Run as approval, and its next run waits for approval only if someone else changes it or an agent it uses. | +| `waiting` | The workflow's last run is waiting on Microsoft 365 | A run is waiting, with a link to Approvals. | +| `approved` | The workflow records an approved Run as binding | Run as is approved. | +| `null` | Anything else, such as a revision someone else saved | The first run waits for the requester's approval. | + +Accepting never records a Run as approval. A revision the requester saved runs +as them without one, as any workflow does. See +[Run as approval for a revision you saved](M365_RUN_AS_SELF_AUTHORED_APPROVAL.md). ### URL Access diff --git a/docs/explanation/features/M365_RUN_AS_SELF_AUTHORED_APPROVAL.md b/docs/explanation/features/M365_RUN_AS_SELF_AUTHORED_APPROVAL.md new file mode 100644 index 000000000..dc908b197 --- /dev/null +++ b/docs/explanation/features/M365_RUN_AS_SELF_AUTHORED_APPROVAL.md @@ -0,0 +1,262 @@ +# Microsoft 365 Run As Approval For A Revision You Saved (v0.261.229) + +## Overview + +A workflow that runs as someone's connected Microsoft 365 account needs that +person's **Run as** approval, because the workflow acts as them while they +aren't watching. Before this release the approval was tied to an exact +workflow revision and conversation audience. Every edit made a new revision, so +the Run as user had to approve the workflow again after each change, even +when they made the change themselves. Adding a participant to the +conversation asked them again too. + +Since this release, a revision the Run as user saved runs as them without a +separate approval. Their save counts as their review of the whole revision, +including earlier edits by others. SimpleChat records an approved, audited +authorization for that exact revision and connection, sends no notification, +and never shows it as pending. The first run of a workflow they saved doesn't +wait either. + +Approval is still required when someone else is responsible for what runs: +someone else saved the current revision, or changed an agent or action it +uses after the Run as user's save. Audience changes never ask again. Explicit +decisions still win: a denial or cancellation stops its run, and a revoked +authorization is not silently re-created for the same revision. + +Implemented in version: **0.261.229**, tracked in +`application/single_app/config.py`. This is the Run as part of #1621. + +Dependencies: + +- The Microsoft 365 approval service (`functions_m365_approvals.py`) and its + `m365_workflow_run_as` records in the approvals container. +- The workflow execution boundary (`functions_m365_execution.py`) and runtime + (`functions_m365_runtime.py`), which prepare a binding before any Microsoft + 365 operation of a run. +- Server-stamped `modified_by` and `modified_at` on workflows, agents and + actions. + +No setting, container, route or index is added. + +## How a run is authorized + +### A revision the Run as user saved + +A revision is self-authored when all of these hold: + +- the workflow's `modified_by` is its `m365_run_as_user_id`; +- that account is the run's data user; +- every agent and action the revision runs was last changed by the Run as + user, or was last changed at or before the Run as user's save of the + workflow (`modified_at`). + +The binding is then created, or reused, as an approved record marked +`self_authored: true` for that exact revision: the execution fingerprint, the +Microsoft 365 connection id and the connection generation. A later run of the +same revision on the same connection reuses it. + +### Someone else's revision + +When someone else saved the current revision, or `modified_by` is missing, the +Run as user approves it as before. The run waits on a pending +`m365_workflow_run_as` approval, and the user is notified. These still ask +again: + +- a later edit by someone else, which is a new revision; +- an agent or action the revision runs, changed by someone else after the Run + as user's save, even when the workflow itself is unchanged; +- a new connection id or connection generation for the Run as account. The + generation increments when the account reconnects or disconnects + (`functions_m365_connections.py`). + +A revision the Run as user saved is authorized again, without asking, for a new +connection or generation. + +### Audience changes + +The binding records the conversation's `audience_version`, but +`ensure_workflow_binding` and `validate_workflow_binding` no longer compare it. +Adding or removing participants never asks for Run as again. + +Unchanged by this release: + +- source-sharing approvals (`m365_source_sharing`, `authorize_sources`, + `effective_sharing_grant`), which still follow the audience of each request; +- the runtime checks that stop a run or a prepared delivery when the audience + changes while it is in progress (`validate_m365_workflow_execution`, + `validate_m365_approval_decision` and the pending delivery context). + +### Explicit decisions still win + +- A denial or cancellation recorded for a run still stops that run with + `m365_workflow_declined`, even after the Run as user saves the revision. A + new run is authorized normally. +- Revoking a binding in Profile revokes every approved binding for the same + workflow revision, including copies approved for earlier audiences. A + revision with a revoked binding is never self-authored again: its next run + waits for an ordinary approval. Approving it, or saving a new revision, lets + the workflow run again. + +## Technical specifications + +### Where authorship comes from + +`modified_by` is written by the server from the authenticated actor, never +from a payload: + +| Save path | Actor | +| --- | --- | +| `POST /api/user/workflows` (`save_personal_workflow`) | The signed-in user | +| `POST /api/group/workflows` (`save_group_workflow`) | The signed-in group member | +| Workflows created from chat proposals (`create_personal_workflow_if_absent`) | The requester who accepted | +| AI assist | Saves through the routes above; the assistant writes nothing | +| The SimpleChat agent action's workflow creation (`create_personal_workflow_for_current_user`) | The signed-in user | +| The Data Management Cosmos DB editor | The editing administrator (see below) | + +`build_personal_workflow_document` and `build_group_workflow_document` build the +stored document field by field, so a `modified_by` in a payload is ignored. A +structured (version 3) payload lists it among the server-managed fields. +Runtime progress updates (`update_workflow_runtime_record`) can't change it. + +Agents and actions record `modified_by` and `modified_at` the same way. +`workflow_m365_manifests` copies each selected agent's and Microsoft 365 action's +authorship into `m365_revision_authorship` on the workflow it hands to the +binding. That field is outside `workflow_execution_fingerprint`, so saving an +agent without changing it doesn't change the revision. + +### Raw administrator edits + +The Data Management Cosmos DB editor can edit workflows, agents and actions +directly. When it saves a changed record in one of those containers, it now +records the administrator as `modified_by`, and the edit time as +`modified_at`, through `attribute_raw_authored_record_edit`. A raw edit +therefore never keeps a previous author, and never makes changed content run as +that author without their approval. The activity log still lists exactly the +paths the administrator changed. + +Data Management backups, restores and migrations keep the recorded authorship. +They never transfer Run as approvals or Microsoft 365 connections, so the Run as +user must connect in the target environment before any run. + +### The self-authored binding + +The record keeps the shape `/api/m365/bindings` and Approvals already read: + +| Field | Value | +| --- | --- | +| `request_type` | `m365_workflow_run_as` | +| `status` | `approved` from creation | +| `self_authored` | `true` | +| `approved_by_id` | The Run as user | +| `requester_id` | The run's actor | +| `binding` | The workflow id, fingerprint, connection id and generation, sources, conversation, recorded audience and review | +| `decision_event_id` | The id of its audit event | +| `execution_status` | `not_required` | +| `continuation_status` | `delivered`, so the continuation outbox never picks it up | +| `notification_status` | `not_required` | +| `expires_at` | Not set; nothing is requested | + +The record and its append-only audit event, `self_authored_approved`, are +written in one transactional batch in the Run as user's partition. Two runs +that race to record the same revision converge on one record. The record's +`reason` explains that the user saved the revision and that revoking it makes +the revision wait for their approval. + +### Revocation + +`revoke_workflow_binding` revokes the chosen binding first, then every other +approved binding with the same workflow id and fingerprint. It marks each +binding it revokes `revision_wide: true`. Two checks keep the revocation in +force when it overlaps a run: + +- `ensure_workflow_binding` reuses an approved binding of a revoked revision + only when it is an ordinary approval granted after the latest revision-wide + revocation. A run that finds any other approved copy, such as one an + interrupted revocation missed, revokes it. The copy is then neither reused nor + returned by `_create_request` as an existing approval. +- `_create_self_authored_binding` writes its record and then looks for a + revocation of the revision. Because a revocation also writes before it looks + for copies, either the revocation finds the new record or the new record finds + the revocation. When it finds one, it revokes its own record and the run + waits for an ordinary approval. + +Revoking a copy that changed after it was read is retried up to three times. + +Only the bindings a user's revocation withdraws are marked, so only they set the +cut-off for other approvals: + +- A copy a run withdraws later stays unmarked. It never moves the cut-off past + an approval the user granted in between. +- A revocation made before 0.261.229 withdrew one binding, for one audience or + connection generation, and is unmarked. Other approvals of that revision keep + working after the upgrade, but the revision is never self-authored. + +### Interfaces + +| Function | Change | +| --- | --- | +| `workflow_revision_self_authored(workflow, user_id, components)` | New, pure. Fails closed on missing or unreadable authorship. | +| `workflow_saved_by_run_as(workflow, user_id)` | New, pure. The workflow-level check the proposal card uses. | +| `M365ApprovalService.ensure_workflow_binding(..., self_authored=False)` | New keyword. Stops comparing `audience_version`, prefers an approved binding over a pending one, creates the self-authored binding, and revokes approved copies a revocation missed. | +| `M365ApprovalService.validate_workflow_binding` | Stops comparing `audience_version`. | +| `M365ApprovalService.revoke_workflow_binding` | Also revokes the revision's other approved bindings. | +| `prepare_m365_workflow_binding` | Decides `self_authored` from the stored workflow and its recorded authorship. | + +### Interface text + +- **Workflow proposal card (V2).** The status route's `m365.approval_state` + adds `self_authored`. Before Create it is reported for a workflow that runs as + the requester; after Create, when the stored workflow's `modified_by` and Run + as account are both the requester. The card then says the workflow needs no + separate Run as approval and waits only if someone else changes it or an agent + it uses, instead of claiming that its first run waits. +- **Workflow editor (V2).** **Saving requires re-approving Run as** no longer + appears when the draft runs as the signed-in user. The AI assistant's + `run_as_reapproval` warning follows the same rule. +- **Run as field (V2 and classic).** The help text explains that the selected + person approves revisions someone else saved, and that a revision they saved + needs no separate approval. +- **Profile and Approvals (classic).** A self-authored binding reads + "Decision: approved automatically because you saved this workflow revision + yourself." It can still be revoked. + +## Usage + +There is nothing to turn on. + +- **Run as users** save their own workflows and run them. Profile lists each + automatic authorization under the workflow authorizations, where it can be + revoked. +- **Group members who edit a workflow that runs as someone else** see + **Saving requires re-approving Run as** before saving. The Run as user + approves the new revision from Approvals. +- **Administrators** who edit workflow, agent or action records with the + Cosmos DB editor become their last author, so affected workflows ask their + Run as users before running as them again. + +## Testing and validation + +| Test | Covers | +| --- | --- | +| `functional_tests/test_m365_run_as_self_authored.py` | A self-authored first run and the Run as user's own edits are approved, audited and silent; someone else's revision, a missing `modified_by`, a later edit by someone else, and agents or actions changed after the save ask; connection id and generation changes; audience changes; denials, cancellations and revocations, including a revocation that races a new binding or is interrupted, and one made before this release; the runtime's authorship plumbing; save routes record the signed-in actor; raw edits name the administrator. | +| `functional_tests/test_m365_approvals_execution.py` | An approved binding stays valid after an audience change. | +| `functional_tests/test_orchestration_workflow_proposal_routes.py`, `test_orchestration_workflow_proposal_done_when.py` | `approval_state` before and after Create. | +| `functional_tests/test_workflow_assist_scenarios.py`, `test_workflow_assist_dry_run_parity.py`, `test_workflow_assist_candidate_parity.py`, `test_v2_workflow_change_tracking.py` | The assistant's warning and the editor's note agree, for your own and someone else's Run as account. | +| `ui_tests/test_v2_orchestration_workflow_proposal_card.py` | The card's Run as line for every approval state. | +| `ui_tests/test_v2_workflow_change_tracking.py`, `ui_tests/test_v2_workflow_ask_ai.py` | The Run as note and warning in the browser. | +| `ui_tests/test_m365_lifecycle_and_approvals.py` | A self-authored binding in Profile, the Approvals list and the saved decision. | + +Known limitations: + +- The proposal card judges a created workflow from its own `modified_by` only. + If someone else later changes an agent the workflow uses, the card still says + no separate approval is needed until a run waits, when it shows the waiting + state. +- An agent or action with no readable change time counts as changed after the + save, so its workflows ask for approval. Saving the workflow again records a + newer save time, but a component with no timestamp still asks. +- Authorship times come from the app servers' clocks, so an agent edit within + clock skew of the Run as user's save counts as reviewed by that save. +- Revocation and approval times also come from those clocks. An approval + granted within clock skew after a revocation can count as granted before it, + and the run then asks once more. diff --git a/docs/explanation/features/MICROSOFT_365_ACTIONS.md b/docs/explanation/features/MICROSOFT_365_ACTIONS.md index 5cd1c0fa6..826dea024 100644 --- a/docs/explanation/features/MICROSOFT_365_ACTIONS.md +++ b/docs/explanation/features/MICROSOFT_365_ACTIONS.md @@ -249,9 +249,14 @@ their own approval flows. ## Workflows Manual and scheduled runs use an explicitly selected, consenting Run as user. -The connection does not grant other workflows access automatically. Material -instructions, agent/action capabilities, inputs, or destinations require new -Run as approval. The full review is retained without truncating task or agent +The connection does not grant other workflows access automatically. When +someone else saved the current revision, a change to instructions, agent or +action capabilities, inputs, or destinations requires new Run as approval. Since +0.261.229, a revision the Run as user saved themselves, with no later change by +anyone else to its agents and actions, is approved in their name without asking +them, and an audience change never asks again. See +[Run as approval for a revision you saved](M365_RUN_AS_SELF_AUTHORED_APPROVAL.md). +The full review is retained without truncating task or agent instructions; reviews over 1.5 MB require splitting the workflow. A personal workflow uses its owner's account; group Run as accounts must be eligible group members. diff --git a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md index dd48efcae..7b6cea0ed 100644 --- a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md +++ b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md @@ -187,7 +187,7 @@ without echoing the value. | Code | When | | --- | --- | -| `run_as_reapproval` | The workflow has Run as (`m365_run_as_user_id`) and the change alters a key in `workflow_execution_fingerprint`, so saving asks to re-approve Run as. A change that leaves every fingerprinted key alone, such as an alert change, doesn't warn, matching the editor's `workflowRunAsConsequence`. | +| `run_as_reapproval` | The workflow runs as someone other than the caller (`m365_run_as_user_id`), and the change alters a key in `workflow_execution_fingerprint`, so saving asks that person to re-approve Run as. Since 0.261.229 a workflow that runs as the caller doesn't warn: the caller's save is their own revision, which needs no separate approval. A change that leaves every fingerprinted key alone, such as an alert change, doesn't warn either, matching the editor's `workflowRunAsConsequence`. | | `email_requires_m365_agent` | A task this turn added or changed sends email, and the agent that runs it doesn't have the Microsoft 365 action (or no agent runs it). Targets the task. | | `m365_not_connected` | A task sends email, the workflow has no Run as or runs as the caller, and the caller's stored Microsoft 365 connection says they aren't connected. It reads the stored record only and never calls Microsoft Graph. | | `draft_has_errors` | The draft already failed a save check before this change, and the change caused no new failure. | diff --git a/docs/explanation/features/WORKFLOW_CALENDAR_SCHEDULES.md b/docs/explanation/features/WORKFLOW_CALENDAR_SCHEDULES.md index ea9f3806a..1da952358 100644 --- a/docs/explanation/features/WORKFLOW_CALENDAR_SCHEDULES.md +++ b/docs/explanation/features/WORKFLOW_CALENDAR_SCHEDULES.md @@ -209,8 +209,9 @@ fingerprint (`functions_m365_workflow_binding.py`). Existing interval workflows keep their stored schedule byte for byte, and re-saving one without changing it produces the same schedule, so their fingerprints and approvals don't change. Changing a workflow's schedule, including to a calendar -schedule, changes its fingerprint and needs renewed approval, as any schedule -change always has. +schedule, changes its fingerprint. Since 0.261.229 the new revision needs +renewed approval only when someone other than the Run as user saved it; a +schedule change the Run as user saved runs as them without asking. ### Editor options, routes and the MCP summary diff --git a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md index 2dabf6496..2edc1736c 100644 --- a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md +++ b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md @@ -521,6 +521,8 @@ Known limitations: should stay spent is left to #1547, which owns proposal state. - The service checks that an agent is available, not that it has the actions a task needs, such as sending email. -- `run_as: self` records the Run as user without an approval. As for any - workflow, the user must approve Microsoft 365 access before the workflow can - act as them. +- `run_as: self` records the Run as user without an approval. The user creates + the workflow, so since 0.261.229 that revision runs as them without a separate + Run as approval; a later change by someone else, to the workflow or to an + agent or action it runs, asks them first. Microsoft 365 must still be + connected before the workflow can act as them. diff --git a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md index 4a5b4ebd6..019381088 100644 --- a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md +++ b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md @@ -17,6 +17,7 @@ what makes AI-authored edits reviewable later: each change records whether you o turn made it, and a save that includes AI changes asks you to review them first. Implemented in version: **0.261.203**, tracked in `application/single_app/config.py`. +The Run as note leaves out your own Run as account since version **0.261.229**. Phase 3a of the [chat orchestration workflows roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md) (#1548, part of #1543). @@ -118,9 +119,12 @@ and moves focus to the list of changes. When the saved workflow runs as a Microsoft 365 account and the draft still does, the top of the Changes tab shows **Saving requires re-approving Run as** whenever a change touches a field the -server fingerprints. The note is informational and appears whoever made the change. Saving such a -change clears the stored Run as approval, as it always has, so the account holder must approve the -new revision before it runs as them again; the note says so before you save rather than after. +server fingerprints. The note is informational and appears whoever made the change, unless the +draft's Run as account is the signed-in user (since 0.261.229). Saving such a change makes a new +revision; when someone else saves it, the account holder must approve it before it runs as them +again, and the note says so before you save rather than after. A revision the Run as account +holder saved themselves needs no separate approval, so their own saves show no note. See +[Run as approval for a revision you saved](M365_RUN_AS_SELF_AUTHORED_APPROVAL.md). ### When nothing is shown @@ -234,8 +238,11 @@ answer, and **Draft with AI** calls it with drafted task instructions. `lib/workflowRunAsFingerprint.ts` mirrors the fields the server hashes in `workflow_execution_fingerprint` (`functions_m365_workflow_binding.py`): `M365_WORKFLOW_FIELDS`, the optional structured fields, and the Run as account itself. `workflowRunAsConsequence` is true -when the saved workflow and the draft both have a Run as account and a change covers one of those -fields. `test_workflow_run_as_fingerprint_parity.py` fails when the two lists drift. +when the saved workflow and the draft both have a Run as account, a change covers one of those +fields, and the draft's Run as account is not `currentUserId`, the signed-in user from the +bootstrap (0.261.229). The server's AI assist `run_as_reapproval` warning uses the same rule, and +`test_workflow_assist_candidate_parity.py` holds the two to it. +`test_workflow_run_as_fingerprint_parity.py` fails when the field lists drift. ### Performance @@ -300,7 +307,8 @@ session measured 0.17 ms in the logic tests. - `ui_tests/test_v2_workflow_change_tracking.py` (Playwright with stubbed routes): badges, Previously, Revert, and Removed · Restore on List and Flow; the Changes tab's Jump, Revert and Restore to here; highlights following Undo and Redo; structured alert edits; one-click save; - the Run as note; read-only editors, including one opened read-only for a stored schedule it + the Run as note, shown when another member must approve and not for your own Run as account; + read-only editors, including one opened read-only for a stored schedule it can't show; a group workflow; light and dark themes; a narrow viewport; a new workflow; and keyboard focus staying in a schedule, Run when, output contract or final outputs control, with every typed character kept, while its highlight appears or clears. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 8021104c3..a0c124bb5 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,20 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.229)** + +#### Breaking Changes + +* **Microsoft 365 Workflow Run As Approval Only When Someone Else Authored The Revision** + * A workflow revision saved by its own Run as user now runs as them without a separate approval, including on its first run. SimpleChat records an approved, audited Run as authorization for that exact revision and connection, sends no notification, and never shows it as pending. Before, every edit asked the Run as user to approve again, even their own. + * Approval is still required when someone else is responsible for what runs: someone else saved the current revision, `modified_by` is missing, or someone else changed an agent or action the workflow uses after the Run as user's save. A new connection or connection generation asks again for a revision someone else saved. + * Conversation audience changes no longer ask for Run as approval again; the authorization still records the audience. Source-sharing approvals and the runtime checks that guard runs and deliveries already in progress are unchanged. + * Explicit decisions still win. A denial or cancellation recorded for a run still stops that run. Revoking a Run as authorization in Profile revokes every approved authorization for that revision, which then waits for approval even if its Run as user saved it. That holds when the revocation overlaps a run recording a new authorization, or is interrupted: a run that finds an authorization the revocation missed revokes it instead of using it. + * Raw edits of workflows, agents and actions in the Data Management Cosmos DB editor now record the administrator as the last author, so an edited record can't run as someone else without asking them. + * The V2 workflow proposal card, the editor's **Saving requires re-approving Run as** note, the AI assistant's `run_as_reapproval` warning, the Run as help text in both editors, and the classic Profile and Approvals labels follow the new rule. + * **Migration**: None. Existing approvals keep working. A workflow its Run as user saved last is authorized automatically the next time it needs a new authorization. To require approval for such a revision, revoke its authorization in Profile. An authorization revoked before this release still withdraws only itself, so other approvals of that revision keep working, but that revision is never authorized automatically. + * (Ref: #1621, `functions_m365_approvals.py`, `functions_m365_execution.py`, `functions_m365_workflow_binding.py`, `functions_m365_runtime.py`, `functions_m365_data_lifecycle.py`, `functions_data_management.py`, `functions_orchestration_workflow_proposals.py`, `functions_workflow_assist.py`, `WorkflowProposalCard.tsx`, `WorkflowChangeTracking.tsx`, [Run as approval for a revision you saved](features/M365_RUN_AS_SELF_AUTHORED_APPROVAL.md)) + ### **(v0.261.228)** #### User Interface Enhancements diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md index c801c0edd..f72bc1a59 100644 --- a/docs/guides/create-a-workflow.md +++ b/docs/guides/create-a-workflow.md @@ -267,10 +267,13 @@ Native V2 **Run as** authoring is implemented in version **0.261.122**. Use account; group workflows load eligible choices for the selected group. New workflows start with **No Microsoft 365 account selected**. Neither workflow -ownership nor having an account in the list grants consent. The selected person -must connect Microsoft 365 and approve the workflow. Changes to instructions, -capabilities, or destinations require approval again; choosing an account does -not establish that the current revision is approved. +ownership nor having an account in the list grants consent; the selected person +must connect Microsoft 365. Since version **0.261.229**, a revision the selected +person saved themselves runs as them without a separate approval: their save is +their review. When someone else saves the workflow, or changes an agent or +action it uses, the selected person approves that revision before it runs as +them. A change to the conversation audience never asks again. See +[Microsoft 365 data and approvals]({{ '/guides/microsoft-365-conversation-data/' | relative_url }}). Save the workflow to persist your choice. To explicitly remove it, choose **No Microsoft 365 account selected** and save. If the list cannot load or a saved diff --git a/docs/guides/microsoft-365-conversation-data.md b/docs/guides/microsoft-365-conversation-data.md index da3789fbe..33f793f4f 100644 --- a/docs/guides/microsoft-365-conversation-data.md +++ b/docs/guides/microsoft-365-conversation-data.md @@ -31,7 +31,7 @@ walk; continuation and coverage results describe the available search window. | Microsoft Entra consent/sign-in | The application may call the selected Microsoft 365 APIs as the user. Source permissions still apply. | | Sharing acknowledgement | Retrieved answers and retained source evidence may be published to conversation participants. | | Extended analysis | The selected files may receive additional staged processing beyond the fast windows. | -| Workflow Run as approval | A specific workflow revision may use the selected person's connected account. | +| Workflow Run as approval | A specific workflow revision may use the selected person's connected account. A revision that person saved themselves needs no separate approval. | | Outgoing action review | The data owner may send the exact prepared email or invitation in manual mode, or stop a configured delayed delivery. | One decision does not substitute for another. An administrator who owns an @@ -171,10 +171,33 @@ an outgoing message. Existing action limits, delivery review, and workflow Run as approval remain separate. Older read-only connections need an explicit reconnect before workflows can use newly requested write permissions. -Select the **Microsoft 365 Run as** account on the workflow. That person must -approve the workflow's sources, instructions, and destinations. Material edits -require approval again. Disconnecting the account stops future credential use -and pauses affected work; it does not remove conversation evidence. +Select the **Microsoft 365 Run as** account on the workflow. Selecting an +account never grants consent on its own. + +- **The Run as person saved it.** When the Run as person saved the workflow's + current revision, it runs as them without a separate approval. Their save + counts as their review of the whole workflow, including earlier edits by + others. SimpleChat records an approved authorization for that exact revision + and connection in their audit history, sends no notification, and never shows + it as pending. This includes the first run. +- **Someone else saved it.** When someone else saved the current revision, or + changed an agent or action it uses after the Run as person's save, the Run as + person must approve the workflow's sources, instructions, and destinations + before it runs as them. A later edit by someone else asks again. +- **The account changed.** Reconnecting or disconnecting the Run as account + needs a new authorization for that connection. A revision someone else saved + asks again; a revision the Run as person saved is authorized for the new + connection without asking. +- **The audience changed.** Adding or removing conversation participants never + asks for Run as approval again. Source-sharing acknowledgements still apply + to shared conversations as described above. + +Explicit decisions still win. Denying or cancelling a run's request stops that +run. Revoking an authorization in Profile revokes every approved authorization +for that workflow revision, and that revision then waits for the Run as +person's approval even if they saved it. A new revision they save runs without +asking again. Disconnecting the account stops future credential use and pauses +affected work; it does not remove conversation evidence. Outgoing mail and calendar review is separate from permission to access a source. Manual outgoing actions notify the Run as user and appear in workflow activity; diff --git a/functional_tests/test_m365_approvals_execution.py b/functional_tests/test_m365_approvals_execution.py index dfbed7d08..347c45bf9 100644 --- a/functional_tests/test_m365_approvals_execution.py +++ b/functional_tests/test_m365_approvals_execution.py @@ -734,9 +734,18 @@ def test_workflow_binding_requires_fresh_authorization_and_material_match(self): ["email"], connection, ) self.assertEqual(reused["id"], binding["id"]) + # An audience change never asks for Run as again (0.261.229); the binding keeps its record of it. + moved = replace(authorized_context, audience_version="changed-audience") + moved_allowed = self.service.validate_workflow_binding(moved, connection, "email") + moved_reused = self.service.ensure_workflow_binding( + replace(moved, run_id="moved-run", request_id="moved-request", binding_id=None), + ["email"], connection, + ) + self.assertEqual(moved_allowed["status"], "approved") + self.assertEqual(moved_allowed["binding"]["audience_version"], "audience-1") + self.assertEqual(moved_reused["id"], binding["id"]) for changed_context, changed_connection in ( (replace(authorized_context, workflow_fingerprint="changed"), connection), - (replace(authorized_context, audience_version="changed-audience"), connection), (replace(authorized_context, conversation_id="different-conversation"), connection), (authorized_context, {**connection, "generation": 4}), (authorized_context, {**connection, "status": "disconnected"}), diff --git a/functional_tests/test_m365_run_as_self_authored.py b/functional_tests/test_m365_run_as_self_authored.py new file mode 100644 index 000000000..8dd1a37db --- /dev/null +++ b/functional_tests/test_m365_run_as_self_authored.py @@ -0,0 +1,844 @@ +#!/usr/bin/env python3 +# test_m365_run_as_self_authored.py +""" +Functional test for Microsoft 365 workflow Run as approval when the Run as user saved the revision. +Version: 0.261.229 +Implemented in: 0.261.229 +Refs: microsoft/simplechat#1621 + +This test ensures that a workflow revision saved by its own Run as user runs as them without a +pending approval: the real approval service records an approved, audited, self-authored binding +for that exact revision and connection, and sends no notification. A revision someone else saved, +a later edit by someone else to the workflow or to an agent or action it runs, a missing +``modified_by``, or a changed Microsoft 365 connection still asks the Run as user. An audience +change never asks again. A denial or cancellation recorded for a run still stops that run, and a +revoked self-authored binding is never silently re-created. A revocation also withdraws an +approved copy of the revision that it missed, whether the copy raced the revocation or the +revocation was interrupted, so only an approval granted after it stands. A revocation made before +this release withdrew one binding, so other approvals of its revision keep working. Every save +path records the authenticated actor as ``modified_by``, never the payload, and a raw +administrator edit names the administrator. + +The approval service and execution boundary are the real modules over the conditional Cosmos +fakes in ``test_support.m365``. The runtime manifest test loads the real +``functions_m365_runtime`` with its cloud dependencies stubbed, and the save tests run the real +workflow stores and save routes through the save parity harness. +""" + +import copy +import importlib.util +import os +import sys +import types +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest +from flask import Flask, g + +ROOT = Path(__file__).resolve().parents[1] +APP_DIR = ROOT / "application" / "single_app" +sys.path.insert(0, str(APP_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +# Application imports follow the test-only source-path setup. +import functions_m365_approvals as approvals # noqa: E402 +import functions_m365_connections as connections # noqa: E402 +import functions_m365_execution as execution # noqa: E402 +from functions_m365_data_lifecycle import ( # noqa: E402 + M365_AUTHORED_RECORD_CONTAINERS, + RAW_EDITOR_AUTHOR, + attribute_raw_authored_record_edit, +) +from functions_m365_workflow_binding import ( # noqa: E402 + M365_REVISION_AUTHORSHIP_FIELD, + workflow_execution_fingerprint, + workflow_revision_self_authored, +) +from test_support.m365 import Clock, CosmosContainer, Notifications, WORKFLOW_REVIEW # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +RUN_AS = "run-as-user" +EDITOR = "group-editor" +OWNER = "workflow-owner" +ADMIN = "app-admin" +TENANT = "tenant-a" +WORKFLOW_ID = "workflow-a" +CONVERSATION = "conversation-a" +# Workflow stores write aware UTC times; agent and action stores write naive UTC times. +SAVED_AT = "2026-03-08T04:00:00+00:00" +BEFORE_SAVE = "2026-03-08T03:00:00.000000" +AFTER_SAVE = "2026-03-08T04:30:00.000000" +EDITED_AT = datetime(2026, 3, 9, tzinfo=timezone.utc) + + +def manifest(**changes): + value = { + "id": "mail", "name": "mail", "type": "m365_email", "enabled_functions": ["get_my_messages"], + "additionalFields": {"maximum_sharing_acknowledgement": "always"}, + } + value.update(changes) + return value + + +def authored(kind, modified_by, modified_at): + return {"kind": kind, "id": f"{kind}-1", "modified_by": modified_by, "modified_at": modified_at} + + +def workflow(*, modified_by=RUN_AS, modified_at=SAVED_AT, prompt="Summarize my mail.", authorship=None): + """The stored workflow as the runtime hands it to the binding, with its components' authorship.""" + value = { + "id": WORKFLOW_ID, "user_id": OWNER, "task_prompt": prompt, "m365_run_as_user_id": RUN_AS, + "modified_by": modified_by, "modified_at": modified_at, + M365_REVISION_AUTHORSHIP_FIELD: authorship if authorship is not None else [ + authored("agent", RUN_AS, BEFORE_SAVE), authored("action", OWNER, BEFORE_SAVE), + ], + } + if modified_by is None: + value.pop("modified_by") + return value + + +class Harness: + """The real approval service and execution boundary for one Run as user's runs.""" + + def __init__(self): + self.clock = Clock() + self.container = CosmosContainer() + self.notifications = Notifications() + self.service = approvals.M365ApprovalService( + container_factory=lambda: self.container, notification_sender=self.notifications, + decision_validator=lambda approval: True, clock=self.clock, + ) + self.connection = { + "id": "connection-a", "user_id": RUN_AS, "tenant_id": TENANT, + "generation": 1, "status": "connected", "sources": ["email"], + } + self.runs = 0 + + def current_connection(self, user_id, tenant_id): + assert (user_id, tenant_id) == (RUN_AS, TENANT) + return copy.deepcopy(self.connection) + + def context(self, run_id, audience): + return execution.M365ExecutionContext( + actor_user_id=OWNER, data_user_id=RUN_AS, tenant_id=TENANT, + conversation_id=CONVERSATION, shared=True, request_id=run_id, + workflow_id=WORKFLOW_ID, run_id=run_id, audience_version=audience, + ) + + def prepare(self, stored, *, run_id=None, audience="audience-1", manifests=None): + """Bind one run, as the workflow runtime does before any Microsoft 365 operation.""" + self.runs += 1 + context = self.context(run_id or f"run-{self.runs}", audience) + return execution.prepare_m365_workflow_binding( + context, stored, manifests if manifests is not None else [manifest()], review=WORKFLOW_REVIEW, + ) + + def validate(self, prepared): + """The check every Microsoft 365 operation of the run makes.""" + return self.service.validate_workflow_binding(prepared, copy.deepcopy(self.connection), "email") + + def run(self, stored, **options): + prepared = self.prepare(stored, **options) + return prepared, self.validate(prepared) + + def waits(self, stored, **options): + """Run once and return, as the approvals UI sees it, the pending approval the run waits for.""" + prepared = self.prepare(stored, **options) + with pytest.raises(approvals.M365ApprovalRequired) as raised: + self.validate(prepared) + assert raised.value.request_type == approvals.TYPE_WORKFLOW_RUN_AS + return prepared, approvals.sanitize_m365_approval( + self.service.get_approval(raised.value.approval_id, RUN_AS), + ) + + def approve(self, approval_id): + return self.service.decide(approval_id, RUN_AS, {"choice": "approve"}) + + def bindings(self, **fields): + return [ + item for item in self.container.items.values() + if item.get("record_kind") == "m365_approval" + and item.get("request_type") == approvals.TYPE_WORKFLOW_RUN_AS + and all(item.get(key) == value for key, value in fields.items()) + ] + + def audit(self, event_type): + return [ + item for item in self.container.items.values() + if item.get("record_kind") == "m365_audit" and item.get("event_type") == event_type + ] + + +@pytest.fixture +def harness(): + value = Harness() + connection_service = types.SimpleNamespace(current_connection=value.current_connection) + with patch.object(approvals, "_service", value.service), \ + patch.object(connections, "_service", connection_service), \ + patch.object(execution, "_workflow_validator", lambda context: True): + yield value + + +def test_version_is_at_least_the_implementing_release(): + assert_app_version_at_least("0.261.229") + + +# --------------------------------------------------------------------------------------------- +# A revision its Run as user saved +# --------------------------------------------------------------------------------------------- + +def test_a_self_authored_first_run_is_approved_audited_and_silent(harness): + prepared, allowed = harness.run(workflow()) + + record = harness.service.get_approval(prepared.binding_id, RUN_AS) + assert allowed["status"] == "approved" and allowed["self_authored"] is True + assert record["status"] == "approved" and record["self_authored"] is True + assert record["approved_by_id"] == RUN_AS and record["requester_id"] == OWNER + assert record["binding"]["workflow_fingerprint"] == prepared.workflow_fingerprint + assert record["binding"]["connection_id"] == "connection-a" + assert record["binding"]["connection_generation"] == 1 + assert record["binding"]["review"] == WORKFLOW_REVIEW + assert "expires_at" not in record + assert record["execution_status"] == "not_required" + assert record["continuation_status"] == "delivered" + # No notification, and nothing is ever pending or queued for continuation. + continuations = harness.service.list_pending_continuations()["items"] + assert harness.notifications.calls == [] + assert harness.bindings(status="pending") == [] + assert continuations == [] + [event] = harness.audit("self_authored_approved") + assert event["approval_id"] == prepared.binding_id + assert event["id"] == record["decision_event_id"] + assert event["context"]["workflow_fingerprint"] == prepared.workflow_fingerprint + assert (event["connection_generation"], event["sources"]) == (1, ["email"]) + # The bindings route lists it, approved, with a reason the user can read and no decision to make. + [listed] = harness.service.list_records(RUN_AS, request_type=approvals.TYPE_WORKFLOW_RUN_AS)["items"] + assert (listed["id"], listed["status"], listed["self_authored"]) == (prepared.binding_id, "approved", True) + assert listed["can_approve"] is False and listed["can_deny"] is False + assert "You saved this workflow revision yourself" in listed["reason"] + + +def test_the_same_revision_reuses_one_binding_and_one_audit_event(harness): + first, _allowed = harness.run(workflow()) + second, _allowed = harness.run(workflow(modified_at="2026-03-08T04:10:00+00:00")) + + assert second.binding_id == first.binding_id + assert len(harness.bindings()) == 1 + assert len(harness.audit("self_authored_approved")) == 1 + assert harness.notifications.calls == [] + + +def test_the_run_as_users_own_edits_never_wait(harness): + ids = [] + for prompt in ("Summarize my mail.", "Summarize my mail by sender.", "Summarize only unread mail."): + prepared, allowed = harness.run(workflow(prompt=prompt)) + assert allowed["status"] == "approved" + ids.append(prepared.binding_id) + + assert len(set(ids)) == 3 + assert {item["status"] for item in harness.bindings()} == {"approved"} + assert all(item["self_authored"] is True for item in harness.bindings()) + assert len(harness.audit("self_authored_approved")) == 3 + assert harness.notifications.calls == [] + + +def test_a_run_as_save_reviews_earlier_edits_by_others(harness): + edited = workflow(modified_by=EDITOR, prompt="Forward my mail to the team.") + _prepared, pending = harness.waits(edited) + assert pending["status"] == "pending" and "self_authored" not in pending + + # The Run as user saves the same revision; their save counts as their review of it. + resaved = workflow(prompt="Forward my mail to the team.", modified_at="2026-03-08T04:20:00+00:00") + prepared, allowed = harness.run(resaved) + assert allowed["status"] == "approved" and allowed["self_authored"] is True + assert prepared.workflow_fingerprint == pending["binding"]["workflow_fingerprint"] + + +# --------------------------------------------------------------------------------------------- +# Someone else's revision +# --------------------------------------------------------------------------------------------- + +def test_a_revision_someone_else_saved_needs_the_run_as_users_approval(harness): + stored = workflow(modified_by=EDITOR) + prepared, pending = harness.waits(stored) + + assert pending["status"] == "pending" and pending["can_approve"] is True + assert [call["status"] for call in harness.notifications.calls] == ["pending"] + assert harness.audit("self_authored_approved") == [] + harness.approve(pending["id"]) + allowed = harness.validate(prepared) + assert allowed["status"] == "approved" and "self_authored" not in allowed + next_run, _allowed = harness.run(stored) + assert next_run.binding_id == pending["id"] + + +def test_a_missing_modified_by_needs_approval(harness): + _prepared, pending = harness.waits(workflow(modified_by=None)) + + assert pending["status"] == "pending" + assert harness.bindings(self_authored=True) == [] + + +def test_a_later_edit_by_someone_else_asks_again(harness): + first, allowed = harness.run(workflow()) + assert allowed["self_authored"] is True + + _prepared, pending = harness.waits(workflow(modified_by=EDITOR, prompt="Send my mail to an outside address.")) + assert pending["binding"]["workflow_fingerprint"] != first.workflow_fingerprint + harness.approve(pending["id"]) + _prepared, again = harness.waits(workflow(modified_by=EDITOR, prompt="Delete my mail after reading it.")) + assert again["id"] != pending["id"] + + +@pytest.mark.parametrize("kind,author,changed_at,asks", [ + ("agent", EDITOR, BEFORE_SAVE, False), + ("agent", EDITOR, AFTER_SAVE, True), + ("action", EDITOR, AFTER_SAVE, True), + ("action", ADMIN, "2026-03-08T04:00:00.000001", True), + ("agent", RUN_AS, AFTER_SAVE, False), + ("agent", None, BEFORE_SAVE, False), + ("agent", None, AFTER_SAVE, True), + ("agent", EDITOR, "not a time", True), +], ids=[ + "agent edited by someone else before the save", "agent edited by someone else after the save", + "action edited by someone else after the save", "action edited a microsecond after the save", + "agent edited by the Run as user after the save", "unattributed agent edit before the save", + "unattributed agent edit after the save", "unreadable agent edit time", +]) +def test_an_agent_or_action_someone_else_changed_after_the_save_asks(harness, kind, author, changed_at, asks): + other = "action" if kind == "agent" else "agent" + stored = workflow(authorship=[authored(kind, author, changed_at), authored(other, RUN_AS, BEFORE_SAVE)]) + if asks: + _prepared, pending = harness.waits(stored) + assert pending["status"] == "pending" + else: + _prepared, allowed = harness.run(stored) + assert allowed["self_authored"] is True + + +@pytest.mark.parametrize("authorship", [None, "absent", [{"kind": "agent"}, "not a component"]], + ids=["no authorship", "authorship never recorded", "malformed authorship"]) +def test_unknown_component_authorship_needs_approval(harness, authorship): + stored = workflow() + if authorship == "absent": + stored.pop(M365_REVISION_AUTHORSHIP_FIELD) + else: + stored[M365_REVISION_AUTHORSHIP_FIELD] = authorship + _prepared, pending = harness.waits(stored) + + assert pending["status"] == "pending" + + +def test_a_new_connection_or_generation_asks_again_for_someone_elses_revision(harness): + stored = workflow(modified_by=EDITOR) + approved_run, pending = harness.waits(stored) + harness.approve(pending["id"]) + harness.validate(approved_run) + + harness.connection["generation"] = 2 + with pytest.raises(approvals.M365PolicyError) as stale: + harness.validate(approved_run) + assert stale.value.code == "m365_run_as_invalid" + _prepared, regenerated = harness.waits(stored) + assert regenerated["id"] != pending["id"] and regenerated["binding"]["connection_generation"] == 2 + + harness.connection.update(id="connection-b", generation=1) + _prepared, reconnected = harness.waits(stored) + assert reconnected["binding"]["connection_id"] == "connection-b" + + +def test_a_new_connection_generation_records_a_new_self_authored_binding(harness): + first, _allowed = harness.run(workflow()) + harness.connection["generation"] = 2 + + with pytest.raises(approvals.M365PolicyError): + harness.validate(first) + second, allowed = harness.run(workflow()) + assert second.binding_id != first.binding_id + assert allowed["self_authored"] is True and allowed["binding"]["connection_generation"] == 2 + assert harness.notifications.calls == [] + + +# --------------------------------------------------------------------------------------------- +# Audience changes +# --------------------------------------------------------------------------------------------- + +def test_an_audience_change_never_asks_again(harness): + stored = workflow(modified_by=EDITOR) + _prepared, pending = harness.waits(stored, audience="audience-1") + harness.approve(pending["id"]) + notified = len(harness.notifications.calls) + + moved, allowed = harness.run(stored, audience="audience-2") + assert moved.binding_id == pending["id"] and allowed["status"] == "approved" + assert len(harness.bindings()) == 1 + assert len(harness.notifications.calls) == notified + # The binding still records the audience it was approved for. + recorded = harness.service.get_approval(pending["id"], RUN_AS) + assert recorded["binding"]["audience_version"] == "audience-1" + + own, _allowed = harness.run(workflow(prompt="My own revision."), audience="audience-1") + shared, _allowed = harness.run(workflow(prompt="My own revision."), audience="audience-3") + assert shared.binding_id == own.binding_id + + +# --------------------------------------------------------------------------------------------- +# Explicit decisions still win +# --------------------------------------------------------------------------------------------- + +def test_a_denial_recorded_for_a_run_still_blocks_that_run(harness): + _prepared, pending = harness.waits(workflow(modified_by=EDITOR), run_id="run-denied") + harness.service.decide(pending["id"], RUN_AS, {"choice": "deny"}) + + for stored in (workflow(modified_by=EDITOR), workflow()): + with pytest.raises(approvals.M365PolicyError) as declined: + harness.prepare(stored, run_id="run-denied") + assert declined.value.code == "m365_workflow_declined" + _prepared, allowed = harness.run(workflow(), run_id="run-next") + assert allowed["self_authored"] is True + + +def test_a_cancellation_recorded_for_a_run_still_blocks_that_run(harness): + _prepared, pending = harness.waits(workflow(modified_by=EDITOR), run_id="run-cancelled") + harness.service.record_execution_status(pending["id"], RUN_AS, "run-cancelled", "cancelled") + + with pytest.raises(approvals.M365PolicyError) as declined: + harness.prepare(workflow(), run_id="run-cancelled") + assert declined.value.code == "m365_workflow_declined" + + +def test_a_revoked_self_authored_binding_is_not_silently_recreated(harness): + stored = workflow() + revoked_run, _allowed = harness.run(stored) + harness.service.revoke_workflow_binding(revoked_run.binding_id, RUN_AS) + + with pytest.raises(approvals.M365PolicyError) as invalid: + harness.validate(revoked_run) + assert invalid.value.code == "m365_run_as_invalid" + prepared, pending = harness.waits(stored) + assert pending["status"] == "pending" and "self_authored" not in pending + assert [item["status"] for item in harness.bindings(self_authored=True)] == ["revoked"] + assert len(harness.audit("self_authored_approved")) == 1 + # Approving it explicitly lets the revision run again, and a new save of their own needs nothing. + harness.approve(pending["id"]) + approved = harness.validate(prepared) + assert approved["status"] == "approved" + _prepared, allowed = harness.run(workflow(prompt="A new revision of my own.")) + assert allowed["self_authored"] is True + + +def test_revoking_a_revision_revokes_every_approved_copy_of_it(harness): + stored = workflow(modified_by=EDITOR) + fingerprint = workflow_execution_fingerprint(stored, [manifest()]) + copies = [] + for audience in ("audience-1", "audience-2"): + context = harness.context(f"legacy-{audience}", audience) + context = replace(context, workflow_fingerprint=fingerprint, connection_id="connection-a") + binding = harness.service.create_workflow_binding( + context, ["email"], copy.deepcopy(harness.connection), review=WORKFLOW_REVIEW, + ) + harness.approve(binding["id"]) + copies.append(binding["id"]) + assert len(set(copies)) == 2 + + harness.service.revoke_workflow_binding(copies[0], RUN_AS) + statuses = {harness.service.get_approval(item, RUN_AS)["status"] for item in copies} + assert statuses == {"revoked"} + _prepared, pending = harness.waits(workflow(), audience="audience-2") + assert pending["id"] not in copies + + +def test_a_revocation_racing_a_new_self_authored_binding_still_wins(harness): + stored = workflow() + first, _allowed = harness.run(stored) + harness.connection["generation"] = 2 + harness.clock.advance(minutes=1) + write_binding = harness.service._create_self_authored_binding + + def revoke_while_writing(*args): + # The run read the records before the user revoked, and writes its new binding after. + harness.service.revoke_workflow_binding(first.binding_id, RUN_AS) + return write_binding(*args) + + with patch.object(harness.service, "_create_self_authored_binding", revoke_while_writing): + raced, pending = harness.waits(stored) + assert pending["status"] == "pending" and "self_authored" not in pending + assert raced.binding_id == pending["id"] + assert [item["status"] for item in harness.bindings(self_authored=True)] == ["revoked", "revoked"] + for _attempt in range(2): + _prepared, again = harness.waits(stored) + assert again["id"] == pending["id"] + + +def test_a_self_authored_copy_an_interrupted_revocation_missed_is_withdrawn(harness): + stored = workflow() + first, _allowed = harness.run(stored) + harness.connection["generation"] = 2 + second, _allowed = harness.run(stored) + harness.clock.advance(minutes=1) + + with patch.object(harness.service, "_revoke_revision_copies", side_effect=RuntimeError("interrupted")): + with pytest.raises(RuntimeError): + harness.service.revoke_workflow_binding(first.binding_id, RUN_AS) + missed = harness.service.get_approval(second.binding_id, RUN_AS) + assert missed["status"] == "approved" + + _prepared, pending = harness.waits(stored) + assert pending["status"] == "pending" and "self_authored" not in pending + assert [item["status"] for item in harness.bindings(self_authored=True)] == ["revoked", "revoked"] + with pytest.raises(approvals.M365PolicyError) as invalid: + harness.validate(second) + assert invalid.value.code == "m365_run_as_invalid" + + +def test_only_an_approval_granted_after_a_revocation_outlives_it(harness): + stored = workflow(modified_by=EDITOR) + _prepared, before = harness.waits(stored) + harness.approve(before["id"]) + harness.connection["generation"] = 2 + _prepared, granted = harness.waits(stored) + harness.approve(granted["id"]) + harness.clock.advance(minutes=1) + + with patch.object(harness.service, "_revoke_revision_copies", side_effect=RuntimeError("interrupted")): + with pytest.raises(RuntimeError): + harness.service.revoke_workflow_binding(before["id"], RUN_AS) + # The copy approved before the revocation is withdrawn, not reused or returned as a renewal. + _prepared, renewed = harness.waits(stored) + withdrawn = harness.service.get_approval(granted["id"], RUN_AS) + assert renewed["id"] not in {before["id"], granted["id"]} + assert withdrawn["status"] == "revoked" + + harness.clock.advance(minutes=1) + harness.approve(renewed["id"]) + rerun, allowed = harness.run(stored) + assert rerun.binding_id == renewed["id"] and allowed["status"] == "approved" + + +def test_revoking_retries_a_copy_that_changed_meanwhile(harness): + first, _allowed = harness.run(workflow()) + harness.connection["generation"] = 2 + second, _allowed = harness.run(workflow()) + transition = harness.service._transition + attempts = [] + + def changed_once(approval, status, **fields): + attempts.append(approval["id"]) + if approval["id"] == second.binding_id and attempts.count(second.binding_id) == 1: + raise approvals.M365ApprovalConflict() + return transition(approval, status, **fields) + + with patch.object(harness.service, "_transition", changed_once): + harness.service.revoke_workflow_binding(first.binding_id, RUN_AS) + assert attempts.count(second.binding_id) == 2 + assert [item["status"] for item in harness.bindings(self_authored=True)] == ["revoked", "revoked"] + assert all(item.get("revision_wide") is True for item in harness.bindings(self_authored=True)) + + +def test_only_the_users_revocation_marks_the_revision_wide_cut_off(harness): + stored = workflow() + first, _allowed = harness.run(stored) + harness.connection["generation"] = 2 + write_binding = harness.service._create_self_authored_binding + + def revoke_while_writing(*args): + harness.service.revoke_workflow_binding(first.binding_id, RUN_AS) + return write_binding(*args) + + with patch.object(harness.service, "_create_self_authored_binding", revoke_while_writing): + harness.waits(stored) + marks = {item["id"]: item.get("revision_wide") for item in harness.bindings(self_authored=True)} + assert marks[first.binding_id] is True + assert [mark for key, mark in marks.items() if key != first.binding_id] == [None] + + +def test_a_revocation_from_before_this_release_keeps_other_approvals_working(harness): + stored = workflow(modified_by=EDITOR) + fingerprint = workflow_execution_fingerprint(stored, [manifest()]) + copies = [] + for audience in ("audience-1", "audience-2"): + context = replace( + harness.context(f"legacy-{audience}", audience), + workflow_fingerprint=fingerprint, connection_id="connection-a", + ) + binding = harness.service.create_workflow_binding( + context, ["email"], copy.deepcopy(harness.connection), review=WORKFLOW_REVIEW, + ) + harness.approve(binding["id"]) + copies.append(binding["id"]) + harness.clock.advance(minutes=1) + # Before this release revoking withdrew one binding, and other copies stayed in use. + legacy = harness.service.get_approval(copies[0], RUN_AS) + harness.service._transition(legacy, "revoked", terminal_reason="subject_revoked") + harness.clock.advance(minutes=1) + + kept, allowed = harness.run(stored, audience="audience-2") + still = harness.service.get_approval(copies[1], RUN_AS) + assert kept.binding_id == copies[1] and allowed["status"] == "approved" + assert still["status"] == "approved" + # The revision it revoked is still never self-authored. + harness.connection["generation"] = 2 + _prepared, pending = harness.waits(workflow()) + assert pending["status"] == "pending" and "self_authored" not in pending + + +def test_a_run_withdrawing_a_late_copy_never_cancels_a_later_approval(harness): + stored = workflow() + first, _allowed = harness.run(stored) + harness.clock.advance(minutes=1) + harness.service.revoke_workflow_binding(first.binding_id, RUN_AS) + harness.connection["generation"] = 2 + harness.clock.advance(minutes=1) + _prepared, pending = harness.waits(stored) + harness.approve(pending["id"]) + harness.clock.advance(minutes=1) + + # A run that read the records before the revocation withdraws the binding it then records. + stale_run = replace( + harness.context("stale-run", "audience-1"), + workflow_fingerprint=first.workflow_fingerprint, connection_id="connection-a", + ) + withdrawn = harness.service._create_self_authored_binding( + stale_run, ["email"], copy.deepcopy(harness.connection), WORKFLOW_REVIEW, + ) + harness.clock.advance(minutes=1) + rerun, allowed = harness.run(stored) + assert withdrawn is None + assert rerun.binding_id == pending["id"] and allowed["status"] == "approved" + + +def test_only_a_revision_wide_revocation_sets_the_cut_off(): + def revoked(resolved_at, fingerprint="revision-a", **fields): + return { + "status": "revoked", "resolved_at": resolved_at, + "binding": {"workflow_id": WORKFLOW_ID, "workflow_fingerprint": fingerprint}, **fields, + } + + marked = revoked("2026-03-08T05:00:00+00:00", revision_wide=True) + later = revoked("2026-03-08T07:00:00+00:00") + unreadable = revoked("not a time", revision_wide=True) + elsewhere = revoked("2026-03-08T09:00:00+00:00", fingerprint="revision-b", revision_wide=True) + cut_off = datetime(2026, 3, 8, 5, tzinfo=timezone.utc) + approved = {"status": "approved", "approved_at": "2026-03-08T06:00:00+00:00"} + + found = approvals._revision_revocation([later, marked, unreadable, elsewhere], WORKFLOW_ID, "revision-a") + legacy = approvals._revision_revocation([later], WORKFLOW_ID, "revision-a") + untouched = approvals._revision_revocation([elsewhere], WORKFLOW_ID, "revision-a") + assert found == (True, cut_off) + assert legacy == (True, None) + assert untouched == (False, None) + assert approvals._approved_after_revocation(approved, cut_off) is True + assert approvals._approved_after_revocation(approved, None) is True + assert approvals._approved_after_revocation({**approved, "approved_at": "2026-03-08T05:00:00+00:00"}, cut_off) is False + assert approvals._approved_after_revocation({**approved, "approved_at": None}, cut_off) is False + assert approvals._approved_after_revocation({**approved, "self_authored": True}, None) is False + + +# --------------------------------------------------------------------------------------------- +# The runtime records agent and action authorship beside the fingerprint +# --------------------------------------------------------------------------------------------- + +def _module(name, **values): + module = types.ModuleType(name) + module.__dict__.update(values) + return module + + +def _load_runtime(): + spec = importlib.util.spec_from_file_location("test_self_authored_m365_runtime", APP_DIR / "functions_m365_runtime.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, { + "config": _module( + "config", TENANT_ID=TENANT, cosmos_conversations_container=None, + cosmos_m365_execution_runs_container=None, + ), + "functions_appinsights": _module("functions_appinsights", log_event=lambda *args, **kwargs: None), + "functions_collaboration": _module( + "functions_collaboration", + assert_user_can_participate_in_collaboration_conversation=lambda *args: None, + build_conversation_participation_context=lambda *args: None, + get_collaboration_conversation=lambda *args: None, + ), + }): + spec.loader.exec_module(module) + return module + + +def test_runtime_manifests_record_authorship_outside_the_fingerprint(harness): + runtime = _load_runtime() + agent = { + "id": "mailer", "name": "Mailer", "instructions": "Summarize mail.", "actions_to_load": ["mail"], + "modified_by": RUN_AS, "modified_at": BEFORE_SAVE, + } + action = {**manifest(id="personal-mail"), "modified_by": RUN_AS, "modified_at": BEFORE_SAVE} + catalogs = { + "functions_global_actions": _module("functions_global_actions", get_global_actions=lambda **kwargs: []), + "functions_global_agents": _module("functions_global_agents", get_global_agents=lambda: []), + "functions_personal_actions": _module( + "functions_personal_actions", get_personal_actions=lambda *args, **kwargs: [copy.deepcopy(action)], + ), + "functions_personal_agents": _module( + "functions_personal_agents", get_personal_agents=lambda user_id: [copy.deepcopy(agent)], + ), + "functions_group_actions": _module("functions_group_actions", get_group_actions=lambda *args, **kwargs: []), + "functions_group_agents": _module("functions_group_agents", get_group_agents=lambda *args: []), + "functions_group": _module("functions_group", assert_group_role=lambda *args: None), + "functions_keyvault": _module("functions_keyvault", SecretReturnType=types.SimpleNamespace(NAME="name")), + "functions_settings": _module("functions_settings", get_settings=lambda: {}), + } + stored = { + "id": WORKFLOW_ID, "user_id": RUN_AS, "task_prompt": "Summarize my mail.", + "selected_agent": {"id": "mailer", "name": "Mailer", "is_global": False}, + "m365_run_as_user_id": RUN_AS, "modified_by": RUN_AS, "modified_at": SAVED_AT, + } + app = Flask(__name__) + + def bind(): + manifests, fingerprint_workflow = runtime.workflow_m365_manifests(copy.deepcopy(stored)) + context = harness.context(f"runtime-run-{harness.runs}", "audience-1") + harness.runs += 1 + with app.test_request_context("/"): + g.m365_workflow = fingerprint_workflow + g.m365_workflow_manifests = manifests + prepared = runtime.resolve_m365_workflow_binding(context, manifests, {"email": "always"}) + return manifests, fingerprint_workflow, prepared + + with patch.dict(sys.modules, catalogs): + manifests, fingerprint_workflow, prepared = bind() + assert fingerprint_workflow[M365_REVISION_AUTHORSHIP_FIELD] == [ + {"kind": "agent", "id": "mailer", "modified_by": RUN_AS, "modified_at": BEFORE_SAVE}, + {"kind": "action", "id": "personal-mail", "modified_by": RUN_AS, "modified_at": BEFORE_SAVE}, + ] + allowed = harness.validate(prepared) + assert allowed["self_authored"] is True + + # Authorship is not part of the fingerprint: saving the agent unchanged keeps the binding. + agent.update(modified_by=ADMIN, modified_at=AFTER_SAVE) + _manifests, unchanged, again = bind() + assert workflow_execution_fingerprint(unchanged, _manifests) == prepared.workflow_fingerprint + assert again.binding_id == prepared.binding_id + + # An administrator's later change to what the agent does asks the Run as user. + agent["instructions"] = "Forward every message to an outside address." + _manifests, _changed, changed = bind() + with pytest.raises(approvals.M365ApprovalRequired): + harness.validate(changed) + + +def test_the_authorship_check_is_pure_and_fails_closed(): + stored = workflow() + assert workflow_revision_self_authored(stored, RUN_AS, stored[M365_REVISION_AUTHORSHIP_FIELD]) is True + assert workflow_revision_self_authored(stored, EDITOR, stored[M365_REVISION_AUTHORSHIP_FIELD]) is False + assert workflow_revision_self_authored({**stored, "m365_run_as_user_id": EDITOR}, RUN_AS, []) is False + assert workflow_revision_self_authored({**stored, "modified_at": None}, RUN_AS, [ + authored("agent", EDITOR, BEFORE_SAVE), + ]) is False + assert workflow_revision_self_authored(stored, "", []) is False + assert workflow_revision_self_authored(stored, RUN_AS, None) is False + + +# --------------------------------------------------------------------------------------------- +# Every save records the authenticated actor +# --------------------------------------------------------------------------------------------- + +@pytest.fixture +def save_routes(): + from test_workflow_origin_provenance import OriginRoutes + + return OriginRoutes() + + +@pytest.mark.parametrize("version", [2, 3]) +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_save_routes_record_the_signed_in_actor_not_the_payload(save_routes, scope, version): + from test_workflow_draft_save_parity import OWNER_ID + from test_workflow_origin_provenance import _definition + + forged = {**_definition(version), "modified_by": "someone-else", "modified_at": "2020-01-01T00:00:00+00:00"} + created = save_routes.post(scope, forged) + assert created.status_code == 201, created.get_data(as_text=True) + workflow_id = created.json["workflow"]["id"] + assert save_routes.stored(scope, workflow_id)["modified_by"] == OWNER_ID + + update = {**save_routes.load(scope, workflow_id), "description": "Edited", "modified_by": "someone-else"} + updated = save_routes.post(scope, update) + assert updated.status_code == 200, updated.get_data(as_text=True) + stored = save_routes.stored(scope, workflow_id) + assert stored["modified_by"] == OWNER_ID and stored["modified_at"] != "2020-01-01T00:00:00+00:00" + + +def test_group_saves_and_server_creates_record_their_own_actor(): + from test_workflow_draft_save_parity import EDITOR_ID, GROUP_ID, OWNER_ID, SaveParityHarness, _task, _v2 + + stores = SaveParityHarness() + payload = _v2("Inbox digest", tasks=[_task("read", "Read", "Read my email.")]) + created = stores.save_group("create", payload, actor_user_id=OWNER_ID) + edited = stores.save_group("edit", { + **stores.load_group(created["id"]), "description": "Edited by an admin", "modified_by": OWNER_ID, + }, actor_user_id=EDITOR_ID) + assert stores.containers["group_workflows"].items[(GROUP_ID, edited["id"])]["modified_by"] == EDITOR_ID + with stores.active(): + server, made = stores.personal.create_personal_workflow_if_absent( + OWNER_ID, {**payload, "modified_by": "someone-else"}, + workflow_id="0e552320-f2b4-5149-8742-51fe78301563", + origin={ + "source": "orchestration", "conversation_id": "conv-chat", "orchestration_run_id": "run-1", + "proposal_id": "proposal-1", "created_at": "2026-09-28T12:00:00+00:00", + }, + actor_user_id=OWNER_ID, + ) + assert made is True and server["modified_by"] == OWNER_ID + + +# --------------------------------------------------------------------------------------------- +# Raw administrator edits +# --------------------------------------------------------------------------------------------- + +@pytest.mark.parametrize("container", sorted(M365_AUTHORED_RECORD_CONTAINERS)) +def test_a_raw_edit_names_the_administrator(container): + document = {"id": "record-1", "instructions": "Forward my mail.", "modified_by": RUN_AS, "modified_at": BEFORE_SAVE} + stamped = attribute_raw_authored_record_edit(container, document, ADMIN, EDITED_AT) + + # Each store keeps its own time format: aware for workflows, naive UTC for agents and actions. + expected = "2026-03-09T00:00:00+00:00" if container.endswith("_workflows") else "2026-03-09T00:00:00" + assert (stamped["modified_by"], stamped["modified_at"]) == (ADMIN, expected) + assert document["modified_by"] == RUN_AS + anonymous = attribute_raw_authored_record_edit(container, document, None, EDITED_AT) + assert anonymous["modified_by"] == RAW_EDITOR_AUTHOR + + +def test_a_raw_workflow_edit_is_never_self_authored(): + edited = attribute_raw_authored_record_edit( + "personal_workflows", workflow(prompt="Forward my mail."), ADMIN, EDITED_AT, + ) + assert workflow_revision_self_authored(edited, RUN_AS, edited[M365_REVISION_AUTHORSHIP_FIELD]) is False + # An agent a raw edit changed after the Run as user's save asks them too. + agent = attribute_raw_authored_record_edit("group_agents", {"id": "agent-1"}, ADMIN, EDITED_AT) + stored = workflow(authorship=[{"kind": "agent", "id": "agent-1", **{ + key: agent[key] for key in ("modified_by", "modified_at") + }}]) + assert workflow_revision_self_authored(stored, RUN_AS, stored[M365_REVISION_AUTHORSHIP_FIELD]) is False + # Records nothing runs keep exactly what the administrator typed. + conversation = {"id": "conversation-1", "modified_by": RUN_AS} + assert attribute_raw_authored_record_edit("conversations", conversation, ADMIN, EDITED_AT) is conversation + + +def test_the_cosmos_editor_attributes_changed_records_before_saving(): + source = (APP_DIR / "functions_data_management.py").read_text(encoding="utf-8") + start = source.index("def save_data_management_cosmos_editor_document") + body = source[start:source.index("\ndef ", start + 1)] + summary = body.index("change_summary = _summarize_cosmos_editor_changes(original_document, document)") + attribute = body.index("clean_document = attribute_raw_authored_record_edit(") + replace_call = body.index("saved_document = container.replace_item(") + assert summary < attribute < replace_call + assert 'if change_summary["changed_count"]:' in body[summary:attribute] + assert 'container_metadata["logical_name"], clean_document, admin_user_id,' in body + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q", *os.environ.get("PYTEST_ADDOPTS", "").split()])) diff --git a/functional_tests/test_orchestration_workflow_proposal_done_when.py b/functional_tests/test_orchestration_workflow_proposal_done_when.py index 0d4ea426f..ff6acffdf 100644 --- a/functional_tests/test_orchestration_workflow_proposal_done_when.py +++ b/functional_tests/test_orchestration_workflow_proposal_done_when.py @@ -220,7 +220,8 @@ def test_a_monday_email_request_becomes_a_monday_workflow_once_approved(modules, assert [task["instructions"] for task in summary["tasks"]] == [INSTRUCTIONS] assert summary["tasks"][0]["agent_name"] == "Mail helper" assert shown["m365"]["required"] is True and shown["m365"]["run_as"] == "self" - assert shown["m365"]["connected"] is True and shown["m365"]["approval_state"] is None + # The requester creates it, so a revision they saved needs no separate Run as approval. + assert shown["m365"]["connected"] is True and shown["m365"]["approval_state"] == "self_authored" # Create & start stores one enabled Monday 08:00 workflow in the request's zone. created = accept(h, run_id=RUN, conversation_id=CONVERSATION, mode="enabled") @@ -271,6 +272,9 @@ def test_a_monday_email_request_becomes_a_monday_workflow_once_approved(modules, assert shown["workflow"] == {"id": workflow_id, "name": NAME, "is_enabled": True} assert shown["actions"] == {"accept": False, "edit": False, "deny": False, "create_again": False, "open_workflow": True} + # The created workflow names the requester as its Run as account and as the one who saved it. + assert workflow["modified_by"] == OWNER + assert shown["m365"]["approval_state"] == "self_authored" if __name__ == "__main__": diff --git a/functional_tests/test_orchestration_workflow_proposal_routes.py b/functional_tests/test_orchestration_workflow_proposal_routes.py index 536b05427..b4efec4a4 100644 --- a/functional_tests/test_orchestration_workflow_proposal_routes.py +++ b/functional_tests/test_orchestration_workflow_proposal_routes.py @@ -968,18 +968,31 @@ def test_microsoft_365_needs_are_shown_from_stored_records_only(h): }}) login(h) proposal = only(h) + # Accepting creates the workflow in the requester's name, so its Run as needs no separate approval. assert proposal["m365"] == { "required": True, "can_send": True, "run_as": "self", "sources": ["email"], "connected": True, - "approval_state": None, + "approval_state": "self_authored", } assert h.record.connections == [(OWNER, TENANT)] h.record.connected = False assert only(h)["m365"]["connected"] is False assert accept(h, mode="enabled").status_code == 201 - stored_workflow(h)["last_run_status"] = "awaiting_run_as_approval" + workflow = stored_workflow(h) + assert workflow["modified_by"] == OWNER + # A created workflow is described from its stored record: this blueprint runs as no one. + assert workflow["m365_run_as_user_id"] == "" + assert only(h)["m365"]["approval_state"] is None + workflow["m365_run_as_user_id"] = OWNER + assert only(h)["m365"]["approval_state"] == "self_authored" + workflow["modified_by"] = OTHER + assert only(h)["m365"]["approval_state"] is None + workflow.pop("modified_by") + assert only(h)["m365"]["approval_state"] is None + workflow["modified_by"] = OWNER + workflow["last_run_status"] = "awaiting_run_as_approval" assert only(h)["m365"]["approval_state"] == "waiting" - stored_workflow(h)["m365_binding_approval_id"] = "approval-1" + workflow["m365_binding_approval_id"] = "approval-1" assert only(h)["m365"]["approval_state"] == "approved" diff --git a/functional_tests/test_v2_workflow_change_tracking_logic.ts b/functional_tests/test_v2_workflow_change_tracking_logic.ts index bff6436da..e8b99b493 100644 --- a/functional_tests/test_v2_workflow_change_tracking_logic.ts +++ b/functional_tests/test_v2_workflow_change_tracking_logic.ts @@ -200,6 +200,16 @@ function attributionAndSave() { check('a schedule change re-approves Run as', workflowRunAsConsequence(runAs, rescheduled, diffWorkflowChanges(runAs, rescheduled).changes)); const alerted = { ...runAs, description: 'x' } as WorkflowDefinition; check('a description change does not re-approve Run as', !workflowRunAsConsequence(runAs, alerted, diffWorkflowChanges(runAs, alerted).changes)); + const taskChanges = diffWorkflowChanges(runAs, runAsTasks).changes; + check('the Run as account saving its own change does not re-approve Run as', + !workflowRunAsConsequence(runAs, runAsTasks, taskChanges, 'owner-oid') + && !workflowRunAsConsequence(runAs, runAsTasks, taskChanges, ' owner-oid ')); + check('someone else saving a task change still re-approves Run as', + workflowRunAsConsequence(runAs, runAsTasks, taskChanges, 'group-editor-oid') + && workflowRunAsConsequence(runAs, runAsTasks, taskChanges, '')); + const handedOver = { ...runAsTasks, m365_run_as_user_id: 'group-editor-oid' } as WorkflowDefinition; + check('choosing someone else as Run as asks them, even when you saved it before', + workflowRunAsConsequence(runAs, handedOver, diffWorkflowChanges(runAs, handedOver).changes, 'owner-oid')); } export function runLibraryChecks() { diff --git a/functional_tests/test_workflow_assist_candidate_parity.py b/functional_tests/test_workflow_assist_candidate_parity.py index 6e17f77ba..e5e1bd518 100644 --- a/functional_tests/test_workflow_assist_candidate_parity.py +++ b/functional_tests/test_workflow_assist_candidate_parity.py @@ -168,6 +168,10 @@ def _scenario(name, operations, *, stored, draft=None, references=()): {"op": "move_task", "task": "task_2", "after": "start"}, {"op": "set_task_inputs", "task": "new_1", "mode": "tasks", "from": ["task_1"]}, ], stored=_with_run_as()), + # Saving as your own Run as account is your own revision: neither layer asks to re-approve it. + _scenario("rewritten task with my own Run as", [ + {"op": "set_task_instructions", "task": "task_2", "instructions": "Check each document.\nList every problem."}, + ], stored=wa.stored_workflow(m365_run_as_user_id=wa.USER_ID)), _scenario("removed task", [{"op": "remove_task", "task": "task_3"}], stored=_review_workflow()), _scenario("workflow and task runners", [ {"op": "set_workflow_runner", "runner": "agent", "agent": "agent_1"}, @@ -250,6 +254,7 @@ def _run_case(scenario): checked = [payload for _user_id, payload in bundle.recorder.named("dry_run")] return { "name": scenario["name"], + "user_id": wa.USER_ID, "stored": stored, "original": editor.strip_undefined(original) if original is not None else None, "draft": body["draft"], diff --git a/functional_tests/test_workflow_assist_candidate_parity_logic.ts b/functional_tests/test_workflow_assist_candidate_parity_logic.ts index 2aa31df9c..3355307ac 100644 --- a/functional_tests/test_workflow_assist_candidate_parity_logic.ts +++ b/functional_tests/test_workflow_assist_candidate_parity_logic.ts @@ -44,6 +44,8 @@ interface RefusedCandidate { interface ParityCase { name: string; + /** The signed-in user who asked the assistant and would save the result. */ + user_id: string; stored: Row | null; original: Row | null; draft: Row; @@ -213,7 +215,7 @@ function checkCase(item: ParityCase, options: Row, turn: number) { const listed = difference(editorChanges(changes), serverChanges(item.changes)); check(`${name}: the editor lists the changes and Jump to targets the server reports`, listed === null, listed); - const runAs = workflowRunAsConsequence(draft, candidate, changes); + const runAs = workflowRunAsConsequence(draft, candidate, changes, item.user_id); check(`${name}: the editor asks to re-approve Run as exactly when the server warns`, runAs === item.run_as_warning, { editor: runAs, server: item.run_as_warning }); diff --git a/functional_tests/test_workflow_assist_dry_run_parity.py b/functional_tests/test_workflow_assist_dry_run_parity.py index 979cee1a1..37becfa02 100644 --- a/functional_tests/test_workflow_assist_dry_run_parity.py +++ b/functional_tests/test_workflow_assist_dry_run_parity.py @@ -194,8 +194,9 @@ def read_item(self, item, partition_key, **kwargs): # The save check is the save # --------------------------------------------------------------------------------------------- -@pytest.mark.parametrize("run_as", ["", OWNER_ID], ids=["without Run as", "with Run as"]) -def test_the_weekday_schedule_and_urgent_alert_pass_the_real_check_and_save_as_checked(harness, run_as): +@pytest.mark.parametrize("run_as, warned", [("", False), (OWNER_ID, False), (wa.RUN_AS_ID, True)], + ids=["without Run as", "with the owner as Run as", "with someone else as Run as"]) +def test_the_weekday_schedule_and_urgent_alert_pass_the_real_check_and_save_as_checked(harness, run_as, warned): stored = _seed(harness, m365_run_as_user_id=run_as) assert stored["m365_run_as_user_id"] == run_as writes = harness.writes() @@ -210,7 +211,8 @@ def test_the_weekday_schedule_and_urgent_alert_pass_the_real_check_and_save_as_c assert harness.writes() == writes assert result["outcome"] == "changed" - assert [warning["code"] for warning in result["warnings"]] == (["run_as_reapproval"] if run_as else []) + # Only someone else's Run as account has to approve the owner's change again. + assert [warning["code"] for warning in result["warnings"]] == (["run_as_reapproval"] if warned else []) candidate = result["candidate"] # The assistant checked the editor's save projection of the candidate, once. [checked] = _checked(bundle) diff --git a/functional_tests/test_workflow_assist_scenarios.py b/functional_tests/test_workflow_assist_scenarios.py index 2a9e2295b..0df9f6a94 100644 --- a/functional_tests/test_workflow_assist_scenarios.py +++ b/functional_tests/test_workflow_assist_scenarios.py @@ -586,7 +586,8 @@ def agent_email_capable(user_id, agent): @pytest.mark.parametrize("run_as, checked, expected", [ ("", True, ["email_requires_m365_agent", "m365_not_connected"]), - (wa.USER_ID, True, ["run_as_reapproval", "email_requires_m365_agent", "m365_not_connected"]), + # Saving as your own Run as account is your own revision, so it never needs re-approving. + (wa.USER_ID, True, ["email_requires_m365_agent", "m365_not_connected"]), (wa.RUN_AS_ID, False, ["run_as_reapproval", "email_requires_m365_agent"]), ], ids=["runs as the caller implicitly", "runs as the caller explicitly", "runs as someone else"]) def test_a_missing_m365_connection_is_the_callers_own_only(run_as, checked, expected): @@ -625,7 +626,11 @@ def broken(*_args): assert SENTINEL not in json.dumps(bundle.logs) -def test_with_little_time_left_the_stored_record_checks_are_skipped_but_run_as_is_not(): +@pytest.mark.parametrize("run_as, expected", [ + (wa.USER_ID, ["email_requires_m365_agent"]), + (wa.RUN_AS_ID, ["run_as_reapproval", "email_requires_m365_agent"]), +], ids=["runs as the caller", "runs as someone else"]) +def test_with_little_time_left_the_stored_record_checks_are_skipped_but_run_as_is_not(run_as, expected): clock = wa.FakeClock() lookups = [] @@ -637,11 +642,12 @@ def dry_run(_user_id, payload): clock.advance(3) return {"ok": True, "workflow": payload, "errors": []} - stored = wa.stored_workflow(m365_run_as_user_id=wa.USER_ID) + stored = wa.stored_workflow(m365_run_as_user_id=run_as) model = wa.ScriptedModel(wa.reply("changed", "Done.", [EMAIL_TASK]), on_call=lambda _call: clock.advance(144)) bundle = wa.services(model, stored=stored, clock=clock, dry_run=dry_run, m365_connected=m365_connected) result = wa.run(wa.request_body(stored=stored), bundle) - assert _codes(result) == ["run_as_reapproval", "email_requires_m365_agent"] + # The connection lookup is skipped for want of time; the Run as check reads no stored record. + assert _codes(result) == expected assert lookups == [] diff --git a/ui_tests/test_m365_lifecycle_and_approvals.py b/ui_tests/test_m365_lifecycle_and_approvals.py index 1e1ca820b..62792d4f2 100644 --- a/ui_tests/test_m365_lifecycle_and_approvals.py +++ b/ui_tests/test_m365_lifecycle_and_approvals.py @@ -6,6 +6,7 @@ In-chat onboarding regression coverage implemented in: 0.261.032 Independent Profile chat reconnect coverage implemented in: 0.261.034 Shared pending-action renderer coverage implemented in: 0.261.038 +Self-authored Run as binding coverage implemented in: 0.261.229 Uses the real local templates, Bootstrap, and browser modules with deterministic same-origin API fixtures. Set AZURE_PLAYWRIGHT_WS_ENDPOINT, AZURE_SUBSCRIPTION_ID, @@ -22,6 +23,7 @@ import json import mimetypes import os +import sys from pathlib import Path from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit @@ -33,6 +35,11 @@ APP_ROOT = Path(__file__).resolve().parents[1] / "application" / "single_app" +sys.path.insert(0, str(APP_ROOT)) + +# The approval projection the server sends; a pure policy module with no cloud I/O at import. +from functions_m365_approvals import sanitize_m365_approval # noqa: E402 + ORIGIN = "http://simplechat.test" CSRF_TOKEN = "ui-test-only-m365-anti-forgery-token-not-a-credential" SOURCES = ("calendar", "email", "onedrive", "spo") @@ -887,7 +894,12 @@ def test_workflow_run_as_selection_preserves_an_unavailable_saved_account(ui): }""") select = page.get_by_label("Microsoft 365 Run as", exact=True) expect(select).to_have_value("removed-reader") - expect(page.locator("#workflow-m365-run-as-help")).to_contain_text("manual and scheduled") + help_text = page.locator("#workflow-m365-run-as-help") + expect(help_text).to_contain_text("manual and scheduled") + # Since 0.261.229 only someone else's revision needs the selected person's approval. + expect(help_text).to_contain_text("When someone else saves the workflow") + expect(help_text).to_contain_text("A revision they saved themselves needs no separate approval.") + expect(help_text).not_to_contain_text("require approval again") select.select_option("data-user") selected = page.evaluate("window.runAsControl.getValue()") assert selected == "data-user" @@ -1585,6 +1597,45 @@ def test_profile_own_connection_disconnect_and_binding_revoke(ui): assert not api.errors +@pytest.mark.ui +def test_self_authored_binding_reads_as_your_own_save_everywhere(ui): + """Since 0.261.229 a revision its Run as user saved is approved without a request or a decision.""" + page, api = ui + status = "Decision: approved automatically because you saved this workflow revision yourself." + record = approval("self-authored", "m365_workflow_run_as", shared=False) + record.pop("expires_at") + record.update({ + "status": "approved", "self_authored": True, "execution_status": "not_required", + "continuation_status": "delivered", + }) + record["context"]["workflow_id"] = "own-workflow" + record = sanitize_m365_approval(record) + assert (record["can_approve"], record["can_deny"]) == (False, False) + api.records[record["id"]] = record + + page.goto(f"{ORIGIN}/profile") + bindings = page.locator("#m365-workflow-bindings") + expect(bindings).to_contain_text(f"Workflow own-workflow: {status}") + expect(bindings.get_by_role("button", name="Revoke workflow authorization", exact=True)).to_be_visible() + + page.goto(f"{ORIGIN}/approvals") + page.evaluate("""record => { + document.getElementById('approvalsTableBody').appendChild( + window.SimpleChatM365ApprovalList.renderRow(record) + ); + }""", record) + expect(page.locator("#approvalsTableBody")).to_contain_text(status) + page.get_by_role("button", name="View saved decision", exact=True).click() + saved = page.locator("#m365-approval-records") + expect(saved).to_contain_text(status) + expect(saved).to_contain_text(record["reason"]) + expect(saved).not_to_contain_text("Request expires") + expect(page.get_by_role("button", name="Allow this workflow revision", exact=True)).to_have_count(0) + expect(page.locator("#m365-approvals-apply")).to_be_disabled() + assert api.decisions == [] + assert not api.errors + + @pytest.mark.ui @pytest.mark.parametrize("action_type", ["m365_calendar", "m365_email", "m365_onedrive", "m365_sharepoint"]) def test_four_source_action_forms_and_inherited_endpoint(ui, action_type): diff --git a/ui_tests/test_v2_orchestration_workflow_proposal_card.py b/ui_tests/test_v2_orchestration_workflow_proposal_card.py index 5f60b2027..d2684c354 100644 --- a/ui_tests/test_v2_orchestration_workflow_proposal_card.py +++ b/ui_tests/test_v2_orchestration_workflow_proposal_card.py @@ -61,6 +61,11 @@ CONNECT_HREF = "/profile?tab=settings#m365-connection-status" CLOCK_START = datetime(2026, 9, 28, 12, 0, tzinfo=timezone.utc) URL_ACCESS_NOTE = "Create the workflow first, then turn on URL Access in the workflow editor." +RUN_AS_SELF = "You. Microsoft 365 steps use your account." +SELF_AUTHORED_NOTE = ( + "so it needs no separate Run as approval. " + "If someone else changes it or an agent it uses, its next run waits for your approval." +) # The draft route's answer for the Monday email proposal, as the server returns it. DRAFT = { @@ -215,7 +220,7 @@ def agent_proposal(): ) value["m365"] = { "required": True, "can_send": True, "run_as": "self", "sources": ["email", "calendar"], - "connected": False, "approval_state": None, + "connected": False, "approval_state": "self_authored", } value["similar_workflows"] = [{ "workflow_id": "wf-existing", "name": "Weekly digest", @@ -479,7 +484,7 @@ def test_pending_card_discloses_everything_and_renders_planner_text_inert(card_u "About 4 runs a month.", "A notification after every run, severity Info. It appears in your notifications and never pops up.", "Uses Email, Calendar. Can send email or calendar invitations.", - "You. Microsoft 365 steps use your account. Before its first run, you approve Run as.", + f"{RUN_AS_SELF} You create it, {SELF_AUTHORED_NOTE}", "Each run saves checkpoints and can resume after an interruption.", "Runs with the agent Inbox helper.", "Needs: Email.", @@ -525,6 +530,42 @@ def test_pending_card_discloses_everything_and_renders_planner_text_inert(card_u assert not api.writes() +def run_as_proposal(state, approval_state): + """A proposal that runs as the requester, in ``state``, with the server's Run as approval state.""" + value = proposal(state) + value["m365"] = { + "required": True, "can_send": False, "run_as": "self", "sources": ["email"], + "connected": True, "approval_state": approval_state, + } + return value + + +@pytest.mark.parametrize("state,approval_state,expected", [ + ("pending", "self_authored", f"{RUN_AS_SELF} You create it, {SELF_AUTHORED_NOTE}"), + ("pending", None, f"{RUN_AS_SELF} Before its first run, you approve Run as."), + ("created_enabled", "self_authored", f"{RUN_AS_SELF} You saved it, {SELF_AUTHORED_NOTE}"), + ("created_paused", None, f"{RUN_AS_SELF} The first run will wait for you to approve Run as."), + ("created_enabled", "waiting", f"{RUN_AS_SELF} A run is waiting for your approval. Review Run as approval"), + ("created_enabled", "approved", f"{RUN_AS_SELF} Run as is approved."), +], ids=[ + "pending, created by the requester", "pending, state unknown", "created and saved by the requester", + "created, saved by someone else", "a run waits for approval", "approved", +]) +def test_run_as_line_follows_the_servers_approval_state(card_ui, state, approval_state, expected): + page, api = card_ui + api.proposal = run_as_proposal(state, approval_state) + mount(page, api) + article = card(page) + run_as = article.locator("div:has(> dt:text-is('Run as')) > dd") + expect(run_as).to_have_text(expected) + # A revision the requester saved never claims its first run waits for approval. + if approval_state == "self_authored": + expect(run_as).not_to_contain_text("The first run will wait") + expect(run_as).not_to_contain_text("Before its first run") + expect(article.get_by_role("link", name="Review Run as approval")).to_have_count(0) + assert not api.writes() + + @pytest.mark.parametrize("label,mode,badge,note", [ ("Create & start", "enabled", "Created", "Created and turned on."), ("Create paused", "paused", "Created, paused", "Created paused. Turn it on in Workflows when you are ready."), diff --git a/ui_tests/test_v2_workflow_ask_ai.py b/ui_tests/test_v2_workflow_ask_ai.py index 9e5ea3793..5e5535756 100644 --- a/ui_tests/test_v2_workflow_ask_ai.py +++ b/ui_tests/test_v2_workflow_ask_ai.py @@ -438,9 +438,13 @@ def test_done_when_instruction_is_highlighted_undone_reverted_and_confirmed(ask_ page.wait_for_timeout(100) -def test_run_as_warnings_and_review_before_saving(ask_ui): +@pytest.mark.parametrize("run_as, warned", [ + ("runas-colleague", True), + (OWNER_ID, False), +], ids=["someone else's account", "your own account"]) +def test_run_as_warnings_and_review_before_saving(ask_ui, run_as, warned): ui = ask_ui - ui.personal_workflows[WORKFLOW_ID]["m365_run_as_user_id"] = OWNER_ID + ui.personal_workflows[WORKFLOW_ID]["m365_run_as_user_id"] = run_as instructions = "Collect only signed evidence." ui.assist.queue(with_warnings( ui.assist.changed({"op": "set_task_instructions", "task": "task_1", "instructions": instructions}), @@ -449,17 +453,19 @@ def test_run_as_warnings_and_review_before_saving(ask_ui): {"code": "draft_has_errors", "message": DRAFT_ERRORS_MESSAGE}, )) editor = open_classic(ui) - expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_value(OWNER_ID) + expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_value(run_as) panel = open_ask_ai(editor) found = answered(panel, ask(ui, panel, "Collect only signed evidence in the first task.")) assert change_keys(found) == ["task:task-a:instructions"] + # Only another person's account must approve the change; your own save is your own revision. + run_as_codes, run_as_texts = (["run_as_reapproval"], [RUN_AS_WARNING]) if warned else ([], []) warnings = found.get_by_role("list", name="Warnings").locator("li[data-workflow-assist-warning]") - expect(warnings).to_have_count(4) + expect(warnings).to_have_count(len(run_as_codes) + 3) assert warnings.evaluate_all("items => items.map((item) => item.dataset.workflowAssistWarning)") == [ - "run_as_reapproval", "email_requires_m365_agent", "m365_not_connected", "draft_has_errors", + *run_as_codes, "email_requires_m365_agent", "m365_not_connected", "draft_has_errors", ] - expect(warnings).to_have_text([RUN_AS_WARNING, f"{EMAIL_MESSAGE}Jump to", M365_MESSAGE, DRAFT_ERRORS_MESSAGE]) + expect(warnings).to_have_text([*run_as_texts, f"{EMAIL_MESSAGE}Jump to", M365_MESSAGE, DRAFT_ERRORS_MESSAGE]) frame = changed(editor, "task:task-a:instructions") expect(author(frame)).to_have_text("AI assist") expect(frame.get_by_label("Instructions", exact=True)).to_have_value(instructions) @@ -467,8 +473,8 @@ def test_run_as_warnings_and_review_before_saving(ask_ui): found.get_by_role("button", name=f"Jump to: {EMAIL_MESSAGE}", exact=True).click() expect(task_item(editor, "task-b").get_by_label("Task name", exact=True)).to_be_focused() - payload = confirm_save(ui, editor, run_as=True) - assert payload["m365_run_as_user_id"] == OWNER_ID + payload = confirm_save(ui, editor, run_as=warned) + assert payload["m365_run_as_user_id"] == run_as assert payload["tasks"][0]["instructions"] == instructions diff --git a/ui_tests/test_v2_workflow_change_tracking.py b/ui_tests/test_v2_workflow_change_tracking.py index 43e77ef06..33d660652 100644 --- a/ui_tests/test_v2_workflow_change_tracking.py +++ b/ui_tests/test_v2_workflow_change_tracking.py @@ -517,24 +517,45 @@ def test_saving_only_your_own_edits_takes_one_click(authoring_ui, kind): expect(ui.page.locator("aside[aria-label='Workflow editor side panel']")).to_have_count(0) -def test_run_as_note_names_the_reapproval_for_fingerprinted_changes(authoring_ui): +def test_saving_as_your_own_run_as_account_needs_no_reapproval(authoring_ui): ui = authoring_ui ui.personal_workflows[WORKFLOW_ID]["m365_run_as_user_id"] = OWNER_ID editor = open_classic(ui) expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_value(OWNER_ID) + expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_accessible_description( + re.compile(r"A revision they saved themselves needs no separate approval\.")) editor.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True).fill( "Only the description changed.") panel = open_panel(editor) expect(change_row(panel, "description")).to_have_count(1) expect(panel.get_by_text(RUN_AS_NOTE, exact=True)).to_have_count(0) + # A change Run as covers, saved by the Run as account itself, is that person's own revision. task_item(editor, "task-a").get_by_label("Instructions", exact=True).fill("Collect only signed evidence.") + expect(change_row(panel, "task:task-a:instructions")).to_have_count(1) + expect(panel.get_by_text(RUN_AS_NOTE, exact=True)).to_have_count(0) + payload = authoring.save(ui, editor) + assert payload["m365_run_as_user_id"] == OWNER_ID + assert payload["tasks"][0]["instructions"] == "Collect only signed evidence." + + +def test_run_as_note_names_the_reapproval_when_someone_else_must_approve(authoring_ui): + ui = authoring_ui + ui.group_workflows[GROUP_ID][FLOW_WORKFLOW_ID]["m365_run_as_user_id"] = OWNER_ID + editor = authoring.open_editor(ui, group_id=GROUP_ID) + run_as = editor.get_by_label("Microsoft 365 Run as", exact=True) + expect(run_as).to_have_value(OWNER_ID) + panel = open_panel(editor) + expect(panel.get_by_text(RUN_AS_NOTE, exact=True)).to_have_count(0) + + # Handing Run as to another member means they approve this revision before it runs as them. + run_as.select_option("group-reviewer") note = panel.get_by_text(RUN_AS_NOTE, exact=True) expect(note).to_be_visible() assert_readable(note) payload = authoring.save(ui, editor) - assert payload["m365_run_as_user_id"] == OWNER_ID - assert payload["tasks"][0]["instructions"] == "Collect only signed evidence." + assert payload["m365_run_as_user_id"] == "group-reviewer" + assert ui.workflow_writes[-1].path == "/api/group/workflows" @pytest.mark.parametrize("restriction", ["reader", "unsupported", "schedule"])