Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.228"
VERSION = "0.261.229"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
7 changes: 7 additions & 0 deletions application/single_app/functions_data_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
)
from functions_appinsights import log_event
from functions_m365_data_lifecycle import (
attribute_raw_authored_record_edit,
is_live_m365_authorization,
strip_m365_runtime_references,
validate_m365_admin_record_edit,
Expand Down Expand Up @@ -13225,6 +13226,12 @@ def save_data_management_cosmos_editor_document(container_name, document_id, par
) from error
change_summary = _summarize_cosmos_editor_changes(original_document, document)
clean_document = _strip_cosmos_system_fields(copy.deepcopy(document))
if change_summary["changed_count"]:
# The administrator, not the previous author, made this edit to what a workflow runs.
clean_document = attribute_raw_authored_record_edit(
container_metadata["logical_name"], clean_document, admin_user_id,
datetime.now(timezone.utc),
)
replace_target = safe_document_id
if isinstance(original_document, dict) and original_document.get("_self"):
replace_target = original_document
Expand Down
367 changes: 297 additions & 70 deletions application/single_app/functions_m365_approvals.py

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions application/single_app/functions_m365_data_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# functions_m365_data_lifecycle.py
"""Exclude live delegated authority and retired actions from data transfers."""

from datetime import timezone

from functions_m365_action_cards import strip_pending_action_references
from json_schema_validation import (
ACTION_MIGRATION_ID_PREFIX,
Expand Down Expand Up @@ -59,6 +61,37 @@ def validate_m365_admin_record_edit(original, document):
validate_legacy_plugin_settings_update(previous, incoming)


# A workflow revision runs as its Run as user without a separate approval only when that user
# saved it, judged from who last changed the workflow and the agents and actions it runs.
M365_AUTHORED_RECORD_CONTAINERS = frozenset({
"personal_workflows", "group_workflows",
"personal_agents", "group_agents", "global_agents",
"personal_actions", "group_actions", "global_actions",
})
# Agent and action stores record naive UTC times; workflow stores record aware ones.
_NAIVE_TIME_CONTAINERS = M365_AUTHORED_RECORD_CONTAINERS - {"personal_workflows", "group_workflows"}
RAW_EDITOR_AUTHOR = "data-management-cosmos-editor"


def attribute_raw_authored_record_edit(container_name, document, editor_id, edited_at):
"""Name the administrator as the last author of a raw edit to an authored record.

Keeping the previous author would let edited content run as that person without asking
them, so a changed workflow, agent or action records who edited it and when. ``edited_at``
is an aware time, written in the format the record's own store uses.
"""
if container_name not in M365_AUTHORED_RECORD_CONTAINERS or not isinstance(document, dict):
return document
edited_at = edited_at.astimezone(timezone.utc)
if container_name in _NAIVE_TIME_CONTAINERS:
edited_at = edited_at.replace(tzinfo=None)
return {
**document,
"modified_by": str(editor_id or "").strip() or RAW_EDITOR_AUTHOR,
"modified_at": edited_at.isoformat(),
}


def strip_m365_runtime_references(document, *, log_event=None):
result = strip_pending_action_references(document)
if "m365_binding_approval_id" in result:
Expand Down
12 changes: 10 additions & 2 deletions application/single_app/functions_m365_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
is_m365_action_type,
)
from functions_msgraph_operations import get_msgraph_enabled_function_names
from functions_m365_workflow_binding import workflow_execution_fingerprint
from functions_m365_workflow_binding import (
M365_REVISION_AUTHORSHIP_FIELD,
workflow_execution_fingerprint,
workflow_revision_self_authored,
)


M365ExecutionContext = m365_context.M365ExecutionContext
Expand Down Expand Up @@ -574,7 +578,11 @@ def prepare_m365_workflow_binding(context, workflow, effective_manifests, *, rev
candidate, connection_id=connection["id"],
)
require_m365_execution_context(resolved)
# Stored authorship, not the caller, decides whether the Run as user saved this revision.
self_authored = workflow_revision_self_authored(
workflow, context.data_user_id, workflow.get(M365_REVISION_AUTHORSHIP_FIELD),
)
approval = get_m365_approval_service().ensure_workflow_binding(
resolved, sources, connection, review=review,
resolved, sources, connection, review=review, self_authored=self_authored,
)
return replace(resolved, binding_id=approval["id"])
11 changes: 10 additions & 1 deletion application/single_app/functions_m365_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@
M365_LEGACY_OPERATION_SOURCES,
)
from functions_m365_connections import preflight_m365_chat_authentication
from functions_m365_workflow_binding import workflow_execution_fingerprint
from functions_m365_workflow_binding import (
M365_REVISION_AUTHORSHIP_FIELD,
m365_revision_component,
workflow_execution_fingerprint,
)
from m365_interaction import M365_AUTH_INTERACTION_CODES


Expand Down Expand Up @@ -451,6 +455,11 @@ def workflow_m365_manifests(workflow):
"actions_to_load": agent.get("actions_to_load"),
} for agent in selected_agents
]
# Who last changed each agent and action this revision runs; never part of the fingerprint.
fingerprint_workflow[M365_REVISION_AUTHORSHIP_FIELD] = [
*(m365_revision_component("agent", agent) for agent in selected_agents),
*(m365_revision_component("action", action) for action in selected_actions),
]
return selected_actions, fingerprint_workflow


Expand Down
78 changes: 78 additions & 0 deletions application/single_app/functions_m365_workflow_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import hashlib
import json
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any


Expand Down Expand Up @@ -33,6 +34,83 @@
"model_endpoint_id",
"model_id",
)
# Who last changed each agent and action a revision runs. It is recorded beside the execution
# fingerprint, never hashed into it, so saving an agent without changing it asks nothing.
M365_REVISION_AUTHORSHIP_FIELD = "m365_revision_authorship"
_AUTHORSHIP_TIMESTAMP_FIELDS = ("modified_at", "updated_at", "last_updated", "created_at")


def m365_revision_component(kind: str, record: Mapping[str, Any]) -> dict[str, Any]:
"""Who last changed one agent or action a revision runs, and when; never its content."""
record = record if isinstance(record, Mapping) else {}
modified_by = record.get("modified_by")
component = {
"kind": kind,
"id": str(record.get("id") or record.get("name") or ""),
"modified_by": modified_by if isinstance(modified_by, str) else None,
}
for name in _AUTHORSHIP_TIMESTAMP_FIELDS:
if isinstance(record.get(name), str):
component[name] = record[name]
return component


def _authorship_time(value: Any) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip())
except ValueError:
return None
# Agent and action stores write naive UTC times; workflow stores write aware ones.
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)


def workflow_saved_by_run_as(workflow: Mapping[str, Any], user_id: str | None) -> bool:
"""Whether ``user_id`` is the workflow's Run as account and saved its stored revision.

Every save path stamps ``modified_by`` from the authenticated actor, so it names whoever
saved the workflow last. A missing ``modified_by`` never counts as the Run as user's save.
"""
user = str(user_id or "").strip()
return bool(
user
and isinstance(workflow, Mapping)
and str(workflow.get("m365_run_as_user_id") or "").strip() == user
and workflow.get("modified_by") == user
)


def workflow_revision_self_authored(
workflow: Mapping[str, Any],
user_id: str | None,
components: Any,
) -> bool:
"""Whether the Run as user saved the whole revision that is about to run as them.

Their save counts as their review of everything the workflow then contained, including
earlier edits by others. Agents and actions are saved on their own, so each one the revision
runs must be the Run as user's own latest edit or be older than their save. Missing or
unreadable authorship never counts as theirs.
"""
if not workflow_saved_by_run_as(workflow, user_id) or not isinstance(components, (list, tuple)):
return False
user = str(user_id).strip()
saved_at = _authorship_time(workflow.get("modified_at"))
for component in components:
if not isinstance(component, Mapping):
return False
if component.get("modified_by") == user:
continue
changes = [
changed for changed in (_authorship_time(component.get(name)) for name in _AUTHORSHIP_TIMESTAMP_FIELDS)
if changed is not None
]
if saved_at is None or not changes or max(changes) > saved_at:
return False
return True


def workflow_execution_fingerprint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

import functions_personal_workflows
from functions_appinsights import log_event, workflow_log_context
from functions_m365_workflow_binding import M365_WAITING_STATES
from functions_m365_workflow_binding import M365_WAITING_STATES, workflow_saved_by_run_as
from functions_orchestration_registry import CAPABILITY_WORKFLOW_PROPOSE
from functions_orchestration_result_contracts import canonical_digest
from functions_orchestration_runs import (
Expand Down Expand Up @@ -531,16 +531,23 @@ def _m365(proposal, workflow, identity):
summary = proposal.sidecar.get('summary') if isinstance(proposal.sidecar.get('summary'), dict) else {}
source = summary.get('m365') if isinstance(summary.get('m365'), dict) else {}
required = source.get('required') is True
run_as = source.get('run_as') if source.get('run_as') in _RUN_AS_VALUES else 'none'
approval_state = None
if isinstance(workflow, dict):
if workflow.get('m365_binding_approval_id'):
approval_state = 'approved'
elif workflow.get('last_run_status') in M365_WAITING_STATES:
approval_state = 'waiting'
elif workflow_saved_by_run_as(workflow, identity.get('user_id')):
# The requester saved the revision their account runs, so it needs no separate approval.
approval_state = 'self_authored'
elif run_as == 'self':
# Accepting creates the workflow in the requester's name, so it is their own revision.
approval_state = 'self_authored'
return {
'required': required,
'can_send': source.get('can_send') is True,
'run_as': source.get('run_as') if source.get('run_as') in _RUN_AS_VALUES else 'none',
'run_as': run_as,
'sources': [value for value in source.get('sources') or () if isinstance(value, str)],
'connected': _m365_connected(identity.get('user_id'), identity.get('tenant_id')) if required else None,
'approval_state': approval_state,
Expand Down Expand Up @@ -788,9 +795,10 @@ def accept_proposal(run, conversation, proposal_id, body, *, identity, settings,
``body`` is the request: ``mode`` (``paused`` or ``enabled``), and for a proposal the
requester changed in the workflow editor, ``workflow``, the editor's draft. ``create_again``
creates the workflow again after the requester deleted the one this proposal created.
Accepting an accepted proposal returns its workflow with status 200. Run as is never approved
here: a Microsoft 365 workflow still asks for approval before it first runs as the user. The
caller records the workflow creation activity when a workflow is returned as created.
Accepting an accepted proposal returns its workflow with status 200. Accept records no Run as
approval: the requester saves the workflow it creates, so a revision they saved runs as them
without one, and a later change by someone else asks them first. The caller records the
workflow creation activity when a workflow is returned as created.
"""
user_id = identity.get('user_id')
mode, create_again, payload = _accept_request(body)
Expand Down
7 changes: 4 additions & 3 deletions application/single_app/functions_workflow_assist.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,9 +909,10 @@ def lookup(check, *args):
return _advisory(check, *args) if run.remaining() >= ASSIST_MIN_WARNING_SECONDS else None

run_as = str(attempt.projection.get('m365_run_as_user_id') or '').strip()
if run_as and workflow_execution_fingerprint(baseline_projection) != workflow_execution_fingerprint(
attempt.projection,
):
# Saving as the Run as account is that person's own revision, which needs no new approval.
if run_as and run_as != run.user_id and workflow_execution_fingerprint(
baseline_projection,
) != workflow_execution_fingerprint(attempt.projection):
warnings.append({'code': 'run_as_reapproval', 'message': _WARNING_MESSAGES['run_as_reapproval']})
tasks = {task.get('id'): task for task in candidate.get('tasks') or [] if isinstance(task, dict)}
email_ids = email_task_ids(candidate, attempt.info or {})
Expand Down
3 changes: 3 additions & 0 deletions application/single_app/static/js/chat/chat-m365-approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@
}

function describeStatus(approval, includeDecisions = true) {
if (approval.self_authored === true && approval.status === 'approved') {
return 'Decision: approved automatically because you saved this workflow revision yourself.';
}
const status = typeof approval.status === 'string' ? approval.status : 'unknown';
const execution = typeof approval.execution_status === 'string' ? approval.execution_status : 'not reported';
const summary = `Decision: ${status.replaceAll('_', ' ')}. Execution: ${execution.replaceAll('_', ' ')}.`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export function createMicrosoft365RunAsControl(anchor, getScope) {
help.id = 'workflow-m365-run-as-help';
help.className = 'form-text';
help.textContent = 'Microsoft 365 actions use this account for manual and scheduled runs. '
+ 'The selected person must connect Microsoft 365 and approve this workflow. '
+ 'Changes to instructions, capabilities, or destinations require approval again.';
+ 'The selected person must connect Microsoft 365. 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.';
const status = document.createElement('div');
status.className = 'alert alert-warning mt-2 d-none';
status.setAttribute('role', 'status');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ function Microsoft365({ proposal }: { proposal: WorkflowProposal }) {
<> A run is waiting for your approval. <a href={M365_APPROVALS_HREF}
className="font-medium text-accent underline underline-offset-2">Review Run as approval</a></>
)
: m365.approval_state === 'self_authored' ? (
`${created ? ' You saved it' : ' You create it'}, 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.'
)
: created ? ' The first run will wait for you to approve Run as.'
: ' Before its first run, you approve Run as.'
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
WORKFLOW_REFERENCE_ORDER_KEY,
type WorkflowAttribution, type WorkflowChange, type WorkflowChangeAuthor, type WorkflowChangeStamp,
} from '../../lib/workflowChangeTracking';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import type { WorkflowAuthoringSession, WorkflowSessionStep } from './WorkflowAuthoringHistory';

type SessionSnapshot = ReturnType<WorkflowAuthoringSession['getSnapshot']>;
Expand All @@ -33,7 +34,10 @@ export interface WorkflowChangeTracking {
readonly attribution: WorkflowAttribution;
/** The draft changed format, so its changes can only be discarded together. */
readonly converted: boolean;
/** Saving changes what the Run as account approved, so it has to be approved again. */
/**
* Saving changes what someone else's Run as account approved, so they have to approve it again.
* Never set when the signed-in user is the Run as account: their own save needs no approval.
*/
readonly runAsConsequence: boolean;
/** The workflow has never been saved, so only AI assist changes are pointed out. */
readonly isNew: boolean;
Expand All @@ -52,7 +56,11 @@ function aiAuthored(change: WorkflowChange, attribution: WorkflowAttribution): b
return workflowChangeAuthors(change, attribution).some((stamp) => stamp.author === 'ai');
}

function buildTracking(session: WorkflowAuthoringSession, snapshot: SessionSnapshot): WorkflowChangeTracking {
function buildTracking(
session: WorkflowAuthoringSession,
snapshot: SessionSnapshot,
currentUserId: string | undefined,
): WorkflowChangeTracking {
const all = diffWorkflowChanges(snapshot.baseline, snapshot.draft);
const attribution = snapshot.attribution;
const isNew = !snapshot.baseline.id;
Expand All @@ -71,7 +79,7 @@ function buildTracking(session: WorkflowAuthoringSession, snapshot: SessionSnaps
}
return {
changes, byKey, added, byItem, attribution, converted: all.converted,
runAsConsequence: workflowRunAsConsequence(snapshot.baseline, snapshot.draft, all.changes),
runAsConsequence: workflowRunAsConsequence(snapshot.baseline, snapshot.draft, all.changes, currentUserId),
isNew, steps: snapshot.steps, trimmed: snapshot.trimmed, session,
};
}
Expand All @@ -87,7 +95,11 @@ export function WorkflowChangeTrackingScope({ session, enabled, children }: {
}) {
const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
const deferred = useDeferredValue(snapshot);
const value = useMemo(() => (enabled ? buildTracking(session, deferred) : null), [session, deferred, enabled]);
const currentUserId = useBootstrapStore((state) => state.data?.user?.id);
const value = useMemo(
() => (enabled ? buildTracking(session, deferred, currentUserId) : null),
[session, deferred, enabled, currentUserId],
);
return <WorkflowChangeContext.Provider value={value}>{children}</WorkflowChangeContext.Provider>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ export function WorkflowMicrosoft365RunAs({
const help = (
<p id={helpId} className="text-xs text-text-3">
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.
</p>
);

Expand Down
Loading
Loading