From c16b7dbe2138a70d684da2dc667e50b7808a02fd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 11:25:51 -0400 Subject: [PATCH 1/4] Add V2 Control Center activity investigations and bounded exports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 16 +- .../single_app/functions_authentication.py | 15 +- .../functions_control_center_activity.py | 242 ++++++++++++ .../single_app/functions_cosmos_indexing.py | 17 +- .../route_backend_control_center.py | 71 ++++ .../controlCenter/ActivityLogsSection.tsx | 373 ++++++++++++++++++ .../v2_ui/src/pages/ControlCenterPage.tsx | 2 + .../explanation/features/V2_CONTROL_CENTER.md | 33 +- docs/explanation/release_notes.md | 10 + docs/guides/v2-control-center.md | 14 +- .../test_route_blueprint_policy_inventory.py | 3 + ..._control_center_activity_logs_hardening.py | 10 +- ...test_cosmos_wave3a_indexing_maintenance.py | 18 +- ...v2_control_center_activity_logs_queries.py | 265 +++++++++++++ ..._v2_control_center_activity_logs_routes.py | 137 +++++++ .../test_v2_control_center_activity_logs.py | 170 ++++++++ 16 files changed, 1376 insertions(+), 20 deletions(-) create mode 100644 application/single_app/functions_control_center_activity.py create mode 100644 application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx create mode 100644 functional_tests/test_v2_control_center_activity_logs_queries.py create mode 100644 functional_tests/test_v2_control_center_activity_logs_routes.py create mode 100644 ui_tests/test_v2_control_center_activity_logs.py diff --git a/application/single_app/config.py b/application/single_app/config.py index c6d1df134..9f4377c03 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.282" +VERSION = "0.261.283" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform @@ -1063,9 +1063,21 @@ def _create_container_if_not_exists_with_conflict_recovery(*args, **kwargs): ) cosmos_activity_logs_container_name = "activity_logs" +ACTIVITY_LOGS_INDEXING_POLICY = { + "indexingMode": "consistent", + "automatic": True, + "includedPaths": [{"path": "/*"}], + "excludedPaths": [{"path": "/\"_etag\"/?"}], + "compositeIndexes": [[ + {"path": "/timestamp", "order": "descending"}, + {"path": "/id", "order": "descending"}, + {"path": "/user_id", "order": "descending"}, + ]], +} cosmos_activity_logs_container = cosmos_database.create_container_if_not_exists( id=cosmos_activity_logs_container_name, - partition_key=PartitionKey(path="/user_id") + partition_key=PartitionKey(path="/user_id"), + indexing_policy=ACTIVITY_LOGS_INDEXING_POLICY, ) cosmos_notifications_container_name = "notifications" diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index 8d65a9ee2..1d466e4a4 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -1005,7 +1005,7 @@ def control_center_required(access_level='admin'): Unified Control Center access control decorator. Args: - access_level: 'admin' for full admin access, 'dashboard' for dashboard-only access + access_level: 'admin', 'dashboard', or 'activity_logs' for the matching capability Access logic when require_member_of_control_center_admin is ENABLED: - ControlCenterAdmin role → Full access to everything (admin + dashboard) @@ -1026,11 +1026,14 @@ def decorated_function(*args, **kwargs): settings = get_settings() require_member_of_control_center_admin = settings.get("require_member_of_control_center_admin", False) capabilities = get_control_center_capabilities(user, settings) - permitted = ( - capabilities['can_view_dashboard'] - if access_level == 'dashboard' - else capabilities['can_manage_users'] - ) + if access_level == 'dashboard': + permitted = capabilities['can_view_dashboard'] + elif access_level == 'activity_logs': + permitted = capabilities['can_view_activity_logs'] + elif access_level == 'admin': + permitted = capabilities['can_manage_users'] + else: + permitted = False if permitted: return f(*args, **kwargs) diff --git a/application/single_app/functions_control_center_activity.py b/application/single_app/functions_control_center_activity.py new file mode 100644 index 000000000..a1d1d1092 --- /dev/null +++ b/application/single_app/functions_control_center_activity.py @@ -0,0 +1,242 @@ +# functions_control_center_activity.py +"""Bounded, parameterized activity queries independent of Flask and Azure bootstrap.""" + +import base64 +import binascii +import csv +import hashlib +import json +from collections import Counter +from datetime import date, datetime, timedelta, timezone +from io import StringIO + + +ACTIVITY_PAGE_MAX = 200 +ACTIVITY_SUMMARY_MAX = 5000 +ACTIVITY_EXPORT_MAX = 10000 +ACTIVITY_ORDER = " ORDER BY c.timestamp DESC, c.id DESC, c.user_id DESC" +ACTIVITY_COMPOSITE_INDEX = [ + {"path": "/timestamp", "order": "descending"}, + {"path": "/id", "order": "descending"}, + {"path": "/user_id", "order": "descending"}, +] +ACTIVITY_SEARCH_FIELDS = ( + "id", "activity_type", "user_id", "admin_email", "requester_email", + "member_email", "member_name", "description", "action", "group_name", + "workspace_name", "public_workspace_name", "conversation_id", + "document.file_name", "conversation.title", "usage.model", + "group.group_name", "workspace_context.group_id", + "workspace_context.public_workspace_id", +) + + +def parse_activity_filters(args, now=None): + """Inclusive UTC dates, at most 366 days; normalize the dashboard link contract.""" + today = (now or datetime.now(timezone.utc)).date() + single_date = args.get("date", "") + end = date.fromisoformat(args.get("end_date") or single_date or today.isoformat()) + start = date.fromisoformat( + args.get("start_date") or single_date or (end - timedelta(days=29)).isoformat() + ) + if not 0 <= (end - start).days < 366: + raise ValueError("Invalid date range") + types = args.getlist("activity_type") if hasattr(args, "getlist") else [args.get("activity_type", "")] + types = sorted({item.strip() for value in types for item in value.split(",") if item.strip() and item != "all"}) + if len(types) > 30 or any(len(item) > 100 for item in types): + raise ValueError("Invalid activity types") + result = {"start_date": start.isoformat(), "end_date": end.isoformat(), "activity_types": types} + for key in ("user_id", "workspace_type", "workspace_id", "group_id", "public_workspace_id", + "search", "token_type", "model", "status"): + value = (args.get(key) or "").strip() + if len(value) > (200 if key == "search" else 256): + raise ValueError("Filter too long") + result[key] = value + if result["workspace_type"] == "public_workspace": + result["workspace_type"] = "public" + if result["workspace_type"] not in ("", "personal", "group", "public"): + raise ValueError("Invalid workspace type") + if result["workspace_id"] and not result["workspace_type"]: + raise ValueError("Workspace ID needs a workspace type") + return result + + +def activity_query_context(filters): + end_exclusive = (date.fromisoformat(filters["end_date"]) + timedelta(days=1)).isoformat() + clauses = ["IS_STRING(c.timestamp)", "IS_STRING(c.id)", + "(IS_STRING(c.user_id) OR IS_NULL(c.user_id) OR NOT IS_DEFINED(c.user_id))", + "c.timestamp >= @start", "c.timestamp < @end"] + parameters = [{"name": "@start", "value": filters["start_date"]}, + {"name": "@end", "value": end_exclusive}] + + def add(clause, name, value): + if value: + clauses.append(clause) + parameters.append({"name": name, "value": value}) + + add("ARRAY_CONTAINS(@types, c.activity_type)", "@types", filters["activity_types"]) + add("(c.user_id = @user OR c.changed_by.user_id = @user OR c.admin_user_id = @user)", + "@user", filters["user_id"]) + workspace_type = filters["workspace_type"] + if workspace_type == "public": + add("c.workspace_type IN ('public', 'public_workspace')", "@workspace_type", workspace_type) + else: + add("c.workspace_type = @workspace_type", "@workspace_type", workspace_type) + group_id = filters["group_id"] or (filters["workspace_id"] if workspace_type == "group" else "") + public_id = filters["public_workspace_id"] or (filters["workspace_id"] if workspace_type == "public" else "") + add("(c.workspace_context.group_id = @group OR c.group_id = @group OR c.group.group_id = @group)", + "@group", group_id) + add("(c.workspace_context.public_workspace_id = @public OR c.public_workspace_id = @public)", + "@public", public_id) + if workspace_type == "personal": + add("c.user_id = @personal", "@personal", filters["workspace_id"]) + add("c.token_type = @token_type", "@token_type", filters["token_type"]) + add("c.usage.model = @model", "@model", filters["model"]) + if filters["status"] == "failed": + add("(CONTAINS(c.status, @status, true) OR CONTAINS(c.status, 'error', true) " + "OR CONTAINS(c.document.status, @status, true) OR CONTAINS(c.document.status, 'error', true))", + "@status", filters["status"]) + else: + add("(c.status = @status OR c.status_change.new_status = @status OR c.document.status = @status)", + "@status", filters["status"]) + if filters["search"]: + search = " OR ".join(f"CONTAINS(c.{field}, @search, true)" for field in ACTIVITY_SEARCH_FIELDS) + add(f"({search})", "@search", filters["search"]) + return " AND ".join(clauses), parameters + + +def activity_filter_scope(filters): + return hashlib.sha256(json.dumps(filters, sort_keys=True).encode("utf-8")).hexdigest() + + +def encode_activity_cursor(record, filters, snapshot): + envelope = {"v": 1, "scope": activity_filter_scope(filters), "snapshot": snapshot, + "timestamp": record["timestamp"], "id": record["id"], "user_id": record.get("user_id"), + "user_defined": "user_id" in record} + return base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).decode() + + +def decode_activity_cursor(value, filters): + try: + if not isinstance(value, str) or len(value) > 4096: + raise ValueError("Invalid cursor") + cursor = json.loads(base64.b64decode(value.encode("ascii"), altchars=b"-_", validate=True)) + if not isinstance(cursor, dict) or cursor.get("v") != 1 or cursor.get("scope") != activity_filter_scope(filters): + raise ValueError("Wrong cursor scope") + for key in ("timestamp", "id", "snapshot"): + if not isinstance(cursor.get(key), str) or not 1 <= len(cursor[key]) <= 256: + raise ValueError("Invalid cursor field") + if cursor.get("user_id") is not None and ( + not isinstance(cursor["user_id"], str) or len(cursor["user_id"]) > 256 + ): + raise ValueError("Invalid partition") + if not isinstance(cursor.get("user_defined"), bool): + raise ValueError("Invalid partition presence") + datetime.fromisoformat(cursor["timestamp"].replace("Z", "+00:00")) + datetime.fromisoformat(cursor["snapshot"].replace("Z", "+00:00")) + return cursor + except (ValueError, TypeError, UnicodeError, binascii.Error) as ex: + raise ValueError("Invalid activity cursor") from ex + + +def query_activity_rows(container, filters, limit, cursor=None, snapshot=None, projection="*"): + """Three-part key: IDs are only unique inside a user partition, not globally.""" + where, parameters = activity_query_context(filters) + snapshot = cursor["snapshot"] if cursor else snapshot or datetime.now(timezone.utc).isoformat() + # Compare calendar instants from both legacy naive-UTC and aware-UTC writers. + # The cursor keeps the stored timestamp verbatim so lexical ordering and seeking agree. + snapshot = snapshot.removesuffix("+00:00").removesuffix("Z") + where += " AND c.timestamp <= @snapshot" + parameters.append({"name": "@snapshot", "value": snapshot}) + if cursor: + partition_tie = "(NOT IS_DEFINED(c.user_id) OR IS_NULL(c.user_id))" + if cursor["user_id"] is not None: + partition_tie = f"(c.user_id < @cursor_user OR {partition_tie})" + parameters.append({"name": "@cursor_user", "value": cursor["user_id"]}) + elif cursor["user_defined"]: + partition_tie = "NOT IS_DEFINED(c.user_id)" + else: + partition_tie = "false" + where += ( + " AND (c.timestamp < @cursor_time OR (c.timestamp = @cursor_time AND " + f"(c.id < @cursor_id OR (c.id = @cursor_id AND {partition_tie}))))" + ) + parameters.extend([{"name": "@cursor_time", "value": cursor["timestamp"]}, + {"name": "@cursor_id", "value": cursor["id"]}]) + parameters.append({"name": "@limit", "value": limit}) + rows = list(container.query_items( + query=f"SELECT TOP @limit {projection} FROM c WHERE {where}{ACTIVITY_ORDER}", + parameters=parameters, enable_cross_partition_query=True, max_item_count=min(limit, ACTIVITY_PAGE_MAX), + )) + return rows, snapshot + + +def activity_page(container, filters, page_size=50, cursor_value=None): + if not 1 <= page_size <= ACTIVITY_PAGE_MAX: + raise ValueError("Invalid page size") + cursor = decode_activity_cursor(cursor_value, filters) if cursor_value else None + rows, snapshot = query_activity_rows(container, filters, page_size + 1, cursor=cursor) + more = len(rows) > page_size + records = rows[:page_size] + return { + "items": records, "page_size": page_size, "snapshot": snapshot, + "next_cursor": encode_activity_cursor(records[-1], filters, snapshot) if more else None, + } + + +def activity_summary(container, filters): + """Bounded projection, not COUNT/GROUP BY scans. Disclose sampling in the contract.""" + rows, snapshot = query_activity_rows( + container, filters, ACTIVITY_SUMMARY_MAX + 1, projection="c.timestamp, c.id, c.user_id, c.activity_type", + ) + truncated = len(rows) > ACTIVITY_SUMMARY_MAX + rows = rows[:ACTIVITY_SUMMARY_MAX] + facets = Counter( + row["activity_type"] if isinstance(row.get("activity_type"), str) and row["activity_type"] else "unknown" + for row in rows + ) + start = date.fromisoformat(filters["start_date"]) + days = (date.fromisoformat(filters["end_date"]) - start).days + 1 + width = max(1, (days + 30) // 31) + bins = { (start + timedelta(days=offset)).isoformat(): 0 for offset in range(0, days, width) } + for row in rows: + day = date.fromisoformat(row["timestamp"][:10]) + key = (start + timedelta(days=((day - start).days // width) * width)).isoformat() + if key in bins: + bins[key] += 1 + return {"facets": [{"activity_type": key, "count": value} for key, value in sorted(facets.items())], + "histogram": [{"date": key, "count": value} for key, value in bins.items()], + "bucket_days": width, "sample_size": len(rows), "sample_limit": ACTIVITY_SUMMARY_MAX, + "truncated": truncated, "snapshot": snapshot} + + +def activity_csv_cell(value): + text = "" if value is None else str(value) + if text.startswith(("=", "+", "-", "@", "\t", "\r")) or text.lstrip().startswith(("=", "+", "-", "@")): + text = "'" + text + return text + + +def activity_csv_stream(container, filters, first_rows, snapshot): + """Stream page-sized reads with a final status row when the export hits its cap.""" + output = StringIO() + writer = csv.writer(output) + + def line(values): + output.seek(0) + output.truncate(0) + writer.writerow([activity_csv_cell(value) for value in values]) + return output.getvalue() + + yield line(("timestamp", "id", "user_id", "activity_type", "workspace_type", "raw_json")) + rows = first_rows + count = 0 + while rows: + for row in rows[:ACTIVITY_EXPORT_MAX - count]: + yield line((row.get("timestamp"), row.get("id"), row.get("user_id"), row.get("activity_type"), + row.get("workspace_type"), json.dumps(row, ensure_ascii=False))) + count += 1 + if count >= ACTIVITY_EXPORT_MAX: + yield line(("", "", "", "export_limit_reached", "", f"Export capped at {ACTIVITY_EXPORT_MAX} rows; narrow the filters.")) + return + cursor = decode_activity_cursor(encode_activity_cursor(rows[-1], filters, snapshot), filters) + rows, _ = query_activity_rows(container, filters, min(ACTIVITY_PAGE_MAX, ACTIVITY_EXPORT_MAX - count), cursor) diff --git a/application/single_app/functions_cosmos_indexing.py b/application/single_app/functions_cosmos_indexing.py index 84d12c1b0..8efd2ced4 100644 --- a/application/single_app/functions_cosmos_indexing.py +++ b/application/single_app/functions_cosmos_indexing.py @@ -10,6 +10,8 @@ import config as app_config from config import ( + cosmos_activity_logs_container, + cosmos_activity_logs_container_name, cosmos_collaboration_messages_container, cosmos_collaboration_messages_container_name, cosmos_conversations_container, @@ -29,7 +31,7 @@ from functions_appinsights import log_event -COSMOS_INDEXING_POLICY_DEFINITION_VERSION = 2 +COSMOS_INDEXING_POLICY_DEFINITION_VERSION = 3 COSMOS_INDEXING_POLICY_APPLY_SETTING = 'app_maintenance_apply_cosmos_indexing_policies' COSMOS_INDEXING_POLICY_MAX_REPLACE_RETRIES = 3 @@ -49,6 +51,19 @@ def _composite_index(*paths): COSMOS_INDEXING_POLICY_DEFINITIONS = [ + { + 'container_name': cosmos_activity_logs_container_name, + 'container': cosmos_activity_logs_container, + 'partition_key_path': '/user_id', + 'description': 'Control Center activity feed keyset ordering with partition-aware ties.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index( + ('/timestamp', 'descending'), ('/id', 'descending'), ('/user_id', 'descending'), + ), + ], + }, + }, { 'container_name': cosmos_conversations_container_name, 'container': cosmos_conversations_container, diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index b07b83f42..fa60ef153 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -18,6 +18,14 @@ get_control_center_auto_refresh_schedule, parse_control_center_auto_refresh_datetime, ) +from functions_control_center_activity import ( + ACTIVITY_PAGE_MAX, + activity_csv_stream, + activity_page, + activity_summary, + parse_activity_filters, + query_activity_rows, +) from functions_settings import * from functions_logging import * from functions_activity_logging import * @@ -3294,6 +3302,69 @@ def get_document_storage_size(doc, cosmos_container, container_name, folder_pref def register_route_backend_control_center(bp): + @bp.route('/api/v2/control-center/activity-logs', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('activity_logs') + def api_v2_control_center_activity_logs(): + try: + filters = parse_activity_filters(request.args) + payload = activity_page( + cosmos_activity_logs_container, filters, + page_size=int(request.args.get('page_size', 50)), cursor_value=request.args.get('cursor'), + ) + return jsonify(payload) + except ValueError: + return jsonify({'error': 'Invalid activity filters, page size, or cursor. Use a UTC date range of up to 366 days.'}), 400 + except Exception as ex: + log_event('[CONTROL_CENTER] Activity feed query failed.', + extra={'error_type': type(ex).__name__}, level=logging.ERROR) + return jsonify({'error': 'Unable to load activity logs. Check the activity-log composite index in App Maintenance, then retry.'}), 500 + + @bp.route('/api/v2/control-center/activity-logs/summary', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('activity_logs') + def api_v2_control_center_activity_summary(): + try: + filters = parse_activity_filters(request.args) + return jsonify(activity_summary(cosmos_activity_logs_container, filters)) + except ValueError: + return jsonify({'error': 'Invalid activity filters. Use a UTC date range of up to 366 days.'}), 400 + except Exception as ex: + log_event('[CONTROL_CENTER] Activity summary query failed.', + extra={'error_type': type(ex).__name__}, level=logging.ERROR) + return jsonify({'error': 'Unable to load the activity summary. Check App Maintenance indexing status and retry.'}), 500 + + @bp.route('/api/v2/control-center/activity-logs/export.csv', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('activity_logs') + def api_v2_control_center_activity_export(): + try: + filters = parse_activity_filters(request.args) + rows, snapshot = query_activity_rows(cosmos_activity_logs_container, filters, ACTIVITY_PAGE_MAX) + except ValueError: + return jsonify({'error': 'Invalid activity export filters. Use a UTC date range of up to 366 days.'}), 400 + except Exception as ex: + log_event('[CONTROL_CENTER] Activity export query failed.', + extra={'error_type': type(ex).__name__}, level=logging.ERROR) + return jsonify({'error': 'Unable to export activity logs. Check App Maintenance indexing status and retry.'}), 500 + + def generate(): + try: + yield from activity_csv_stream(cosmos_activity_logs_container, filters, rows, snapshot) + except Exception as ex: + log_event('[CONTROL_CENTER] Activity export stream interrupted.', + extra={'error_type': type(ex).__name__}, level=logging.ERROR) + raise + + response = Response(stream_with_context(generate()), mimetype='text/csv') + response.headers['Content-Disposition'] = 'attachment; filename="activity_logs.csv"' + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Export-Row-Limit'] = '10000' + return response + # User Management APIs @bp.route('/api/admin/control-center/users', methods=['GET']) diff --git a/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx b/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx new file mode 100644 index 000000000..46d0e9bf8 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx @@ -0,0 +1,373 @@ +// ActivityLogsSection.tsx +// THESIS: Follow evidence from a bounded activity feed, without implying unbounded totals. +// OWN-WORLD: Inherit the Control Center's semantic surfaces, blue accent, and workhorse type. +// STORY: Filter a UTC window, inspect its distribution, then open the record and its related entity. +// FIRST VIEWPORT: Filters precede the histogram and facet chips; the chronological table owns the workspace. +// FORM: Operate; a local extension of the established management pane, with keyboard-safe details. + +import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { Download, RefreshCw } from 'lucide-react'; +import { clsx } from 'clsx'; +import { api, apiUrl, CREDENTIALS_MODE } from '../../lib/apiClient'; +import { useBootstrapStore } from '../../stores/bootstrapStore'; +import { cartesianOptions, StatsChart } from '../settings/StatsChart'; +import { GlassButton, GlassPanel } from '../ui/primitives'; +import { DetailDrawer } from './ControlCenterPrimitives'; + +type ActivityRecord = { + id: string; + timestamp: string; + activity_type?: unknown; + user_id?: unknown; + workspace_type?: unknown; + [key: string]: unknown; +}; +type ActivityPage = { items: ActivityRecord[]; next_cursor: string | null; snapshot: string }; +type ActivitySummary = { + facets: { activity_type: string; count: number }[]; + histogram: { date: string; count: number }[]; + bucket_days: number; + sample_size: number; + sample_limit: number; + truncated: boolean; +}; +type SavedView = { name: string; query: string }; +const FILTER_KEYS = ['start_date', 'end_date', 'user_id', 'workspace_type', 'workspace_id', 'group_id', + 'public_workspace_id', 'search', 'token_type', 'model', 'status'] as const; +type FilterKey = typeof FILTER_KEYS[number]; +type Filters = Record & { activity_type: string[] }; +const FIELD_CLASS = 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent'; +const COMMON_TYPES = ['user_login', 'chat_activity', 'conversation_creation', 'conversation_deletion', + 'document_creation', 'document_deletion', 'token_usage', 'group_status_change', 'public_workspace_status_change']; + +function readFilters(params: URLSearchParams): Filters { + const end = new Date().toISOString().slice(0, 10); + const start = new Date(`${end}T00:00:00Z`); + start.setUTCDate(start.getUTCDate() - 29); + const fields = Object.fromEntries(FILTER_KEYS.map((key) => [key, params.get(key) ?? ''])) as Record; + fields.start_date ||= params.get('date') || start.toISOString().slice(0, 10); + fields.end_date ||= params.get('date') || end; + if (fields.workspace_type === 'public_workspace') fields.workspace_type = 'public'; + return { + ...fields, + activity_type: [...new Set(params.getAll('activity_type').flatMap((value) => value.split(',')).filter((value) => value && value !== 'all'))], + }; +} + +function filterParams(filters: Filters): URLSearchParams { + const params = new URLSearchParams(); + FILTER_KEYS.forEach((key) => { if (filters[key]) params.set(key, filters[key]); }); + filters.activity_type.forEach((value) => params.append('activity_type', value)); + return params; +} + +function recordText(record: ActivityRecord, ...path: string[]): string { + let value: unknown = record; + for (const part of path) { + if (typeof value !== 'object' || value === null) return ''; + value = (value as Record)[part]; + } + return typeof value === 'string' ? value : ''; +} + +function recordUser(record: ActivityRecord): string { + return recordText(record, 'user_id') || recordText(record, 'changed_by', 'user_id') || recordText(record, 'admin_user_id'); +} + +function recordGroup(record: ActivityRecord): string { + return recordText(record, 'workspace_context', 'group_id') || recordText(record, 'group_id') || recordText(record, 'group', 'group_id'); +} + +function recordWorkspace(record: ActivityRecord): string { + return recordText(record, 'workspace_context', 'public_workspace_id') || recordText(record, 'public_workspace_id'); +} + +function ActivityDetail({ record, onClose }: { record: ActivityRecord; onClose: () => void }) { + const user = recordUser(record); + const group = recordGroup(record); + const workspace = recordWorkspace(record); + const approval = recordText(record, 'approval_id') || recordText(record, 'approval', 'id'); + return ( + +
+ {[['Activity', recordText(record, 'activity_type') || 'Unknown'], ['UTC timestamp', record.timestamp], + ['Record ID', record.id], ['User', user || 'Not recorded'], + ['Workspace type', recordText(record, 'workspace_type') || 'Not recorded']].map(([label, value]) => ( +
{label}
{value}
+ ))} +
+ +

Raw JSON

+
+                {JSON.stringify(record, null, 2)}
+            
+
+ ); +} + +export function ActivityLogsSection() { + const [params, setParams] = useSearchParams(); + const paramString = params.toString(); + const applied = useMemo(() => readFilters(new URLSearchParams(paramString)), [paramString]); + const query = filterParams(applied).toString(); + const [draft, setDraft] = useState(applied); + const [paging, setPaging] = useState<{ query: string; cursors: (string | null)[]; index: number }>({ query, cursors: [null], index: 0 }); + const currentPaging = paging.query === query ? paging : { query, cursors: [null], index: 0 }; + const cursor = currentPaging.cursors[currentPaging.index]; + const [data, setData] = useState(null); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [summaryError, setSummaryError] = useState(''); + const [selected, setSelected] = useState(null); + const [refresh, setRefresh] = useState(0); + const [density, setDensity] = useState<'comfortable' | 'compact'>('comfortable'); + const [viewName, setViewName] = useState(''); + const [views, setViews] = useState([]); + const [storageError, setStorageError] = useState(''); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(''); + const userId = useBootstrapStore((state) => state.data?.user.id ?? ''); + const storageKey = `simplechat.activity-views.${userId}`; + + useEffect(() => { setDraft(applied); setSelected(null); }, [applied]); + useEffect(() => { + try { + const stored: unknown = JSON.parse(localStorage.getItem(storageKey) || '[]'); + if (!Array.isArray(stored) || stored.length > 20 || !stored.every((item: unknown) => ( + typeof item === 'object' && item !== null && 'name' in item && 'query' in item + && typeof item.name === 'string' && item.name.length <= 80 + && typeof item.query === 'string' && item.query.length <= 4096 + ))) throw new Error('Invalid saved views'); + setViews(stored as SavedView[]); + setStorageError(''); + } catch { + setViews([]); + setStorageError('Saved views could not be read from this browser.'); + } + }, [storageKey]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + setData(null); + const pageQuery = new URLSearchParams(query); + pageQuery.set('page_size', '50'); + if (cursor) pageQuery.set('cursor', cursor); + api.get(`/api/v2/control-center/activity-logs?${pageQuery}`, controller.signal) + .then((result) => { if (!controller.signal.aborted) setData(result); }) + .catch((failure: unknown) => { + if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : 'Unable to load activity. Retry or narrow the filters.'); + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [query, cursor, refresh]); + + useEffect(() => { + const controller = new AbortController(); + setSummary(null); + setSummaryError(''); + api.get(`/api/v2/control-center/activity-logs/summary?${query}`, controller.signal) + .then((result) => { if (!controller.signal.aborted) setSummary(result); }) + .catch((failure: unknown) => { + if (!controller.signal.aborted) setSummaryError(failure instanceof Error ? failure.message : 'Unable to load the summary. Retry.'); + }); + return () => controller.abort(); + }, [query, refresh]); + + const update = (key: FilterKey, value: string) => setDraft((previous) => ({ ...previous, [key]: value })); + const apply = (event: FormEvent) => { event.preventDefault(); setParams(filterParams(draft)); }; + const toggleType = (type: string) => { + const types = applied.activity_type.includes(type) ? applied.activity_type.filter((item) => item !== type) : [...applied.activity_type, type]; + setParams(filterParams({ ...applied, activity_type: types })); + }; + const persistViews = (next: SavedView[]) => { + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + setViews(next); + setStorageError(''); + setViewName(''); + } catch { + setStorageError('This browser could not save the view. Check storage permissions and retry.'); + } + }; + const saveView = (event: FormEvent) => { + event.preventDefault(); + const name = viewName.trim(); + if (!name) return; + persistViews([...views.filter((view) => view.name !== name), { name, query }].slice(-20)); + }; + const openPreset = (activityType: string) => { + const preset = readFilters(new URLSearchParams()); + const start = new Date(`${preset.end_date}T00:00:00Z`); + start.setUTCDate(start.getUTCDate() - 6); + preset.start_date = start.toISOString().slice(0, 10); + preset.activity_type = [activityType]; + setParams(filterParams(preset)); + }; + const exportCsv = async () => { + setExporting(true); + setExportError(''); + try { + const response = await fetch(apiUrl(`/api/v2/control-center/activity-logs/export.csv?${query}`), { credentials: CREDENTIALS_MODE }); + if (!response.ok) throw new Error('Activity export failed. Check the filters and retry.'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'activity_logs.csv'; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + } catch (failure) { + setExportError(failure instanceof Error ? failure.message : 'Activity export failed. Retry.'); + } finally { + setExporting(false); + } + }; + const types = [...new Set([...COMMON_TYPES, ...applied.activity_type, ...(summary?.facets.map((facet) => facet.activity_type) || [])])]; + const counts = new Map(summary?.facets.map((facet) => [facet.activity_type, facet.count])); + const cellClass = density === 'compact' ? 'px-4 py-2' : 'px-4 py-4'; + + return ( +
+
+

Activity Logs

+

Trace recorded activity across users and workspaces. Date filters use UTC.

+
+ { setPaging({ query, cursors: [null], index: 0 }); setRefresh((value) => value + 1); }}> + + void exportCsv()}> + +
+
+
+
+ + + + + + + + +
+
More filters +
+ {(['group_id', 'public_workspace_id', 'status'] as const).map((key) => ( + + ))} +
+
+
+ Apply filters + setParams(new URLSearchParams())}>Clear filters + Up to 366 days. CSV includes at most 10,000 records. +
+
+ {exportError ?

{exportError}

: null} +
+
+ openPreset('user_login')}>Recent logins + openPreset('token_usage')}>Recent token usage +
+ +
+ + Save current filters +
+ {views.length ?
Manage saved views + {views.map((view) =>
{view.name} + persistViews(views.filter((item) => item.name !== view.name))}>Remove +
)}
: null} + {storageError ?

{storageError}

: null} +
+ +

Activity in this range

+ {summaryError ?

{summaryError}

: !summary ?

Loading summary...

: <> +

{summary.truncated + ? `Sampled: newest ${summary.sample_limit.toLocaleString()} matching records. Counts are not full-range totals.` + : `${summary.sample_size.toLocaleString()} matching records in this range.`} UTC buckets of {summary.bucket_days} day(s).

+ ({ + type: 'bar', data: { labels: summary.histogram.map((bin) => bin.date), + datasets: [{ label: 'Matching records', data: summary.histogram.map((bin) => bin.count), backgroundColor: '#4f8cff' }] }, + options: cartesianOptions(theme, false), + })} /> +
Histogram data and date drill-through + + {summary.histogram.map((bin) => )} +
Activity histogram data
UTC bucket startRecords
+ {bin.count.toLocaleString()}
+
+ } +
+
Activity types +
{types.map((type) => ( + + ))}
+
+
+ Newest first. Page {currentPaging.index + 1}{data ? ` · ${data.items.length} records` : ''} + +
+
+
+ + + {['UTC time', 'Activity', 'User', 'Workspace', 'Details'].map((label) => )} + + {loading ? + : error ? + : !data?.items.length ? + : data.items.map((record) => + + + + + + )} + +
Activity logs in newest-first order
{label}
Loading activity...
{error}
No activity matches these filters. Widen the dates or clear a filter.
{record.timestamp.replace('T', ' ').replace(/\+00:00$|Z$/, '')}{recordText(record, 'activity_type').replaceAll('_', ' ') || 'Unknown'}{recordUser(record) || 'Not recorded'}{recordText(record, 'workspace_type') || 'Not recorded'}{recordGroup(record) || recordWorkspace(record) ? {recordGroup(record) || recordWorkspace(record)} : null} setSelected(record)}>Inspect
+
+
+ setPaging({ ...currentPaging, index: currentPaging.index - 1 })}>Previous + { + if (data?.next_cursor) setPaging({ query, cursors: [...currentPaging.cursors.slice(0, currentPaging.index + 1), data.next_cursor], index: currentPaging.index + 1 }); + }}>Next +
+
+ {selected ? setSelected(null)} /> : null} +
+ ); +} diff --git a/application/v2_ui/src/pages/ControlCenterPage.tsx b/application/v2_ui/src/pages/ControlCenterPage.tsx index 3faa260c3..45b499510 100644 --- a/application/v2_ui/src/pages/ControlCenterPage.tsx +++ b/application/v2_ui/src/pages/ControlCenterPage.tsx @@ -14,6 +14,7 @@ import { PageHeader } from '../components/layout/PageHeader'; import { ConfirmDialog } from '../components/ui/ConfirmDialog'; import { GlassButton, GlassPanel } from '../components/ui/primitives'; import { DashboardSection } from '../components/controlCenter/DashboardSection'; +import { ActivityLogsSection } from '../components/controlCenter/ActivityLogsSection'; import type { ControlCenterCapabilities } from '../lib/types'; import { useBootstrapStore } from '../stores/bootstrapStore'; import { useUserSettingsStore } from '../stores/userSettingsStore'; @@ -229,6 +230,7 @@ export function ControlCenterPage() { ) : section === 'dashboard' ? : section === 'users' ? : section === 'groups' ? + : section === 'activity-logs' ? : section === 'data-health' ? : } diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index 9088d432d..dc763e62d 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -6,6 +6,7 @@ The V2 Control Center is a permission-aware administration pane for managing Sim **Foundation implemented in version:** 0.261.278 **Users implemented in version:** 0.261.280 **Groups implemented in version:** 0.261.281 +**Activity Logs implemented in version:** 0.261.283 **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. @@ -101,6 +102,36 @@ Delete group, delete all documents, take ownership and transfer ownership reuse `functional_tests/test_v2_control_center_groups.py` exercises filters/sorts/paging, cache reuse/expiry, detail projection, roles, bulk cap/exclusions/reasons, real status-writer audit parity, denied access, formula-safe export, and approval creation without direct mutation. `ui_tests/test_v2_control_center_groups.py` covers desktop/mobile drawers, URL filters, cross-page bulk selection, partial failures, status history, escaped text/raw JSON, member search/CSV/roles/removal, retention, ownership approvals and permission-limited controls. Route policy tests include all five new routes; prior Control Center layers and `test_v2_api_security.py` are included in regression validation. +## Activity Logs + +Implemented in version: **0.261.283**, tracked by `VERSION` in `application/single_app/config.py`. + +Activity Logs is an investigation surface for administrators with `can_view_activity_logs`. It links dashboard trends, a user's recent activity, and workspace timelines to the same filtered evidence. All three APIs use the existing login-protected Control Center Blueprint, `@swagger_route(security=get_auth_security())`, and `control_center_required('activity_logs')`; dashboard-only readers cannot query or export activity. + +### Query and paging contract + +`GET /api/v2/control-center/activity-logs` accepts inclusive UTC `start_date`/`end_date`, or the dashboard's single-day `date`. The default is the latest 30 UTC dates; ranges are limited to 366 days. Filters include repeated or comma-separated `activity_type` values (OR within types, AND with other filters), `user_id`, `workspace_type`, `workspace_id`, `group_id`, `public_workspace_id`, `search`, `token_type`, `model`, and recorded `status`. Search is a case-insensitive substring across stored IDs, actor emails, names, descriptions, file names, conversation titles and model names, not a full-text index. It is parameterized, limited to 200 characters, and does not trigger profile or Graph enrichment. + +The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. User filtering includes the stored partition user and the actor fields used by status/admin writers. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. + +Responses contain `items`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs cannot participate in this ordered feed. + +### Index rollout + +New `activity_logs` containers receive the composite index on `/timestamp`, `/id`, `/user_id`, all descending, in `config.py`. **Existing deployments must apply the expected indexing policies using the existing Admin Settings App Maintenance tooling and wait for Cosmos index transformation before using the new feed.** `functions_cosmos_indexing.py` registers the index, preserves existing indexing paths and composites, and retains the maintenance setting/explicit-apply controls; opening Activity Logs never changes a cloud policy. An unavailable index produces a visible, generic API error with an App Maintenance recovery instruction. The partition key remains `/user_id`; browsing and export are cross-partition queries. + +### Bounded distribution and export + +`GET /api/v2/control-center/activity-logs/summary` applies the same filters and projects the newest **at most 5,000** matching records, using one extra projected row to detect truncation. It returns activity-type facets, a UTC histogram with no more than 31 buckets, bucket width, sample size/limit and `truncated`. Facets describe the current filtered result, including selected activity types; they are not disjunctive counts of unselected categories. When truncated, the UI explicitly labels the data as a newest-record sample, not full-range totals. The histogram has an accessible data table and date drill-through. + +`GET /api/v2/control-center/activity-logs/export.csv` streams the same filtered order in page-sized reads, with an upper limit of **10,000 activity rows** and a final `export_limit_reached` status row when the cap is reached. Narrow the filters for a complete larger investigation. The CSV includes timestamp, ID, user ID, activity type, workspace type and JSON; cells beginning with `=`, `+`, `-`, `@`, tab or carriage return are apostrophe-prefixed, including whitespace-prefixed formulas. It is an uncached attachment with `X-Export-Row-Limit`. The first storage query runs before response headers; later storage failures log and interrupt the stream rather than producing a success-shaped fallback. + +### Browser behavior and validation + +`ActivityLogsSection.tsx` keeps applied filters in React Router search parameters, honors dashboard/bookmark drill-through, resets paging on filter changes, cancels stale requests, and provides loading, empty, error/retry and compact/comfortable states. Recent login/token presets start a clean seven-day investigation. Saved views store at most 20 named filter sets in per-user localStorage, on this browser only. Storage failures are visible. Detail drawers show formatted fields and escaped raw JSON, plus recorded user/group/public-workspace links and `/approvals/all/` links with `group_id` for group requests. Router links omit `/v2` because the application basename supplies it. Chart runtime and built assets remain local; no raw HTML rendering is used. + +`functional_tests/test_v2_control_center_activity_logs_queries.py` covers ties across partitions, timestamp spelling, cursor/filter validation, parameter binding, bounded sampling, histogram buckets, streamed export and formula injection. `functional_tests/test_v2_control_center_activity_logs_routes.py` executes the actual handlers and permission decorators, including denial before storage access and generic error handling. The indexing-maintenance regression verifies safe index merging. `ui_tests/test_v2_control_center_activity_logs.py` covers desktop/mobile filters, cursor paging, escaped JSON, related links, keyboard drawer behavior, saved views, export, empty/error recovery and capability gating against built local assets. These isolated checks do not replace a live Cosmos index-transformation/query smoke test. + ## Usage Open **Account → Control Center**, then select a section in its internal rail. A bookmarked section URL opens that section directly. On the Dashboard, select a date range and optional token filters; charts include accessible data tables, and chart selections link to the corresponding filtered activity view. Export downloads the trend data as CSV. “Chat with these trends” creates a conversation containing the selected trend data. The Data health page is intended for explicit diagnosis or a known recovery scenario; check first, and run the backfill only when the result and operational context justify it. @@ -111,4 +142,4 @@ Functional checks cover dashboard status normalization, period deltas, cache exp ## Version tracking -The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, and group management in **0.261.281**. +The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.281**, and Activity Logs in **0.261.283**. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index b3b1c0e38..5d1f8aaa0 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ 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.283)** + +#### New Features + +* **V2 Control Center Activity Logs** + * Adds URL-filtered activity investigations, deterministic keyset paging, a bounded histogram and activity-type facets, saved browser views, density controls, and escaped JSON detail drawers with related entity/approval links. + * CSV export streams the same filters, escapes spreadsheet formulas and caps exports at 10,000 activity records. Summary charts disclose sampling above 5,000 matching records. + * Existing deployments must apply the expected activity-log composite index through App Maintenance and wait for index transformation; legacy activity browsing remains unchanged. + * (Ref: `ActivityLogsSection.tsx`, `functions_control_center_activity.py`, `functions_cosmos_indexing.py`, `route_backend_control_center.py`, [V2 Control Center](features/V2_CONTROL_CENTER.md)) + ### **(v0.261.282)** #### New Features diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index 17e30cd1f..eaeb39def 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -32,7 +32,7 @@ The login heatmap reports UTC hours with Monday as weekday zero. Charts include Group management was implemented in **0.261.281**. Open **Groups** to locate shared workspaces by name, description, owner, status, member-count range, document presence, creation date or last activity. Sort by name, owner, members, documents, tokens or activity to prioritize a review. Counts and all-time tokens use a server snapshot, timestamped in the list and cached for 90 seconds. **Refresh groups** rebuilds that snapshot. Export downloads all matching groups, not just the visible page. -Select a group to inspect its Overview, Members, Ownership, Status, Retention, Activity and Documents tabs. Owner/member links open the corresponding user details. Activity shows the most recent 20 records, offers their raw JSON and a CSV of that subset, and links to the future Activity Logs view with the group scope. +Select a group to inspect its Overview, Members, Ownership, Status, Retention, Activity and Documents tabs. Owner/member links open the corresponding user details. Activity shows the most recent 20 records, offers their raw JSON and a CSV of that subset, and links to Activity Logs with the group scope. Select rows, then optionally select all matches across pages, to apply a bulk status. Bulk updates are limited to 500 groups. Locked and inactive changes require a reason and record each actual transition in the status history. Locked groups keep document viewing and chat but disallow document changes; upload-disabled groups prohibit uploads; inactive groups are unavailable. Individual failures remain visible after the list refreshes. @@ -42,6 +42,18 @@ Changing member roles, removing members and saving retention still require group Requesting group deletion, deleting all group documents, taking ownership, or transferring ownership to a member requires a reason and creates an approval request. Nothing is deleted and ownership remains unchanged at submission. Follow **View approval requests** in the result notice to the approvals page. +## Investigate activity + +Activity Logs was implemented in **0.261.283**. Open it from a dashboard chart, a user/workspace activity link, or the section rail. Choose a UTC date window (default 30 days, maximum 366), select one or more activity-type chips, and narrow by user or workspace ID, model, token type, text or recorded status. **Apply filters** updates the URL so a bookmark preserves the investigation. **More filters** exposes explicit group/public-workspace IDs and status. + +The histogram and chip counts describe the filtered records. For busy ranges, they describe only the newest 5,000 matches and clearly say **Sampled**; do not use them as organization-wide totals. Expand the histogram data table to select a UTC bucket and investigate that date window. The table reads 50 records at a time in newest-first order. **Refresh** starts over with newly recorded activity; changing filters resets paging. + +Choose **Inspect** for a record's fields and raw JSON. Related links open its recorded user, group, public workspace or approval. The drawer supports Escape and restores keyboard focus to the opener. Use compact density to scan more rows. Saved views preserve applied filters for the signed-in user on this browser, not across devices; removing a saved view does not delete activity. + +**Export CSV** uses the same filters, not just the visible page, and exports no more than 10,000 activity rows. A final `export_limit_reached` row means the limit was reached; narrow the filters to export a smaller complete range. Spreadsheet formula prefixes are escaped. The export includes raw JSON, so handle the downloaded audit information according to your organization's data policies. + +On an existing deployment, an indexing error requires an administrator to apply the new expected activity-log composite index in **Admin Settings → App Maintenance** and wait for Cosmos index transformation. The activity page does not automatically apply cloud changes. A date cutoff stabilizes forward paging against newer events, but cannot freeze deletes or late/backdated writes; the feed is not a transactional snapshot. + ## Check activity-log data health The **Data health** section is available to users with maintenance access. Its backfill is a legacy repair operation for conversation and document creation events; ordinary application workflows already write activity logs, so the backfill is normally unnecessary. diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 4cc49d43c..69003420c 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -154,6 +154,9 @@ SENSITIVE_ROUTE_POLICIES = { ("route_backend_control_center.py", "api_v2_control_center_dashboard_summary"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_dashboard_insights"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_activity_logs"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_activity_summary"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_activity_export"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_users"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_user_detail"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_users_bulk_action"): ("login_required", "control_center_required"), diff --git a/functional_tests/test_control_center_activity_logs_hardening.py b/functional_tests/test_control_center_activity_logs_hardening.py index f40b2cae5..a67b8b6dd 100644 --- a/functional_tests/test_control_center_activity_logs_hardening.py +++ b/functional_tests/test_control_center_activity_logs_hardening.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for Control Center activity logs hardening. -Version: 0.241.021 +Version: 0.261.283 Implemented in: 0.241.021 This test ensures that the Control Center activity logs flow validates @@ -14,6 +14,7 @@ from pathlib import Path import sys +from test_support.versioning import assert_app_version_at_least ROOT = Path(__file__).resolve().parents[1] APP_DIR = ROOT / "application" / "single_app" @@ -122,12 +123,7 @@ def test_control_center_javascript_uses_dedicated_export_route() -> bool: def test_config_version_bumped_for_activity_log_fix() -> bool: """Validate the repository version bump for the activity log fix.""" print("Testing config version bump...") - config_content = read_text("application/single_app/config.py") - - if 'VERSION = "0.241.021"' not in config_content: - print("Config version was not bumped to 0.241.021") - return False - + assert_app_version_at_least("0.241.021") print("Config version bump found.") return True diff --git a/functional_tests/test_cosmos_wave3a_indexing_maintenance.py b/functional_tests/test_cosmos_wave3a_indexing_maintenance.py index 8b3f30d46..0a1a2172d 100644 --- a/functional_tests/test_cosmos_wave3a_indexing_maintenance.py +++ b/functional_tests/test_cosmos_wave3a_indexing_maintenance.py @@ -2,12 +2,13 @@ #!/usr/bin/env python3 """ Functional test for Cosmos Wave 3A indexing policy maintenance. -Version: 0.250.104 +Version: 0.261.283 Implemented in: 0.250.008 Maintenance cleanup integration updated in: 0.250.038 Manual admin apply override updated in: 0.250.039 Data Management history pagination index updated in: 0.250.103 CodeQL remediation version alignment updated in: 0.250.104 +Activity Logs keyset index updated in: 0.261.283 This test ensures expected Cosmos indexing policies can be compared, safely merged, and invoked through the app maintenance framework without live Azure @@ -146,6 +147,7 @@ def replace_container(self, **kwargs): def _build_fake_environment(): containers = { + "activity_logs": FakeCosmosContainer("activity_logs"), "conversations": FakeCosmosContainer("conversations"), "messages": FakeCosmosContainer("messages"), "data_management_jobs": FakeCosmosContainer("data_management_jobs"), @@ -201,6 +203,8 @@ def _load_wave3_modules(): fake_config = types.ModuleType("config") fake_config.VERSION = "0.250.039" fake_config.cosmos_database = database + fake_config.cosmos_activity_logs_container = containers["activity_logs"] + fake_config.cosmos_activity_logs_container_name = "activity_logs" fake_config.cosmos_settings_container = settings_container fake_config.cosmos_governance_policies_container = governance_container fake_config.cosmos_conversations_container = containers["conversations"] @@ -266,7 +270,17 @@ def test_indexing_policy_report_is_read_only(): for definition in indexing.COSMOS_INDEXING_POLICY_DEFINITIONS if definition["container_name"] == "data_management_jobs" ) - assert indexing.COSMOS_INDEXING_POLICY_DEFINITION_VERSION == 2 + assert indexing.COSMOS_INDEXING_POLICY_DEFINITION_VERSION == 3 + activity_definition = next( + definition for definition in indexing.COSMOS_INDEXING_POLICY_DEFINITIONS + if definition["container_name"] == "activity_logs" + ) + assert activity_definition["partition_key_path"] == "/user_id" + assert activity_definition["expected_policy"]["compositeIndexes"] == [[ + {"path": "/timestamp", "order": "descending"}, + {"path": "/id", "order": "descending"}, + {"path": "/user_id", "order": "descending"}, + ]] assert data_management_definition["expected_policy"]["compositeIndexes"] == [[ {"path": "/created_at", "order": "descending"}, {"path": "/id", "order": "descending"}, diff --git a/functional_tests/test_v2_control_center_activity_logs_queries.py b/functional_tests/test_v2_control_center_activity_logs_queries.py new file mode 100644 index 000000000..0b52bda43 --- /dev/null +++ b/functional_tests/test_v2_control_center_activity_logs_queries.py @@ -0,0 +1,265 @@ +# test_v2_control_center_activity_logs_queries.py +""" +Functional tests for bounded Control Center activity queries, paging and export. +Version: 0.261.283 +Implemented in: 0.261.283 + +Executes the actual dependency-neutral helper module with a query-contract storage fake. +No cloud calls, Flask bootstrap replacement, or production module mutations. +""" + +import base64 +import csv +import importlib.util +import json +from datetime import datetime, timezone +from io import StringIO +from pathlib import Path + +import pytest +from werkzeug.datastructures import MultiDict + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +SPEC = importlib.util.spec_from_file_location("activity_queries", APP / "functions_control_center_activity.py") +activity = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(activity) +NOW = datetime(2026, 10, 7, 14, tzinfo=timezone.utc) + + +def filters(**values): + return activity.parse_activity_filters(MultiDict({"date": "2026-10-01", **values}), NOW) + + +def partition_key(row): + value = row.get("user_id") + return (2, value) if isinstance(value, str) else (1, "") if "user_id" in row else (0, "") + + +def order_key(row): + return row["timestamp"], row["id"], partition_key(row) + + +class QueryContainer: + """Implements only the new feed's parameterized query and deterministic ordering.""" + + def __init__(self, rows): + self.rows = rows + self.calls = [] + + def query_items(self, **kwargs): + self.calls.append(kwargs) + query = kwargs["query"] + assert query.startswith("SELECT TOP @limit ") + assert query.endswith(activity.ACTIVITY_ORDER) + assert "OFFSET" not in query and "COUNT" not in query + assert kwargs["enable_cross_partition_query"] is True + assert kwargs["max_item_count"] <= 200 + params = {item["name"]: item["value"] for item in kwargs["parameters"]} + rows = [] + for row in self.rows: + if not isinstance(row.get("timestamp"), str) or not isinstance(row.get("id"), str): + continue + if row.get("user_id") is not None and not isinstance(row["user_id"], str): + continue + if not params["@start"] <= row["timestamp"] < params["@end"] or row["timestamp"] > params["@snapshot"]: + continue + if "@types" in params and row.get("activity_type") not in params["@types"]: + continue + if "@user" in params and row.get("user_id") != params["@user"]: + continue + if "@model" in params and row.get("usage", {}).get("model") != params["@model"]: + continue + if "@cursor_time" in params: + cursor_user = (2, params["@cursor_user"]) if "@cursor_user" in params else ( + (1, "") if "AND NOT IS_DEFINED(c.user_id)" in query else (0, "") + ) + boundary = (params["@cursor_time"], params["@cursor_id"], cursor_user) + if order_key(row) >= boundary: + continue + rows.append(row.copy()) + return sorted(rows, key=order_key, reverse=True)[:params["@limit"]] + + +def test_keyset_equal_timestamps_and_duplicate_ids_across_partitions(): + records = [ + {"id": f"record-{index:03d}", "timestamp": f"2026-10-01T12:00:0{index % 3}", "user_id": user} + for index in range(70) for user in ("user-a", "user-z") + ] + records += [{"id": "shared", "timestamp": "2026-10-01T12:00:02", "user_id": None}, + {"id": "shared", "timestamp": "2026-10-01T12:00:02"}] + container = QueryContainer(records) + selected = filters() + seen = [] + cursor = None + while True: + page = activity.activity_page(container, selected, page_size=7, cursor_value=cursor) + seen.extend(page["items"]) + cursor = page["next_cursor"] + if not cursor: + break + assert [order_key(row) for row in seen] == [order_key(row) for row in sorted(records, key=order_key, reverse=True)] + assert len(seen) == len({order_key(row) for row in seen}) == 142 + assert len(container.calls) == 21 + + +def test_partition_null_and_undefined_ties_on_page_boundaries(): + rows = [{"id": "same", "timestamp": "2026-10-01T12:00:00", "user_id": "u"}, + {"id": "same", "timestamp": "2026-10-01T12:00:00", "user_id": None}, + {"id": "same", "timestamp": "2026-10-01T12:00:00"}] + container = QueryContainer(rows) + selected = filters() + seen = [] + cursor = None + for _ in range(3): + page = activity.activity_page(container, selected, page_size=1, cursor_value=cursor) + seen.extend(page["items"]) + cursor = page["next_cursor"] + assert [order_key(row) for row in seen] == [order_key(row) for row in rows] + assert cursor is None + + +def test_malformed_partition_values_are_not_used_as_cursor_keys(): + container = QueryContainer([ + {"id": "array", "timestamp": "2026-10-01T12:00:00", "user_id": ["legacy"]}, + {"id": "valid", "timestamp": "2026-10-01T12:00:00", "user_id": "u", "activity_type": {}}, + ]) + page = activity.activity_page(container, filters()) + assert [row["id"] for row in page["items"]] == ["valid"] + summary = activity.activity_summary(container, filters()) + assert summary["facets"] == [{"activity_type": "unknown", "count": 1}] + + +def test_timestamp_cutoff_survives_newer_inserts_and_cursor_keeps_stored_spelling(): + container = QueryContainer([ + {"id": "a", "timestamp": "2026-10-01T12:00:00.000001Z", "user_id": "u"}, + {"id": "b", "timestamp": "2026-10-01T12:00:00.000001+00:00", "user_id": "u"}, + ]) + selected = filters() + first = activity.activity_page(container, selected, page_size=1) + decoded = activity.decode_activity_cursor(first["next_cursor"], selected) + assert decoded["timestamp"] == first["items"][0]["timestamp"] + container.rows.append({"id": "new", "timestamp": "2099-01-01T00:00:00", "user_id": "u"}) + second = activity.activity_page(container, selected, page_size=1, cursor_value=first["next_cursor"]) + assert len(second["items"]) == 1 + assert second["snapshot"] == first["snapshot"] + assert second["items"][0]["id"] != first["items"][0]["id"] + + +@pytest.mark.parametrize("size", [0, -1, 201]) +def test_page_size_is_bounded(size): + with pytest.raises(ValueError): + activity.activity_page(QueryContainer([]), filters(), page_size=size) + + +def test_cursor_rejects_malformed_and_different_filter_scope(): + selected = filters() + valid = activity.encode_activity_cursor( + {"id": "x", "timestamp": "2026-10-01T01:00:00", "user_id": "u"}, selected, NOW.isoformat(), + ) + for value in ("not base64", "x" * 4097, base64.b64encode(b"[]").decode()): + with pytest.raises(ValueError): + activity.decode_activity_cursor(value, selected) + with pytest.raises(ValueError): + activity.decode_activity_cursor(valid, filters(user_id="different")) + + +def test_filters_match_dashboard_contract_and_bind_every_value(): + selected = filters( + activity_type=["token_usage", "chat_activity"], workspace_type="group", + workspace_id="group' OR true", user_id="admin-id", token_type="chat", model="model'", + status="failed", search="' OR true --", + ) + where, parameters = activity.activity_query_context(selected) + params = {item["name"]: item["value"] for item in parameters} + assert params["@types"] == ["chat_activity", "token_usage"] + assert params["@group"] == "group' OR true" + assert params["@search"] == "' OR true --" + assert params["@end"] == "2026-10-02" + assert "OR true" not in where + assert "c.changed_by.user_id" in where + assert "c.group.group_id" in where and "c.workspace_context.group_id" in where + assert "c.document.status" in where + assert "c.usage.model = @model" in where + public_where, public_params = activity.activity_query_context(filters(workspace_type="public_workspace", workspace_id="pub")) + assert "c.workspace_type IN ('public', 'public_workspace')" in public_where + assert {"name": "@public", "value": "pub"} in public_params + + +@pytest.mark.parametrize("values", [ + {"start_date": "2026-01-01", "end_date": "2027-01-02"}, + {"start_date": "2026-10-02", "end_date": "2026-10-01"}, + {"date": "invalid"}, {"workspace_type": "alien"}, + {"workspace_id": "missing-type"}, {"search": "x" * 201}, + {"activity_type": [str(index) for index in range(31)]}, +]) +def test_invalid_filters(values): + with pytest.raises(ValueError): + filters(**values) + + +def test_query_applies_date_type_user_and_model_filters(): + rows = [ + {"id": "in", "timestamp": "2026-10-01T23:59:59.999999Z", "user_id": "u", "activity_type": "token_usage", "usage": {"model": "m"}}, + {"id": "out", "timestamp": "2026-10-02T00:00:00", "user_id": "u", "activity_type": "token_usage"}, + {"id": "wrong-user", "timestamp": "2026-10-01T12:00:00", "user_id": "v", "activity_type": "token_usage"}, + {"id": "wrong-type", "timestamp": "2026-10-01T12:00:00", "user_id": "u", "activity_type": "user_login"}, + ] + page = activity.activity_page(QueryContainer(rows), filters(user_id="u", activity_type="token_usage", model="m")) + assert [row["id"] for row in page["items"]] == ["in"] + + +def test_summary_is_bounded_discloses_sampling_and_bucket_counts(monkeypatch): + monkeypatch.setattr(activity, "ACTIVITY_SUMMARY_MAX", 3) + rows = [{"id": str(index), "timestamp": "2026-10-01T12:00:00", "user_id": "u", + "activity_type": "user_login"} for index in range(6)] + container = QueryContainer(rows) + summary = activity.activity_summary(container, filters()) + assert summary["truncated"] is True and summary["sample_size"] == 3 + assert summary["facets"] == [{"activity_type": "user_login", "count": 3}] + assert sum(bin["count"] for bin in summary["histogram"]) == 3 + wide = activity.activity_summary(QueryContainer([]), filters(start_date="2026-01-01", end_date="2026-12-31")) + assert len(wide["histogram"]) <= 31 + assert container.calls[0]["query"].startswith("SELECT TOP @limit c.timestamp, c.id, c.user_id, c.activity_type") + + +@pytest.mark.parametrize("value", ["=SUM(1)", "+1", "-1", "@cmd", "\tfoo", "\rfoo", " =1", "\n@formula"]) +def test_csv_injection(value): + assert activity.activity_csv_cell(value).startswith("'") + + +def test_streamed_csv_reuses_filters_pages_and_caps_records(monkeypatch): + monkeypatch.setattr(activity, "ACTIVITY_EXPORT_MAX", 4) + rows = [{"id": str(index), "timestamp": "2026-10-01T12:00:00", "user_id": "=1", + "activity_type": "user_login"} for index in range(7)] + container = QueryContainer(rows) + selected = filters(user_id="=1") + first, snapshot = activity.query_activity_rows(container, selected, 2) + stream = activity.activity_csv_stream(container, selected, first, snapshot) + header = next(stream) + assert header.startswith("timestamp,id,") + assert len(container.calls) == 1 + contents = header + "".join(stream) + exported = list(csv.reader(StringIO(contents))) + assert len(exported) == 6 + assert all(row[2] == "'=1" for row in exported[1:-1]) + assert exported[-1][3] == "export_limit_reached" + assert len({row[1] for row in exported[1:-1]}) == 4 + assert all({"name": "@user", "value": "=1"} in call["parameters"] for call in container.calls) + assert json.loads(exported[1][-1])["user_id"] == "=1" + + +def test_new_container_and_maintenance_index_contract(): + import ast + tree = ast.parse((APP / "config.py").read_text(encoding="utf-8")) + node = next(node for node in tree.body if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "ACTIVITY_LOGS_INDEXING_POLICY" for target in node.targets)) + policy = ast.literal_eval(node.value) + assert policy["compositeIndexes"] == [activity.ACTIVITY_COMPOSITE_INDEX] + maintenance = (APP / "functions_cosmos_indexing.py").read_text(encoding="utf-8") + assert "'container_name': cosmos_activity_logs_container_name" in maintenance + assert "('/timestamp', 'descending'), ('/id', 'descending'), ('/user_id', 'descending')" in maintenance + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_v2_control_center_activity_logs_routes.py b/functional_tests/test_v2_control_center_activity_logs_routes.py new file mode 100644 index 000000000..25ac4f67a --- /dev/null +++ b/functional_tests/test_v2_control_center_activity_logs_routes.py @@ -0,0 +1,137 @@ +# test_v2_control_center_activity_logs_routes.py +""" +Functional tests for the Activity Logs HTTP and capability contracts. +Version: 0.261.283 +Implemented in: 0.261.283 + +Registers the actual new handlers with real authentication/capability decorators, +isolating unrelated Azure bootstrap. Checks authorization before query execution. +""" + +import ast +import importlib.util +import logging +from functools import wraps +from importlib.metadata import version +from pathlib import Path + +import pytest +import werkzeug +from flask import Blueprint, Flask, Response, jsonify, request, session, stream_with_context + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +SPEC = importlib.util.spec_from_file_location("activity_route_queries", APP / "functions_control_center_activity.py") +activity = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(activity) +PATHS = ("/api/v2/control-center/activity-logs", "/api/v2/control-center/activity-logs/summary", + "/api/v2/control-center/activity-logs/export.csv") +HANDLERS = {"api_v2_control_center_activity_logs", "api_v2_control_center_activity_summary", + "api_v2_control_center_activity_export"} + + +@pytest.fixture +def environment(monkeypatch): + if not hasattr(werkzeug, "__version__"): + monkeypatch.setattr(werkzeug, "__version__", version("werkzeug"), raising=False) + app = Flask(__name__) + app.secret_key = "local-test-only" + queries = [] + logs = [] + settings = {"require_member_of_control_center_admin": True, "require_member_of_control_center_dashboard_reader": True} + auth = {"wraps": wraps, "session": session, "request": request, "jsonify": jsonify, + "get_settings": lambda: settings, "debug_print": lambda *args, **kwargs: None} + auth_tree = ast.parse((APP / "functions_authentication.py").read_text(encoding="utf-8")) + functions = [node for node in auth_tree.body if isinstance(node, ast.FunctionDef) + and node.name in {"login_required", "control_center_required", "get_control_center_capabilities"}] + exec(compile(ast.Module(body=functions, type_ignores=[]), "authentication", "exec"), auth) + + class Container: + failure = False + + def query_items(self, **kwargs): + queries.append(kwargs) + if self.failure: + raise RuntimeError("SECRET connection provider error") + return [] + + container = Container() + bp = Blueprint("backend_control_center", __name__) + namespace = {**vars(activity), **auth, "bp": bp, "cosmos_activity_logs_container": container, + "swagger_route": lambda **kwargs: lambda function: function, + "get_auth_security": lambda: [], "Response": Response, "stream_with_context": stream_with_context, + "log_event": lambda *args, **kwargs: logs.append((args, kwargs)), "logging": logging} + tree = ast.parse((APP / "route_backend_control_center.py").read_text(encoding="utf-8")) + registrar = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_control_center") + handlers = [node for node in registrar.body if isinstance(node, ast.FunctionDef) and node.name in HANDLERS] + assert len(handlers) == 3 + for handler in handlers: + decorators = [ast.unparse(item) for item in handler.decorator_list] + assert decorators[1] == "swagger_route(security=get_auth_security())" + assert "control_center_required('activity_logs')" in decorators + exec(compile(ast.Module(body=handlers, type_ignores=[]), "activity-routes", "exec"), namespace) + app.register_blueprint(bp) + yield app.test_client(), container, queries, logs, settings + + +def login(client, roles): + with client.session_transaction() as current: + current["user"] = {"oid": "viewer", "roles": roles} + + +@pytest.mark.parametrize("roles", [None, ["User"], ["Admin"], ["ControlCenterDashboardReader"]]) +def test_denied_roles_and_anonymous_never_query(environment, roles): + client, _, queries, _, _ = environment + if roles is not None: + login(client, roles) + for path in PATHS: + response = client.get(path) + assert response.status_code in (401, 302) if roles is None else response.status_code == 403 + assert not queries + + +def test_control_center_admin_and_regular_admin_settings(environment): + client, _, queries, _, settings = environment + login(client, ["ControlCenterAdmin"]) + for path in PATHS: + response = client.get(path) + assert response.status_code == 200 + assert len(queries) == 3 + settings["require_member_of_control_center_admin"] = False + login(client, ["Admin"]) + response = client.get(PATHS[0]) + assert response.status_code == 200 + + +def test_invalid_cursor_filters_and_page_size_return_400(environment): + client, _, queries, _, _ = environment + login(client, ["ControlCenterAdmin"]) + for suffix in ("?cursor=garbage", "?page_size=201", "?page_size=abc", "?start_date=invalid"): + response = client.get(PATHS[0] + suffix) + assert response.status_code == 400 + assert not queries + + +def test_storage_failures_are_logged_without_provider_details(environment): + client, container, _, logs, _ = environment + container.failure = True + login(client, ["ControlCenterAdmin"]) + for path in PATHS: + response = client.get(path) + assert response.status_code == 500 + assert "SECRET" not in response.get_data(as_text=True) + assert len(logs) == 3 + + +def test_csv_response_is_attachment_uncached_and_bounded(environment): + client, _, _, _, _ = environment + login(client, ["ControlCenterAdmin"]) + response = client.get(PATHS[2] + "?date=2026-10-01") + assert response.headers["X-Export-Row-Limit"] == "10000" + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Content-Disposition"] == 'attachment; filename="activity_logs.csv"' + assert response.get_data(as_text=True).startswith("timestamp,id,user_id,activity_type,workspace_type,raw_json") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/test_v2_control_center_activity_logs.py b/ui_tests/test_v2_control_center_activity_logs.py new file mode 100644 index 000000000..93817a452 --- /dev/null +++ b/ui_tests/test_v2_control_center_activity_logs.py @@ -0,0 +1,170 @@ +# test_v2_control_center_activity_logs.py +""" +Browser coverage for V2 Activity Logs. +Version: 0.261.283 +Implemented in: 0.261.283 + +Uses local built assets and intercepted APIs, with the shared Azure Playwright +connection helper when a workspace is configured. Covers desktop and mobile. +""" + +import sys +import re +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest +from playwright.sync_api import expect + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from playwright_connection import connect_options +from test_v2_control_center_users import ORIGIN, UsersFixture + + +RECORD = { + "id": "record-1", "timestamp": "2026-10-01T12:00:00Z", "activity_type": "user_login", + "user_id": "user-1", "workspace_type": "group", "workspace_context": {"group_id": "group-1"}, + "public_workspace_id": "workspace-1", "approval_id": "approval-1", + "description": "", +} + + +class ActivityFixture(UsersFixture): + def __init__(self, page): + self.fail = False + self.empty = False + self.allowed = True + self.console_errors = [] + super().__init__(page) + page.on("pageerror", lambda error: self.console_errors.append(str(error))) + + def _route(self, route): + parsed = urlsplit(route.request.url) + path = parsed.path + if path.startswith("/api/v2/control-center/activity-logs"): + self.requests.append((route.request.method, path, parsed.query)) + if self.fail: + route.fulfill(status=500, json={"error": "Unable to load activity logs. Retry."}) + elif path.endswith("/summary"): + route.fulfill(json={ + "facets": [{"activity_type": "user_login", "count": 5000}], + "histogram": [{"date": "2026-10-01", "count": 5000}], + "sample_size": 5000, "sample_limit": 5000, "truncated": True, "bucket_days": 1, + }) + elif path.endswith("/export.csv"): + route.fulfill(content_type="text/csv", body="timestamp,id,user_id\n2026-10-01,record-1,user-1\n", + headers={"Content-Disposition": 'attachment; filename="activity_logs.csv"'}) + else: + second = "cursor" in parse_qs(parsed.query) + record = {**RECORD, "id": "record-2"} if second else RECORD + route.fulfill(json={"items": [] if self.empty else [record], "next_cursor": None if second else "test-cursor", + "snapshot": "2026-10-07T12:00:00"}) + elif path == "/api/v2/bootstrap" and not self.allowed: + route.fulfill(json={ + "version": "0.261.283", "user": {"id": "reader", "display_name": "Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, + "branding": {"app_title": "SimpleChat", "show_logo": False}, "features": {}, + "control_center": {"can_view_dashboard": True, "can_manage_users": False, "can_manage_groups": False, + "can_manage_workspaces": False, "can_view_activity_logs": False, "can_run_maintenance": False}, + "catalogs": {"models": [], "agents": [], "prompts": []}, "scope": {"groups": [], "public_workspaces": []}, + "navigation": {"custom_pages": {"enabled": False, "items": []}, "external_links": {"enabled": False, "items": []}}, + "workspace": {"sections": {}}, "admin_nav": [], "notices": {"ai": {}, "web_search": {}}, "settings": {}, + }) + else: + super()._route(route) + + def open(self, query="?date=2026-10-01&activity_type=user_login&workspace_type=group&workspace_id=group-1", mobile=False): + self.page.set_viewport_size({"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}) + self.page.goto(f"{ORIGIN}/v2/control-center/activity-logs{query}", wait_until="networkidle") + + def assert_clean(self): + super().assert_clean() + assert not self.console_errors, self.console_errors + + +@pytest.fixture +def activity_ui(page): + fixture = ActivityFixture(page) + yield fixture + fixture.assert_clean() + + +pytestmark = pytest.mark.ui + + +@pytest.mark.parametrize("mobile", [False, True]) +def test_filters_paging_details_and_safe_links(activity_ui, mobile): + activity_ui.open(mobile=mobile) + page = activity_ui.page + expect(page.get_by_role("heading", name="Activity Logs", exact=True)).to_be_visible() + expect(page.get_by_label("Start date (UTC)")).to_have_value("2026-10-01") + expect(page.get_by_text("Sampled: newest 5,000 matching records.", exact=False)).to_be_visible() + page.get_by_role("button", name="Inspect activity record-1").click() + drawer = page.get_by_role("dialog", name="Activity details") + expect(drawer).to_be_visible() + expect(drawer.locator("pre")).to_contain_text("") + expect(page.locator("img[src=x]")).to_have_count(0) + expect(drawer.get_by_role("link", name="View user")).to_have_attribute("href", "/v2/control-center/users?user_id=user-1") + expect(drawer.get_by_role("link", name="View group")).to_have_attribute("href", "/v2/control-center/groups?id=group-1") + expect(drawer.get_by_role("link", name="View workspace")).to_have_attribute("href", "/v2/control-center/public-workspaces?id=workspace-1") + expect(drawer.get_by_role("link", name="View approval")).to_have_attribute("href", "/v2/approvals/all/approval-1?group_id=group-1") + page.keyboard.press("Escape") + expect(drawer).not_to_be_visible() + expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_focused() + page.get_by_role("button", name="Next", exact=True).click() + expect(page.get_by_role("button", name="Inspect activity record-2")).to_be_visible() + page.get_by_role("button", name="Previous", exact=True).click() + expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() + page.get_by_label("Search activity").fill("file") + page.get_by_role("button", name="Apply filters").click() + expect(page).to_have_url(re.compile("search=file")) + expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() + latest = [parse_qs(query) for _, path, query in activity_ui.requests if path.endswith("activity-logs")][-1] + assert latest["search"] == ["file"] and "cursor" not in latest + page.get_by_label("Density").select_option("compact") + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + +def test_saved_views_filters_export_and_histogram(activity_ui): + activity_ui.open() + page = activity_ui.page + page.get_by_label("View name", exact=True).fill("Group logins") + page.get_by_role("button", name="Save current filters").click() + page.get_by_role("button", name="Clear filters").click() + page.get_by_label("Saved views", exact=True).select_option("Group logins") + expect(page.get_by_label("Workspace ID", exact=True)).to_have_value("group-1") + page.reload(wait_until="networkidle") + expect(page.get_by_label("Saved views").locator("option")).to_have_count(2) + page.get_by_role("button", name="token usage", exact=True).click() + expect(page).to_have_url(re.compile("activity_type=token_usage")) + with page.expect_download() as download: + page.get_by_role("button", name="Export CSV", exact=True).click() + assert download.value.suggested_filename == "activity_logs.csv" + export_query = [parse_qs(query) for _, path, query in activity_ui.requests if path.endswith("export.csv")][-1] + assert export_query["activity_type"] == ["user_login", "token_usage"] + page.get_by_text("Histogram data and date drill-through", exact=True).click() + page.get_by_role("button", name="2026-10-01", exact=True).click() + expect(page.get_by_label("End date (UTC)")).to_have_value("2026-10-01") + page.get_by_role("button", name="Recent logins", exact=True).click() + expect(page).to_have_url(re.compile("activity_type=user_login")) + expect(page.get_by_label("Workspace ID", exact=True)).to_have_value("") + + +def test_empty_error_retry_and_permission_gating(activity_ui): + activity_ui.empty = True + activity_ui.open() + page = activity_ui.page + expect(page.get_by_text("No activity matches these filters.", exact=False)).to_be_visible() + activity_ui.empty = False + activity_ui.fail = True + page.get_by_role("button", name="Refresh", exact=True).click() + expect(page.get_by_role("button", name="Retry", exact=True)).to_be_visible() + activity_ui.fail = False + page.get_by_role("button", name="Retry", exact=True).click() + expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() + activity_ui.allowed = False + activity_ui.requests.clear() + page.reload(wait_until="networkidle") + expect(page.get_by_role("heading", name="Access unavailable")).to_be_visible() + assert not activity_ui.requests From 8e6ce415b23e99f53d88cbed49b05c2f9c20c816 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 11:32:22 -0400 Subject: [PATCH 2/4] Finalize integrated Control Center activity documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/explanation/features/V2_CONTROL_CENTER.md | 6 +++--- docs/guides/v2-control-center.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index b9f21aa03..084a6c9ec 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -1,6 +1,6 @@ # V2 Control Center -The V2 Control Center is a permission-aware administration pane for managing SimpleChat. The foundation release provides the shared navigation and capability contract, placeholders for management areas that will arrive in later phases, and a manual activity-log data-health tool. +The V2 Control Center is a permission-aware administration pane for managing SimpleChat. It provides a usage dashboard, user/group/public-workspace management, activity investigations, and an explicitly invoked activity-log data-health tool. **Dashboard implemented in version:** 0.261.279 **Foundation implemented in version:** 0.261.278 @@ -89,7 +89,7 @@ The response includes `groups`, `pagination`, and `metrics_freshness` with the s ### Drawer and existing actions -`GET /api/v2/control-center/groups/` reads current ownership, members, roles and status history, then returns the overview, retention policy, document summary, token total and the 20 most recent projected activity records. It does not expose model endpoints, credentials, logos, or the raw group document. The activity view shows the returned record as JSON and exports that recent subset as CSV. **View in Activity Logs** carries `workspace_type=group`, `workspace_id`, and `group_id` to the Activity Logs section; that section remains a placeholder until Phase 6. +`GET /api/v2/control-center/groups/` reads current ownership, members, roles and status history, then returns the overview, retention policy, document summary, token total and the 20 most recent projected activity records. It does not expose model endpoints, credentials, logos, or the raw group document. The activity view shows the returned record as JSON and exports that recent subset as CSV. **View in Activity Logs** carries `workspace_type=group`, `workspace_id`, and `group_id` to the scoped Activity Logs investigation. The drawer has Overview, Members, Ownership, Status, Retention, Activity and Documents tabs. Owner and member links open the Users drawer. `EntityDetailSections.tsx` provides reusable keyboard-operable detail tabs, timeline/JSON presentation and date formatting for later workspace management. `DetailDrawer` reuses the application's modal focus trap, focus restoration and innermost-dialog Escape handling. @@ -115,7 +115,7 @@ Activity Logs is an investigation surface for administrators with `can_view_acti The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. User filtering includes the stored partition user and the actor fields used by status/admin writers. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. -Responses contain `items`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs cannot participate in this ordered feed. +Responses contain `items`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs, or with malformed non-string/non-null user partitions, cannot participate in this ordered feed; legacy browsing remains available for those records. ### Index rollout diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index 97631ede7..4990272fe 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -66,7 +66,7 @@ The detail drawer shares the Groups tabs and supports status history, recent act Individual document deletion, workspace deletion, take-ownership and transfer-to-member workflows request approval on the existing server routes. A submission notice means **requested**, not executed. Its link opens the particular approval with workspace scope. The existing document-deletion executor can report successful deletions while other documents fail; workspace deletion can then proceed after partial cleanup. Review execution logs/results rather than treating the submitted request as completed cleanup. -Activity exports contain only the 20 recent projected records displayed in the drawer. Activity Logs links retain public workspace scope for Phase 6. +Activity exports contain only the 20 recent projected records displayed in the drawer. Activity Logs links open the broader investigation with public workspace scope. ## Check activity-log data health From 4c99eeeeba44b320967a42b294c0b1bf5a88040a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 12:10:28 -0400 Subject: [PATCH 3/4] Return safe Public Workspace validation responses Cover all five handlers with sensitive-exception regressions and remove unused test imports. Isolated for Phase 5 backport. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../route_backend_control_center.py | 20 +++++++++---------- ...control_center_safe_exception_responses.py | 12 ++++++++++- ...est_v2_control_center_public_workspaces.py | 4 +--- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index c0f217980..0be7d9461 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -5235,8 +5235,8 @@ def api_v2_control_center_public_workspaces(): try: filters = parse_workspace_filters(request.args) return jsonify(query_workspaces(cosmos_public_workspaces_container, filters)), 200 - except GroupRequestError as ex: - return jsonify({"error": str(ex)}), 400 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace filters."}), 400 except Exception as ex: log_event("[CONTROL_CENTER] Workspace query failed.", extra={"error_type": type(ex).__name__}, level=logging.ERROR) @@ -5294,8 +5294,8 @@ def api_v2_control_center_public_workspace_detail(workspace_id): "tokens": tokens[0] if tokens and tokens[0] is not None else 0, "activity": activity, "metrics_calculated_at": datetime.now(timezone.utc).isoformat(), }), 200 - except GroupRequestError as ex: - return jsonify({"error": str(ex)}), 400 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace ID."}), 400 except CosmosResourceNotFoundError: return jsonify({"error": "Public workspace not found."}), 404 except Exception as ex: @@ -5312,8 +5312,8 @@ def api_v2_control_center_public_workspaces_bulk_status(): data = request.get_json(silent=True) validate_group_status_payload(data) ids = select_workspace_ids(cosmos_public_workspaces_container, data) - except GroupRequestError as ex: - return jsonify({"error": str(ex)}), 400 + except GroupRequestError: + return jsonify({"error": "Invalid bulk public workspace status request."}), 400 except Exception as ex: log_event("[CONTROL_CENTER] Workspace selection failed.", extra={"error_type": type(ex).__name__}, level=logging.ERROR) @@ -5334,8 +5334,8 @@ def api_v2_control_center_public_workspace_status(workspace_id): try: validate_group_id(workspace_id) validate_group_status_payload(request.get_json(silent=True)) - except GroupRequestError as ex: - return jsonify({"error": str(ex)}), 400 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace status request."}), 400 return api_update_public_workspace_status(workspace_id) @bp.route('/api/v2/control-center/public-workspaces/export.csv', methods=['GET']) @@ -5362,8 +5362,8 @@ def api_v2_control_center_public_workspaces_export(): )) return Response(buffer.getvalue(), mimetype="text/csv", headers={"Content-Disposition": 'attachment; filename="control-center-public-workspaces.csv"'}), 200 - except GroupRequestError as ex: - return jsonify({"error": str(ex)}), 400 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace export filters."}), 400 except Exception as ex: log_event("[CONTROL_CENTER] Workspace export failed.", extra={"error_type": type(ex).__name__}, level=logging.ERROR) diff --git a/functional_tests/test_control_center_safe_exception_responses.py b/functional_tests/test_control_center_safe_exception_responses.py index df3dfb11d..c34022ea9 100644 --- a/functional_tests/test_control_center_safe_exception_responses.py +++ b/functional_tests/test_control_center_safe_exception_responses.py @@ -1,7 +1,7 @@ # test_control_center_safe_exception_responses.py """ Functional test for safe Control Center exception responses. -Version: 0.261.283 +Version: 0.261.286 Implemented in: 0.261.283 This test ensures validation exceptions in the V2 Control Center routes do not @@ -77,6 +77,16 @@ def fail(*_args, **_kwargs): "PUT", None, ("group-1",), "Invalid group status request."), ("api_v2_control_center_groups_export", "parse_group_filters", GroupRequestError, "GET", None, (), "Invalid group export filters."), + ("api_v2_control_center_public_workspaces", "parse_workspace_filters", GroupRequestError, + "GET", None, (), "Invalid public workspace filters."), + ("api_v2_control_center_public_workspace_detail", "validate_group_id", GroupRequestError, + "GET", None, ("workspace-1",), "Invalid public workspace ID."), + ("api_v2_control_center_public_workspaces_bulk_status", "validate_group_status_payload", GroupRequestError, + "POST", {}, (), "Invalid bulk public workspace status request."), + ("api_v2_control_center_public_workspace_status", "validate_group_id", GroupRequestError, + "PUT", None, ("workspace-1",), "Invalid public workspace status request."), + ("api_v2_control_center_public_workspaces_export", "parse_workspace_filters", GroupRequestError, + "GET", None, (), "Invalid public workspace export filters."), ], ) def test_validation_exceptions_return_stable_safe_messages( diff --git a/functional_tests/test_v2_control_center_public_workspaces.py b/functional_tests/test_v2_control_center_public_workspaces.py index 2c15e236d..aa1ff4184 100644 --- a/functional_tests/test_v2_control_center_public_workspaces.py +++ b/functional_tests/test_v2_control_center_public_workspaces.py @@ -1,18 +1,16 @@ # test_v2_control_center_public_workspaces.py """ Functional tests for V2 Control Center public workspace management. -Version: 0.261.284 +Version: 0.261.286 Implemented in: 0.261.283 Execute real query builders, guarded status writes, authentication, approval creation and dispatch against isolated Cosmos containers. No Azure calls. """ -import ast import copy import csv import importlib.util -import logging import sys from functools import wraps from io import StringIO From 730acb4347b4a721ea4f516ef2890b708058fea0 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 12:10:28 -0400 Subject: [PATCH 4/4] Document public workspace validation safety and bump patch version Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- docs/explanation/features/V2_CONTROL_CENTER.md | 2 +- .../fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md | 7 +++++++ docs/explanation/release_notes.md | 9 +++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 4148152a8..5cc11cf7e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.285" +VERSION = "0.261.286" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index b3a87c0f0..63648c68a 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -8,7 +8,7 @@ The V2 Control Center is a permission-aware administration pane for managing Sim **Groups implemented in version:** 0.261.282 **Activity Logs implemented in version:** 0.261.284 **Public Workspaces implemented in version:** 0.261.283 -**Current version:** 0.261.285 (Activity Logs integrated above Public Workspaces and Groups validation-safety fixes) +**Current version:** 0.261.286 (Public Workspace validation-safety fixes above the integrated management phases) **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md index 04338c0f7..0dd13c8cd 100644 --- a/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md @@ -2,12 +2,16 @@ Fixed in version: **0.261.283**, tracked in `application/single_app/config.py`. +Extended to Public Workspaces in version: **0.261.286**. + ## Issue and root cause Users and Groups API validation handlers returned exception text to the browser. Even when a custom validation exception normally contains a reviewed message, returning exception text creates an unnecessary client-visible exception boundary. CodeQL identified nine such handlers in the V2 Control Center routes. +The Public Workspace list, detail, bulk-status, status and export handlers had +the same exception boundary; all five now return stable validation messages. ## Technical details @@ -23,6 +27,9 @@ Focused Users and Groups regression tests cover validation failures and safe storage-error responses. The prior Control Center tests and route-policy tests check that the merged integration retains the same authorization boundaries. The built local V2 bundle is exercised by Groups and Users browser workflows. +`functional_tests/test_control_center_safe_exception_responses.py` injects +sensitive exception text into all fourteen validation handlers and checks the +exact safe response and HTTP 400 status. Before the fix, client responses could contain exception text. After the fix, validation failures use stable messages and do not expose exception details. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 6f1256db8..69579d0b0 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ 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.286)** + +#### Bug Fixes + +* **Public Workspace Validation Error Safety** + * Public Workspace list, detail, status, bulk-status and export APIs return stable validation messages rather than exception text. + * Preserves authorization and HTTP status behavior; adds sensitive-error regression coverage. + * (Ref: `route_backend_control_center.py`, `test_control_center_safe_exception_responses.py`, [Control Center Validation Error Safety](fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md)) + ### **(v0.261.285)** #### New Features