Skip to content

Send safety warnings immediately and show restricted users an Access restricted screen - #1709

Merged
Paul Lizer (paullizer) merged 7 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-safety-warnings-access-restricted
Oct 8, 2026
Merged

Paul Lizer (paullizer) merged 7 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-safety-warnings-access-restricted

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Merge order: #1709 → #1710 → #1711. These are dependent PRs from the paullizer fork. Native GitHub stacks don't support fork branches, so each later PR targets paullizer-react-v2-ui and includes the earlier commits until they merge.

Summary

Layer 1 of 3 of the Admin Review Center stack. Layer 2 (Review center at /admin/review) and Layer 3 (AI assist and batch triage) stack on this branch.

  • Warn user is sent immediately. In a one-administrator deployment a safety reviewer's Warn user created an approval request nobody could approve (a requester can never approve their own request), so the warning was never sent. A warning restricts nothing, so it is now sent as soon as the review is saved, audited, and never re-sent when the record is saved again. Two overlapping saves (a double-click, or two reviewers) send it once: a save first claims the violation with a write conditional on the ETag it read (action_request_status: "sending"), and the other save sends nothing and gets 409 safety_warning_in_progress. A claim left by a save that stopped is released after five minutes. The classic Save Review button is disabled while its request is in flight. Suspend user and Block user keep the second-reviewer approval exactly as before, a requester still cannot approve their own request, and Warn User approvals created before this version still execute when approved.
  • Warnings must be acknowledged. The user sees A warning from your administrators in V2 until they choose I understand (it reappears in every tab and device until then). Reviewers see Warning acknowledged / Not yet acknowledged, and users see the same in Settings > Violations. New owner-only routes GET /api/safety/warnings/pending and POST /api/safety/warnings/<log_id>/acknowledge; bootstrap carries the pending count, so users without warnings make no extra request. Warnings sent before this version never pop up. A violation can be warned about again, so a warning is told apart by its send time: the acknowledgment carries the issued_at the user read, and one replaced by a newer warning gets 409 safety_warning_replaced and records nothing, while the dialog shows the newer warning.
  • Escalate is retired. It was a label with no workflow. It can no longer be chosen in either interface; the API returns 400 for a change to Escalate, while records that already carry it stay editable and read Escalated (legacy). escalate_count stays in the stats JSON; the V2 tile is now Blocked.
  • Access restricted screen. An executed suspension or block now stores its notice in settings.access.notice. Restricted (non-Admin) users can still sign in, but every page sends them to an Access restricted screen (/v2/access-restricted or classic /access-restricted) with the notice, the restore time in their locale for a suspension, the violation reference and Sign out; API calls get 403 {"error": "access_restricted", "message", "restriction", "restricted_url"} with the caller's own restriction only. Admins are never restricted.

Linked issue

None. No GitHub issue was requested for this stack.

Release Notes & Latest Features

  • New Feature
  • Bug Fix
  • UI Enhancement
  • Breaking Change
  • Internal only

Is this visible to end users?

  • Yes
  • No

Is this admin-facing (Admin Settings, governance, deployment, config)?

  • Yes
  • No

Should this become a Latest Feature card?

  • Yes
  • No
  • Already added

Screenshot needed for the card?

  • Yes
  • No
  • Attached

Version bump

  • application/single_app/config.py VERSION third segment bumped, or not needed because this is docs-only (0.261.296 on the base → 0.261.297; the base moved during the work, so the branch merged it and re-bumped)
  • deployers/version.txt bumped, or not needed because deployers/ was not changed

Testing / validation

Run on the merged tree (base f34094ae5) at head 7e192f61 unless noted.

New and updated tests:

  • python -m pytest functional_tests/test_safety_warning_send_claim.py functional_tests/test_safety_warning_acknowledgment.py functional_tests/test_access_restricted_gate.py functional_tests/test_safety_escalate_removal.py functional_tests/test_safety_violation_remediation_approvals.py -q -p no:cacheprovider → 32 passed (two overlapping saves send one warning; saves, deletes and acknowledgments wait while a warning is being sent, and sending never counts as pending; a stale claim is retried once; a lost claim is reported and audited, not overwritten; warn immediate, suspend/block still two-person, requester cannot approve, legacy warn approvals, acknowledgment ownership/idempotency/legacy warnings, withdrawn and replaced warnings, Escalate rules, notice persistence, structured 403 and redirects, login-only routes return only the caller's data, expiry restore; real modules in fresh processes, normal and -O)
  • node functional_tests/test_v2_access_restriction_and_safety_warning_logic.mjs → 11 checks passed
  • python -m pytest ui_tests/test_v2_access_restricted_and_safety_warning.py ui_tests/test_classic_safety_review_and_access_restricted.py ui_tests/test_v2_approvals_and_terms_pages.py -q -p no:cacheprovider → 16 passed (V2 restricted page, warning dialog and a warning replaced while on screen; classic review page, including Save disabled while a save is in flight, and classic restricted page; existing approvals/terms fixtures)

Route policy:

  • python functional_tests/route_tests/test_route_blueprint_policy_inventory.py → 13/13 passed
  • python functional_tests/route_tests/test_route_unauthenticated_policy_contract.py → 8/8 passed
  • python functional_tests/route_tests/test_route_policy_test_coverage.py → 3/3 passed

Mutation checks: each of these makes the tests above fail: keying the V2 store on the violation alone; removing the server's replaced-warning check; an unconditional claim; no fresh-claim guard; no retry when a writer kept the claim; claims that never go stale; removing the classic Save button guard.

Existing related tests:

  • python -m pytest functional_tests/test_v2_admin_review_pages.py functional_tests/test_admin_review_record_lifecycle.py functional_tests/test_content_safety_violation_message.py functional_tests/test_v2_terms_and_approvals.py -q -p no:cacheprovider → 18 passed
  • node functional_tests/test_v2_api_error_message_logic.mjs → 8 passed
  • node functional_tests/test_v2_orchestration_workflow_handoff_client.mjs → 19 pass, 0 fail
  • python -m pytest ui_tests/test_v2_control_center_activity_logs.py ui_tests/test_v2_settings_workspace_open.py ui_tests/test_v2_support_menu.py ui_tests/test_v2_control_center_dashboard.py ui_tests/test_v2_control_center_users.py ui_tests/test_v2_workflow_alerts.py ui_tests/test_v2_appearance_branding_and_nav.py ui_tests/test_v2_sidebar_primary_nav_links.py ui_tests/test_v2_workflow_run_tracker_spa.py ui_tests/test_v2_workspace_draft_navigation.py -q -p no:cacheprovider → 78 passed, 14 skipped (the skips need SIMPLECHAT_UI_BASE_URL, a live deployment)
  • python -m pytest ui_tests/test_v2_sidebar_conversation_scroll.py -q -p no:cacheprovider → 8 passed, 1 failed (at 2252dae7): test_the_phone_drawer_holds_search_under_new_chat is a pre-existing order-dependent flake. It passes on its own and failed 3 of 4 full-file runs on both the base worktree and this branch.
  • python -m pytest functional_tests/test_v2_bootstrap_branding_and_navigation.py functional_tests/test_v2_control_center_activity_display.py functional_tests/test_v2_control_center_activity_logs_queries.py functional_tests/test_v2_control_center_activity_logs_routes.py functional_tests/test_v2_control_center_cosmos_query_compatibility.py functional_tests/test_v2_control_center_groups.py functional_tests/test_v2_settings_tabs.py functional_tests/test_v2_support_menu.py functional_tests/test_v2_user_settings_tutorials_latest_features.py -q -p no:cacheprovider (at ffc943b9) → 118 passed, 32 errors, all 32 in test_v2_control_center_groups.py from this machine's Flask 2.2 / Werkzeug 3 mismatch (werkzeug.__version__). With a shim, python -c "import sys, werkzeug; werkzeug.__version__ = '3.0.0'; import pytest; sys.exit(pytest.main(['functional_tests/test_v2_control_center_groups.py', '-q', '-p', 'no:cacheprovider']))" → 37 passed.

V2 build:

  • npm run typecheck (in application/v2_ui) → passed
  • npm run build (in application/v2_ui) → passed (existing chunk-size warning only)

Docs:

  • python scripts/build_docs_inventory.py → regenerated; docs/_data/app_surface.yml committed
  • python functional_tests/test_docs_app_surface_coverage.py → 7/7 passed
  • python functional_tests/test_docs_site_quality.py → 6/6 passed

CI guardrails, run locally with CI's file filters:

  • python scripts/check_xss_sinks.py --base-sha f34094ae5 --head-sha HEAD <changed application/** files> → passed (28 files)
  • python scripts/check_broken_access_control.py --base-sha f34094ae5 --head-sha HEAD <changed application/** .py files> → passed (9 files)
  • python scripts/check_swagger_routes.py <changed application/single_app/** .py files> → passed (5 route files)
  • python -m py_compile <changed .py files> → OK; node --check application/single_app/static/js/admin/admin-safety-violations.js → OK; git diff --check → clean

Known pre-existing failures, unrelated to this change:

  • python functional_tests/test_logging_tag_standardization.py → fails on nonstandard tags in other modules (EditorAssist, Workflows). The new tags ([ACCESS_RESTRICTION], [SAFETY_WARNINGS], [V2_BOOTSTRAP]) are documented in docs/reference/logging-tags.md.
  • python functional_tests/test_broken_access_control_guardrails_checker.py → 3/4; the self-test expects actions/upload-artifact@v4, but the workflow now pins it by SHA. It runs in CI only when guardrail files change.

Known limitation (unchanged writers): archiving, the user's own notes save, and saves that don't send a warning still write without an ETag condition. One that read a violation just before a claim and writes after it can replace the claim, which the send then reports as safety_warning_not_recorded, or the recorded warning.

Not run:

  • No end-to-end run against a deployed environment (Entra ID sign-in, Cosmos DB). The Access restricted screen and the warning dialog are covered by the browser fixtures above against closed, in-memory APIs.
  • UI suites that need SIMPLECHAT_UI_BASE_URL (a live deployment) were skipped.

Documentation

  • Release notes updated, or not needed (docs/explanation/release_notes.md, new v0.261.297 section)
  • Feature documentation updated, or not needed (docs/explanation/features/ACCESS_RESTRICTED_SIGN_IN_SCREEN.md; features/v0.241.127/SAFETY_VIOLATION_REMEDIATION_APPROVALS.md updated)
  • Fix documentation updated, or not needed (docs/explanation/fixes/SAFETY_REMEDIATION_ACTIONS_FIX.md, including the separation-of-duties rationale: the two-person rule is relaxed only for non-restrictive warnings, and how overlapping saves send one warning)

Also updated: docs/guides/admin-review-safety-violations.md, docs/guides/review-approval-requests.md, docs/admin/security.md, docs/reference/logging-tags.md.

Security checklist

  • New Flask routes include @swagger_route(security=get_auth_security())
  • Settings sent to non-admin frontends use sanitize_settings_for_user() (the classic restricted page renders sanitized settings; GET /api/v2/access-restriction returns only the caller's restriction and the page's branding fields)
  • Browser JavaScript is served from local SimpleChat static assets only; no CDN-hosted JS
  • No secrets, keys, connection strings, or local-only artifacts are included

Access-control notes: the warning routes and GET /api/v2/access-restriction read and change only the caller's own records; acknowledging another user's warning, or one that doesn't exist, returns the same 404. The three restricted-screen routes require sign-in but not user_required (a restricted user must reach them); this is recorded and justified in functional_tests/route_tests/. They are exempt from the Terms of Use gate so a restricted user is not bounced between gates; the idle-session timeout still applies.

Paul Lizer (paullizer) and others added 5 commits October 7, 2026 22:46
…restricted screen

Layer 1 of 3 of the Admin Review Center stack.

- Warn user executes when the safety reviewer saves the review, without an
  approval request, and is audited in the activity log. Saving again does not
  resend it. Suspend and Block still need a second eligible reviewer.
- Executed warnings must be acknowledged: new user-scoped routes
  GET /api/safety/warnings/pending and
  POST /api/safety/warnings/<id>/acknowledge (owner-only, idempotent,
  ETag-conditional), a V2 dialog fed by a bootstrap pending count, and the
  acknowledgment state in the admin review and the user's Violations tab.
- Escalate can no longer be chosen; legacy records keep it, labelled
  "Escalated (legacy)". escalate_count stays in the stats JSON.
- Approved suspensions and blocks store the notice the user was sent in
  settings.access.notice.
- user_required answers restricted users with a structured 403
  (error: access_restricted) for API calls and redirects pages to
  /v2/access-restricted or /access-restricted. New login-only routes serve
  those pages and GET /api/v2/access-restriction (caller's own data only),
  exempt from the Terms of Use gate.
- Version 0.261.296.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the fix and feature notes, updates the safety review and approval
request guides and the Content Safety settings page, records the release
notes for 0.261.296, lists the new logging tags, and regenerates the docs
app surface inventory for the classic Access restricted page.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ser test

Render the real classic templates and scripts behind a closed API: no Escalate choice, the legacy label, the second-reviewer help text, the warning acknowledgment line, Blocked statistics, and the localized restore time on the classic Access restricted page.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…restricted branch

The base now uses 0.261.296, so this branch moves to 0.261.297: config.py, its tests, docs and a new release-notes section above the base's 0.261.296 entry. Both route-inventory coverage lines are kept.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A reviewer can warn about the same violation again. The V2 store hid any warning whose violation it had already acknowledged, so a newer warning never showed in a tab that stayed open. It now keys each warning on the violation and its send time.

The acknowledge route also takes the issued_at of the warning the user read. When the violation now holds a newer warning, it answers 409 safety_warning_replaced and records nothing, and the dialog reads the pending warnings again to show the newer one. A body without issued_at keeps the previous behaviour.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/functions_authentication.py
Comment thread application/single_app/route_backend_safety.py Fixed
Comment thread ui_tests/test_classic_safety_review_and_access_restricted.py Fixed
Two overlapping saves with Warn user, such as a double-click or two reviewers, both passed the already-sent check, so each sent a notification and only one was recorded. A save now claims the violation with a write conditional on the ETag it read (action_request_status 'sending') before anything is sent; the other save sends nothing and gets 409 safety_warning_in_progress. The outcome is written on the claimed version. While a claim is fresh, other saves and deletes wait, and 'sending' never counts as a warning to acknowledge. A claim older than five minutes reads as a failed send and can be retried once. A send whose claim was lost is audited and reported as safety_warning_not_recorded instead of overwriting another writer. The classic Save Review button is disabled while its request is in flight.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…and drop an unused test variable

Addresses two CodeQL review notes: get_pending_safety_warnings returned a 2-tuple for a session without a user id and 3-tuples otherwise, and a classic UI test assigned a modal it never used.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 8a9e0f7 into microsoft:paullizer-react-v2-ui Oct 8, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants