From 78baabde96532c1f073de236ecab61ffa39e8f8c Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 02:11:42 +0800 Subject: [PATCH 01/20] fix(operation-trace): speed up audit reads and align i18n locales Performance of the operation traceability surface: - Cache the retention / row-budget / dedupe policy per Workspace and invalidate it on governance change, so the hot write path no longer issues three metadata round trips per recorded operation. - Resolve the governance payload with one metadata query instead of four. - Turn the dedupe probe into an existence check (LIMIT 1) instead of a full COUNT over the dedupe window. - Defer the row-budget COUNT to every N inserts; prune still enforces the exact budget, and the maintenance loop keeps it bounded. - Panel: fetch only the records page when paging or filtering instead of re-requesting governance and the filter catalogue every time. - Export: include the effective filters (and limit) in the artifact so a downloaded CSV is self-describing. Fixes and cleanup: - Drop the unused OperationLogPruneReport import that broke `tsc`. - i18n: remove the 9 redundant blank lines unique to en-US and format the six non-compliant locale files with the project Prettier config; all eight locales keep identical key sets, key order and placeholders. --- src/langbot/pkg/api/http/controller/group.py | 142 +- .../api/http/controller/groups/settings.py | 244 +++ .../api/http/controller/groups/workspaces.py | 76 + src/langbot/pkg/api/http/service/settings.py | 1521 +++++++++++++++++ src/langbot/pkg/core/app.py | 41 + src/langbot/pkg/core/stages/build_app.py | 4 + .../pkg/entity/persistence/operation_log.py | 179 ++ src/langbot/pkg/persistence/alembic/env.py | 1 + .../versions/0032_workspace_operation_logs.py | 133 ++ .../versions/0033_operation_log_integrity.py | 70 + src/langbot/pkg/persistence/mgr.py | 1 + src/langbot/pkg/persistence/tenant_uow.py | 3 + .../components/home-sidebar/HomeSidebar.tsx | 14 + .../settings-dialog/SettingsDialog.tsx | 26 +- .../OperationTracePanel.tsx | 692 ++++++++ web/src/app/infra/entities/operation-log.ts | 96 ++ web/src/app/infra/http/BackendClient.ts | 49 + web/src/i18n/locales/en-US.ts | 84 +- web/src/i18n/locales/es-ES.ts | 76 + web/src/i18n/locales/ja-JP.ts | 76 + web/src/i18n/locales/ru-RU.ts | 76 + web/src/i18n/locales/th-TH.ts | 76 + web/src/i18n/locales/vi-VN.ts | 76 + web/src/i18n/locales/zh-Hans.ts | 75 + web/src/i18n/locales/zh-Hant.ts | 75 + 25 files changed, 3893 insertions(+), 13 deletions(-) create mode 100644 src/langbot/pkg/api/http/controller/groups/settings.py create mode 100644 src/langbot/pkg/api/http/service/settings.py create mode 100644 src/langbot/pkg/entity/persistence/operation_log.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py create mode 100644 web/src/app/home/components/workspace-settings/OperationTracePanel.tsx create mode 100644 web/src/app/infra/entities/operation-log.ts diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 6126eebd4..1b102ff09 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -4,6 +4,7 @@ import abc import typing import enum import quart +import time import traceback import inspect import uuid @@ -94,12 +95,23 @@ class RouterGroup(abc.ABC): rule: str, auth_type: AuthType = AuthType.USER_TOKEN, permission: Permission | str | None = None, + operation_log_meta: dict[str, typing.Any] | None = None, **options: typing.Any, ) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator - """Register a route""" + """Register a route. + + ``operation_log_meta`` optionally enriches the traceability record for + this route with trusted server-side facts. Supported keys: + + * ``resource_param``: handler kwarg holding the target resource id, + * ``changes``: a pre-computed before/after diff to persist, + * ``detail``: extra structured context (sensitive keys are redacted). + """ if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None: raise ValueError('Account-token routes cannot declare Workspace permissions') + if operation_log_meta is not None and not isinstance(operation_log_meta, dict): + raise ValueError('operation_log_meta must be a dict') def decorator(f: RouteCallable) -> RouteCallable: nonlocal rule @@ -223,6 +235,7 @@ class RouterGroup(abc.ABC): except Exception as e: return self._auth_error_response(e) + observed_at = time.monotonic() try: if request_context is not None: diagnostics.workspace(request_context) @@ -245,11 +258,40 @@ class RouterGroup(abc.ABC): # runtimes, uploads, or streamed clients. # Services that need atomic writes open a UoW. async with persistence_mgr.tenant_scope(request_context.workspace_uuid): - return await f(*args, **kwargs) - return await f(*args, **kwargs) - return await f(*args, **kwargs) + response = await f(*args, **kwargs) + else: + response = await f(*args, **kwargs) + else: + response = await f(*args, **kwargs) + + if request_context is not None: + # Trace after the handler released its tenant scope so + # auditing never extends a business transaction. The + # call is a no-op (and costs no database round trip) + # when tracing is off for this Workspace. + await self._record_operation( + request_context, + rule, + options, + kwargs, + observed_at, + response, + meta=operation_log_meta, + ) + return response except Exception as e: # 自动 500 + if request_context is not None: + await self._record_operation( + request_context, + rule, + options, + kwargs, + observed_at, + None, + failure=e, + meta=operation_log_meta, + ) if isinstance(e, CodexProviderError): return self.http_status(e.status_code, e.error_code, str(e)) if isinstance(e, AuthorizationError): @@ -301,6 +343,98 @@ class RouterGroup(abc.ABC): return decorator + async def _record_operation( + self, + ctx: RequestContext, + route: str, + options: dict[str, typing.Any], + kwargs: dict[str, typing.Any], + observed_at: float, + response: typing.Any, + *, + failure: BaseException | None = None, + meta: dict[str, typing.Any] | None = None, + ) -> None: + """Best-effort traceability for one finished request. + + This runs after the handler released its tenant scope. It never raises: + an audit failure must not turn a successful business response into a + 500. The service performs its own cheap level check before touching the + database, so a disabled Workspace pays nothing at steady state. + """ + + service = getattr(self.ap, 'workspace_settings_service', None) + if service is None: + return + + try: + method = str(quart.request.method or options.get('methods', ['GET'])[0]) + status_code = self._extract_status_code(response, failure) + if failure is not None: + outcome = 'denied' if isinstance(failure, AuthorizationError) else 'error' + elif status_code >= 500: + outcome = 'error' + elif status_code >= 400: + outcome = 'denied' + else: + outcome = 'ok' + + resolved_meta = meta or {} + resource_id = None + resource_param = resolved_meta.get('resource_param') + if isinstance(resource_param, str): + candidate = kwargs.get(resource_param) + if candidate is not None: + resource_id = str(candidate) + + # Handlers may publish request-local traceability facts when a + # static route declaration cannot describe the runtime diff, e.g. + # the previous and the new role of a member. ``quart.g`` is + # request-local, so concurrent requests never share these values. + changes = resolved_meta.get('changes') + detail = resolved_meta.get('detail') + request_changes = getattr(quart.g, 'operation_log_changes', None) + request_detail = getattr(quart.g, 'operation_log_detail', None) + request_resource_id = getattr(quart.g, 'operation_log_resource_id', None) + request_summary = getattr(quart.g, 'operation_log_summary', None) + if request_changes: + changes = request_changes + if request_detail: + detail = request_detail + if request_resource_id: + resource_id = str(request_resource_id) + + await service.record_request( + ctx, + method=method, + route=route, + status_code=status_code, + outcome=outcome, + duration_ms=int(max(time.monotonic() - observed_at, 0.0) * 1000), + resource_id=resource_id, + changes=changes or None, + detail=detail or None, + ) + except Exception as exc: # pragma: no cover - auditing is best effort + logger = getattr(self.ap, 'logger', self.quart_app.logger) + logger.debug(f'Operation trace skipped for {route}: {exc}') + + @staticmethod + def _extract_status_code(response: typing.Any, failure: BaseException | None) -> int: + """Resolve the HTTP status of a handler result without consuming it.""" + + if failure is not None: + status = getattr(failure, 'status_code', None) + if isinstance(status, int): + return status + return 500 + if isinstance(response, tuple) and len(response) >= 2 and isinstance(response[1], int): + return response[1] + status = getattr(response, 'status_code', None) + if isinstance(status, int): + return status + return 200 + async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]: account: typing.Any = None resolver = getattr(self.ap.user_service, 'get_authenticated_account', None) diff --git a/src/langbot/pkg/api/http/controller/groups/settings.py b/src/langbot/pkg/api/http/controller/groups/settings.py new file mode 100644 index 000000000..72a4826a0 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/settings.py @@ -0,0 +1,244 @@ +"""Workspace governance settings routes. + +Exposes the "成员操作日志溯源" (member operation traceability) surface: + +* ``GET /api/v1/settings/governance`` read level + template table +* ``PUT /api/v1/settings/governance`` change level / retention +* ``GET /api/v1/settings/operation-logs`` page through records +* ``GET /api/v1/settings/operation-logs/filters`` available filter values +* ``POST /api/v1/settings/operation-logs/prune`` enforce retention now +* ``DEL /api/v1/settings/operation-logs`` clear records + +Every route requires ``audit.view``, which is granted to the Workspace owner +and admin only. Write routes additionally require an owning/admin role so a +future permission grant cannot silently widen who may reconfigure tracing. +""" + +from __future__ import annotations + +import datetime +import json +import typing + +import quart + +from ...authz import Permission +from ...context import RequestContext +from ...service import settings as settings_service +from .. import group + + +def _parse_timestamp(value: str | None) -> datetime.datetime | None: + """Parse an ISO-8601 query parameter, tolerating a trailing ``Z``.""" + + if not value: + return None + candidate = value.strip() + if not candidate: + return None + if candidate.endswith('Z'): + candidate = candidate[:-1] + '+00:00' + try: + parsed = datetime.datetime.fromisoformat(candidate) + except ValueError: + return None + # Stored timestamps are naive UTC; normalise aware inputs before comparing. + if parsed.tzinfo is not None: + parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return parsed + + +def _parse_int(value: str | None, default: int) -> int: + try: + return int(value) if value is not None else default + except (TypeError, ValueError): + return default + + +def _csv_cell(value: typing.Any) -> str: + """Render one CSV cell with the standard quoting rules.""" + + if value is None: + return '' + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + return '"' + text.replace('"', '""') + '"' + + +@group.group_class('settings', '/api/v1/settings') +class SettingsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route( + '/governance', + methods=['GET', 'PUT'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.AUDIT_VIEW, + ) + async def _(request_context: RequestContext) -> typing.Any: + service = self.ap.workspace_settings_service + if quart.request.method == 'GET': + return self.success(data=await service.describe_governance(request_context.workspace_uuid)) + + if not settings_service.role_may_configure(request_context.workspace.role): + return self.http_status( + 403, + 'permission_denied', + 'Only Workspace owners and admins can change tracing settings', + ) + + data = await quart.request.get_json(silent=True) or {} + # The previous level is captured before the write so the trace can + # answer "what was changed into what" for the governance change + # itself, not only for the resources it protects. + before = await service.describe_governance(request_context.workspace_uuid) + updated = await service.set_operation_level( + request_context.workspace_uuid, + data.get('level', before['configured_level']), + retention_days=data.get('retention_days'), + max_rows=data.get('max_rows'), + dedupe_window_seconds=data.get('dedupe_window_seconds'), + ) + + account_name = None + if request_context.account_uuid and self.ap.user_service is not None: + account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) + account_name = account.user if account is not None else None + + changes = settings_service.changed_fields( + { + 'configured_level': before['configured_level'], + 'retention_days': before['retention_days'], + 'max_rows': before['max_rows'], + 'dedupe_window_seconds': before['dedupe_window_seconds'], + }, + { + 'configured_level': updated['configured_level'], + 'retention_days': updated['retention_days'], + 'max_rows': updated['max_rows'], + 'dedupe_window_seconds': updated['dedupe_window_seconds'], + }, + ) + if changes: + await service.record( + request_context.workspace_uuid, + rule=settings_service.ACTION_RULES_BY_ACTION['settings_update'], + level=max( + await service.effective_level( + request_context.workspace_uuid, + request_context.workspace.role, + ), + settings_service.OPERATION_LEVEL_MUTATION, + ), + actor_account_uuid=request_context.account_uuid, + actor_name=account_name, + actor_role=request_context.workspace.role, + principal_type=request_context.principal.principal_type.value, + request_id=request_context.request_id, + http_method=quart.request.method, + route='/api/v1/settings/governance', + outcome='ok', + summary=settings_service.build_summary( + settings_service.ACTION_RULES_BY_ACTION['settings_update'], changes + ), + changes=changes, + ) + return self.success(data=updated) + + @self.route( + '/operation-logs', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.AUDIT_VIEW, + ) + async def _(request_context: RequestContext) -> typing.Any: + # Read-only by design: operation records are immutable and cannot + # be deleted on demand. Only the retention policy may prune them. + service = self.ap.workspace_settings_service + + args = quart.request.args + level_raw = args.get('level') + result = await service.query_logs( + request_context.workspace_uuid, + limit=_parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE), + offset=_parse_int(args.get('offset'), 0), + action=args.get('action') or None, + resource_type=args.get('resource_type') or None, + actor_account_uuid=args.get('actor') or None, + level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None, + since=_parse_timestamp(args.get('since')), + until=_parse_timestamp(args.get('until')), + ) + return self.success(data=result) + + @self.route( + '/operation-logs/filters', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.AUDIT_VIEW, + ) + async def _(request_context: RequestContext) -> typing.Any: + service = self.ap.workspace_settings_service + options = await service.list_filter_options(request_context.workspace_uuid) + options['governance'] = await service.describe_governance(request_context.workspace_uuid) + return self.success(data=options) + + @self.route( + '/operation-logs/export', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.AUDIT_VIEW, + ) + async def _(request_context: RequestContext) -> typing.Any: + """Download the filtered records as a CSV attachment. + + The export is read-only and shares the exact filters of the panel, + so what an operator sees is what the artifact contains. + """ + + args = quart.request.args + level_raw = args.get('level') + service = self.ap.workspace_settings_service + document = await service.export_logs( + request_context.workspace_uuid, + action=args.get('action') or None, + resource_type=args.get('resource_type') or None, + actor_account_uuid=args.get('actor') or None, + level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None, + since=_parse_timestamp(args.get('since')), + until=_parse_timestamp(args.get('until')), + ) + + columns = ( + 'id', + 'created_at', + 'action', + 'resource_type', + 'resource_id', + 'actor_account_uuid', + 'actor_name', + 'actor_role', + 'principal_type', + 'http_method', + 'route', + 'outcome', + 'status_code', + 'level_name', + 'integrity_ok', + 'chain_ok', + 'tampered', + 'record_hash', + 'prev_hash', + 'duration_ms', + 'summary', + 'changes', + 'detail', + ) + lines = [','.join(columns)] + for record in document['records']: + lines.append(','.join(_csv_cell(record.get(column)) for column in columns)) + + # Prepend a UTF-8 BOM so spreadsheet tools detect the encoding. + body = '\ufeff' + '\r\n'.join(lines) + '\r\n' + filename = f"operation-logs-{document['exported_at'].replace(':', '')}.csv" + response = quart.Response(body, mimetype='text/csv', content_type='text/csv; charset=utf-8') + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + return response diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 4640596cd..9b3f2ac99 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -12,6 +12,7 @@ from .....entity.persistence.workspace import WorkspaceSource from .....workspace.collaboration import WorkspaceMemberView from .....workspace.errors import WorkspaceNotFoundError from .....workspace.invitation_delivery import InvitationDeliveryService +from ...service import settings as settings_service from .. import group @@ -218,6 +219,18 @@ class WorkspacesRouterGroup(group.RouterGroup): ) created = await self._run_in_workspace_uow(workspace_uuid, create_invitation) + invite_rule = settings_service.ACTION_RULES_BY_ACTION['member_invite'] + invite_changes = [ + { + 'field': 'invitation', + 'before': None, + 'after': f"{created.invitation.normalized_email}:{created.invitation.role}", + } + ] + quart.g.operation_log_changes = invite_changes + quart.g.operation_log_resource_id = created.invitation.uuid + quart.g.operation_log_detail = {'role': created.invitation.role} + quart.g.operation_log_summary = settings_service.build_summary(invite_rule, invite_changes) delivery_service = self._invitation_delivery_service() link = delivery_service.build_invitation_link(created.token) workspace = await self.ap.workspace_service.get_workspace(workspace_uuid) @@ -266,6 +279,11 @@ class WorkspacesRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) + + # Capture the previous role before mutating so the operation trace + # can answer "what was changed into what" for this member. + before_role = await self._member_role(workspace_uuid, account_uuid) + if quart.request.method == 'DELETE': if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions: return self.http_status(403, 'permission_denied', 'Member removal permission is required') @@ -276,6 +294,17 @@ class WorkspacesRouterGroup(group.RouterGroup): ) member = await self._run_in_workspace_uow(workspace_uuid, remove_member) + await self._publish_member_trace( + rule_action='member_remove', + account_uuid=account_uuid, + changes=[ + { + 'field': 'member', + 'before': f'{account_uuid}:{before_role or "unknown"}', + 'after': 'removed', + } + ], + ) return self.success(data={'account_uuid': member.account_uuid}) data = await quart.request.get_json(silent=True) or {} @@ -290,6 +319,18 @@ class WorkspacesRouterGroup(group.RouterGroup): member = await self._run_in_workspace_uow(workspace_uuid, update_member_role) account = await self.ap.user_service.get_user_by_uuid(member.account_uuid) + + role_changes = [ + {'field': 'role', 'before': before_role, 'after': member.role}, + ] + await self._publish_member_trace( + rule_action='member_role_update', + account_uuid=account_uuid, + changes=role_changes, + detail={ + 'member_email': account.normalized_email if account is not None else None, + }, + ) return self.success( data={ 'member': _membership_payload( @@ -300,6 +341,41 @@ class WorkspacesRouterGroup(group.RouterGroup): } ) + async def _member_role(self, workspace_uuid: str, account_uuid: str) -> str | None: + """Return a member's current role without failing the request.""" + + try: + members = await self.ap.workspace_collaboration_service.list_members( + workspace_uuid, quart.g.workspace_membership + ) + except Exception: + return None + for view in members: + if view.membership.account_uuid == account_uuid: + return view.membership.role + return None + + async def _publish_member_trace( + self, + *, + rule_action: str, + account_uuid: str, + changes: list[dict[str, typing.Any]], + detail: dict[str, typing.Any] | None = None, + ) -> None: + """Publish request-local trace facts consumed by the route wrapper. + + ``quart.g`` is request-local, so concurrent member operations never + share these values. + """ + + rule = settings_service.ACTION_RULES_BY_ACTION.get(rule_action) + quart.g.operation_log_changes = changes + quart.g.operation_log_resource_id = account_uuid + quart.g.operation_log_detail = detail or {} + if rule is not None: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) + @staticmethod def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None: if workspace_uuid != request_context.workspace_uuid: diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/api/http/service/settings.py new file mode 100644 index 000000000..8027a2c0e --- /dev/null +++ b/src/langbot/pkg/api/http/service/settings.py @@ -0,0 +1,1521 @@ +"""Workspace governance settings and operation traceability. + +This service owns everything behind the "成员操作日志溯源" (member operation +traceability) feature: + +* the three capture levels and how a Workspace role caps them, +* the action classification table that maps an HTTP request to a normalized + action / resource pair, +* the append-only writer that persists one row per traced operation while + answering "what was changed into what", +* the tamper-evidence chain that makes silent edits detectable, +* the dedupe window that keeps an idle WebUI from inflating the log, +* read/prune APIs used by the settings panel. + +Every write path is best-effort: auditing must never break the business +request. Failures are logged and swallowed. + +Human-readable labels are never returned from this module. The persisted +``action`` value is a stable key and the UI resolves +``operationTrace.actions.`` from the i18n locale files. +""" + +from __future__ import annotations + +import dataclasses +import datetime +import enum +import hashlib +import hmac +import json +import typing + +import sqlalchemy + +from ....core import app +from ....entity.persistence import metadata as persistence_metadata +from ....entity.persistence import operation_log as persistence_operation_log +from ....entity.persistence.operation_log import ( + OPERATION_LEVEL_MUTATION, + OPERATION_LEVEL_NONE, + OPERATION_LEVEL_READ, +) +from ....utils import constants +from ..authz import WorkspaceRole +from ..context import PrincipalType, RequestContext + + +# --------------------------------------------------------------------------- +# Storage keys (WorkspaceMetadata.value is a short string column) +# --------------------------------------------------------------------------- + +OPERATION_LEVEL_KEY = 'operation_log_level' +OPERATION_RETENTION_DAYS_KEY = 'operation_log_retention_days' +OPERATION_MAX_ROWS_KEY = 'operation_log_max_rows' +OPERATION_DEDUPE_WINDOW_KEY = 'operation_log_dedupe_seconds' + +DEFAULT_OPERATION_LEVEL = OPERATION_LEVEL_READ +DEFAULT_RETENTION_DAYS = 30 +DEFAULT_MAX_ROWS = 20000 + +#: Repeated identical read observations inside this window collapse into one +#: row. This is what stops a live WebUI or a polling client from inflating the +#: log while the page simply stays open. +DEFAULT_DEDUPE_WINDOW_SECONDS = 60 + +# Hard bounds so a misconfigured Workspace cannot grow the log unbounded. +MIN_RETENTION_DAYS = 1 +MAX_RETENTION_DAYS = 3650 +MIN_MAX_ROWS = 100 +MAX_MAX_ROWS = 500000 +MIN_DEDUPE_WINDOW_SECONDS = 0 +MAX_DEDUPE_WINDOW_SECONDS = 3600 + +MAX_PAGE_SIZE = 200 +DEFAULT_PAGE_SIZE = 50 + +#: Upper bound for a single export so a download cannot load the whole table. +MAX_EXPORT_ROWS = 10000 + +#: Number of inserts a Workspace may accumulate before the row budget is +#: re-checked. Enforcing the budget with a ``COUNT`` on every insert turns a +#: cheap append into a scan of the whole Workspace history; deferring it keeps +#: the overshoot bounded by this stride while the maintenance loop still +#: enforces the exact limit. +_ROW_BUDGET_CHECK_STRIDE = 64 + +_MAX_SUMMARY_FIELDS = 6 +_MAX_FIELD_VALUE_CHARS = 320 +_MAX_USER_AGENT_CHARS = 512 +_MAX_RESOURCE_ID_CHARS = 255 +_MAX_ROUTE_CHARS = 512 + +# Fields that must never be copied into the traceable diff, matched as +# case-insensitive substrings so ``space_access_token`` is covered too. +_SENSITIVE_FIELD_MARKERS: typing.Final = ( + 'password', + 'passwd', + 'secret', + 'token', + 'hash', + 'api_key', + 'apikey', + 'credential', + 'private_key', + 'salt', + 'passkey', + 'totp', + 'recovery_code', + 'authorization', +) + +#: Machine sentinel written instead of a masked value. It is deliberately not +#: human-readable copy: the frontend maps it to ``operationTrace.redacted`` so +#: no interface text lives in the backend. +REDACTED_SENTINEL = '__redacted__' + +#: Domain separator for the tamper-evidence HMAC. Changing it invalidates all +#: previously stored hashes, so it must stay stable across releases. +_HASH_KEY_SALT = 'langbot.operation_log.integrity.v1' +_HASH_FIELDS: typing.Final = ( + 'workspace_uuid', + 'actor_account_uuid', + 'actor_name', + 'actor_role', + 'principal_type', + 'api_key_uuid', + 'auth_type', + 'http_method', + 'route', + 'action', + 'resource_type', + 'resource_id', + 'level', + 'outcome', + 'status_code', + 'summary', + 'changes', + 'client_ip', + 'prev_hash', +) + + +# --------------------------------------------------------------------------- +# Action catalog +# --------------------------------------------------------------------------- + + +class OperationLevel(enum.IntEnum): + """The three operator-selectable capture levels.""" + + OFF = OPERATION_LEVEL_NONE + MUTATION = OPERATION_LEVEL_MUTATION + READ = OPERATION_LEVEL_READ + + +#: How a role caps the Workspace-selected level. An operator or developer may +#: never force read-level tracing on for the whole Workspace, and a viewer is +#: never traced at all, keeping the write amplification predictable. +_ROLE_LEVEL_CAP: typing.Final[dict[str, int]] = { + WorkspaceRole.OWNER.value: OPERATION_LEVEL_READ, + WorkspaceRole.ADMIN.value: OPERATION_LEVEL_READ, + WorkspaceRole.DEVELOPER.value: OPERATION_LEVEL_MUTATION, + WorkspaceRole.OPERATOR.value: OPERATION_LEVEL_MUTATION, + WorkspaceRole.VIEWER.value: OPERATION_LEVEL_NONE, +} + +#: Principals without a Workspace role (API keys, legacy keys) behave like a +#: developer: mutations only. +_UNROLED_LEVEL_CAP = OPERATION_LEVEL_MUTATION + +#: Roles allowed to change the Workspace tracing level / retention. +_SETTINGS_WRITE_ROLES: typing.Final = frozenset({WorkspaceRole.OWNER.value, WorkspaceRole.ADMIN.value}) + + +@dataclasses.dataclass(frozen=True, slots=True) +class ActionRule: + """One row of the action classification template. + + ``action`` doubles as the i18n key suffix, so this table never carries a + human-readable label. + """ + + action: str + """Normalized action verb persisted in the log and used as the i18n key.""" + + category: str + """Coarse family used for grouping (member, resource, settings, audit...).""" + + bucket: str + """Capture bucket: ``read``, ``write`` or ``audit``.""" + + resource_type: str + """Default resource family when the route declares none.""" + + @property + def i18n_key(self) -> str: + """Return the frontend translation key for this action.""" + + return f'operationTrace.actions.{self.action}' + + +#: Ordered template table. Order matters: the first matching rule wins, so +#: specific routes must appear before the generic HTTP-verb fallbacks. +ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( + # --- Audit surface itself (always traced once tracing is on) -------- + ActionRule( + action='audit_log_view', + category='audit', + bucket='audit', + resource_type='operation_log', + ), + ActionRule( + action='clear_prune', + category='audit', + bucket='write', + resource_type='operation_log', + ), + ActionRule( + action='settings_update', + category='settings', + bucket='write', + resource_type='workspace_settings', + ), + ActionRule( + action='settings_view', + category='settings', + bucket='read', + resource_type='workspace_settings', + ), + # --- Member management ---------------------------------------------- + ActionRule( + action='member_invite', + category='member', + bucket='write', + resource_type='member_invitation', + ), + ActionRule( + action='member_role_update', + category='member', + bucket='write', + resource_type='member', + ), + ActionRule( + action='member_remove', + category='member', + bucket='write', + resource_type='member', + ), + ActionRule( + action='member_view', + category='member', + bucket='read', + resource_type='member', + ), + # --- Generic resource verbs ----------------------------------------- + ActionRule( + action='export', + category='resource', + bucket='read', + resource_type='resource', + ), + ActionRule( + action='execute', + category='runtime', + bucket='write', + resource_type='runtime', + ), + ActionRule( + action='debug', + category='runtime', + bucket='write', + resource_type='runtime', + ), + ActionRule( + action='publish', + category='resource', + bucket='write', + resource_type='resource', + ), + ActionRule( + action='create', + category='resource', + bucket='write', + resource_type='resource', + ), + ActionRule( + action='update', + category='resource', + bucket='write', + resource_type='resource', + ), + ActionRule( + action='delete', + category='resource', + bucket='write', + resource_type='resource', + ), + ActionRule( + action='view', + category='resource', + bucket='read', + resource_type='resource', + ), + ActionRule( + action='probe', + category='system', + bucket='read', + resource_type='system', + ), +) + +ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule for rule in ACTION_RULE_TABLE} + +_READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'}) + +# Route fragments, evaluated in order, that refine the generic verb mapping. +# Longer, more specific fragments must precede their prefixes. +_ROUTE_RULES: typing.Final[tuple[tuple[str, str], ...]] = ( + ('/settings/operation-logs/export', 'export'), + ('/settings/operation-logs', 'audit_log_view'), + ('/settings/operation-level', 'settings_update'), + ('/settings/governance', 'settings_update'), + ('/settings/limits', 'settings_update'), + ('/members', 'member_view'), + ('/invitations', 'member_invite'), + ('/export', 'export'), + ('/debug', 'debug'), + ('/execute', 'execute'), + ('/publish', 'publish'), +) + + +def classify(method: str, route: str) -> ActionRule: + """Map one HTTP request to its normalized action rule. + + ``route`` must be the registered Core route identity, never a raw URL, so + user identifiers cannot leak into classification. + """ + + upper_method = (method or 'GET').upper() + lowered_route = (route or '').lower() + + route_action: str | None = None + for fragment, candidate in _ROUTE_RULES: + if fragment in lowered_route: + route_action = candidate + break + + if route_action is not None: + if route_action == 'settings_update' and upper_method in _READ_METHODS: + return ACTION_RULES_BY_ACTION['settings_view'] + if route_action == 'member_view' and upper_method not in _READ_METHODS: + # POST /members/ style routes are writes on the member family. + return ACTION_RULES_BY_ACTION['member_role_update'] + return ACTION_RULES_BY_ACTION[route_action] + + if upper_method in _READ_METHODS: + return ACTION_RULES_BY_ACTION['view'] + if upper_method == 'POST': + return ACTION_RULES_BY_ACTION['create'] + if upper_method in {'PUT', 'PATCH'}: + return ACTION_RULES_BY_ACTION['update'] + if upper_method == 'DELETE': + return ACTION_RULES_BY_ACTION['delete'] + return ACTION_RULES_BY_ACTION['probe'] + + +def level_cap_for_role(role: str | None) -> int: + """Return the maximum capture level a role is allowed to produce.""" + + if not role: + return _UNROLED_LEVEL_CAP + return _ROLE_LEVEL_CAP.get(str(role), _UNROLED_LEVEL_CAP) + + +def role_may_configure(role: str | None) -> bool: + """Return whether a role may change Workspace tracing settings.""" + + return str(role or '') in _SETTINGS_WRITE_ROLES + + +def bucket_allows(bucket: str, effective_level: int) -> bool: + """Return whether a capture bucket should be persisted at this level.""" + + if effective_level <= OPERATION_LEVEL_NONE: + return False + if bucket == 'read': + return effective_level >= OPERATION_LEVEL_READ + # ``write`` and ``audit`` are both recorded from mutation level upward. + return effective_level >= OPERATION_LEVEL_MUTATION + + +# --------------------------------------------------------------------------- +# Payload helpers +# --------------------------------------------------------------------------- + + +def is_sensitive_field(field_name: str) -> bool: + """Return whether a field name must never be copied into a diff.""" + + lowered = (field_name or '').lower() + return any(marker in lowered for marker in _SENSITIVE_FIELD_MARKERS) + + +def truncate(value: typing.Any, limit: int) -> typing.Any: + """Return ``value`` rendered as a bounded string (or the value itself).""" + + if value is None or isinstance(value, (int, float, bool)): + return value + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + if len(text) <= limit: + return text + return text[: max(limit - 1, 0)] + '…' + + +def redact_payload(payload: typing.Any, *, depth: int = 0) -> typing.Any: + """Recursively drop sensitive keys from a payload before persisting it.""" + + if depth > 4: + return truncate(payload, _MAX_FIELD_VALUE_CHARS) + if isinstance(payload, dict): + redacted: dict[str, typing.Any] = {} + for key, value in payload.items(): + if is_sensitive_field(str(key)): + redacted[str(key)] = REDACTED_SENTINEL + continue + redacted[str(key)] = redact_payload(value, depth=depth + 1) + return redacted + if isinstance(payload, (list, tuple)): + return [redact_payload(item, depth=depth + 1) for item in payload] + return payload + + +def changed_fields( + before: typing.Mapping[str, typing.Any] | None, + after: typing.Mapping[str, typing.Any] | None, + *, + ignore: typing.Iterable[str] = (), +) -> list[dict[str, typing.Any]]: + """Return the field-level diff between two mappings. + + The result is exactly what the traceability UI needs: for every changed + field it reports the previous and the new value. + """ + + before_map = dict(before or {}) + after_map = dict(after or {}) + ignored = set(ignore) + + changes: list[dict[str, typing.Any]] = [] + for field_name in sorted(set(before_map) | set(after_map)): + if field_name in ignored: + continue + old_value = before_map.get(field_name) + new_value = after_map.get(field_name) + if old_value == new_value: + continue + if is_sensitive_field(str(field_name)): + changes.append( + { + 'field': str(field_name), + 'before': REDACTED_SENTINEL, + 'after': REDACTED_SENTINEL, + } + ) + continue + changes.append( + { + 'field': str(field_name), + 'before': truncate(old_value, _MAX_FIELD_VALUE_CHARS), + 'after': truncate(new_value, _MAX_FIELD_VALUE_CHARS), + } + ) + return changes + + +def build_summary(rule: ActionRule, changes: list[dict[str, typing.Any]]) -> str: + """Build a short change digest stored alongside the record. + + The digest is display-neutral: the card renders the localized action label + from i18n and shows this digest as the concrete ``field: a → b`` detail. + """ + + if not changes: + return '' + fragments: list[str] = [] + for change in changes[:_MAX_SUMMARY_FIELDS]: + fragments.append(f"{change['field']}: {change['before']} → {change['after']}") + remaining = len(changes) - len(fragments) + if remaining > 0: + fragments.append(f'+{remaining}') + return '; '.join(fragments) + + +def changes_payload(changes: list[dict[str, typing.Any]]) -> str | None: + """Serialize a diff into the stored JSON string.""" + + if not changes: + return None + return json.dumps(changes, ensure_ascii=False, default=str) + + +def decode_changes(raw: str | None) -> list[dict[str, typing.Any]]: + """Decode a stored diff, tolerating legacy or corrupt payloads.""" + + if not raw: + return [] + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(parsed, list): + return [] + return [item for item in parsed if isinstance(item, dict)] + + +def decode_json_object(raw: str | None) -> dict[str, typing.Any]: + """Decode a stored JSON object, tolerating legacy or corrupt payloads.""" + + if not raw: + return {} + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def clamp_level(value: typing.Any) -> int: + """Coerce an untrusted level input into the valid 0..2 range.""" + + try: + parsed = int(value) + except (TypeError, ValueError): + return DEFAULT_OPERATION_LEVEL + return max(OPERATION_LEVEL_NONE, min(OPERATION_LEVEL_READ, parsed)) + + +def clamp_retention(value: typing.Any) -> int: + """Coerce an untrusted retention input into the valid day range.""" + + try: + parsed = int(value) + except (TypeError, ValueError): + return DEFAULT_RETENTION_DAYS + return max(MIN_RETENTION_DAYS, min(MAX_RETENTION_DAYS, parsed)) + + +def clamp_max_rows(value: typing.Any) -> int: + """Coerce an untrusted row-budget input into the valid range.""" + + try: + parsed = int(value) + except (TypeError, ValueError): + return DEFAULT_MAX_ROWS + return max(MIN_MAX_ROWS, min(MAX_MAX_ROWS, parsed)) + + +def clamp_dedupe_window(value: typing.Any) -> int: + """Coerce an untrusted dedupe window input into the valid second range.""" + + try: + parsed = int(value) + except (TypeError, ValueError): + return DEFAULT_DEDUPE_WINDOW_SECONDS + return max(MIN_DEDUPE_WINDOW_SECONDS, min(MAX_DEDUPE_WINDOW_SECONDS, parsed)) + + +def level_name(level: int) -> str: + return persistence_operation_log.OPERATION_LEVEL_NAMES.get(int(level), 'unknown') + + +def _utcnow() -> datetime.datetime: + """Return naive UTC, matching the database's ``CURRENT_TIMESTAMP``. + + Comparing a local-time cutoff against a UTC column would silently disable + dedupe and retention in non-UTC timezones such as UTC+8. + """ + + return datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + + +# --------------------------------------------------------------------------- +# Tamper evidence +# --------------------------------------------------------------------------- + + +def _integrity_secret() -> bytes: + """Derive the per-instance HMAC key without adding configuration. + + The instance id is generated and persisted by LangBot itself; salting it + with a domain separator keeps this key independent from other uses. + """ + + return f'{_HASH_KEY_SALT}:{constants.instance_id}'.encode('utf-8') + + +def compute_record_hash(row: typing.Mapping[str, typing.Any]) -> str: + """Return the keyed hash over the canonical content of one record. + + The parent pointer (``prev_hash``) is part of the payload, so the whole + chain is anchored: editing a row or dropping a link changes the hash of + every record computed after it. + """ + + canonical = {field: row.get(field) for field in _HASH_FIELDS} + payload = json.dumps(canonical, sort_keys=True, ensure_ascii=False, default=str).encode('utf-8') + return hmac.new(_integrity_secret(), payload, hashlib.sha256).hexdigest() + + +def verify_record_hash(row: typing.Mapping[str, typing.Any], stored_hash: str | None) -> bool: + """Return whether a stored hash still matches the row content.""" + + if not stored_hash: + return False + return hmac.compare_digest(compute_record_hash(row), str(stored_hash)) + + +def compute_dedupe_key( + *, + actor_account_uuid: str | None, + action: str, + route: str | None, + resource_id: str | None, +) -> str: + """Build the key that collapses repeated identical observations.""" + + material = '|'.join( + [ + str(actor_account_uuid or ''), + str(action or ''), + str(route or ''), + str(resource_id or ''), + ] + ) + return hashlib.sha256(material.encode('utf-8')).hexdigest() + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + + +class WorkspaceSettingsService: + """Governance settings + operation traceability for one Workspace.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + # Level cache keyed by Workspace UUID: {"level": int, "expires": float} + self._level_cache: dict[str, tuple[int, float]] = {} + self._level_cache_ttl = 5.0 + # Retention / row budget / dedupe window cache keyed by Workspace UUID. + # Reading all three on every insert would add up to three metadata + # round trips to the hot write path; they change only through the + # governance route, which invalidates this cache. + self._policy_cache: dict[str, tuple[dict[str, int], float]] = {} + self._policy_cache_ttl = 5.0 + # Inserts since the last row-budget check, keyed by Workspace UUID. + self._insert_counters: dict[str, int] = {} + + # -- level resolution ------------------------------------------------- + + def _cache_level(self, workspace_uuid: str, level: int) -> None: + self._level_cache[workspace_uuid] = ( + int(level), + datetime.datetime.now().timestamp() + self._level_cache_ttl, + ) + + def _cached_level(self, workspace_uuid: str) -> int | None: + entry = self._level_cache.get(workspace_uuid) + if entry is None: + return None + level, expires_at = entry + if expires_at < datetime.datetime.now().timestamp(): + self._level_cache.pop(workspace_uuid, None) + return None + return level + + def invalidate_level(self, workspace_uuid: str) -> None: + """Drop the cached level and policy after a settings change.""" + + self._level_cache.pop(workspace_uuid, None) + self._policy_cache.pop(workspace_uuid, None) + + async def _read_metadata(self, workspace_uuid: str, key: str) -> str | None: + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value).where( + persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid, + persistence_metadata.WorkspaceMetadata.key == key, + ) + ) + return result.scalar_one_or_none() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log metadata read failed for {key}: {exc}') + return None + + async def _write_metadata(self, workspace_uuid: str, values: dict[str, str]) -> None: + for key, value in values.items(): + try: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_metadata.WorkspaceMetadata).where( + persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid, + persistence_metadata.WorkspaceMetadata.key == key, + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_metadata.WorkspaceMetadata).values( + workspace_uuid=workspace_uuid, + key=key, + value=value, + ) + ) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.warning(f'Operation log metadata write failed for {key}: {exc}') + + async def _read_metadata_values(self, workspace_uuid: str, keys: typing.Iterable[str]) -> dict[str, str]: + """Read several metadata rows for one Workspace in a single query.""" + + wanted = list(dict.fromkeys(keys)) + if not wanted: + return {} + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select( + persistence_metadata.WorkspaceMetadata.key, + persistence_metadata.WorkspaceMetadata.value, + ).where( + persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid, + persistence_metadata.WorkspaceMetadata.key.in_(wanted), + ) + ) + return {row[0]: row[1] for row in result.all()} + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log metadata batch read failed: {exc}') + return {} + + def _resolve_policy(self, values: dict[str, str]) -> dict[str, int]: + """Clamp the raw policy values, falling back to the defaults.""" + + def _coerce(key: str, clamp: typing.Callable[[typing.Any], int], default: int) -> int: + raw = values.get(key) + return default if raw is None else clamp(raw) + + return { + 'retention_days': _coerce(OPERATION_RETENTION_DAYS_KEY, clamp_retention, DEFAULT_RETENTION_DAYS), + 'max_rows': _coerce(OPERATION_MAX_ROWS_KEY, clamp_max_rows, DEFAULT_MAX_ROWS), + 'dedupe_window_seconds': _coerce( + OPERATION_DEDUPE_WINDOW_KEY, clamp_dedupe_window, DEFAULT_DEDUPE_WINDOW_SECONDS + ), + } + + async def _operations_policy(self, workspace_uuid: str) -> dict[str, int]: + """Return the cached retention / budget / dedupe configuration. + + Every recorded operation consults this policy (row budget and dedupe + window), so it is cached per Workspace and invalidated whenever the + governance route changes it. + """ + + entry = self._policy_cache.get(workspace_uuid) + if entry is not None: + values, expires_at = entry + if expires_at >= datetime.datetime.now().timestamp(): + return values + self._policy_cache.pop(workspace_uuid, None) + + raw = await self._read_metadata_values( + workspace_uuid, + (OPERATION_RETENTION_DAYS_KEY, OPERATION_MAX_ROWS_KEY, OPERATION_DEDUPE_WINDOW_KEY), + ) + policy = self._resolve_policy(raw) + self._policy_cache[workspace_uuid] = ( + policy, + datetime.datetime.now().timestamp() + self._policy_cache_ttl, + ) + return policy + + async def get_configured_level(self, workspace_uuid: str) -> int: + """Return the Workspace-selected capture level (uncapped).""" + + raw = await self._read_metadata(workspace_uuid, OPERATION_LEVEL_KEY) + if raw is None: + return DEFAULT_OPERATION_LEVEL + return clamp_level(raw) + + async def effective_level(self, workspace_uuid: str, role: str | None) -> int: + """Return the level actually used for a request by ``role``.""" + + cached = self._cached_level(workspace_uuid) + if cached is None: + cached = await self.get_configured_level(workspace_uuid) + self._cache_level(workspace_uuid, cached) + return min(cached, level_cap_for_role(role)) + + async def set_operation_level( + self, + workspace_uuid: str, + level: typing.Any, + *, + retention_days: typing.Any = None, + max_rows: typing.Any = None, + dedupe_window_seconds: typing.Any = None, + ) -> dict[str, typing.Any]: + """Persist a new capture level together with optional retention. + + Changing the level only changes whether *future* operations are + stored. Records already written are never hidden, rewritten or + deleted; only the retention policy may prune them. The new retention + values apply immediately through ``prune``. + """ + + resolved_level = clamp_level(level) + values = { + OPERATION_LEVEL_KEY: str(resolved_level), + } + if retention_days is not None: + values[OPERATION_RETENTION_DAYS_KEY] = str(clamp_retention(retention_days)) + if max_rows is not None: + values[OPERATION_MAX_ROWS_KEY] = str(clamp_max_rows(max_rows)) + if dedupe_window_seconds is not None: + values[OPERATION_DEDUPE_WINDOW_KEY] = str(clamp_dedupe_window(dedupe_window_seconds)) + await self._write_metadata(workspace_uuid, values) + self.invalidate_level(workspace_uuid) + # Apply the retention policy right away; the maintenance loop keeps + # enforcing it afterwards. There is no manual prune action. + await self.prune(workspace_uuid) + return await self.describe_governance(workspace_uuid) + + async def get_retention_days(self, workspace_uuid: str) -> int: + return (await self._operations_policy(workspace_uuid))['retention_days'] + + async def get_max_rows(self, workspace_uuid: str) -> int: + return (await self._operations_policy(workspace_uuid))['max_rows'] + + async def get_dedupe_window(self, workspace_uuid: str) -> int: + """Return the dedupe window in seconds for repeated observations.""" + + return (await self._operations_policy(workspace_uuid))['dedupe_window_seconds'] + + async def describe_governance(self, workspace_uuid: str) -> dict[str, typing.Any]: + """Return the governance payload rendered by the settings panel. + + Every human-readable string is expressed as an i18n key so the frontend + can localize without the backend shipping translations. + """ + + # One query resolves the level together with the retention policy; the + # four ``get_*`` helpers below would otherwise issue four round trips. + raw = await self._read_metadata_values( + workspace_uuid, + ( + OPERATION_LEVEL_KEY, + OPERATION_RETENTION_DAYS_KEY, + OPERATION_MAX_ROWS_KEY, + OPERATION_DEDUPE_WINDOW_KEY, + ), + ) + configured = clamp_level(raw[OPERATION_LEVEL_KEY]) if OPERATION_LEVEL_KEY in raw else DEFAULT_OPERATION_LEVEL + policy = self._resolve_policy(raw) + return { + 'configured_level': configured, + 'configured_level_name': level_name(configured), + 'retention_days': policy['retention_days'], + 'max_rows': policy['max_rows'], + 'dedupe_window_seconds': policy['dedupe_window_seconds'], + 'supported_levels': [ + {'level': OPERATION_LEVEL_NONE, 'i18n_key': 'operationTrace.levels.off'}, + {'level': OPERATION_LEVEL_MUTATION, 'i18n_key': 'operationTrace.levels.mutation'}, + {'level': OPERATION_LEVEL_READ, 'i18n_key': 'operationTrace.levels.read'}, + ], + 'limits': { + 'min_retention_days': MIN_RETENTION_DAYS, + 'max_retention_days': MAX_RETENTION_DAYS, + 'min_max_rows': MIN_MAX_ROWS, + 'max_max_rows': MAX_MAX_ROWS, + 'min_dedupe_window_seconds': MIN_DEDUPE_WINDOW_SECONDS, + 'max_dedupe_window_seconds': MAX_DEDUPE_WINDOW_SECONDS, + }, + } + + # -- writer ----------------------------------------------------------- + + async def record( + self, + workspace_uuid: str, + *, + rule: ActionRule, + level: int, + actor_account_uuid: str | None = None, + actor_name: str | None = None, + actor_role: str | None = None, + principal_type: str | None = None, + api_key_uuid: str | None = None, + auth_type: str | None = None, + request_id: str | None = None, + http_method: str | None = None, + route: str | None = None, + resource_id: str | None = None, + outcome: str = 'ok', + status_code: int | None = None, + summary: str | None = None, + changes: list[dict[str, typing.Any]] | None = None, + detail: typing.Mapping[str, typing.Any] | None = None, + client_ip: str | None = None, + user_agent: str | None = None, + duration_ms: int = 0, + ) -> bool: + """Append one operation row. Returns whether a row was persisted. + + The level only decides *whether* an operation is stored; it never + rewrites or hides records already written. + """ + + if level <= OPERATION_LEVEL_NONE: + return False + if not bucket_allows(rule.bucket, level): + return False + if level < OPERATION_LEVEL_MUTATION: + return False + + try: + record_fields: dict[str, typing.Any] = { + 'workspace_uuid': workspace_uuid, + 'actor_account_uuid': actor_account_uuid, + 'actor_name': str(actor_name)[:255] if actor_name else None, + 'actor_role': actor_role, + 'principal_type': str(principal_type)[:32] if principal_type else None, + 'api_key_uuid': str(api_key_uuid)[:255] if api_key_uuid else None, + 'auth_type': str(auth_type)[:32] if auth_type else None, + 'http_method': (http_method or '')[:12] or None, + 'route': (route or '')[:_MAX_ROUTE_CHARS] or None, + 'action': rule.action, + 'resource_type': rule.resource_type, + 'resource_id': str(resource_id)[:_MAX_RESOURCE_ID_CHARS] if resource_id else None, + 'level': int(level), + 'outcome': outcome, + 'status_code': int(status_code) if status_code is not None else None, + 'summary': summary, + 'changes': changes_payload(changes or []), + 'client_ip': str(client_ip)[:64] if client_ip else None, + } + + # Collapse repeated identical observations (e.g. a WebUI left open + # polling the same list) so page liveness cannot inflate the log. + dedupe_key = compute_dedupe_key( + actor_account_uuid=actor_account_uuid, + action=rule.action, + route=record_fields['route'], + resource_id=record_fields['resource_id'], + ) + record_fields['dedupe_key'] = dedupe_key + if await self._is_duplicate_observation(workspace_uuid, dedupe_key): + return False + + # Link each record to its predecessor so silent edits or removals + # become detectable when the chain is re-verified on read. + record_fields['prev_hash'] = await self._latest_record_hash(workspace_uuid) + record_fields['record_hash'] = compute_record_hash(record_fields) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_operation_log.WorkspaceOperationLog).values( + **record_fields, + request_id=str(request_id)[:128] if request_id else None, + detail=json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str) + if detail + else None, + user_agent=str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None, + duration_ms=max(int(duration_ms), 0), + ) + ) + await self._maybe_enforce_row_budget(workspace_uuid) + return True + except Exception as exc: # pragma: no cover - auditing is best effort + self.ap.logger.debug(f'Operation log write skipped: {exc}') + return False + + async def _latest_record_hash(self, workspace_uuid: str) -> str | None: + """Return the hash of the newest record, forming the chain link.""" + + try: + model = persistence_operation_log.WorkspaceOperationLog + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.record_hash) + .where(model.workspace_uuid == workspace_uuid) + .order_by(model.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log chain read skipped: {exc}') + return None + + async def _is_duplicate_observation(self, workspace_uuid: str, dedupe_key: str) -> bool: + """Return whether the same observation was just recorded.""" + + window = await self.get_dedupe_window(workspace_uuid) + if window <= 0: + return False + cutoff = _utcnow() - datetime.timedelta(seconds=window) + try: + model = persistence_operation_log.WorkspaceOperationLog + # Existence only: ``LIMIT 1`` stops at the first match instead of + # counting every row inside the window, which keeps the check a + # single index seek even under a burst of identical observations. + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.id) + .where( + model.workspace_uuid == workspace_uuid, + model.dedupe_key == dedupe_key, + model.created_at >= cutoff, + ) + .limit(1) + ) + return result.first() is not None + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log dedupe check skipped: {exc}') + return False + + async def record_request( + self, + ctx: RequestContext | None, + *, + workspace_uuid: str | None = None, + method: str, + route: str, + status_code: int | None = None, + outcome: str | None = None, + duration_ms: int = 0, + resource_id: str | None = None, + changes: list[dict[str, typing.Any]] | None = None, + detail: typing.Mapping[str, typing.Any] | None = None, + ) -> bool: + """Classify and persist one HTTP request from the route wrapper.""" + + target_workspace = workspace_uuid or (ctx.workspace_uuid if ctx is not None else None) + if not target_workspace: + return False + + role = ctx.workspace.role if ctx is not None else None + level = await self.effective_level(target_workspace, role) + rule = classify(method, route) + + if not bucket_allows(rule.bucket, level): + return False + + resolved_outcome = outcome or ('ok' if (status_code is None or status_code < 400) else 'error') + summary = build_summary(rule, changes or []) or None + + actor_name: str | None = None + actor_account_uuid: str | None = None + api_key_uuid: str | None = None + principal_type: str | None = None + auth_type: str | None = None + request_id: str | None = None + if ctx is not None: + principal_type = ( + ctx.principal.principal_type.value + if isinstance(ctx.principal.principal_type, PrincipalType) + else str(ctx.principal.principal_type) + ) + actor_account_uuid = ctx.principal.account_uuid or ctx.principal.actor_account_uuid + api_key_uuid = ctx.principal.api_key_uuid + auth_type = ctx.auth_type + request_id = ctx.request_id + + if actor_account_uuid and self.ap.user_service is not None: + try: + account = await self.ap.user_service.get_user_by_uuid(actor_account_uuid) + if account is not None: + actor_name = account.user + except Exception: # pragma: no cover - defensive + actor_name = None + + client_ip = None + user_agent = None + try: + import quart + + client_ip = quart.request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or None + if not client_ip: + client_ip = quart.request.remote_addr + user_agent = quart.request.headers.get('User-Agent') + except Exception: # pragma: no cover - no active request context + client_ip = None + user_agent = None + + return await self.record( + target_workspace, + rule=rule, + level=level, + actor_account_uuid=actor_account_uuid, + actor_name=actor_name, + actor_role=role, + principal_type=principal_type, + api_key_uuid=api_key_uuid, + auth_type=auth_type, + request_id=request_id, + http_method=method, + route=route, + resource_id=resource_id, + outcome=resolved_outcome, + status_code=status_code, + summary=summary, + changes=changes, + detail=detail, + client_ip=client_ip, + user_agent=user_agent, + duration_ms=duration_ms, + ) + + # -- reader ----------------------------------------------------------- + + async def count_logs( + self, + workspace_uuid: str, + *, + since: datetime.datetime | None = None, + level: int | None = None, + ) -> int: + try: + query = sqlalchemy.select(sqlalchemy.func.count()).select_from( + persistence_operation_log.WorkspaceOperationLog + ).where(persistence_operation_log.WorkspaceOperationLog.workspace_uuid == workspace_uuid) + if since is not None: + query = query.where(persistence_operation_log.WorkspaceOperationLog.created_at >= since) + if level is not None: + query = query.where(persistence_operation_log.WorkspaceOperationLog.level == int(level)) + result = await self.ap.persistence_mgr.execute_async(query) + return int(result.scalar_one_or_none() or 0) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log count skipped: {exc}') + return 0 + + async def query_logs( + self, + workspace_uuid: str, + *, + limit: int = DEFAULT_PAGE_SIZE, + offset: int = 0, + action: str | None = None, + resource_type: str | None = None, + actor_account_uuid: str | None = None, + level: int | None = None, + since: datetime.datetime | None = None, + until: datetime.datetime | None = None, + ) -> dict[str, typing.Any]: + """Return one page of operation records plus a coarse summary.""" + + resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) + resolved_offset = max(int(offset or 0), 0) + + try: + model = persistence_operation_log.WorkspaceOperationLog + filters = [model.workspace_uuid == workspace_uuid] + if action: + filters.append(model.action == action) + if resource_type: + filters.append(model.resource_type == resource_type) + if actor_account_uuid: + filters.append(model.actor_account_uuid == actor_account_uuid) + if level is not None: + filters.append(model.level == int(level)) + if since is not None: + filters.append(model.created_at >= since) + if until is not None: + filters.append(model.created_at <= until) + + total_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*filters) + ) + total = int(total_result.scalar_one_or_none() or 0) + + rows_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model) + .where(*filters) + .order_by(model.id.desc()) + .limit(resolved_limit) + .offset(resolved_offset) + ) + rows = list(rows_result.all()) + + # Fetch one extra older row so the oldest visible record can still + # have its chain link verified instead of being reported as broken. + chain_rows: list[typing.Any] = list(rows) + if rows: + oldest_id = rows[-1].id + older_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model) + .where(model.workspace_uuid == workspace_uuid, model.id < oldest_id) + .order_by(model.id.desc()) + .limit(1) + ) + chain_rows = list(rows) + list(older_result.all()) + + records = [ + self._serialize_log( + row, + previous_row=chain_rows[index + 1] if index + 1 < len(chain_rows) else None, + ) + for index, row in enumerate(rows) + ] + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log query skipped: {exc}') + total = 0 + records = [] + + return { + 'records': records, + 'total': total, + 'limit': resolved_limit, + 'offset': resolved_offset, + 'tampered_count': sum(1 for record in records if record.get('tampered')), + } + + def _serialize_log(self, row: typing.Any, *, previous_row: typing.Any | None = None) -> dict[str, typing.Any]: + """Serialize one row and re-verify its tamper-evidence chain. + + ``previous_row`` is the chronologically older neighbour. When supplied, + a mismatch between this row's ``prev_hash`` and the older row's + ``record_hash`` marks the chain as broken. + """ + + rule = ACTION_RULES_BY_ACTION.get(row.action or '') + hash_payload = { + 'workspace_uuid': row.workspace_uuid, + 'actor_account_uuid': row.actor_account_uuid, + 'actor_name': row.actor_name, + 'actor_role': row.actor_role, + 'principal_type': row.principal_type, + 'api_key_uuid': row.api_key_uuid, + 'auth_type': row.auth_type, + 'http_method': row.http_method, + 'route': row.route, + 'action': row.action, + 'resource_type': row.resource_type, + 'resource_id': row.resource_id, + 'level': row.level, + 'outcome': row.outcome, + 'status_code': row.status_code, + 'summary': row.summary, + 'changes': row.changes, + 'client_ip': row.client_ip, + 'prev_hash': row.prev_hash, + } + integrity_ok = verify_record_hash(hash_payload, row.record_hash) + chain_ok = True + if previous_row is not None: + chain_ok = str(row.prev_hash or '') == str(previous_row.record_hash or '') + + return { + 'id': row.id, + 'workspace_uuid': row.workspace_uuid, + 'integrity_ok': integrity_ok, + 'chain_ok': chain_ok, + 'tampered': (not integrity_ok) or (not chain_ok), + 'record_hash': row.record_hash, + 'prev_hash': row.prev_hash, + 'actor_account_uuid': row.actor_account_uuid, + 'actor_name': row.actor_name, + 'actor_role': row.actor_role, + 'principal_type': row.principal_type, + 'api_key_uuid': row.api_key_uuid, + 'auth_type': row.auth_type, + 'request_id': row.request_id, + 'http_method': row.http_method, + 'route': row.route, + 'action': row.action, + 'action_category': rule.category if rule else 'resource', + 'action_i18n_key': rule.i18n_key if rule else f'operationTrace.actions.{row.action or "unknown"}', + 'resource_type': row.resource_type, + 'resource_id': row.resource_id, + 'level': row.level, + 'level_name': level_name(row.level), + 'outcome': row.outcome, + 'status_code': row.status_code, + 'summary': row.summary, + 'changes': decode_changes(row.changes), + 'detail': decode_json_object(row.detail), + 'client_ip': row.client_ip, + 'user_agent': row.user_agent, + 'duration_ms': row.duration_ms, + 'created_at': row.created_at.isoformat() if row.created_at else None, + } + + async def export_logs( + self, + workspace_uuid: str, + *, + action: str | None = None, + resource_type: str | None = None, + actor_account_uuid: str | None = None, + level: int | None = None, + since: datetime.datetime | None = None, + until: datetime.datetime | None = None, + max_rows: int | None = None, + ) -> dict[str, typing.Any]: + """Export the filtered records as a structured document. + + Export is capped so a download can never materialize an unbounded table + in memory. The document carries the records, the effective filters and + the integrity summary, which keeps the artifact self-describing. + """ + + limit = MAX_EXPORT_ROWS if max_rows is None else max(1, min(int(max_rows), MAX_EXPORT_ROWS)) + + try: + model = persistence_operation_log.WorkspaceOperationLog + filters = [model.workspace_uuid == workspace_uuid] + if action: + filters.append(model.action == action) + if resource_type: + filters.append(model.resource_type == resource_type) + if actor_account_uuid: + filters.append(model.actor_account_uuid == actor_account_uuid) + if level is not None: + filters.append(model.level == int(level)) + if since is not None: + filters.append(model.created_at >= since) + if until is not None: + filters.append(model.created_at <= until) + + rows_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model).where(*filters).order_by(model.id.desc()).limit(limit) + ) + rows = list(rows_result.all()) + + # One extra older row so the oldest exported record still has its + # chain link verifiable inside the artifact. + chain_rows: list[typing.Any] = list(rows) + if rows: + oldest_id = rows[-1].id + older_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model) + .where(model.workspace_uuid == workspace_uuid, model.id < oldest_id) + .order_by(model.id.desc()) + .limit(1) + ) + chain_rows = list(rows) + list(older_result.all()) + + records = [ + self._serialize_log( + row, + previous_row=chain_rows[index + 1] if index + 1 < len(chain_rows) else None, + ) + for index, row in enumerate(rows) + ] + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log export skipped: {exc}') + records = [] + + return { + 'exported_at': _utcnow().isoformat(timespec='seconds'), + 'exported': len(records), + 'tampered_count': sum(1 for record in records if record.get('tampered')), + # Echo the effective filters so the artifact is self-describing: + # an operator can tell which slice of the log a file represents. + 'filters': { + 'action': action, + 'resource_type': resource_type, + 'actor_account_uuid': actor_account_uuid, + 'level': int(level) if level is not None else None, + 'since': since.isoformat() if since is not None else None, + 'until': until.isoformat() if until is not None else None, + }, + 'limit': limit, + 'records': records, + } + + async def list_filter_options(self, workspace_uuid: str) -> dict[str, typing.Any]: + """Return the distinct filter values available in the panel.""" + + try: + model = persistence_operation_log.WorkspaceOperationLog + actions = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.action).where(model.workspace_uuid == workspace_uuid).distinct() + ) + ).scalars().all() + resources = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.resource_type).where(model.workspace_uuid == workspace_uuid).distinct() + ) + ).scalars().all() + actors = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.actor_account_uuid, model.actor_name) + .where(model.workspace_uuid == workspace_uuid) + .distinct() + ) + ).all() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log filter options skipped: {exc}') + actions, resources, actors = [], [], [] + + return { + 'actions': sorted({item for item in actions if item}), + 'resource_types': sorted({item for item in resources if item}), + 'actors': [ + {'account_uuid': actor[0], 'name': actor[1] or actor[0]} for actor in actors if actor and actor[0] + ], + } + + # -- maintenance ------------------------------------------------------ + + async def _maybe_enforce_row_budget(self, workspace_uuid: str) -> None: + """Check the Workspace row budget once every few inserts. + + A ``COUNT`` on every append would make each recorded operation scan the + whole Workspace history. The counter defers the check so the overshoot + never exceeds ``_ROW_BUDGET_CHECK_STRIDE`` rows, while the maintenance + loop and ``prune`` still enforce the exact budget. + """ + + pending = self._insert_counters.get(workspace_uuid, 0) + 1 + if pending < _ROW_BUDGET_CHECK_STRIDE: + self._insert_counters[workspace_uuid] = pending + return + self._insert_counters[workspace_uuid] = 0 + await self._enforce_row_budget(workspace_uuid) + + async def _enforce_row_budget(self, workspace_uuid: str) -> None: + """Drop the oldest rows when the Workspace row budget is exceeded.""" + + budget = await self.get_max_rows(workspace_uuid) + total = await self.count_logs(workspace_uuid) + if total <= budget: + return + await self._delete_oldest(workspace_uuid, total - budget) + + async def _delete_oldest(self, workspace_uuid: str, count: int) -> int: + if count <= 0: + return 0 + try: + model = persistence_operation_log.WorkspaceOperationLog + oldest_ids = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model.id) + .where(model.workspace_uuid == workspace_uuid) + .order_by(model.id.asc()) + .limit(int(count)) + ) + ).scalars().all() + if not oldest_ids: + return 0 + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(model).where(model.id.in_(list(oldest_ids))) + ) + return len(oldest_ids) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log trim skipped: {exc}') + return 0 + + async def prune( + self, + workspace_uuid: str, + *, + retention_days: int | None = None, + max_rows: int | None = None, + ) -> dict[str, int]: + """Enforce retention by age and by row budget for one Workspace. + + Operation records are immutable. This retention policy is the only + deletion path in the product, so evidence cannot be erased on demand + by a privileged click. + """ + + days = await self.get_retention_days(workspace_uuid) if retention_days is None else clamp_retention(retention_days) + budget = await self.get_max_rows(workspace_uuid) if max_rows is None else clamp_max_rows(max_rows) + cutoff = _utcnow() - datetime.timedelta(days=days) + + expired = 0 + try: + model = persistence_operation_log.WorkspaceOperationLog + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(model).where( + model.workspace_uuid == workspace_uuid, + model.created_at < cutoff, + ) + ) + expired = int(result.rowcount or 0) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log retention prune skipped: {exc}') + + total = await self.count_logs(workspace_uuid) + trimmed = await self._delete_oldest(workspace_uuid, max(total - budget, 0)) + # The budget was just enforced in full, so the deferred counter restarts. + self._insert_counters[workspace_uuid] = 0 + return {'expired': expired, 'trimmed': trimmed, 'remaining': await self.count_logs(workspace_uuid)} + + async def prune_all_workspaces(self) -> dict[str, int]: + """Run retention pruning for every Workspace that enabled tracing. + + Returns a coarse report; individual Workspace failures are contained. + """ + + processed = 0 + removed = 0 + try: + workspaces = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid).where( + persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY + ) + ) + ).scalars().all() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log workspace enumeration skipped: {exc}') + return {'workspaces': 0, 'removed': 0} + + for workspace_uuid in {item for item in workspaces if item}: + try: + report = await self.prune(workspace_uuid) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation log prune failed for {workspace_uuid}: {exc}') + continue + processed += 1 + removed += report['expired'] + report['trimmed'] + return {'workspaces': processed, 'removed': removed} diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 6b491362d..2d3b50b3d 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -36,6 +36,7 @@ from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service +from ..api.http.service import settings as workspace_settings_service from ..api.http.service import skill as skill_service from ..api.http.service import maintenance as maintenance_service from ..discover import engine as discover_engine @@ -198,6 +199,8 @@ class Application: monitoring_service: monitoring_service.MonitoringService = None + workspace_settings_service: workspace_settings_service.WorkspaceSettingsService = None + skill_service: skill_service.SkillService = None skill_mgr: skill_mgr.SkillManager = None @@ -434,6 +437,24 @@ class Application: * 3600 ) + # Operation-log retention piggybacks on the shared maintenance loop + # instead of starting another long-lived task, so enabling + # traceability adds no scheduling cost of its own. + operation_log_cleanup_cfg = ( + self.instance_config.data.get('operation_log', {}).get('auto_cleanup', {}) + ) + operation_log_enabled = ( + operation_log_cleanup_cfg.get('enabled', True) and self.workspace_settings_service is not None + ) + operation_log_interval_seconds = ( + self._get_positive_float_config( + operation_log_cleanup_cfg.get('check_interval_hours', 1), + default=1, + name='operation_log.auto_cleanup.check_interval_hours', + ) + * 3600 + ) + maintenance_intervals: dict[str, float] = {} if monitoring_enabled: maintenance_intervals['monitoring'] = monitoring_interval_seconds @@ -441,6 +462,8 @@ class Application: maintenance_intervals['storage'] = storage_interval_seconds if self.workspace_collaboration_service is not None: maintenance_intervals['invitations'] = 3600.0 + if operation_log_enabled: + maintenance_intervals['operation_logs'] = operation_log_interval_seconds if maintenance_intervals: @@ -506,6 +529,24 @@ class Application: self.logger.warning( f'Storage maintenance failed for Workspace {context.workspace_uuid}: {exc}' ) + if 'operation_logs' in due: + try: + report = await self.workspace_settings_service.prune( + context.workspace_uuid, + ) + removed = report['expired'] + report['trimmed'] + if removed > 0: + self.logger.info( + f'Operation log auto-cleanup for Workspace ' + f'{context.workspace_uuid}: removed {removed} records' + ) + except asyncio.CancelledError: + raise + except Exception as exc: + self.logger.warning( + f'Operation log auto-cleanup failed for ' + f'Workspace {context.workspace_uuid}: {exc}' + ) if 'invitations' in due: try: await self.workspace_collaboration_service.cleanup_expired_invitations( diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 96f81a57f..bcc388cbf 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -29,6 +29,7 @@ from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service +from ...api.http.service import settings as settings_service from ...api.http.service import skill as skill_service from ...skill import manager as skill_mgr from ...api.http.service import maintenance as maintenance_service @@ -127,6 +128,9 @@ class BuildAppStage(stage.BootingStage): mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst + workspace_settings_service_inst = settings_service.WorkspaceSettingsService(ap) + ap.workspace_settings_service = workspace_settings_service_inst + apikey_service_inst = apikey_service.ApiKeyService(ap) ap.apikey_service = apikey_service_inst diff --git a/src/langbot/pkg/entity/persistence/operation_log.py b/src/langbot/pkg/entity/persistence/operation_log.py new file mode 100644 index 000000000..b98ffd67e --- /dev/null +++ b/src/langbot/pkg/entity/persistence/operation_log.py @@ -0,0 +1,179 @@ +"""Workspace operation log entity for member action traceability. + +This table is the append-only fact source for "who did what to which +resource" inside one Workspace. It is deliberately separate from +:mod:`event_log` (Runner/runtime facts) and from monitoring tables +(LLM/tool telemetry) so that administrative traceability can be enabled, +queried and pruned without touching the hot runtime path. + +Design constraints +------------------ +* One row per recorded operation; no updates after insert (append-only). +* ``changes`` holds the before/after diff as a compact JSON string so the + log answers the question "what was changed into what". +* Rows are tenant-owned: every row carries ``workspace_uuid`` and is + covered by the shared Row Level Security contract. +""" + +from __future__ import annotations + +import datetime + +import sqlalchemy + +from .base import Base + + +# Operation levels, ordered by increasing capture scope. They live in the +# entity module so both the persistence layer and the HTTP layer can import +# one single source of truth without a circular dependency. +OPERATION_LEVEL_NONE = 0 +"""Record nothing (tracing disabled for the Workspace).""" + +OPERATION_LEVEL_MUTATION = 1 +"""Record create / update / delete / role changes only.""" + +OPERATION_LEVEL_READ = 2 +"""Additionally record every read / view / list operation.""" + +OPERATION_LEVEL_NAMES = { + OPERATION_LEVEL_NONE: 'off', + OPERATION_LEVEL_MUTATION: 'mutation', + OPERATION_LEVEL_READ: 'read', +} + + +class WorkspaceOperationLog(Base): + """One traceable administrative operation inside a Workspace.""" + + __tablename__ = 'workspace_operation_logs' + + id = sqlalchemy.Column(sqlalchemy.BigInteger().with_variant(sqlalchemy.Integer, 'sqlite'), primary_key=True, autoincrement=True) + + workspace_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + """Owning Workspace; also the tenant isolation key.""" + + # --- Actor: who performed the operation ----------------------------- + actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True) + """Account UUID of the actor, when the principal is an Account.""" + + actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Human readable actor identifier captured at write time.""" + + actor_role = sqlalchemy.Column(sqlalchemy.String(32), nullable=True) + """Workspace role the actor held while performing the operation.""" + + principal_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True) + """Principal kind (account, api_key, support_admin, system).""" + + api_key_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """API key UUID when the operation was driven by an API key.""" + + auth_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True) + """Authentication mode used for the request.""" + + # --- Request correlation -------------------------------------------- + request_id = sqlalchemy.Column(sqlalchemy.String(128), nullable=True, index=True) + """Stable request id shared with logs and error responses.""" + + http_method = sqlalchemy.Column(sqlalchemy.String(12), nullable=True) + """HTTP verb of the traced request.""" + + route = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, index=True) + """Registered Core route identity (never a raw user-supplied URL).""" + + # --- What happened --------------------------------------------------- + action = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True) + """Normalized action verb (create, update, delete, view, ...).""" + + resource_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True) + """Resource family the operation targeted (member, resource, ...).""" + + resource_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Identifier of the targeted resource, when known.""" + + level = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1', index=True) + """Capture level that produced this row (1 = mutation, 2 = read).""" + + outcome = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='ok') + """Result bucket: ok, denied or error.""" + + status_code = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) + """HTTP status code when the operation surfaced one.""" + + # --- Payloads -------------------------------------------------------- + summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Short human readable description of the operation.""" + + changes = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Before/after diff as a JSON string: what was changed into what.""" + + detail = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional structured context as a JSON string.""" + + # --- Tamper evidence (append-only hash chain) ------------------------ + record_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + """Keyed hash over this row's canonical content. + + Recomputed on read: a mismatch marks the card as tampered. + """ + + prev_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + """Previous record's ``record_hash`` in the same Workspace. + + Linking rows this way makes silent edits or deletions detectable. + """ + + dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True) + """Hash used to collapse repeated read observations into one row.""" + + # --- Client fingerprint --------------------------------------------- + client_ip = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + """Best-effort client address.""" + + user_agent = sqlalchemy.Column(sqlalchemy.String(512), nullable=True) + """Client user agent, truncated before insert.""" + + duration_ms = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='0') + """Handler wall-clock duration in milliseconds.""" + + created_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + index=True, + ) + """When the operation was recorded.""" + + __table_args__ = ( + sqlalchemy.Index( + 'ix_workspace_operation_logs_workspace_created', + 'workspace_uuid', + 'created_at', + ), + sqlalchemy.Index( + 'ix_workspace_operation_logs_workspace_resource', + 'workspace_uuid', + 'resource_type', + 'created_at', + ), + sqlalchemy.Index( + 'ix_workspace_operation_logs_workspace_dedupe', + 'workspace_uuid', + 'dedupe_key', + 'created_at', + ), + sqlalchemy.CheckConstraint( + 'level IN (0, 1, 2)', + name='ck_workspace_operation_logs_level', + ), + sqlalchemy.CheckConstraint( + "outcome IN ('ok', 'denied', 'error')", + name='ck_workspace_operation_logs_outcome', + ), + ) diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index 12050313a..0ceaae5bd 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -28,6 +28,7 @@ from langbot.pkg.entity.persistence import ( metadata, # noqa: F401 model, # noqa: F401 monitoring, # noqa: F401 + operation_log, # noqa: F401 pipeline, # noqa: F401 plugin, # noqa: F401 rag, # noqa: F401 diff --git a/src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py b/src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py new file mode 100644 index 000000000..1a5073124 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py @@ -0,0 +1,133 @@ +"""add workspace operation logs for member/resource traceability + +Revision ID: 0032_workspace_operation_logs +Revises: 0031_merge_totp_assistant +Create Date: 2026-09-25 + +The table is append-only and tenant-owned. Fresh installs already receive it +from SQLAlchemy ``create_all``; this revision makes the change safe for +existing databases and mirrors the shared Row Level Security contract so the +Cloud release validation can enforce it. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0032_workspace_operation_logs' +down_revision = '0031_merge_totp_assistant' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'workspace_operation_logs' +_POLICY_NAME = 'langbot_workspace_isolation' +_TENANT_SETTING = 'langbot.workspace_uuid' + + +def _setting(name: str) -> str: + return f"NULLIF(current_setting('{name}', true), '')" + + +def _quote(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, 'sqlite'), nullable=False), + sa.Column( + 'workspace_uuid', + sa.String(36), + sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('actor_account_uuid', sa.String(36), nullable=True), + sa.Column('actor_name', sa.String(255), nullable=True), + sa.Column('actor_role', sa.String(32), nullable=True), + sa.Column('principal_type', sa.String(32), nullable=True), + sa.Column('api_key_uuid', sa.String(255), nullable=True), + sa.Column('auth_type', sa.String(32), nullable=True), + sa.Column('request_id', sa.String(128), nullable=True), + sa.Column('http_method', sa.String(12), nullable=True), + sa.Column('route', sa.String(512), nullable=True), + sa.Column('action', sa.String(64), nullable=True), + sa.Column('resource_type', sa.String(64), nullable=True), + sa.Column('resource_id', sa.String(255), nullable=True), + sa.Column('level', sa.Integer(), nullable=False, server_default='1'), + sa.Column('outcome', sa.String(16), nullable=False, server_default='ok'), + sa.Column('status_code', sa.Integer(), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('changes', sa.Text(), nullable=True), + sa.Column('detail', sa.Text(), nullable=True), + sa.Column('client_ip', sa.String(64), nullable=True), + sa.Column('user_agent', sa.String(512), nullable=True), + sa.Column('duration_ms', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('id'), + sa.CheckConstraint('level IN (0, 1, 2)', name='ck_workspace_operation_logs_level'), + sa.CheckConstraint( + "outcome IN ('ok', 'denied', 'error')", + name='ck_workspace_operation_logs_outcome', + ), + ) + op.create_index( + 'ix_workspace_operation_logs_workspace_created', + _TABLE_NAME, + ['workspace_uuid', 'created_at'], + unique=False, + ) + op.create_index( + 'ix_workspace_operation_logs_workspace_resource', + _TABLE_NAME, + ['workspace_uuid', 'resource_type', 'created_at'], + unique=False, + ) + op.create_index( + 'ix_workspace_operation_logs_workspace_uuid', + _TABLE_NAME, + ['workspace_uuid'], + unique=False, + ) + op.create_index('ix_workspace_operation_logs_created_at', _TABLE_NAME, ['created_at'], unique=False) + op.create_index('ix_workspace_operation_logs_action', _TABLE_NAME, ['action'], unique=False) + op.create_index('ix_workspace_operation_logs_resource_type', _TABLE_NAME, ['resource_type'], unique=False) + op.create_index('ix_workspace_operation_logs_level', _TABLE_NAME, ['level'], unique=False) + + if conn.dialect.name != 'postgresql': + return + + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == 'postgresql': + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.drop_index('ix_workspace_operation_logs_level', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_resource_type', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_action', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_created_at', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_workspace_uuid', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_workspace_resource', table_name=_TABLE_NAME) + op.drop_index('ix_workspace_operation_logs_workspace_created', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py b/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py new file mode 100644 index 000000000..126f5b3ed --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py @@ -0,0 +1,70 @@ +"""add tamper-evidence columns to workspace operation logs + +Revision ID: 0033_operation_log_integrity +Revises: 0032_workspace_operation_logs +Create Date: 2026-09-25 + +Adds the append-only hash chain (``record_hash`` / ``prev_hash``) and the +``dedupe_key`` used to collapse repeated read observations. Fresh databases +already receive these columns from SQLAlchemy ``create_all``; this revision +upgrades existing databases and is guarded so neither path fails. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0033_operation_log_integrity' +down_revision = '0032_workspace_operation_logs' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'workspace_operation_logs' +_NEW_COLUMNS = ( + sa.Column('record_hash', sa.String(64), nullable=True), + sa.Column('prev_hash', sa.String(64), nullable=True), + sa.Column('dedupe_key', sa.String(64), nullable=True), +) +_DEDUPE_INDEX = 'ix_workspace_operation_logs_workspace_dedupe' + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): + return + + existing = {column['name'] for column in sa.inspect(conn).get_columns(_TABLE_NAME)} + missing = [column for column in _NEW_COLUMNS if column.name not in existing] + if missing: + with op.batch_alter_table(_TABLE_NAME, recreate='auto') as batch: + for column in missing: + batch.add_column(column) + + index_names = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE_NAME)} + if _DEDUPE_INDEX not in index_names: + op.create_index( + _DEDUPE_INDEX, + _TABLE_NAME, + ['workspace_uuid', 'dedupe_key', 'created_at'], + unique=False, + ) + + +def downgrade() -> None: + conn = op.get_bind() + if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): + return + + index_names = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE_NAME)} + if _DEDUPE_INDEX in index_names: + op.drop_index(_DEDUPE_INDEX, table_name=_TABLE_NAME) + + existing = {column['name'] for column in sa.inspect(conn).get_columns(_TABLE_NAME)} + removable = [column.name for column in _NEW_COLUMNS if column.name in existing] + if removable: + with op.batch_alter_table(_TABLE_NAME, recreate='auto') as batch: + for name in removable: + batch.drop_column(name) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 5419bf130..d303d88d1 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -87,6 +87,7 @@ _ALEMBIC_TENANT_TABLES = { 'monitoring_errors', 'monitoring_embedding_calls', 'monitoring_feedback', + 'workspace_operation_logs', 'langbot_vectors', 'directory_projection_states', 'directory_projection_inbox', diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index ec1a5557c..0eacc85c5 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -72,6 +72,9 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { 'monitoring_errors': 'workspace_uuid', 'monitoring_embedding_calls': 'workspace_uuid', 'monitoring_feedback': 'workspace_uuid', + # Administrative traceability for member and resource operations. Owned by + # the Workspace and therefore protected by the same RLS contract. + 'workspace_operation_logs': 'workspace_uuid', # Created by 0013 rather than ORM metadata; it is still part of the same # business-database RLS contract and permits no discovery policies. 'langbot_vectors': 'workspace_uuid', diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 76218d760..230d13c2b 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -32,6 +32,7 @@ import { Github, Zap, FilePlus2, + History, Sparkles, Server, Puzzle, @@ -2220,6 +2221,19 @@ export default function HomeSidebar({ {t('models.title')} + {/* Operation traceability is an audit surface: only roles holding + audit.view (owner / admin) get a direct sidebar entry. */} + {currentWorkspace?.permissions.includes('audit.view') && ( + + openSettings('operationTrace')} + tooltip={t('operationTrace.title')} + > + + {t('operationTrace.title')} + + + )} {/* API-key management is available only to authorized Workspace roles. */} diff --git a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx index 63453a3f5..f727442bc 100644 --- a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx +++ b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { HardDrive, + History, KeyRound, Settings, Sparkles, @@ -29,12 +30,14 @@ import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/Ap import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel'; import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel'; import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel'; +import OperationTracePanel from '@/app/home/components/workspace-settings/OperationTracePanel'; import { useCurrentWorkspace } from '@/app/infra/http'; // The set of settings sections shown in the unified dialog. The string values // are also reused as the ?action= query param suffix so deep links keep working. export type SettingsSection = | 'workspace' + | 'operationTrace' | 'account' | 'apiIntegration' | 'models' @@ -45,6 +48,7 @@ export type SettingsSection = // showStorageAnalysis) continue to resolve to the right section. export const SETTINGS_ACTION_BY_SECTION: Record = { workspace: 'showWorkspaceSettings', + operationTrace: 'showOperationTrace', account: 'showAccountSettings', apiIntegration: 'showApiIntegrationSettings', models: 'showModelSettings', @@ -101,6 +105,13 @@ export default function SettingsDialog({ description: t('workspace.description'), icon: , }, + { + id: 'operationTrace', + label: t('settingsDialog.nav.operationTrace'), + title: t('operationTrace.title'), + description: t('operationTrace.description'), + icon: , + }, { id: 'models', label: t('settingsDialog.nav.models'), @@ -135,6 +146,9 @@ export default function SettingsDialog({ const canViewAudit = permissions.includes('audit.view'); const canViewStorageAnalysis = currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit; + // Operation traceability is an audit surface: it is limited to the roles + // that also hold the audit permission (owner / admin). + const canViewOperationTrace = canViewAudit; const navItems = allNavItems.filter((item) => { if (item.id === 'apiIntegration') { return canManageApiKeys; @@ -142,19 +156,24 @@ export default function SettingsDialog({ if (item.id === 'storageAnalysis') { return canViewStorageAnalysis; } + if (item.id === 'operationTrace') { + return canViewOperationTrace; + } return true; }); useEffect(() => { const forbiddenSection = (section === 'apiIntegration' && !canManageApiKeys) || - (section === 'storageAnalysis' && !canViewStorageAnalysis); + (section === 'storageAnalysis' && !canViewStorageAnalysis) || + (section === 'operationTrace' && !canViewOperationTrace); if (open && forbiddenSection) { onSectionChange('workspace'); } }, [ canManageApiKeys, canViewStorageAnalysis, + canViewOperationTrace, open, section, onSectionChange, @@ -253,6 +272,11 @@ export default function SettingsDialog({ active={open && section === 'workspace'} /> )} + {section === 'operationTrace' && canViewOperationTrace && ( + + )} {section === 'models' && ( { + if (value === null || value === undefined || value === '') return '—'; + // The backend stores a machine sentinel for masked fields; the label is + // localized here so no interface text ships from the backend. + if (value === REDACTED_SENTINEL) return redactedLabel; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }; + return ( +
+ {change.field} + + {format(change.before)} + + + + {format(change.after)} + +
+ ); +} + +function outcomeVariant( + outcome: OperationLogRecord['outcome'], +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (outcome === 'error') return 'destructive'; + if (outcome === 'denied') return 'outline'; + return 'secondary'; +} + +export default function OperationTracePanel({ + active, +}: OperationTracePanelProps) { + const { t } = useTranslation(); + const currentWorkspace = useCurrentWorkspace(); + + const [governance, setGovernance] = useState( + null, + ); + const [filters, setFilters] = useState(null); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [query, setQuery] = useState({}); + const [retentionDays, setRetentionDays] = useState(30); + const [maxRows, setMaxRows] = useState(20000); + const [dedupeSeconds, setDedupeSeconds] = useState(60); + + const role = currentWorkspace?.membership.role ?? null; + const canConfigure = role === 'owner' || role === 'admin'; + + // Fetch only the records page. Paging through the log must not re-request + // the governance payload and the filter catalogue: those change rarely and + // the two extra round trips made every page click three times slower. + const loadPage = useCallback( + async (nextQuery: OperationLogQuery) => { + setLoading(true); + try { + const pageResponse = await backendClient.getOperationLogs({ + limit: PAGE_SIZE, + offset: nextQuery.offset ?? 0, + ...nextQuery, + }); + setPage(pageResponse); + } catch { + toast.error(t('operationTrace.loadFailed')); + } finally { + setLoading(false); + } + }, + [t], + ); + + const load = useCallback( + async (nextQuery: OperationLogQuery) => { + setLoading(true); + try { + const [governanceResponse, filtersResponse, pageResponse] = + await Promise.all([ + backendClient.getOperationGovernance(), + backendClient.getOperationLogFilters(), + backendClient.getOperationLogs({ + limit: PAGE_SIZE, + offset: nextQuery.offset ?? 0, + ...nextQuery, + }), + ]); + setGovernance(governanceResponse); + setFilters(filtersResponse); + setPage(pageResponse); + setRetentionDays(governanceResponse.retention_days); + setMaxRows(governanceResponse.max_rows); + setDedupeSeconds(governanceResponse.dedupe_window_seconds); + } catch { + toast.error(t('operationTrace.loadFailed')); + } finally { + setLoading(false); + } + }, + [t], + ); + + // Track visibility so the first render after opening the panel performs the + // full load, while later query changes (filters, pagination) only refresh + // the records page. + const wasActiveRef = useRef(false); + + useEffect(() => { + if (!active) { + wasActiveRef.current = false; + return; + } + if (!wasActiveRef.current) { + wasActiveRef.current = true; + void load(query); + return; + } + void loadPage(query); + // Reload the full payload only when the panel becomes visible; filter and + // pagination changes are handled by the records-only path above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, query]); + + const offset = query.offset ?? 0; + const total = page?.total ?? 0; + const canGoBack = offset > 0; + const canGoForward = offset + PAGE_SIZE < total; + + // Render the server page as-is. Record ids are unique and stable, the + // backend already collapses repeated observations at write time, and a + // client-side dedupe would hide legitimately distinct rows (making the + // total badge disagree with the list) whenever a page is re-fetched. + const visibleRecords = page?.records ?? []; + + async function changeLevel(level: OperationLevel) { + if (!canConfigure) return; + setSaving(true); + try { + const updated = await backendClient.updateOperationGovernance({ level }); + setGovernance(updated); + toast.success(t('operationTrace.levelUpdated')); + await load({ ...query, offset: 0 }); + } catch { + toast.error(t('operationTrace.levelUpdateFailed')); + } finally { + setSaving(false); + } + } + + async function downloadLogs() { + if (exporting) return; + setExporting(true); + try { + // Fetch through the HTTP client so the session token and the active + // Workspace header are applied. A bare ``window.open`` navigation would + // drop both and the audit endpoint would reject the request. + const url = backendClient.buildOperationLogExportURL({ + action: query.action, + resource_type: query.resource_type, + actor: query.actor, + level: query.level, + since: query.since, + until: query.until, + }); + const response = await backendClient.downloadFile(url); + const disposition = response.headers['content-disposition'] as + | string + | undefined; + const match = disposition?.match(/filename="?([^";\n]+)"?/); + const filename = match?.[1] ?? `operation-logs-${Date.now()}.csv`; + const blob = new Blob([response.data], { + type: 'text/csv;charset=utf-8;', + }); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(objectUrl); + } catch { + toast.error(t('operationTrace.exportFailed')); + } finally { + setExporting(false); + } + } + + async function saveRetention() { + if (!canConfigure || !governance) return; + setSaving(true); + try { + // Saving the retention policy applies it immediately and keeps the + // maintenance loop enforcing it afterwards; there is no manual prune. + const updated = await backendClient.updateOperationGovernance({ + level: governance.configured_level, + retention_days: retentionDays, + max_rows: maxRows, + dedupe_window_seconds: dedupeSeconds, + }); + setGovernance(updated); + toast.success(t('operationTrace.retentionUpdated')); + await load({ ...query, offset: 0 }); + } catch { + toast.error(t('operationTrace.retentionUpdateFailed')); + } finally { + setSaving(false); + } + } + + const levelOptions = useMemo( + () => governance?.supported_levels ?? [], + [governance], + ); + + if (loading && !governance) { + return ( +
+ +
+ ); + } + + return ( + <> + +
+

+ {t('operationTrace.title')} +

+
+ {governance && ( + 0 ? 'default' : 'outline'} + > + {t(`operationTrace.levelNames.${governance.configured_level_name}`)} + + )} + +
+ + +
+
+ +

+ {t('operationTrace.captureLevel')} +

+
+
+ {levelOptions.map((option) => { + const selected = governance?.configured_level === option.level; + return ( + + ); + })} +
+
+ +
+

+ {t('operationTrace.retention')} +

+
+ + + + +
+
+ +
+
+
+ +

+ {t('operationTrace.records')} +

+ {total} + {(page?.tampered_count ?? 0) > 0 && ( + + {t('operationTrace.tamperedCount', { + count: page?.tampered_count ?? 0, + })} + + )} +
+
+ + + + +
+
+ +
+ {visibleRecords.length === 0 && ( +

+ {t('operationTrace.empty')} +

+ )} + {visibleRecords.map((record) => ( + + + {record.tampered ? ( + + ) : ( + + )} + + + + + {t(record.action_i18n_key, { + defaultValue: record.action ?? '', + })} + + + {t(`operationTrace.outcomes.${record.outcome}`)} + + L{record.level} + {record.resource_type && ( + {record.resource_type} + )} + {record.tampered ? ( + + + {t('operationTrace.tamperedBadge')} + + ) : ( + + + {t('operationTrace.verifiedBadge')} + + )} + + + + {record.actor_name ?? + record.actor_account_uuid ?? + t('operationTrace.systemActor')} + + + + {record.actor_role + ? t(`workspace.roles.${record.actor_role}`) + : t('operationTrace.systemActor')} + + {record.http_method && ( + <> + + {record.http_method} + + )} + {record.status_code !== null && ( + <> + + {record.status_code} + + )} + + + {record.created_at + ? new Date(record.created_at).toLocaleString() + : ''} + + + {record.summary && ( +

+ {record.summary} +

+ )} + {record.changes.length > 0 && ( +
+ {record.changes.map((change, index) => ( + + ))} +
+ )} +
+ +
+ + {record.duration_ms}ms + + {record.record_hash && ( + + + {record.record_hash.slice(0, 8)} + + )} +
+
+
+ ))} +
+ +
+ + {t('operationTrace.pageInfo', { + from: total === 0 ? 0 : offset + 1, + to: Math.min(offset + PAGE_SIZE, total), + total, + })} + +
+ + +
+
+
+
+ + ); +} diff --git a/web/src/app/infra/entities/operation-log.ts b/web/src/app/infra/entities/operation-log.ts new file mode 100644 index 000000000..cbeb75647 --- /dev/null +++ b/web/src/app/infra/entities/operation-log.ts @@ -0,0 +1,96 @@ +/** + * Types for the operation traceability surface. + * + * The backend stores one append-only row per traced operation and answers + * "what was changed into what" through the ``changes`` diff. All + * human-readable strings arrive as i18n keys so the panel owns the copy. + */ + +/** Capture levels, ordered by increasing scope. */ +export type OperationLevel = 0 | 1 | 2; + +export interface OperationLevelOption { + level: OperationLevel; + /** Frontend translation key, e.g. ``operationTrace.levels.read``. */ + i18n_key: string; +} + +export interface OperationLogLimits { + min_retention_days: number; + max_retention_days: number; + min_max_rows: number; + max_max_rows: number; + min_dedupe_window_seconds: number; + max_dedupe_window_seconds: number; +} + +export interface OperationGovernance { + configured_level: OperationLevel; + configured_level_name: string; + retention_days: number; + max_rows: number; + /** Seconds during which an identical observation is collapsed into one row. */ + dedupe_window_seconds: number; + supported_levels: OperationLevelOption[]; + limits: OperationLogLimits; +} + +export interface OperationChangeField { + field: string; + before: unknown; + after: unknown; +} + +export interface OperationLogRecord { + id: number; + /** Whether the stored hash still matches the row content. */ + integrity_ok: boolean; + /** Whether this row still links to its predecessor correctly. */ + chain_ok: boolean; + /** Either check failed: the card is flagged as possibly tampered. */ + tampered: boolean; + record_hash: string | null; + actor_account_uuid: string | null; + actor_name: string | null; + actor_role: string | null; + http_method: string | null; + action: string | null; + /** Translation key resolved by the panel, e.g. ``operationTrace.actions.view``. */ + action_i18n_key: string; + resource_type: string | null; + resource_id: string | null; + level: OperationLevel; + level_name: string; + outcome: 'ok' | 'denied' | 'error'; + status_code: number | null; + summary: string | null; + changes: OperationChangeField[]; + duration_ms: number; + created_at: string | null; +} + +export interface OperationLogPage { + records: OperationLogRecord[]; + total: number; + limit: number; + offset: number; + /** How many records on this page failed hash or chain verification. */ + tampered_count: number; +} + +export interface OperationLogFilters { + actions: string[]; + resource_types: string[]; + actors: { account_uuid: string; name: string }[]; +} + +export interface OperationLogQuery { + limit?: number; + offset?: number; + action?: string; + resource_type?: string; + actor?: string; + level?: OperationLevel; + since?: string; + until?: string; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a66418e65..f87b851d7 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -89,6 +89,13 @@ import type { WorkspaceRole, WorkspaceSpaceBilling, } from '@/app/infra/entities/workspace'; +import type { + OperationGovernance, + OperationLevel, + OperationLogFilters, + OperationLogPage, + OperationLogQuery, +} from '@/app/infra/entities/operation-log'; /** * 后端服务客户端 @@ -1824,6 +1831,48 @@ export class BackendClient extends BaseHttpClient { ); } + // ============ Workspace governance / operation traceability ============ + + public getOperationGovernance(): Promise { + return this.get('/api/v1/settings/governance'); + } + + public updateOperationGovernance(request: { + level: OperationLevel; + retention_days?: number; + max_rows?: number; + dedupe_window_seconds?: number; + }): Promise { + return this.put('/api/v1/settings/governance', request); + } + + public getOperationLogs( + query: OperationLogQuery = {}, + ): Promise { + return this.get('/api/v1/settings/operation-logs', query); + } + + public getOperationLogFilters(): Promise { + return this.get('/api/v1/settings/operation-logs/filters'); + } + + /** + * Build the export URL so the browser can download the CSV attachment + * directly; axios would try to parse the response body as JSON. + */ + public buildOperationLogExportURL(query: OperationLogQuery = {}): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + params.set(key, String(value)); + } + const suffix = params.toString(); + return `${this.getBaseUrl()}/api/v1/settings/operation-logs/export${ + suffix ? `?${suffix}` : '' + }`; + } + + public setPassword( newPassword: string, currentPassword?: string, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f448dfd83..e73341c75 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,5 +1,4 @@ import pipelineMigration from './pipeline-migration/en-US'; - const enUS = { assistant: { modelHint: 'Switch assistant model for your next message', @@ -99,7 +98,6 @@ const enUS = { guidedTour: { eventDebugDescription: 'Choose a supported event, enter test data, and review the output and tool results. Platform actions are simulated; models and other tools run as configured.', - pipeline: { trigger: { title: 'Choose which messages to answer', @@ -727,7 +725,6 @@ const enUS = { internalErrorHint: 'An unexpected error occurred. Check the backend logs using the reference below.', errorReference: 'Error reference: {{id}}', - title: 'Bots', description: 'Create and manage bots, which are the entry points for LangBot to connect with various platforms', @@ -1127,13 +1124,11 @@ const enUS = { pluginSettings: 'Plugin settings', pluginSettingsDescription: 'Parameters declared by this plugin.', selectToDebug: 'Select a plugin above to start debugging.', - debugOutput: 'Processor output', debugDescription: 'Input events, plugin logs and action results for this test session.', debugNotice: 'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.', - create: 'Create plugin processor', type: 'Plugin processor', description: @@ -1170,7 +1165,6 @@ const enUS = { debugData: { chatId: 'Chat ID', feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)', - title: 'Event data', form: 'Common fields', json: 'Full JSON', @@ -1633,7 +1627,6 @@ const enUS = { runnerUsageAll: 'All', runnerUsageAgent: 'Agent / Pipeline', runnerUsageEvent: 'Plugin processor', - searchPlaceholder: 'Search plugins...', searchPlaceholderCount: 'Search {{count}} extensions, capabilities, or use cases...', @@ -2719,12 +2712,88 @@ const enUS = { title: 'Settings', nav: { workspace: 'Workspace', + operationTrace: 'Traceability', models: 'Models', api: 'API', storage: 'Storage', account: 'Account', }, }, + operationTrace: { + title: 'Operation traceability', + description: + 'Trace admin and owner changes and views, recording what was changed into what.', + captureLevel: 'Capture level', + retention: 'Retention', + retentionDays: 'Retention days', + maxRows: 'Max records', + dedupeWindow: 'Dedupe window (s)', + records: 'Operation records', + levelUpdated: 'Capture level updated', + levelUpdateFailed: 'Failed to update capture level', + retentionUpdated: 'Retention updated', + retentionUpdateFailed: 'Failed to update retention', + export: 'Download logs', + exportFailed: 'Failed to download operation logs', + empty: 'No operation records yet', + systemActor: 'System', + pageInfo: '{{from}}-{{to}} of {{total}}', + previousPage: 'Previous page', + nextPage: 'Next page', + filterAction: 'Action', + filterAllActions: 'All actions', + filterResource: 'Resource', + filterAllResources: 'All resources', + filterActor: 'Actor', + filterAllActors: 'All actors', + loadFailed: 'Failed to load traceability data', + refresh: 'Refresh', + save: 'Save', + redacted: 'Redacted', + tamperedBadge: 'Possibly tampered', + verifiedBadge: 'Verified', + tamperedCount: '{{count}} records failed verification', + levels: { + off: { + label: 'Tracing off', + }, + mutation: { + label: 'Mutations only', + }, + read: { + label: 'Everything (incl. views)', + }, + }, + levelNames: { + off: 'Off', + mutation: 'Mutations', + read: 'Everything', + }, + outcomes: { + ok: 'Succeeded', + denied: 'Denied', + error: 'Failed', + }, + actions: { + audit_log_view: 'View operation logs', + clear_prune: 'Prune records by policy', + settings_update: 'Update tracing settings', + settings_view: 'View tracing settings', + member_invite: 'Invite member', + member_role_update: 'Update member role', + member_remove: 'Remove member', + member_view: 'View members', + export: 'Export data', + execute: 'Execute task', + debug: 'Debug task', + publish: 'Publish resource', + create: 'Create resource', + update: 'Update resource', + delete: 'Delete resource', + view: 'View resource', + probe: 'Probe endpoint', + }, + }, storageAnalysis: { title: 'Storage Analysis', description: 'Inspect storage usage and cleanup candidates', @@ -3182,5 +3251,4 @@ const enUS = { invalidPage: 'Invalid plugin page', }, }; - export default enUS; diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 41b813442..78f4b0ec6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2767,12 +2767,88 @@ const esES = { title: 'Configuración', nav: { workspace: 'Workspace', + operationTrace: 'Trazabilidad', models: 'Modelos', api: 'API', storage: 'Almacenamiento', account: 'Cuenta', }, }, + operationTrace: { + title: 'Trazabilidad de operaciones', + description: + 'Rastrea los cambios y las consultas de administradores y propietarios, registrando qué se cambió y a qué.', + captureLevel: 'Nivel de captura', + retention: 'Retención', + retentionDays: 'Días de retención', + maxRows: 'Registros máximos', + dedupeWindow: 'Ventana de deduplicación (s)', + records: 'Registros de operación', + levelUpdated: 'Nivel de captura actualizado', + levelUpdateFailed: 'No se pudo actualizar el nivel de captura', + retentionUpdated: 'Retención actualizada', + retentionUpdateFailed: 'No se pudo actualizar la retención', + export: 'Descargar registros', + exportFailed: 'No se pudieron descargar los registros', + empty: 'Aún no hay registros de operación', + systemActor: 'Sistema', + pageInfo: '{{from}}-{{to}} de {{total}}', + previousPage: 'Página anterior', + nextPage: 'Página siguiente', + filterAction: 'Acción', + filterAllActions: 'Todas las acciones', + filterResource: 'Recurso', + filterAllResources: 'Todos los recursos', + filterActor: 'Actor', + filterAllActors: 'Todos los actores', + loadFailed: 'No se pudieron cargar los datos de trazabilidad', + refresh: 'Actualizar', + save: 'Guardar', + redacted: 'Oculto', + tamperedBadge: 'Posible manipulación', + verifiedBadge: 'Verificado', + tamperedCount: '{{count}} registros fallaron la verificación', + levels: { + off: { + label: 'Sin trazabilidad', + }, + mutation: { + label: 'Solo cambios', + }, + read: { + label: 'Todo (incl. consultas)', + }, + }, + levelNames: { + off: 'Desactivado', + mutation: 'Solo cambios', + read: 'Todo', + }, + outcomes: { + ok: 'Correcto', + denied: 'Denegado', + error: 'Fallido', + }, + actions: { + audit_log_view: 'Ver registros de operación', + clear_prune: 'Depurar registros según la política', + settings_update: 'Actualizar ajustes de trazabilidad', + settings_view: 'Ver ajustes de trazabilidad', + member_invite: 'Invitar miembro', + member_role_update: 'Actualizar rol del miembro', + member_remove: 'Eliminar miembro', + member_view: 'Ver miembros', + export: 'Exportar datos', + execute: 'Ejecutar tarea', + debug: 'Depurar tarea', + publish: 'Publicar recurso', + create: 'Crear recurso', + update: 'Actualizar recurso', + delete: 'Eliminar recurso', + view: 'Ver recurso', + probe: 'Sondear endpoint', + }, + }, storageAnalysis: { title: 'Análisis de almacenamiento', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 78a7778f0..7f205da2d 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2730,12 +2730,88 @@ const jaJP = { title: '設定', nav: { workspace: 'ワークスペース', + operationTrace: '操作トレーサビリティ', models: 'モデル', api: 'API', storage: 'ストレージ', account: 'アカウント', }, }, + operationTrace: { + title: '操作トレーサビリティ', + description: + '管理者とオーナーの変更・閲覧操作を追跡し、「何が何に変更されたか」を記録します。', + captureLevel: 'キャプチャレベル', + retention: '保持ポリシー', + retentionDays: '保持日数', + maxRows: '最大レコード数', + dedupeWindow: '重複排除ウィンドウ(秒)', + records: '操作レコード', + levelUpdated: 'キャプチャレベルを更新しました', + levelUpdateFailed: 'キャプチャレベルの更新に失敗しました', + retentionUpdated: '保持ポリシーを更新しました', + retentionUpdateFailed: '保持ポリシーの更新に失敗しました', + export: 'ログをダウンロード', + exportFailed: '操作ログのダウンロードに失敗しました', + empty: '操作レコードはまだありません', + systemActor: 'システム', + pageInfo: '{{from}}-{{to}} / 全 {{total}} 件', + previousPage: '前のページ', + nextPage: '次のページ', + filterAction: '操作', + filterAllActions: 'すべての操作', + filterResource: 'リソース', + filterAllResources: 'すべてのリソース', + filterActor: '実行者', + filterAllActors: 'すべての実行者', + loadFailed: 'トレーサビリティデータの読み込みに失敗しました', + refresh: '更新', + save: '保存', + redacted: 'マスク済み', + tamperedBadge: '改ざんの可能性', + verifiedBadge: '検証済み', + tamperedCount: '{{count}} 件が検証に失敗', + levels: { + off: { + label: 'トレース無効', + }, + mutation: { + label: '変更のみ', + }, + read: { + label: '最上位(閲覧含む)', + }, + }, + levelNames: { + off: '無効', + mutation: '変更のみ', + read: '最上位', + }, + outcomes: { + ok: '成功', + denied: '拒否', + error: '失敗', + }, + actions: { + audit_log_view: '操作ログを閲覧', + clear_prune: 'ポリシーで記録を整理', + settings_update: 'トレーサビリティ設定を変更', + settings_view: 'トレーサビリティ設定を閲覧', + member_invite: 'メンバーを招待', + member_role_update: 'メンバーのロールを変更', + member_remove: 'メンバーを削除', + member_view: 'メンバーを閲覧', + export: 'データをエクスポート', + execute: 'タスクを実行', + debug: 'タスクをデバッグ', + publish: 'リソースを公開', + create: 'リソースを作成', + update: 'リソースを変更', + delete: 'リソースを削除', + view: 'リソースを閲覧', + probe: 'エンドポイントを確認', + }, + }, storageAnalysis: { title: 'ストレージ分析', description: 'ストレージ使用量とクリーンアップ候補を確認します', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index faa6bf053..cf98777d2 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2737,12 +2737,88 @@ const ruRU = { title: 'Настройки', nav: { workspace: 'Workspace', + operationTrace: 'Трассировка', models: 'Модели', api: 'API', storage: 'Хранилище', account: 'Аккаунт', }, }, + operationTrace: { + title: 'Трассировка операций', + description: + 'Отслеживание изменений и просмотров администраторов и владельцев с записью того, что и на что было изменено.', + captureLevel: 'Уровень записи', + retention: 'Хранение', + retentionDays: 'Дней хранения', + maxRows: 'Максимум записей', + dedupeWindow: 'Окно дедупликации (с)', + records: 'Записи операций', + levelUpdated: 'Уровень записи обновлён', + levelUpdateFailed: 'Не удалось обновить уровень записи', + retentionUpdated: 'Хранение обновлено', + retentionUpdateFailed: 'Не удалось обновить хранение', + export: 'Скачать журнал', + exportFailed: 'Не удалось скачать журнал операций', + empty: 'Записей операций пока нет', + systemActor: 'Система', + pageInfo: '{{from}}-{{to}} из {{total}}', + previousPage: 'Предыдущая страница', + nextPage: 'Следующая страница', + filterAction: 'Действие', + filterAllActions: 'Все действия', + filterResource: 'Ресурс', + filterAllResources: 'Все ресурсы', + filterActor: 'Исполнитель', + filterAllActors: 'Все исполнители', + loadFailed: 'Не удалось загрузить данные трассировки', + refresh: 'Обновить', + save: 'Сохранить', + redacted: 'Скрыто', + tamperedBadge: 'Возможна подмена', + verifiedBadge: 'Проверено', + tamperedCount: 'Записей с ошибкой проверки: {{count}}', + levels: { + off: { + label: 'Трассировка выключена', + }, + mutation: { + label: 'Только изменения', + }, + read: { + label: 'Всё (включая просмотры)', + }, + }, + levelNames: { + off: 'Выкл', + mutation: 'Изменения', + read: 'Всё', + }, + outcomes: { + ok: 'Успех', + denied: 'Отказано', + error: 'Ошибка', + }, + actions: { + audit_log_view: 'Просмотр журнала операций', + clear_prune: 'Очистка записей по политике', + settings_update: 'Изменение настроек трассировки', + settings_view: 'Просмотр настроек трассировки', + member_invite: 'Приглашение участника', + member_role_update: 'Изменение роли участника', + member_remove: 'Удаление участника', + member_view: 'Просмотр участников', + export: 'Экспорт данных', + execute: 'Выполнение задачи', + debug: 'Отладка задачи', + publish: 'Публикация ресурса', + create: 'Создание ресурса', + update: 'Изменение ресурса', + delete: 'Удаление ресурса', + view: 'Просмотр ресурса', + probe: 'Проверка эндпоинта', + }, + }, storageAnalysis: { title: 'Анализ хранилища', description: 'Проверьте использование хранилища и кандидатов на очистку', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index f4f83777c..63710062f 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2664,12 +2664,88 @@ const thTH = { title: 'การตั้งค่า', nav: { workspace: 'Workspace', + operationTrace: 'การติดตาม', models: 'โมเดล', api: 'API', storage: 'พื้นที่จัดเก็บ', account: 'บัญชี', }, }, + operationTrace: { + title: 'การติดตามการดำเนินการ', + description: + 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ โดยบันทึกว่าอะไรเปลี่ยนเป็นอะไร', + captureLevel: 'ระดับการบันทึก', + retention: 'นโยบายการเก็บรักษา', + retentionDays: 'จำนวนวันเก็บรักษา', + maxRows: 'จำนวนบันทึกสูงสุด', + dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)', + records: 'บันทึกการดำเนินการ', + levelUpdated: 'อัปเดตระดับการบันทึกแล้ว', + levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ', + retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว', + retentionUpdateFailed: 'อัปเดตนโยบายการเก็บรักษาไม่สำเร็จ', + export: 'ดาวน์โหลดบันทึก', + exportFailed: 'ดาวน์โหลดบันทึกการดำเนินการไม่สำเร็จ', + empty: 'ยังไม่มีบันทึกการดำเนินการ', + systemActor: 'ระบบ', + pageInfo: '{{from}}-{{to}} จาก {{total}}', + previousPage: 'หน้าก่อนหน้า', + nextPage: 'หน้าถัดไป', + filterAction: 'การดำเนินการ', + filterAllActions: 'ทุกการดำเนินการ', + filterResource: 'ทรัพยากร', + filterAllResources: 'ทุกทรัพยากร', + filterActor: 'ผู้ดำเนินการ', + filterAllActors: 'ผู้ดำเนินการทั้งหมด', + loadFailed: 'โหลดข้อมูลการติดตามไม่สำเร็จ', + refresh: 'รีเฟรช', + save: 'บันทึก', + redacted: 'ปิดบังแล้ว', + tamperedBadge: 'อาจถูกแก้ไข', + verifiedBadge: 'ตรวจสอบผ่าน', + tamperedCount: 'มี {{count}} บันทึกที่ตรวจสอบไม่ผ่าน', + levels: { + off: { + label: 'ปิดการติดตาม', + }, + mutation: { + label: 'เฉพาะการแก้ไข', + }, + read: { + label: 'สูงสุด (รวมการดู)', + }, + }, + levelNames: { + off: 'ปิด', + mutation: 'เฉพาะแก้ไข', + read: 'สูงสุด', + }, + outcomes: { + ok: 'สำเร็จ', + denied: 'ถูกปฏิเสธ', + error: 'ล้มเหลว', + }, + actions: { + audit_log_view: 'ดูบันทึกการดำเนินการ', + clear_prune: 'ล้างบันทึกตามนโยบาย', + settings_update: 'แก้ไขการตั้งค่าการติดตาม', + settings_view: 'ดูการตั้งค่าการติดตาม', + member_invite: 'เชิญสมาชิก', + member_role_update: 'แก้ไขบทบาทสมาชิก', + member_remove: 'ลบสมาชิก', + member_view: 'ดูสมาชิก', + export: 'ส่งออกข้อมูล', + execute: 'รันงาน', + debug: 'ดีบักงาน', + publish: 'เผยแพร่ทรัพยากร', + create: 'สร้างทรัพยากร', + update: 'แก้ไขทรัพยากร', + delete: 'ลบทรัพยากร', + view: 'ดูทรัพยากร', + probe: 'ตรวจสอบปลายทาง', + }, + }, storageAnalysis: { title: 'วิเคราะห์พื้นที่จัดเก็บ', description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 48307dc34..1bb4209ca 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2699,12 +2699,88 @@ const viVN = { title: 'Cài đặt', nav: { workspace: 'Workspace', + operationTrace: 'Truy vết', models: 'Mô hình', api: 'API', storage: 'Lưu trữ', account: 'Tài khoản', }, }, + operationTrace: { + title: 'Truy vết thao tác', + description: + 'Theo dõi thao tác sửa và xem của quản trị viên và chủ sở hữu, ghi lại "cái gì đã đổi thành cái gì".', + captureLevel: 'Mức ghi nhận', + retention: 'Chính sách lưu trữ', + retentionDays: 'Số ngày lưu', + maxRows: 'Số bản ghi tối đa', + dedupeWindow: 'Khoảng khử trùng lặp (giây)', + records: 'Bản ghi thao tác', + levelUpdated: 'Đã cập nhật mức ghi nhận', + levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại', + retentionUpdated: 'Đã cập nhật chính sách lưu trữ', + retentionUpdateFailed: 'Cập nhật chính sách lưu trữ thất bại', + export: 'Tải nhật ký', + exportFailed: 'Tải nhật ký thao tác thất bại', + empty: 'Chưa có bản ghi thao tác', + systemActor: 'Hệ thống', + pageInfo: '{{from}}-{{to}} trên {{total}}', + previousPage: 'Trang trước', + nextPage: 'Trang sau', + filterAction: 'Thao tác', + filterAllActions: 'Tất cả thao tác', + filterResource: 'Tài nguyên', + filterAllResources: 'Tất cả tài nguyên', + filterActor: 'Người thực hiện', + filterAllActors: 'Tất cả người thực hiện', + loadFailed: 'Tải dữ liệu truy vết thất bại', + refresh: 'Làm mới', + save: 'Lưu', + redacted: 'Đã ẩn', + tamperedBadge: 'Có thể bị sửa', + verifiedBadge: 'Đã xác minh', + tamperedCount: '{{count}} bản ghi không vượt qua kiểm tra', + levels: { + off: { + label: 'Tắt truy vết', + }, + mutation: { + label: 'Chỉ thao tác sửa', + }, + read: { + label: 'Cao nhất (gồm xem)', + }, + }, + levelNames: { + off: 'Tắt', + mutation: 'Chỉ sửa', + read: 'Cao nhất', + }, + outcomes: { + ok: 'Thành công', + denied: 'Bị từ chối', + error: 'Thất bại', + }, + actions: { + audit_log_view: 'Xem nhật ký thao tác', + clear_prune: 'Dọn bản ghi theo chính sách', + settings_update: 'Cập nhật cài đặt truy vết', + settings_view: 'Xem cài đặt truy vết', + member_invite: 'Mời thành viên', + member_role_update: 'Cập nhật vai trò thành viên', + member_remove: 'Xóa thành viên', + member_view: 'Xem thành viên', + export: 'Xuất dữ liệu', + execute: 'Chạy tác vụ', + debug: 'Gỡ lỗi tác vụ', + publish: 'Xuất bản tài nguyên', + create: 'Tạo tài nguyên', + update: 'Cập nhật tài nguyên', + delete: 'Xóa tài nguyên', + view: 'Xem tài nguyên', + probe: 'Dò endpoint', + }, + }, storageAnalysis: { title: 'Phân tích lưu trữ', description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 3a22387e2..968ba5f1f 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2562,12 +2562,87 @@ const zhHans = { title: '设置', nav: { workspace: '工作区', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '存储', account: '账户', }, }, + operationTrace: { + title: '操作溯源', + description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。', + captureLevel: '操作等级', + retention: '保留策略', + retentionDays: '保留天数', + maxRows: '最大记录数', + dedupeWindow: '去重窗口(秒)', + records: '操作记录', + levelUpdated: '操作等级已更新', + levelUpdateFailed: '操作等级更新失败', + retentionUpdated: '保留策略已更新', + retentionUpdateFailed: '保留策略更新失败', + export: '下载日志', + exportFailed: '下载操作日志失败', + empty: '暂无操作记录', + systemActor: '系统', + pageInfo: '第 {{from}}-{{to}} 条,共 {{total}} 条', + previousPage: '上一页', + nextPage: '下一页', + filterAction: '操作类型', + filterAllActions: '全部操作', + filterResource: '资源类型', + filterAllResources: '全部资源', + filterActor: '操作者', + filterAllActors: '全部操作者', + loadFailed: '加载操作溯源数据失败', + refresh: '刷新', + save: '保存', + redacted: '已脱敏', + tamperedBadge: '可能被篡改', + verifiedBadge: '校验通过', + tamperedCount: '{{count}} 条记录校验异常', + levels: { + off: { + label: '关闭溯源', + }, + mutation: { + label: '仅修改操作', + }, + read: { + label: '最高级(含查看)', + }, + }, + levelNames: { + off: '关闭', + mutation: '仅修改', + read: '最高级', + }, + outcomes: { + ok: '成功', + denied: '被拒绝', + error: '失败', + }, + actions: { + audit_log_view: '查看操作日志', + clear_prune: '按策略清理记录', + settings_update: '修改溯源设置', + settings_view: '查看溯源设置', + member_invite: '邀请成员', + member_role_update: '修改成员角色', + member_remove: '移除成员', + member_view: '查看成员', + export: '导出数据', + execute: '执行任务', + debug: '调试任务', + publish: '发布资源', + create: '创建资源', + update: '修改资源', + delete: '删除资源', + view: '查看资源', + probe: '探测接口', + }, + }, storageAnalysis: { title: '存储分析', description: '查看存储占用和可清理文件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 652c1ab70..7491691bc 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2563,12 +2563,87 @@ const zhHant = { title: '設定', nav: { workspace: 'Workspace', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '儲存', account: '帳戶', }, }, + operationTrace: { + title: '操作溯源', + description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。', + captureLevel: '操作等級', + retention: '保留策略', + retentionDays: '保留天數', + maxRows: '最大記錄數', + dedupeWindow: '去重視窗(秒)', + records: '操作記錄', + levelUpdated: '操作等級已更新', + levelUpdateFailed: '操作等級更新失敗', + retentionUpdated: '保留策略已更新', + retentionUpdateFailed: '保留策略更新失敗', + export: '下載日誌', + exportFailed: '下載操作日誌失敗', + empty: '暫無操作記錄', + systemActor: '系統', + pageInfo: '第 {{from}}-{{to}} 條,共 {{total}} 條', + previousPage: '上一頁', + nextPage: '下一頁', + filterAction: '操作類型', + filterAllActions: '全部操作', + filterResource: '資源類型', + filterAllResources: '全部資源', + filterActor: '操作者', + filterAllActors: '全部操作者', + loadFailed: '載入操作溯源資料失敗', + refresh: '重新整理', + save: '儲存', + redacted: '已遮罩', + tamperedBadge: '可能被竄改', + verifiedBadge: '驗證通過', + tamperedCount: '{{count}} 筆記錄驗證異常', + levels: { + off: { + label: '關閉溯源', + }, + mutation: { + label: '僅修改操作', + }, + read: { + label: '最高級(含查看)', + }, + }, + levelNames: { + off: '關閉', + mutation: '僅修改', + read: '最高級', + }, + outcomes: { + ok: '成功', + denied: '被拒絕', + error: '失敗', + }, + actions: { + audit_log_view: '查看操作日誌', + clear_prune: '依策略清理記錄', + settings_update: '修改溯源設定', + settings_view: '查看溯源設定', + member_invite: '邀請成員', + member_role_update: '修改成員角色', + member_remove: '移除成員', + member_view: '查看成員', + export: '匯出資料', + execute: '執行任務', + debug: '偵錯任務', + publish: '發佈資源', + create: '建立資源', + update: '修改資源', + delete: '刪除資源', + view: '查看資源', + probe: '探測介面', + }, + }, storageAnalysis: { title: '儲存分析', description: '查看儲存占用和可清理檔案', From 48d92ad61226baff583ec7a4bb3246abff72605c Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 02:26:48 +0800 Subject: [PATCH 02/20] fix(operation-trace): correct capture scope, broaden coverage, polish panel - Capture scope: treat the ``audit`` bucket (viewing the log / tracing settings) as a read-level observation instead of recording it from the mutation level. At "mutations only" the log no longer fills with GET page views; views are recorded only at the read level. - Coverage: classify plugin lifecycle (install / upgrade / config / page), skills, knowledge bases and MCP servers, with dedicated actions and resource types, so those operations are traced instead of falling into the generic resource bucket. Route rules now support multi-fragment AND matching and per-verb read/write actions. - Panel: move "Download logs" next to "Refresh" in the toolbar. - Verification: count hash mismatches and broken links separately instead of collapsing both into one "tampered" signal. - i18n: add the new action labels plus the split verification labels in all eight locales. --- src/langbot/pkg/api/http/service/settings.py | 155 ++++++++++++++---- .../OperationTracePanel.tsx | 49 +++--- web/src/app/infra/entities/operation-log.ts | 4 + web/src/i18n/locales/en-US.ts | 13 ++ web/src/i18n/locales/es-ES.ts | 13 ++ web/src/i18n/locales/ja-JP.ts | 13 ++ web/src/i18n/locales/ru-RU.ts | 13 ++ web/src/i18n/locales/th-TH.ts | 13 ++ web/src/i18n/locales/vi-VN.ts | 13 ++ web/src/i18n/locales/zh-Hans.ts | 13 ++ web/src/i18n/locales/zh-Hant.ts | 13 ++ 11 files changed, 261 insertions(+), 51 deletions(-) diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/api/http/service/settings.py index 8027a2c0e..114abc64b 100644 --- a/src/langbot/pkg/api/http/service/settings.py +++ b/src/langbot/pkg/api/http/service/settings.py @@ -252,6 +252,74 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='read', resource_type='member', ), + # --- Extension lifecycle (plugins, pages, skills, MCP) -------------- + ActionRule( + action='plugin_view', + category='extension', + bucket='read', + resource_type='plugin', + ), + ActionRule( + action='plugin_config', + category='extension', + bucket='write', + resource_type='plugin', + ), + ActionRule( + action='plugin_install', + category='extension', + bucket='write', + resource_type='plugin', + ), + ActionRule( + action='plugin_upgrade', + category='extension', + bucket='write', + resource_type='plugin', + ), + ActionRule( + action='page_view', + category='extension', + bucket='read', + resource_type='plugin_page', + ), + ActionRule( + action='skill_view', + category='extension', + bucket='read', + resource_type='skill', + ), + ActionRule( + action='skill_install', + category='extension', + bucket='write', + resource_type='skill', + ), + # --- Knowledge & MCP ------------------------------------------------- + ActionRule( + action='knowledge_base_view', + category='knowledge', + bucket='read', + resource_type='knowledge_base', + ), + ActionRule( + action='knowledge_base_update', + category='knowledge', + bucket='write', + resource_type='knowledge_base', + ), + ActionRule( + action='mcp_view', + category='integration', + bucket='read', + resource_type='mcp_server', + ), + ActionRule( + action='mcp_config', + category='integration', + bucket='write', + resource_type='mcp_server', + ), # --- Generic resource verbs ----------------------------------------- ActionRule( action='export', @@ -313,20 +381,43 @@ ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule _READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'}) -# Route fragments, evaluated in order, that refine the generic verb mapping. -# Longer, more specific fragments must precede their prefixes. -_ROUTE_RULES: typing.Final[tuple[tuple[str, str], ...]] = ( - ('/settings/operation-logs/export', 'export'), - ('/settings/operation-logs', 'audit_log_view'), - ('/settings/operation-level', 'settings_update'), - ('/settings/governance', 'settings_update'), - ('/settings/limits', 'settings_update'), - ('/members', 'member_view'), - ('/invitations', 'member_invite'), - ('/export', 'export'), - ('/debug', 'debug'), - ('/execute', 'execute'), - ('/publish', 'publish'), +# Route rules evaluated in order; the first match wins, so the most specific +# rule must precede its prefix. Each rule is ``(fragments, read_action, +# write_action)``: every fragment must appear in the lowered route (a tuple +# expresses an AND, which lets ``/plugins///config`` be told +# apart from ``/plugins//``), and the action is selected by +# whether the method is a read verb. +_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = ( + # --- Audit surface itself ------------------------------------------- + (('/settings/operation-logs/export',), 'export', 'export'), + (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view'), + (('/settings/operation-level',), 'settings_view', 'settings_update'), + (('/settings/governance',), 'settings_view', 'settings_update'), + (('/settings/limits',), 'settings_view', 'settings_update'), + # --- Extension lifecycle: plugins ----------------------------------- + (('/plugins/install',), 'plugin_view', 'plugin_install'), + (('/plugins/github',), 'plugin_view', 'plugin_view'), + (('/plugins/', '/config'), 'plugin_view', 'plugin_config'), + (('/plugins/', '/page-api'), 'page_view', 'page_view'), + (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'), + (('/plugins/', '/logs'), 'plugin_view', 'plugin_view'), + (('/plugins',), 'plugin_view', 'plugin_view'), + (('/extensions',), 'plugin_view', 'plugin_config'), + # --- Extension lifecycle: skills ------------------------------------ + (('/skills/', '/install'), 'skill_view', 'skill_install'), + (('/skills',), 'skill_view', 'skill_view'), + # --- Knowledge bases & MCP servers ---------------------------------- + (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update'), + (('/mcp/', '/config'), 'mcp_view', 'mcp_config'), + (('/mcp',), 'mcp_view', 'mcp_config'), + # --- Member management ---------------------------------------------- + (('/members',), 'member_view', 'member_role_update'), + (('/invitations',), 'member_view', 'member_invite'), + # --- Generic resource verbs ----------------------------------------- + (('/export',), 'export', 'export'), + (('/debug',), 'debug', 'debug'), + (('/execute',), 'execute', 'execute'), + (('/publish',), 'publish', 'publish'), ) @@ -339,22 +430,13 @@ def classify(method: str, route: str) -> ActionRule: upper_method = (method or 'GET').upper() lowered_route = (route or '').lower() + is_read = upper_method in _READ_METHODS - route_action: str | None = None - for fragment, candidate in _ROUTE_RULES: - if fragment in lowered_route: - route_action = candidate - break + for fragments, read_action, write_action in _ROUTE_RULES: + if all(fragment in lowered_route for fragment in fragments): + return ACTION_RULES_BY_ACTION[read_action if is_read else write_action] - if route_action is not None: - if route_action == 'settings_update' and upper_method in _READ_METHODS: - return ACTION_RULES_BY_ACTION['settings_view'] - if route_action == 'member_view' and upper_method not in _READ_METHODS: - # POST /members/ style routes are writes on the member family. - return ACTION_RULES_BY_ACTION['member_role_update'] - return ACTION_RULES_BY_ACTION[route_action] - - if upper_method in _READ_METHODS: + if is_read: return ACTION_RULES_BY_ACTION['view'] if upper_method == 'POST': return ACTION_RULES_BY_ACTION['create'] @@ -380,13 +462,19 @@ def role_may_configure(role: str | None) -> bool: def bucket_allows(bucket: str, effective_level: int) -> bool: - """Return whether a capture bucket should be persisted at this level.""" + """Return whether a capture bucket should be persisted at this level. + + ``write`` buckets are recorded from the mutation level upward. ``read`` + and ``audit`` are both *observation* buckets: viewing a resource and + viewing the audit surface itself are reads, so a mutation-level Workspace + must not fill its log with page views. They are only persisted once the + Workspace opts into the read level. + """ if effective_level <= OPERATION_LEVEL_NONE: return False - if bucket == 'read': + if bucket in ('read', 'audit'): return effective_level >= OPERATION_LEVEL_READ - # ``write`` and ``audit`` are both recorded from mutation level upward. return effective_level >= OPERATION_LEVEL_MUTATION @@ -1212,7 +1300,12 @@ class WorkspaceSettingsService: 'total': total, 'limit': resolved_limit, 'offset': resolved_offset, + # Report the two failure modes separately so the panel can tell a + # content edit (integrity) apart from a dropped link (chain) instead + # of collapsing both into a single "tampered" signal. 'tampered_count': sum(1 for record in records if record.get('tampered')), + 'integrity_failed_count': sum(1 for record in records if not record.get('integrity_ok')), + 'chain_failed_count': sum(1 for record in records if not record.get('chain_ok')), } def _serialize_log(self, row: typing.Any, *, previous_row: typing.Any | None = None) -> dict[str, typing.Any]: diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 06c80d506..a9bc4a46c 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -323,6 +323,19 @@ export default function OperationTracePanel({ )} {t('operationTrace.refresh')} + @@ -429,13 +442,22 @@ export default function OperationTracePanel({ {t('operationTrace.records')} {total} - {(page?.tampered_count ?? 0) > 0 && ( - - {t('operationTrace.tamperedCount', { - count: page?.tampered_count ?? 0, - })} - - )} + {(page?.tampered_count ?? 0) > 0 && + (page?.integrity_failed_count ?? 0) > 0 && ( + + {t('operationTrace.integrityFailedCount', { + count: page?.integrity_failed_count ?? 0, + })} + + )} + {(page?.tampered_count ?? 0) > 0 && + (page?.chain_failed_count ?? 0) > 0 && ( + + {t('operationTrace.chainFailedCount', { + count: page?.chain_failed_count ?? 0, + })} + + )}
- @@ -554,7 +562,9 @@ export default function OperationTracePanel({ size="sm" variant="muted" className={`items-start rounded-lg ${ - record.tampered ? 'border-destructive/60 bg-destructive/5' : '' + record.tampered + ? 'border-destructive/60 bg-destructive/5' + : '' }`} > From cf28b5694d59d8181148419d25bf90069ab4d2c0 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 02:33:45 +0800 Subject: [PATCH 04/20] fix(operation-trace): default tracing to off Tracing is now opt-in: a Workspace records nothing until an owner or admin explicitly turns it on, so auditing stays off the hot path by default. View and configure remain limited to owners and admins: the audit.view permission is granted to those roles only, non-owner/admin callers receive a 403 from the governance write route, and the panel already gates both on the owner/admin membership role. --- src/langbot/pkg/api/http/service/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/api/http/service/settings.py index 114abc64b..6b56c9820 100644 --- a/src/langbot/pkg/api/http/service/settings.py +++ b/src/langbot/pkg/api/http/service/settings.py @@ -54,7 +54,10 @@ OPERATION_RETENTION_DAYS_KEY = 'operation_log_retention_days' OPERATION_MAX_ROWS_KEY = 'operation_log_max_rows' OPERATION_DEDUPE_WINDOW_KEY = 'operation_log_dedupe_seconds' -DEFAULT_OPERATION_LEVEL = OPERATION_LEVEL_READ +#: Tracing is opt-in: a Workspace records nothing until an owner or admin +#: explicitly turns it on. This keeps auditing off the hot path by default and +#: avoids collecting administrative activity before the operator asked for it. +DEFAULT_OPERATION_LEVEL = OPERATION_LEVEL_NONE DEFAULT_RETENTION_DAYS = 30 DEFAULT_MAX_ROWS = 20000 From abca2e7bff79f1fde8cfecd6a50cfc062ca707a4 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 02:43:48 +0800 Subject: [PATCH 05/20] fix(operation-trace): repair export URL, drop dead action, prune unused keys - Export download failed because the URL builder appended the path to a "/" base, producing a protocol-relative "//api/..." URL that the browser read as host "api"; the request never reached the backend. Resolve the base to the current origin, mirroring the other URL builders. - Remove the orphan ``clear_prune`` action: the branch has no prune/clear route and no route rule maps to it, so it could never be produced. Its i18n key is removed too. - Remove ``tamperedCount``, now unreferenced after the verification badge was split into integrity/chain counters. The operationTrace catalogue is now free of unproduced keys. - Correct the settings controller docstring: only governance, operation logs, filters and export routes exist; there is no on-demand delete. Verified: no producer for clear_prune, classify() can no longer emit it, operationTrace orphan scan returns none, tsc/check-i18n/prettier/py_compile all pass. --- .../pkg/api/http/controller/groups/settings.py | 15 +++++++++------ src/langbot/pkg/api/http/service/settings.py | 6 ------ web/src/app/infra/http/BackendClient.ts | 11 +++++++++-- web/src/i18n/locales/en-US.ts | 2 -- web/src/i18n/locales/es-ES.ts | 2 -- web/src/i18n/locales/ja-JP.ts | 2 -- web/src/i18n/locales/ru-RU.ts | 2 -- web/src/i18n/locales/th-TH.ts | 2 -- web/src/i18n/locales/vi-VN.ts | 2 -- web/src/i18n/locales/zh-Hans.ts | 2 -- web/src/i18n/locales/zh-Hant.ts | 2 -- 11 files changed, 18 insertions(+), 30 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/settings.py b/src/langbot/pkg/api/http/controller/groups/settings.py index 72a4826a0..e69e3d704 100644 --- a/src/langbot/pkg/api/http/controller/groups/settings.py +++ b/src/langbot/pkg/api/http/controller/groups/settings.py @@ -2,12 +2,15 @@ Exposes the "成员操作日志溯源" (member operation traceability) surface: -* ``GET /api/v1/settings/governance`` read level + template table -* ``PUT /api/v1/settings/governance`` change level / retention -* ``GET /api/v1/settings/operation-logs`` page through records -* ``GET /api/v1/settings/operation-logs/filters`` available filter values -* ``POST /api/v1/settings/operation-logs/prune`` enforce retention now -* ``DEL /api/v1/settings/operation-logs`` clear records +* ``GET /api/v1/settings/governance`` read level + template table +* ``PUT /api/v1/settings/governance`` change level / retention +* ``GET /api/v1/settings/operation-logs`` page through records +* ``GET /api/v1/settings/operation-logs/filters`` available filter values +* ``GET /api/v1/settings/operation-logs/export`` download the filtered CSV + +Operation records are append-only and there is no route to delete them on +demand: retention is the only deletion path, and it runs automatically when +the governance settings change and from the maintenance loop. Every route requires ``audit.view``, which is granted to the Workspace owner and admin only. Write routes additionally require an owning/admin role so a diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/api/http/service/settings.py index 6b56c9820..3b0907851 100644 --- a/src/langbot/pkg/api/http/service/settings.py +++ b/src/langbot/pkg/api/http/service/settings.py @@ -212,12 +212,6 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='audit', resource_type='operation_log', ), - ActionRule( - action='clear_prune', - category='audit', - bucket='write', - resource_type='operation_log', - ), ActionRule( action='settings_update', category='settings', diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index f87b851d7..71812449f 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1867,12 +1867,19 @@ export class BackendClient extends BaseHttpClient { params.set(key, String(value)); } const suffix = params.toString(); - return `${this.getBaseUrl()}/api/v1/settings/operation-logs/export${ + // A base of "/" means "same origin". Appending the path directly produced + // a protocol-relative "//api/..." URL, which a browser reads as the host + // "api" — the export request then never reached the backend. Resolve the + // base to the current origin first, mirroring the other URL builders here. + const apiBase = + this.instance.defaults.baseURL === '/' || !this.instance.defaults.baseURL + ? window.location.origin + : this.instance.defaults.baseURL.replace(/\/$/, ''); + return `${apiBase}/api/v1/settings/operation-logs/export${ suffix ? `?${suffix}` : '' }`; } - public setPassword( newPassword: string, currentPassword?: string, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index aea9e7490..75e4e41cd 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2752,7 +2752,6 @@ const enUS = { redacted: 'Redacted', tamperedBadge: 'Possibly tampered', verifiedBadge: 'Verified', - tamperedCount: '{{count}} records failed verification', integrityFailedCount: '{{count}} hash mismatches', chainFailedCount: '{{count}} broken links', levels: { @@ -2778,7 +2777,6 @@ const enUS = { }, actions: { audit_log_view: 'View operation logs', - clear_prune: 'Prune records by policy', settings_update: 'Update tracing settings', settings_view: 'View tracing settings', member_invite: 'Invite member', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 3713f3927..8981b2852 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2807,7 +2807,6 @@ const esES = { redacted: 'Oculto', tamperedBadge: 'Posible manipulación', verifiedBadge: 'Verificado', - tamperedCount: '{{count}} registros fallaron la verificación', integrityFailedCount: '{{count}} hashes no coinciden', chainFailedCount: '{{count}} enlaces rotos', levels: { @@ -2833,7 +2832,6 @@ const esES = { }, actions: { audit_log_view: 'Ver registros de operación', - clear_prune: 'Depurar registros según la política', settings_update: 'Actualizar ajustes de trazabilidad', settings_view: 'Ver ajustes de trazabilidad', member_invite: 'Invitar miembro', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 36663dc75..053033ee8 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2770,7 +2770,6 @@ const jaJP = { redacted: 'マスク済み', tamperedBadge: '改ざんの可能性', verifiedBadge: '検証済み', - tamperedCount: '{{count}} 件が検証に失敗', integrityFailedCount: '{{count}} 件のハッシュ不一致', chainFailedCount: '{{count}} 件のリンク断絶', levels: { @@ -2796,7 +2795,6 @@ const jaJP = { }, actions: { audit_log_view: '操作ログを閲覧', - clear_prune: 'ポリシーで記録を整理', settings_update: 'トレーサビリティ設定を変更', settings_view: 'トレーサビリティ設定を閲覧', member_invite: 'メンバーを招待', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 67281e234..63448bcfb 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2777,7 +2777,6 @@ const ruRU = { redacted: 'Скрыто', tamperedBadge: 'Возможна подмена', verifiedBadge: 'Проверено', - tamperedCount: 'Записей с ошибкой проверки: {{count}}', integrityFailedCount: 'Несовпадение хэша: {{count}}', chainFailedCount: 'Разрыв связи: {{count}}', levels: { @@ -2803,7 +2802,6 @@ const ruRU = { }, actions: { audit_log_view: 'Просмотр журнала операций', - clear_prune: 'Очистка записей по политике', settings_update: 'Изменение настроек трассировки', settings_view: 'Просмотр настроек трассировки', member_invite: 'Приглашение участника', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index ca3dcc5f9..debd274e7 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2704,7 +2704,6 @@ const thTH = { redacted: 'ปิดบังแล้ว', tamperedBadge: 'อาจถูกแก้ไข', verifiedBadge: 'ตรวจสอบผ่าน', - tamperedCount: 'มี {{count}} บันทึกที่ตรวจสอบไม่ผ่าน', integrityFailedCount: 'แฮชไม่ตรงกัน {{count}} รายการ', chainFailedCount: 'ลิงก์ขาด {{count}} รายการ', levels: { @@ -2730,7 +2729,6 @@ const thTH = { }, actions: { audit_log_view: 'ดูบันทึกการดำเนินการ', - clear_prune: 'ล้างบันทึกตามนโยบาย', settings_update: 'แก้ไขการตั้งค่าการติดตาม', settings_view: 'ดูการตั้งค่าการติดตาม', member_invite: 'เชิญสมาชิก', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index a500b6965..e89a11ad2 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2739,7 +2739,6 @@ const viVN = { redacted: 'Đã ẩn', tamperedBadge: 'Có thể bị sửa', verifiedBadge: 'Đã xác minh', - tamperedCount: '{{count}} bản ghi không vượt qua kiểm tra', integrityFailedCount: '{{count}} mã băm không khớp', chainFailedCount: '{{count}} liên kết bị đứt', levels: { @@ -2765,7 +2764,6 @@ const viVN = { }, actions: { audit_log_view: 'Xem nhật ký thao tác', - clear_prune: 'Dọn bản ghi theo chính sách', settings_update: 'Cập nhật cài đặt truy vết', settings_view: 'Xem cài đặt truy vết', member_invite: 'Mời thành viên', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a1ab2a8b6..91119b4d4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2601,7 +2601,6 @@ const zhHans = { redacted: '已脱敏', tamperedBadge: '可能被篡改', verifiedBadge: '校验通过', - tamperedCount: '{{count}} 条记录校验异常', integrityFailedCount: '{{count}} 条哈希不匹配', chainFailedCount: '{{count}} 条链路断裂', levels: { @@ -2627,7 +2626,6 @@ const zhHans = { }, actions: { audit_log_view: '查看操作日志', - clear_prune: '按策略清理记录', settings_update: '修改溯源设置', settings_view: '查看溯源设置', member_invite: '邀请成员', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 8a62c8a45..a274484b0 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2602,7 +2602,6 @@ const zhHant = { redacted: '已遮罩', tamperedBadge: '可能被竄改', verifiedBadge: '驗證通過', - tamperedCount: '{{count}} 筆記錄驗證異常', integrityFailedCount: '{{count}} 筆雜湊不符', chainFailedCount: '{{count}} 筆鏈結中斷', levels: { @@ -2628,7 +2627,6 @@ const zhHant = { }, actions: { audit_log_view: '查看操作日誌', - clear_prune: '依策略清理記錄', settings_update: '修改溯源設定', settings_view: '查看溯源設定', member_invite: '邀請成員', From 6981b6643fdc8c12732da662309fcccc98b219da Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 03:23:24 +0800 Subject: [PATCH 06/20] feat(operation-trace): workspace-wide integrity counters, drill-down and resource identity - Count hash mismatches / broken links over the whole filtered history instead of only the returned page, so the badges no longer reset to zero on page 2. - Classify failing record ids during the same scan and expose them through an integrity=all|issues|hash_mismatch|chain_broken listing filter. - Report scanned_count / scan_truncated so an approximate (row-capped) summary is never mistaken for a clean one. - Resolve the acted-on resource identity from route params or the request body, giving every resource family a "which plugin/skill/knowledge base" trace. - Render resource types and the two counters through i18n, keeping all eight locale files key- and line-aligned. --- src/langbot/pkg/api/http/controller/group.py | 22 ++ .../api/http/controller/groups/settings.py | 3 + src/langbot/pkg/api/http/service/settings.py | 240 +++++++++++++++++- .../OperationTracePanel.tsx | 66 ++++- web/src/app/infra/entities/operation-log.ts | 22 +- web/src/i18n/locales/en-US.ts | 19 +- web/src/i18n/locales/es-ES.ts | 19 +- web/src/i18n/locales/ja-JP.ts | 19 +- web/src/i18n/locales/ru-RU.ts | 19 +- web/src/i18n/locales/th-TH.ts | 19 +- web/src/i18n/locales/vi-VN.ts | 19 +- web/src/i18n/locales/zh-Hans.ts | 16 ++ web/src/i18n/locales/zh-Hant.ts | 16 ++ 13 files changed, 470 insertions(+), 29 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 1b102ff09..079020893 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -387,6 +387,28 @@ class RouterGroup(abc.ABC): if candidate is not None: resource_id = str(candidate) + # Routes without an explicit resource_param still name the resource + # they act on, either as a URL parameter or inside the payload of an + # install-style endpoint. Resolving that identity here gives every + # resource family a "which plugin/skill/knowledge base" trace without + # touching each handler. The body is read defensively: an audit path + # must never fail (or block) because a request carries no JSON. + try: + # Imported lazily so the base controller never participates in an + # import cycle with the service layer at module load time. + from ..service import settings as settings_service + + request_body: dict[str, typing.Any] | None = None + if str(quart.request.method or '').upper() not in ('GET', 'HEAD', 'OPTIONS'): + raw_body = await quart.request.get_json(silent=True) + if isinstance(raw_body, dict): + request_body = raw_body + identity = settings_service.resolve_resource_identity(kwargs, request_body) + if identity: + resource_id = identity + except Exception: # pragma: no cover - identity resolution is best effort + pass + # Handlers may publish request-local traceability facts when a # static route declaration cannot describe the runtime diff, e.g. # the previous and the new role of a member. ``quart.g`` is diff --git a/src/langbot/pkg/api/http/controller/groups/settings.py b/src/langbot/pkg/api/http/controller/groups/settings.py index e69e3d704..ee906859b 100644 --- a/src/langbot/pkg/api/http/controller/groups/settings.py +++ b/src/langbot/pkg/api/http/controller/groups/settings.py @@ -169,6 +169,9 @@ class SettingsRouterGroup(group.RouterGroup): level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None, since=_parse_timestamp(args.get('since')), until=_parse_timestamp(args.get('until')), + # Optional verification filter: lets the panel turn the three + # tamper counters into a drill-down instead of a dead badge. + integrity=args.get('integrity') or None, ) return self.success(data=result) diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/api/http/service/settings.py index 3b0907851..a9c541b47 100644 --- a/src/langbot/pkg/api/http/service/settings.py +++ b/src/langbot/pkg/api/http/service/settings.py @@ -28,6 +28,8 @@ import enum import hashlib import hmac import json +import logging +import time import typing import sqlalchemy @@ -44,6 +46,8 @@ from ....utils import constants from ..authz import WorkspaceRole from ..context import PrincipalType, RequestContext +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Storage keys (WorkspaceMetadata.value is a short string column) @@ -77,6 +81,30 @@ MAX_DEDUPE_WINDOW_SECONDS = 3600 MAX_PAGE_SIZE = 200 DEFAULT_PAGE_SIZE = 50 +#: How many newest records the integrity summary reads before the numbers +#: beside the list are served from an approximate scan. Verifying a record means +#: recomputing its HMAC, so an unbounded scan on every page/refresh would make the +#: panel latency grow with the history size. ``MAX_MAX_ROWS`` (500k) is the +#: configured ceiling for a Workspace, so a full-table verification at that scale +#: is exactly what this bound avoids. +MAX_INTEGRITY_SCAN_ROWS = 20000 + +#: ``integrity`` query values accepted by :meth:`query_logs`. ``all`` keeps the +#: previous behaviour; the other two map to the two failure modes the panel +#: surfaces independently (hash mismatch vs. broken chain link). +INTEGRITY_FILTER_ALL = 'all' +INTEGRITY_FILTER_ISSUES = 'issues' +INTEGRITY_FILTER_HASH_MISMATCH = 'hash_mismatch' +INTEGRITY_FILTER_CHAIN_BROKEN = 'chain_broken' +_INTEGRITY_FILTERS = frozenset( + { + INTEGRITY_FILTER_ALL, + INTEGRITY_FILTER_ISSUES, + INTEGRITY_FILTER_HASH_MISMATCH, + INTEGRITY_FILTER_CHAIN_BROKEN, + } +) + #: Upper bound for a single export so a download cannot load the whole table. MAX_EXPORT_ROWS = 10000 @@ -559,6 +587,59 @@ def changed_fields( return changes +#: Ordered key groups that identify the resource a request acts on. Each group +#: is joined with ``/`` so ``author`` + ``plugin_name`` renders as +#: ``author/plugin_name`` — the same identity the UI already shows. Only these +#: keys are consulted: reading arbitrary payload fields would let a caller +#: inject unbounded, unattributed text into the audit trail. +_IDENTITY_KEY_GROUPS: typing.Final[tuple[tuple[str, ...], ...]] = ( + ('plugin_author', 'plugin_name'), + ('author', 'plugin_name'), + ('author', 'name'), + ('skill_uuid',), + ('knowledge_base_uuid',), + ('server_uuid',), + ('pipeline_uuid',), + ('provider_uuid',), + ('model_uuid',), + ('account_uuid',), + ('email',), + ('name',), + ('uuid',), +) + + +def resolve_resource_identity( + path_params: typing.Mapping[str, typing.Any] | None, + body: typing.Mapping[str, typing.Any] | None, +) -> str | None: + """Best-effort identity of the resource one request acts on. + + Path parameters describe the resource a route was registered for and are + therefore trusted; the request body is only consulted for install-style + endpoints that carry the identity in their payload (a marketplace install + names the plugin in the body, not in the URL). The result is bounded and + sensitive-looking keys are skipped so a secret can never be echoed back + through the trace. + """ + + params = path_params or {} + payload = body or {} + for group in _IDENTITY_KEY_GROUPS: + parts: list[str] = [] + for key in group: + value = params.get(key) + if value is None: + value = payload.get(key) + if value is None or value == '' or is_sensitive_field(key): + parts = [] + break + parts.append(str(value)) + if parts: + return typing.cast(str, truncate('/'.join(parts), _MAX_RESOURCE_ID_CHARS)) + return None + + def build_summary(rule: ActionRule, changes: list[dict[str, typing.Any]]) -> str: """Build a short change digest stored alongside the record. @@ -1231,11 +1312,25 @@ class WorkspaceSettingsService: level: int | None = None, since: datetime.datetime | None = None, until: datetime.datetime | None = None, + integrity: str | None = None, ) -> dict[str, typing.Any]: - """Return one page of operation records plus a coarse summary.""" + """Return one page of operation records plus a Workspace-wide summary. + + The three verification counters describe the whole filtered history, not + just the returned page: an operator opening page 2 must still see that + 39 records are tampered. Only ``total`` and ``records`` follow the + pagination window. + + ``integrity`` optionally narrows the listing to the records that failed + verification, which lets the panel make its counters actionable instead + of decorative. + """ resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) resolved_offset = max(int(offset or 0), 0) + resolved_integrity = (integrity or INTEGRITY_FILTER_ALL).strip().lower() + if resolved_integrity not in _INTEGRITY_FILTERS: + resolved_integrity = INTEGRITY_FILTER_ALL try: model = persistence_operation_log.WorkspaceOperationLog @@ -1253,14 +1348,32 @@ class WorkspaceSettingsService: if until is not None: filters.append(model.created_at <= until) + # Verify the filtered history once per request. This replaces the + # previous per-page sum, which made the counters silently restart at + # zero on every page after the first. + summary = await self._integrity_summary(model, filters) + + # ``total`` follows the active listing filter so the pagination badge + # and the pager stay consistent with what the operator asked to see. + visible_filters = list(filters) + if resolved_integrity == INTEGRITY_FILTER_ISSUES: + ids = summary['tampered_ids'] + visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false()) + elif resolved_integrity == INTEGRITY_FILTER_HASH_MISMATCH: + ids = summary['integrity_failed_ids'] + visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false()) + elif resolved_integrity == INTEGRITY_FILTER_CHAIN_BROKEN: + ids = summary['chain_failed_ids'] + visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false()) + total_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*filters) + sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*visible_filters) ) total = int(total_result.scalar_one_or_none() or 0) rows_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(model) - .where(*filters) + .where(*visible_filters) .order_by(model.id.desc()) .limit(resolved_limit) .offset(resolved_offset) @@ -1287,10 +1400,12 @@ class WorkspaceSettingsService: ) for index, row in enumerate(rows) ] + integrity_summary = summary['summary'] except Exception as exc: # pragma: no cover - defensive self.ap.logger.debug(f'Operation log query skipped: {exc}') total = 0 records = [] + integrity_summary = self._empty_integrity_summary() return { 'records': records, @@ -1299,12 +1414,123 @@ class WorkspaceSettingsService: 'offset': resolved_offset, # Report the two failure modes separately so the panel can tell a # content edit (integrity) apart from a dropped link (chain) instead - # of collapsing both into a single "tampered" signal. - 'tampered_count': sum(1 for record in records if record.get('tampered')), - 'integrity_failed_count': sum(1 for record in records if not record.get('integrity_ok')), - 'chain_failed_count': sum(1 for record in records if not record.get('chain_ok')), + # of collapsing both into a single "tampered" signal. The counters + # cover the whole filtered history, not only this page. + 'tampered_count': integrity_summary['tampered'], + 'integrity_failed_count': integrity_summary['integrity_failed'], + 'chain_failed_count': integrity_summary['chain_failed'], + 'scanned_count': integrity_summary['scanned'], + 'scan_truncated': integrity_summary['truncated'], + 'integrity_filter': resolved_integrity, } + @staticmethod + def _empty_integrity_summary() -> dict[str, typing.Any]: + return { + 'tampered': 0, + 'integrity_failed': 0, + 'chain_failed': 0, + 'scanned': 0, + 'truncated': False, + } + + async def _integrity_summary( + self, + model: typing.Any, + filters: list[typing.Any], + ) -> dict[str, typing.Any]: + """Verify the filtered history and classify every failing record. + + Returns the three counters, the number of rows actually verified and the + record id lists needed to narrow the listing to one failure mode. The + scan walks the records newest-first and stops at + :data:`MAX_INTEGRITY_SCAN_ROWS`, so both the counters and the id lists + always describe the same, well-defined slice of the history. + """ + + started = time.monotonic() + try: + rows_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model) + .where(*filters) + .order_by(model.id.desc()) + .limit(MAX_INTEGRITY_SCAN_ROWS) + ) + rows = list(rows_result.all()) + + oldest_id = rows[-1].id if rows else None + previous_row = None + verification_failed = False + if oldest_id is not None: + # Check the link of the oldest verified row against the row that + # precedes it, even when it sits outside the scan window: the + # baseline must still be read from the table, not guessed, or the + # boundary record would be reported as broken forever. + older_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(model) + .where(model.workspace_uuid == rows[0].workspace_uuid, model.id < oldest_id) + .order_by(model.id.desc()) + .limit(1) + ) + previous_row = older_result.first() + + tampered_ids: list[int] = [] + integrity_failed_ids: list[int] = [] + chain_failed_ids: list[int] = [] + for index, row in enumerate(rows): + predecessor = rows[index + 1] if index + 1 < len(rows) else previous_row + record = self._serialize_log(row, previous_row=predecessor) + if not record['integrity_ok']: + integrity_failed_ids.append(row.id) + if not record['chain_ok']: + chain_failed_ids.append(row.id) + if record['tampered']: + tampered_ids.append(row.id) + verification_failed = True + + summary = { + 'tampered': len(tampered_ids), + 'integrity_failed': len(integrity_failed_ids), + 'chain_failed': len(chain_failed_ids), + 'scanned': len(rows), + 'truncated': len(rows) >= MAX_INTEGRITY_SCAN_ROWS, + } + if verification_failed or summary['truncated']: + # Worth logging: either the store was edited underneath us, or the + # history outgrew the verification window and the counters became + # approximate. + logger.warning( + 'Operation log integrity scan: %s tampered / %s hash / %s chain over %s rows (truncated=%s)', + summary['tampered'], + summary['integrity_failed'], + summary['chain_failed'], + summary['scanned'], + summary['truncated'], + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + if elapsed_ms >= 250: + logger.debug( + 'Operation log integrity scan took %sms over %s rows', + elapsed_ms, + summary['scanned'], + ) + return { + 'summary': summary, + 'tampered_ids': tampered_ids, + 'integrity_failed_ids': integrity_failed_ids, + 'chain_failed_ids': chain_failed_ids, + } + except Exception as exc: # pragma: no cover - defensive + # Never take the whole log panel down because verification failed: + # fall back to zeroed counters and an unfiltered listing. + logger.debug(f'Operation log integrity summary skipped: {exc}') + return { + 'summary': self._empty_integrity_summary(), + 'tampered_ids': [], + 'integrity_failed_ids': [], + 'chain_failed_ids': [], + } + def _serialize_log(self, row: typing.Any, *, previous_row: typing.Any | None = None) -> dict[str, typing.Any]: """Serialize one row and re-verify its tamper-evidence chain. diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 62fc7bf96..429affa67 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -34,6 +34,7 @@ import { import type { OperationChangeField, OperationGovernance, + OperationIntegrityFilter, OperationLevel, OperationLogFilters, OperationLogPage, @@ -206,6 +207,19 @@ export default function OperationTracePanel({ // total badge disagree with the list) whenever a page is re-fetched. const visibleRecords = page?.records ?? []; + const integrityFilter: OperationIntegrityFilter = query.integrity ?? 'all'; + + // Clicking a counter turns it into a drill-down: the panel is the only place + // the operator can learn *which* records failed verification, so a badge that + // cannot be acted on would be a dead end. + function toggleIntegrityFilter(next: OperationIntegrityFilter) { + setQuery((prev) => ({ + ...prev, + integrity: prev.integrity === next ? undefined : next, + offset: 0, + })); + } + async function changeLevel(level: OperationLevel) { if (!canConfigure) return; setSaving(true); @@ -235,6 +249,7 @@ export default function OperationTracePanel({ level: query.level, since: query.since, until: query.until, + integrity: query.integrity, }); const response = await backendClient.downloadFile(url); const disposition = response.headers['content-disposition'] as @@ -450,22 +465,44 @@ export default function OperationTracePanel({ {t('operationTrace.records')} {total} - {(page?.tampered_count ?? 0) > 0 && - (page?.integrity_failed_count ?? 0) > 0 && ( + {/* The two failure modes are rendered independently: a record can + drop its chain link without corrupting its own hash, so gating + one badge on the other counter would hide a real mismatch. + The counters cover the whole filtered history, so they stay + stable while the operator pages through the records. */} + {(page?.integrity_failed_count ?? 0) > 0 && ( + + )} + {(page?.chain_failed_count ?? 0) > 0 && ( + + )} + {page?.scan_truncated && ( + + {t('operationTrace.scanTruncated', { + count: page?.scanned_count ?? 0, + })} + + )}
+ setQuery((prev) => ({ + ...prev, + offset: (Number(value) - 1) * PAGE_SIZE, + })) + } + disabled={loading || pageCount <= 1} + > + + + + + {Array.from({ length: pageCount }, (_, index) => ( + + {t('operationTrace.pageOf', { + page: index + 1, + total: pageCount, + })} + + ))} + +
)} + {/* Restore the always-visible right column: each row is + anchored by its duration and tamper-evidence hash, so the + digest stays one click away instead of being the only way + to see it. */} + +
+ + {record.duration_ms}ms + + {record.record_hash && ( + + + {record.record_hash.slice(0, 8)} + + )} +
+
); })} @@ -836,32 +874,35 @@ export default function OperationTracePanel({ > - {/* Jump straight to a page instead of only stepping one at a - time; with hundreds of records the arrows alone are painful. */} - + {/* Numbered pager: the first and last page stay visible with an + ellipsis between, so any page is one click away without the + arrows filling the bar with "Page x / y" rows. */} + {pageWindow(pageIndex, pageCount).map((item, index) => + item === '…' ? ( + + … + + ) : ( + + ), + )} @@ -688,167 +853,186 @@ export default function OperationTracePanel({ : t('operationTrace.emptyFiltered')}

)} - {groupedRecords.map(({ key, record, count }) => { - const expanded = expandedId === record.id; - return ( - - - {record.tampered ? ( - - ) : ( - - )} - - - {/* Primary line: only what changed hands — the action, the - resource, and an exception badge. The level ("L2") and - the "verified" badge were constant for almost every row - and only added noise, so they are gone. */} - - - {t(record.action_i18n_key, { - defaultValue: record.action ?? '', - })} - - {record.resource_type && ( - - {t( - `operationTrace.resourceTypes.${record.resource_type}`, - { - defaultValue: record.resource_type, - }, - )} - - )} - {record.resource_id && ( - - {record.resource_id} - - )} - {record.outcome !== 'ok' && ( - - {t(`operationTrace.outcomes.${record.outcome}`)} - - )} - {record.tampered && ( - - - {t('operationTrace.tamperedBadge')} - - )} - {count > 1 && ( - - ×{count} - - )} - - - - {record.actor_name ?? - record.actor_account_uuid ?? - t('operationTrace.systemActor')} - - - - {record.actor_role - ? t(`workspace.roles.${record.actor_role}`) - : t('operationTrace.systemActor')} - - {record.http_method && ( - <> - - - {record.http_method} - {/* A successful 2xx is the default; only surface the - status when it carries a signal (an error). */} - {record.status_code !== null && - record.status_code >= 400 - ? ` ${record.status_code}` - : ''} + {dayGroups.map((group) => ( +
+
+ + {dayHeading(group.key, { + today: t('operationTrace.today'), + yesterday: t('operationTrace.yesterday'), + unknown: t('operationTrace.unknownDay'), + })} + + +
+ {group.records.map(({ key, record, count }) => { + const expanded = expandedId === record.id; + const actor = + record.actor_name ?? + record.actor_account_uuid ?? + t('operationTrace.systemActor'); + const role = record.actor_role + ? t(`workspace.roles.${record.actor_role}`) + : t('operationTrace.systemActor'); + return ( + setExpandedId(expanded ? null : record.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setExpandedId(expanded ? null : record.id); + } + }} + className={`items-start rounded-lg ${ + record.tampered + ? 'border-destructive/60 bg-destructive/5' + : '' + } ${expanded ? 'ring-1 ring-border' : ''}`} + > + + {record.tampered ? ( + + ) : ( + + )} + + + {/* Identity first: who did it and when. That is the + question the log is opened to answer, so it leads + instead of hiding in a muted description line. */} +
+ + {actor} - - )} - - {formatTimestamp(record.created_at)} - {record.changes.length > 0 && ( - <> - - - - )} - - {/* The registered route is the concrete answer to "what is - this entry?": a bare "Resource / System" label is opaque - without the endpoint it came from. */} - {record.route && ( -

- {record.route} -

- )} - {/* The change digest is only meaningful once expanded, so - the collapsed row stays a single scannable line. */} - {expanded && record.changes.length > 0 && ( -
- {record.changes.map((change, index) => ( - · + {role} + · + + {formatTimestamp(record.created_at)} + +
+ {/* What happened, to which resource — always present, + followed on the next line by the concrete diff. */} + + + {t(record.action_i18n_key, { + defaultValue: record.action ?? '', + })} + + {record.resource_type && ( + + {t( + `operationTrace.resourceTypes.${record.resource_type}`, + { defaultValue: record.resource_type }, + )} + + )} + {record.resource_id && ( + + {record.resource_id} + + )} + {record.outcome !== 'ok' && ( + + {t(`operationTrace.outcomes.${record.outcome}`)} + + )} + {record.tampered && ( + + + {t('operationTrace.tamperedBadge')} + + )} + {count > 1 && ( + + ×{count} + + )} + + {/* "What was changed into what" is the whole point of + the log and the data already arrived with the page, + so the diff is spelled out on the collapsed row. */} + {record.changes.length > 0 && ( +
+ + t('operationTrace.elidedValue', { count: size }) + } + limit={ + expanded ? undefined : COLLAPSED_CHANGE_LIMIT + } + moreLabel={(hidden) => + t('operationTrace.moreChanges', { + count: hidden, + }) + } + collapsed={!expanded} + /> +
+ )} + {/* The whole card is the toggle, so the chevron is the + only affordance needed; clicking anywhere expands + it. That removes the ambiguity of a small text link + whose expanded state looked almost identical. */} + +
- )} -
- {/* Restore the always-visible right column: each row is - anchored by its duration and tamper-evidence hash, so the - digest stays one click away instead of being the only way - to see it. */} - -
- - {record.duration_ms}ms - - {record.record_hash && ( - - - {record.record_hash.slice(0, 8)} + {expanded + ? t('operationTrace.hideDetails') + : t('operationTrace.showDetails')} - )} -
-
-
- ); - })} + {/* Diagnostics are opt-in: route, method, status, + duration and the evidence hash matter when an + operator investigates one entry, not while scanning + the timeline. */} + {expanded && ( +
+ {record.route && ( + + {record.route} + + )} + {record.http_method && ( + + {record.http_method} + {record.status_code !== null + ? ` ${record.status_code}` + : ''} + + )} + {record.duration_ms}ms + {record.record_hash && ( + + + {record.record_hash.slice(0, 16)} + + )} +
+ )} + + + ); + })} +
+ ))}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 454260b1c..574201209 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2757,6 +2757,13 @@ const enUS = { chainFailedCount: '{{count}} broken links', scanTruncated: 'Only the latest {{count}} verified', emptyFiltered: 'No records need verification under the current filters', + mutationsOnly: 'Changes only', + today: 'Today', + yesterday: 'Yesterday', + unknownDay: 'Unknown time', + showDetails: 'Details', + moreChanges: '+{{count}} more', + elidedValue: '{{count}} chars', levels: { off: { label: 'Tracing off', @@ -2821,6 +2828,7 @@ const enUS = { knowledge_base_update: 'Change knowledge base', mcp_view: 'View MCP server', mcp_config: 'Change MCP server', + pipeline_extensions_update: 'Update pipeline extension bindings', export: 'Export data', execute: 'Execute task', debug: 'Debug task', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 13365952f..0875caa77 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2812,6 +2812,13 @@ const esES = { chainFailedCount: '{{count}} enlaces rotos', scanTruncated: 'Solo se verifican los últimos {{count}}', emptyFiltered: 'Ningún registro requiere verificación', + mutationsOnly: 'Solo cambios', + today: 'Hoy', + yesterday: 'Ayer', + unknownDay: 'Fecha desconocida', + showDetails: 'Detalles', + moreChanges: '+{{count}} más', + elidedValue: '{{count}} caracteres', levels: { off: { label: 'Sin trazabilidad', @@ -2876,6 +2883,8 @@ const esES = { knowledge_base_update: 'Cambiar base de conocimiento', mcp_view: 'Ver servidor MCP', mcp_config: 'Cambiar servidor MCP', + pipeline_extensions_update: + 'Actualizar vinculaciones de extensiones del pipeline', export: 'Exportar datos', execute: 'Ejecutar tarea', debug: 'Depurar tarea', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index da11202d0..d1788fe67 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2775,6 +2775,13 @@ const jaJP = { chainFailedCount: '{{count}} 件のリンク断絶', scanTruncated: '最新 {{count}} 件のみ検証', emptyFiltered: '現在のフィルターでは検証が必要な記録はありません', + mutationsOnly: '変更のみ', + today: '今日', + yesterday: '昨日', + unknownDay: '不明な日時', + showDetails: '詳細', + moreChanges: '他 {{count}} 件', + elidedValue: '{{count}} 文字', levels: { off: { label: 'トレース無効', @@ -2839,6 +2846,7 @@ const jaJP = { knowledge_base_update: 'ナレッジベースを変更', mcp_view: 'MCP サーバーを閲覧', mcp_config: 'MCP サーバーを変更', + pipeline_extensions_update: 'パイプライン拡張の紐付けを変更', export: 'データをエクスポート', execute: 'タスクを実行', debug: 'タスクをデバッグ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 8b1009cdb..d42db13ea 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2782,6 +2782,13 @@ const ruRU = { chainFailedCount: 'Разрыв связи: {{count}}', scanTruncated: 'Проверены только последние {{count}}', emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки', + mutationsOnly: 'Только изменения', + today: 'Сегодня', + yesterday: 'Вчера', + unknownDay: 'Неизвестное время', + showDetails: 'Подробнее', + moreChanges: 'ещё {{count}}', + elidedValue: '{{count}} символов', levels: { off: { label: 'Трассировка выключена', @@ -2846,6 +2853,7 @@ const ruRU = { knowledge_base_update: 'Изменение базы знаний', mcp_view: 'Просмотр сервера MCP', mcp_config: 'Изменение сервера MCP', + pipeline_extensions_update: 'Изменение привязок расширений конвейера', export: 'Экспорт данных', execute: 'Выполнение задачи', debug: 'Отладка задачи', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index dd3d22e13..7542b0f81 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2709,6 +2709,13 @@ const thTH = { chainFailedCount: 'ลิงก์ขาด {{count}} รายการ', scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด', emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน', + mutationsOnly: 'เฉพาะการแก้ไข', + today: 'วันนี้', + yesterday: 'เมื่อวาน', + unknownDay: 'ไม่ทราบเวลา', + showDetails: 'รายละเอียด', + moreChanges: 'อีก {{count}} รายการ', + elidedValue: '{{count}} ตัวอักษร', levels: { off: { label: 'ปิดการติดตาม', @@ -2773,6 +2780,7 @@ const thTH = { knowledge_base_update: 'แก้ไขฐานความรู้', mcp_view: 'ดูเซิร์ฟเวอร์ MCP', mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP', + pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์', export: 'ส่งออกข้อมูล', execute: 'รันงาน', debug: 'ดีบักงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 488325746..bd4814195 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2744,6 +2744,13 @@ const viVN = { chainFailedCount: '{{count}} liên kết bị đứt', scanTruncated: 'Chỉ xác minh {{count}} bản ghi mới nhất', emptyFiltered: 'Không có bản ghi nào cần xác minh với bộ lọc hiện tại', + mutationsOnly: 'Chỉ thay đổi', + today: 'Hôm nay', + yesterday: 'Hôm qua', + unknownDay: 'Không rõ thời gian', + showDetails: 'Chi tiết', + moreChanges: '+{{count}} mục', + elidedValue: '{{count}} ký tự', levels: { off: { label: 'Tắt truy vết', @@ -2808,6 +2815,7 @@ const viVN = { knowledge_base_update: 'Thay đổi cơ sở tri thức', mcp_view: 'Xem máy chủ MCP', mcp_config: 'Thay đổi máy chủ MCP', + pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline', export: 'Xuất dữ liệu', execute: 'Chạy tác vụ', debug: 'Gỡ lỗi tác vụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d700edfc4..f1a5c0346 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2607,6 +2607,13 @@ const zhHans = { chainFailedCount: '{{count}} 条链路断裂', scanTruncated: '仅校验最近 {{count}} 条', emptyFiltered: '当前筛选下没有需要校验的记录', + mutationsOnly: '只看修改', + today: '今天', + yesterday: '昨天', + unknownDay: '未知时间', + showDetails: '详情', + moreChanges: '还有 {{count}} 项', + elidedValue: '{{count}} 字符', levels: { off: { label: '关闭溯源', @@ -2671,6 +2678,7 @@ const zhHans = { knowledge_base_update: '修改知识库', mcp_view: '查看 MCP 服务器', mcp_config: '修改 MCP 服务器', + pipeline_extensions_update: '修改管道扩展绑定', export: '导出数据', execute: '执行任务', debug: '调试任务', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 52ff94e55..47067ffec 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2608,6 +2608,13 @@ const zhHant = { chainFailedCount: '{{count}} 筆鏈結中斷', scanTruncated: '僅校驗最近 {{count}} 筆', emptyFiltered: '目前篩選下沒有需要校驗的記錄', + mutationsOnly: '只看修改', + today: '今天', + yesterday: '昨天', + unknownDay: '未知時間', + showDetails: '詳情', + moreChanges: '還有 {{count}} 項', + elidedValue: '{{count}} 字元', levels: { off: { label: '關閉溯源', @@ -2672,6 +2679,7 @@ const zhHant = { knowledge_base_update: '修改知識庫', mcp_view: '查看 MCP 伺服器', mcp_config: '修改 MCP 伺服器', + pipeline_extensions_update: '修改管道擴展綁定', export: '匯出資料', execute: '執行任務', debug: '偵錯任務', From 7eeb0393a60f98b3299b43d9244e350a8da73c20 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Mon, 28 Sep 2026 01:56:00 +0800 Subject: [PATCH 18/20] feat(operation-trace): collapsible retention policy and assistant trace tool - Collapse the retention policy section by default, showing a one-line summary when collapsed. - Add the owner/admin-only list_operation_logs assistant tool backed by audit.view. - Enforce per-tool permissions on both tool listing and invocation. - Add a substring search filter (search) to query_logs and the tool so callers can ask narrow questions (plugin name, account, verb) instead of dumping the full history. - Project records to a compact shape so a page stays under the assistant result budget. - Localize the new assistant tool label and retention summary across all locales. --- src/langbot/pkg/api/http/service/assistant.py | 4 + .../pkg/api/http/service/assistant_tools.py | 182 +++++++++++++++++- src/langbot/pkg/operation_trace/service.py | 19 ++ .../home/components/AssistantToolResult.tsx | 12 +- .../OperationTracePanel.tsx | 145 ++++++++------ web/src/i18n/locales/en-US.ts | 2 + web/src/i18n/locales/es-ES.ts | 2 + web/src/i18n/locales/ja-JP.ts | 2 + web/src/i18n/locales/ru-RU.ts | 2 + web/src/i18n/locales/th-TH.ts | 2 + web/src/i18n/locales/vi-VN.ts | 2 + web/src/i18n/locales/zh-Hans.ts | 2 + web/src/i18n/locales/zh-Hant.ts | 2 + 13 files changed, 309 insertions(+), 69 deletions(-) diff --git a/src/langbot/pkg/api/http/service/assistant.py b/src/langbot/pkg/api/http/service/assistant.py index 19701b9c8..74e7073ae 100644 --- a/src/langbot/pkg/api/http/service/assistant.py +++ b/src/langbot/pkg/api/http/service/assistant.py @@ -25,6 +25,10 @@ Do not claim the application has been tested: this experiment has no chat-test o After creation/configuration show the returned resource URL so the user can open the normal editor, upload documents and use its existing debug chat. If an operation failed or its result is unknown, do not repeat a write automatically; explain the result and ask the user to inspect the resource. +When the list_operation_logs tool is available (owner / admin only), use it to answer "who changed or +viewed what" questions with the recorded before/after fields instead of guessing. Always query it with +a narrow filter (search / resource_type / actor) rather than an unfiltered dump, and never repeat the +same empty query: if a filter matched nothing, widen it once instead of re-asking. """ diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index f5d468194..4d81f29e0 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -20,6 +20,129 @@ class ListResources(Arguments): kind: Literal['models', 'embedding_models', 'pipelines', 'knowledge_bases', 'knowledge_engines'] +#: Canonical resource types, mirrored from the trace service action table. The +#: enum lets the model pick a value that exists instead of guessing a string +#: that matches nothing. +_RESOURCE_TYPES = ( + 'bot', + 'adapter', + 'model_provider', + 'llm_model', + 'pipeline', + 'user', + 'workspace', + 'monitoring', + 'webhook', + 'api_key', + 'workspace_settings', + 'member', + 'member_invitation', + 'operation_log', + 'plugin', + 'plugin_page', + 'skill', + 'knowledge_base', + 'mcp_server', + 'runtime', + 'system', + 'resource', +) + + +class ListOperationLogs(Arguments): + """Read the Workspace operation trace (owner / admin only).""" + + #: Optional free-text filter is mapped to a substring match so the caller + #: can name a plugin, an account or a verb ("install") without knowing the + #: stored action key. Prefer this over ``action`` when unsure of the key. + search: str | None = Field(default=None, max_length=100) + #: Exact stored action key, e.g. ``plugin_install`` (see the action list in + #: the tool description). Only use it when the exact key is known. + action: str | None = Field(default=None, max_length=64) + resource_type: str | None = Field( + default=None, + max_length=64, + description='Optional resource type filter. Valid values: ' + ', '.join(_RESOURCE_TYPES) + '.', + ) + actor: str | None = Field( + default=None, + max_length=64, + description='Optional actor filter: an account UUID or part of the actor name.', + ) + limit: int = Field( + default=20, + ge=1, + le=50, + description='Maximum records to return. Keep it small (10-20) for a targeted answer.', + ) + + +#: Forensics fields the settings panel needs but an answer does not; dropping +#: them keeps a page of records far below the assistant's per-result budget. +_COMPACT_CHANGE_FIELDS = 6 +_COMPACT_CHANGE_CHARS = 160 + + +def _brief(value, limit: int = _COMPACT_CHANGE_CHARS): + """Render a diff value as a bounded string (mirrors the trace service).""" + + if value is None or isinstance(value, (int, float, bool)): + return value + text = value if isinstance(value, str) else str(value) + return text if len(text) <= limit else text[: max(limit - 1, 0)] + '…' + + +def _compact_operation_records(result: dict) -> dict: + """Project operation records onto the fields a "who did what" answer needs. + + ``query_logs`` returns the full panel payload -- hash chain, user agent, + payload details -- which is roughly a kilobyte per row. A whole page of + those overflows the assistant's per-result budget and arrives as a raw + truncated preview the model cannot read, which is why an answer can end up + claiming the data is unavailable. Only the actor, the action, the target, + the timestamp and the verification verdicts are kept here; the heavy + forensic columns stay available through the settings panel export. + """ + + def record(row: dict) -> dict: + changes = [ + { + 'field': change.get('field'), + 'before': _brief(change.get('before')), + 'after': _brief(change.get('after')), + } + for change in (row.get('changes') or [])[:_COMPACT_CHANGE_FIELDS] + ] + return { + 'id': row.get('id'), + 'created_at': row.get('created_at'), + 'actor': row.get('actor_name') or row.get('actor_account_uuid'), + 'actor_role': row.get('actor_role'), + 'action': row.get('action'), + 'action_i18n_key': row.get('action_i18n_key'), + 'resource_type': row.get('resource_type'), + 'resource_id': row.get('resource_id'), + 'summary': row.get('summary'), + 'changes': changes, + 'outcome': row.get('outcome'), + 'http_method': row.get('http_method'), + 'route': row.get('route'), + 'integrity_ok': row.get('integrity_ok'), + 'chain_ok': row.get('chain_ok'), + 'tampered': row.get('tampered'), + } + + records = result.get('records') or [] + return { + 'total': result.get('total', len(records)), + 'returned': len(records), + 'tampered_count': result.get('tampered_count', 0), + 'integrity_failed_count': result.get('integrity_failed_count', 0), + 'chain_failed_count': result.get('chain_failed_count', 0), + 'records': [record(row) for row in records], + } + + class PipelineID(Arguments): pipeline_uuid: UUID @@ -45,29 +168,55 @@ class CreateKnowledgeBase(CreatePipeline): retrieval_settings: dict = Field(default_factory=dict) +#: Each entry is ``(schema, description, writes, extra_permission)``. ``writes`` +#: selects resource.manage vs resource.view; ``extra_permission`` narrows a tool +#: further (e.g. the audit surface needs ``audit.view``, which only the owner +#: and admin hold) and both are enforced identically when listing and calling. TOOLS = { - 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False), - 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False), - 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False), - 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True), + 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False, None), + 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None), + 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None), + 'list_operation_logs': ( + ListOperationLogs, + 'Read the Workspace operation trace (who changed or viewed what, with before/after fields). ' + 'Owner and admin only. Use it to answer "who did what" questions about this Workspace.', + False, + Permission.AUDIT_VIEW, + ), + 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True, None), 'configure_pipeline': ( ConfigurePipeline, 'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.', True, + None, ), 'create_knowledge_base': ( CreateKnowledgeBase, 'Create a knowledge base using an installed engine. Read its schema first. Requires confirmation.', True, + None, ), } +def _required_permission(writes: bool, extra: Permission | None) -> Permission: + """Return the permission a tool needs beyond the base resource verbs.""" + + if extra is not None: + return extra + return Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW + + +def _authorized(context, name: str) -> bool: + _schema, _description, writes, extra = TOOLS[name] + return _required_permission(writes, extra).value in context.workspace.permissions + + def validate_call(context, name: str, arguments: dict) -> Arguments: if name not in TOOLS: raise ValueError('Unknown management tool') - schema, _, writes = TOOLS[name] - require_permission(context, Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW) + schema, _, writes, extra = TOOLS[name] + require_permission(context, _required_permission(writes, extra)) return schema.model_validate(arguments) @@ -80,8 +229,8 @@ def tool_definitions(context) -> list[LLMTool]: parameters=schema.model_json_schema(), func=execute_tool, ) - for name, (schema, description, writes) in TOOLS.items() - if not writes or Permission.RESOURCE_MANAGE in context.workspace.permissions + for name, (schema, description, _writes, _extra) in TOOLS.items() + if _authorized(context, name) ] @@ -101,6 +250,23 @@ async def execute_tool(ap, context, name: str, arguments: dict): fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id') resources = [{key: item[key] for key in fields if key in item} for item in resources] return {'total': len(resources), 'items': redact_secrets(resources)} + if name == 'list_operation_logs': + # Delegates to the operation-trace service, so the assistant shows the + # same records and tamper verification as the settings panel, then + # compacts them so a full page still fits the per-result budget. + result = await ap.workspace_settings_service.query_logs( + context.workspace_uuid, + limit=args['limit'], + search=args['search'], + action=args['action'], + resource_type=args['resource_type'], + actor_account_uuid=args['actor'], + ) + compact = _compact_operation_records(result) + # Echo the applied filters so the model can see why a result is empty + # and widen a filter instead of repeating an empty targeted query. + compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)} + return compact if name == 'get_pipeline': return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid']) if name == 'get_knowledge_schema': diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 8df706640..9ceab345a 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -1653,6 +1653,7 @@ class WorkspaceSettingsService: since: datetime.datetime | None = None, until: datetime.datetime | None = None, integrity: str | None = None, + search: str | None = None, ) -> dict[str, typing.Any]: """Return one page of operation records plus a Workspace-wide summary. @@ -1664,6 +1665,13 @@ class WorkspaceSettingsService: ``integrity`` optionally narrows the listing to the records that failed verification, which lets the panel make its counters actionable instead of decorative. + + ``search`` is a case-insensitive substring match over the action, + resource type, resource id, actor name and summary. It exists so a + caller can ask a narrow question ("install", a plugin name, an account) + without pulling the whole history: the exact ``action`` filter compares + the stored key, so an approximate term like ``install`` would otherwise + return nothing and force a full dump. """ resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) @@ -1681,6 +1689,17 @@ class WorkspaceSettingsService: filters.append(model.resource_type == resource_type) if actor_account_uuid: filters.append(model.actor_account_uuid == actor_account_uuid) + if search: + needle = f'%{str(search).strip()}%' + filters.append( + sqlalchemy.or_( + model.action.ilike(needle), + model.resource_type.ilike(needle), + model.resource_id.ilike(needle), + model.actor_name.ilike(needle), + model.summary.ilike(needle), + ) + ) if level is not None: filters.append(model.level == int(level)) if since is not None: diff --git a/web/src/app/home/components/AssistantToolResult.tsx b/web/src/app/home/components/AssistantToolResult.tsx index 8b7e489e3..4eabe60c1 100644 --- a/web/src/app/home/components/AssistantToolResult.tsx +++ b/web/src/app/home/components/AssistantToolResult.tsx @@ -45,7 +45,9 @@ export default function AssistantToolResult({ ? result : Array.isArray(data.items) ? data.items - : null; + : Array.isArray(data.records) + ? data.records + : null; const total = typeof data.total === 'number' ? data.total : items?.length; const kind = tool?.arguments.kind; const label = @@ -142,7 +144,13 @@ export default function AssistantToolResult({ : {}; return (
  • - {String(entry.name || entry.uuid || '—')} + {String( + entry.name || + entry.summary || + entry.action || + entry.uuid || + '—', + )}
  • ); })} diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 530a033c1..31ed3d04d 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -15,6 +15,11 @@ import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Item, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item'; import { @@ -341,6 +346,9 @@ export default function OperationTracePanel({ const [retentionDays, setRetentionDays] = useState(30); const [maxRows, setMaxRows] = useState(20000); const [dedupeSeconds, setDedupeSeconds] = useState(60); + // Retention is an advanced, rarely-touched policy: it collapses by default so + // the capture level and the records list stay the focus of the panel. + const [retentionOpen, setRetentionOpen] = useState(false); // Only one record shows its full change detail at a time, so the list stays // scannable: the payload diff is long and is opt-in per row. const [expandedId, setExpandedId] = useState(null); @@ -631,66 +639,85 @@ export default function OperationTracePanel({
    -
    -

    - {t('operationTrace.retention')} -

    -
    -
    -
    +

    + {t('operationTrace.retention')} +

    + {!retentionOpen && ( + + {t('operationTrace.retentionSummary', { + days: retentionDays, + rows: maxRows, + })} + + )} + + +
    + + + + +
    +
    + +
    diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 574201209..b36f78c88 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -21,6 +21,7 @@ const enUS = { create_knowledge_base: 'Create knowledge base', get_pipeline: 'Read Pipeline', get_knowledge_schema: 'Read knowledge engine schema', + list_operation_logs: 'Read operation trace', }, resources: { models: 'Find chat models', @@ -2724,6 +2725,7 @@ const enUS = { description: 'Trace admin and owner edits and views, and what changed.', captureLevel: 'Capture level', retention: 'Retention', + retentionSummary: 'Keep {{days}} days · up to {{rows}} records', retentionDays: 'Retention days', maxRows: 'Max records', dedupeWindow: 'Dedupe window (s)', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 0875caa77..a5a1b09e6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -21,6 +21,7 @@ const esES = { create_knowledge_base: 'Crear base de conocimiento', get_pipeline: 'Consultar Pipeline', get_knowledge_schema: 'Consultar el esquema del motor de conocimiento', + list_operation_logs: 'Consultar la trazabilidad de operaciones', }, resources: { models: 'Buscar modelos de chat', @@ -2779,6 +2780,7 @@ const esES = { description: 'Rastrea cambios y consultas de los administradores', captureLevel: 'Nivel de captura', retention: 'Retención', + retentionSummary: 'Conservar {{days}} días · hasta {{rows}} registros', retentionDays: 'Días de retención', maxRows: 'Registros máximos', dedupeWindow: 'Ventana de deduplicación (s)', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d1788fe67..8dcf174ef 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -21,6 +21,7 @@ const jaJP = { create_knowledge_base: 'ナレッジベース作成', get_pipeline: 'Pipeline 参照', get_knowledge_schema: 'エンジン設定の参照', + list_operation_logs: '操作トレーサビリティの参照', }, resources: { models: 'チャットモデル検索', @@ -2742,6 +2743,7 @@ const jaJP = { description: '管理者とオーナーの操作を追跡します。', captureLevel: 'キャプチャレベル', retention: '保持ポリシー', + retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件', retentionDays: '保持日数', maxRows: '最大レコード数', dedupeWindow: '重複排除ウィンドウ(秒)', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index d42db13ea..069dd2f6a 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -21,6 +21,7 @@ const ruRU = { create_knowledge_base: 'Создать базу знаний', get_pipeline: 'Посмотреть Pipeline', get_knowledge_schema: 'Посмотреть схему движка знаний', + list_operation_logs: 'Просмотр трассировки операций', }, resources: { models: 'Найти чат-модели', @@ -2749,6 +2750,7 @@ const ruRU = { description: 'Отслеживание изменений и просмотров администраторов', captureLevel: 'Уровень записи', retention: 'Хранение', + retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей', retentionDays: 'Дней хранения', maxRows: 'Максимум записей', dedupeWindow: 'Окно дедупликации (с)', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 7542b0f81..149c7d822 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -21,6 +21,7 @@ const thTH = { create_knowledge_base: 'สร้างฐานความรู้', get_pipeline: 'ดู Pipeline', get_knowledge_schema: 'ดูสคีมาของ knowledge engine', + list_operation_logs: 'ดูการติดตามการดำเนินการ', }, resources: { models: 'ค้นหาโมเดลแชต', @@ -2676,6 +2677,7 @@ const thTH = { description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ', captureLevel: 'ระดับการบันทึก', retention: 'นโยบายการเก็บรักษา', + retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ', retentionDays: 'จำนวนวันเก็บรักษา', maxRows: 'จำนวนบันทึกสูงสุด', dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index bd4814195..84dd9d7f8 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -22,6 +22,7 @@ const viVN = { create_knowledge_base: 'Tạo cơ sở tri thức', get_pipeline: 'Xem Pipeline', get_knowledge_schema: 'Xem lược đồ knowledge engine', + list_operation_logs: 'Truy vấn truy vết thao tác', }, resources: { models: 'Tìm mô hình trò chuyện', @@ -2711,6 +2712,7 @@ const viVN = { description: 'Theo dõi thao tác sửa và xem của quản trị viên', captureLevel: 'Mức ghi nhận', retention: 'Chính sách lưu trữ', + retentionSummary: 'Lưu {{days}} ngày · tối đa {{rows}} bản ghi', retentionDays: 'Số ngày lưu', maxRows: 'Số bản ghi tối đa', dedupeWindow: 'Khoảng khử trùng lặp (giây)', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index f1a5c0346..6b8929318 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -19,6 +19,7 @@ const zhHans = { create_knowledge_base: '创建知识库', get_pipeline: '查看 Pipeline', get_knowledge_schema: '查看知识引擎配置', + list_operation_logs: '查询操作溯源', }, resources: { models: '查询聊天模型', @@ -2574,6 +2575,7 @@ const zhHans = { description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。', captureLevel: '操作等级', retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条', retentionDays: '保留天数', maxRows: '最大记录数', dedupeWindow: '去重窗口(秒)', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 47067ffec..135627fa0 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -19,6 +19,7 @@ const zhHant = { create_knowledge_base: '建立知識庫', get_pipeline: '查看 Pipeline', get_knowledge_schema: '查看知識引擎設定', + list_operation_logs: '查詢操作溯源', }, resources: { models: '查詢聊天模型', @@ -2575,6 +2576,7 @@ const zhHant = { description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。', captureLevel: '操作等級', retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條', retentionDays: '保留天數', maxRows: '最大記錄數', dedupeWindow: '去重視窗(秒)', From 67ff4d30543a322668fb2710c20554db569b538a Mon Sep 17 00:00:00 2001 From: TyperBody Date: Mon, 28 Sep 2026 02:06:57 +0800 Subject: [PATCH 19/20] fix(operation-trace): record uninstalls and deletes, drop assistant chatter - Split the route rules on the HTTP method so a DELETE is classified as a destructive action instead of falling into the read/write bucket. Uninstalling a plugin or skill and deleting a knowledge base or MCP server were recorded as a view/update and left no readable trace. - Add plugin_uninstall, skill_uninstall, knowledge_base_delete and mcp_delete. - Skip the assistant's own conversation traffic: a chat session produced opaque create/resource rows that answered nothing. - Localize the new actions and the assistant_conversation resource type in all locales. - Sync the assistant tool action list and apply ruff formatting. --- .../pkg/api/http/service/assistant_tools.py | 25 +++- src/langbot/pkg/operation_trace/service.py | 130 +++++++++++++----- web/src/i18n/locales/en-US.ts | 5 + web/src/i18n/locales/es-ES.ts | 5 + web/src/i18n/locales/ja-JP.ts | 5 + web/src/i18n/locales/ru-RU.ts | 5 + web/src/i18n/locales/th-TH.ts | 5 + web/src/i18n/locales/vi-VN.ts | 5 + web/src/i18n/locales/zh-Hans.ts | 5 + web/src/i18n/locales/zh-Hant.ts | 5 + 10 files changed, 157 insertions(+), 38 deletions(-) diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index 4d81f29e0..a850f428e 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -173,17 +173,34 @@ class CreateKnowledgeBase(CreatePipeline): #: further (e.g. the audit surface needs ``audit.view``, which only the owner #: and admin hold) and both are enforced identically when listing and calling. TOOLS = { - 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False, None), + 'list_resources': ( + ListResources, + 'List existing Workspace resources. Discover IDs before using them.', + False, + None, + ), 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None), 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None), 'list_operation_logs': ( ListOperationLogs, - 'Read the Workspace operation trace (who changed or viewed what, with before/after fields). ' - 'Owner and admin only. Use it to answer "who did what" questions about this Workspace.', + 'Search the Workspace operation trace (who changed or viewed what, with before/after fields). ' + 'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" ' + 'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or ' + 'search="install"); use the exact "action" key only when known - valid keys include ' + 'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, ' + 'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, ' + 'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, ' + 'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. ' + 'Combine resource_type and actor to answer "who installed X".', False, Permission.AUDIT_VIEW, ), - 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True, None), + 'create_pipeline': ( + CreatePipeline, + 'Create an unconnected Pipeline draft. Requires user confirmation.', + True, + None, + ), 'configure_pipeline': ( ConfigurePipeline, 'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.', diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 9ceab345a..09318d7c8 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -360,6 +360,15 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='plugin', ), + # Uninstalling is a mutation with its own verb. Without it the DELETE on the + # shared ``/plugins//`` route falls through to the read rule + # and an uninstall is mislabelled as a view. + ActionRule( + action='plugin_uninstall', + category='extension', + bucket='write', + resource_type='plugin', + ), ActionRule( action='plugin_upgrade', category='extension', @@ -384,6 +393,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='skill', ), + ActionRule( + action='skill_uninstall', + category='extension', + bucket='write', + resource_type='skill', + ), # --- Knowledge & MCP ------------------------------------------------- ActionRule( action='knowledge_base_view', @@ -397,6 +412,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='knowledge_base', ), + ActionRule( + action='knowledge_base_delete', + category='knowledge', + bucket='write', + resource_type='knowledge_base', + ), ActionRule( action='mcp_view', category='integration', @@ -409,6 +430,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='mcp_server', ), + ActionRule( + action='mcp_delete', + category='integration', + bucket='write', + resource_type='mcp_server', + ), # --- Pipelines ------------------------------------------------------- ActionRule( action='pipeline_extensions_update', @@ -471,6 +498,16 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='read', resource_type='system', ), + # A management-assistant chat session is not a Workspace resource change: + # creating a conversation and sending a turn produce one opaque + # ``create/resource`` row each. They carry no "what changed" answer, so the + # whole family is dropped rather than cluttering the log with noise. + ActionRule( + action='assistant_session', + category='runtime', + bucket='skip', + resource_type='assistant_conversation', + ), ) ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule for rule in ACTION_RULE_TABLE} @@ -479,45 +516,57 @@ _READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'}) # Route rules evaluated in order; the first match wins, so the most specific # rule must precede its prefix. Each rule is ``(fragments, read_action, -# write_action)``: every fragment must appear in the lowered route (a tuple -# expresses an AND, which lets ``/plugins///config`` be told -# apart from ``/plugins//``), and the action is selected by -# whether the method is a read verb. -_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = ( +# write_action, delete_action)``: every fragment must appear in the lowered +# route (a tuple expresses an AND, which lets ``/plugins///config`` +# be told apart from ``/plugins//``), and the action is selected by +# the method bucket -- read verb, ``DELETE``, or any other verb. +# +# ``delete_action`` is distinct because several resources register their read +# and their destroy on the *same* path (``GET``/``DELETE /plugins//``, +# ``/knowledge/...``, ``/mcp/servers/``). Keying only on fragments made a +# ``DELETE`` fall into the write bucket and record an uninstall as a view or an +# update, so the destructive operation left no readable trace. +_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str, str | None], ...]] = ( # --- Audit surface itself ------------------------------------------- - (('/settings/operation-logs/export',), 'export', 'export'), - (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view'), - (('/settings/operation-level',), 'settings_view', 'settings_update'), - (('/settings/governance',), 'settings_view', 'settings_update'), - (('/settings/limits',), 'settings_view', 'settings_update'), + (('/settings/operation-logs/export',), 'export', 'export', None), + (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None), + (('/settings/operation-level',), 'settings_view', 'settings_update', None), + (('/settings/governance',), 'settings_view', 'settings_update', None), + (('/settings/limits',), 'settings_view', 'settings_update', None), # --- Extension lifecycle: plugins ----------------------------------- - (('/plugins/install',), 'plugin_view', 'plugin_install'), - (('/plugins/github',), 'plugin_view', 'plugin_view'), - (('/plugins/', '/config'), 'plugin_view', 'plugin_config'), - (('/plugins/', '/page-api'), 'page_view', 'page_view'), - (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'), - (('/plugins/', '/logs'), 'plugin_view', 'plugin_view'), - (('/plugins',), 'plugin_view', 'plugin_view'), + # ``/plugins/install`` is an install; the bare ``/plugins//`` + # is a read when fetched and an uninstall when deleted. + (('/plugins/install',), 'plugin_view', 'plugin_install', None), + (('/plugins/github',), 'plugin_view', 'plugin_view', None), + (('/plugins/', '/config'), 'plugin_view', 'plugin_config', None), + (('/plugins/', '/page-api'), 'page_view', 'page_view', None), + (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None), + (('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None), + (('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall'), # The pipeline extension bindings (plugins / MCP servers / skills) live on # ``/pipelines//extensions``, not on a plugin. Without this rule the # generic fragment below would classify the change as a plugin config edit. # The read keeps the generic ``view`` for backwards-compatible labelling. - (('/extensions',), 'view', 'pipeline_extensions_update'), + (('/extensions',), 'view', 'pipeline_extensions_update', None), # --- Extension lifecycle: skills ------------------------------------ - (('/skills/', '/install'), 'skill_view', 'skill_install'), - (('/skills',), 'skill_view', 'skill_view'), + (('/skills/', '/install'), 'skill_view', 'skill_install', None), + (('/skills',), 'skill_view', 'skill_view', 'skill_uninstall'), # --- Knowledge bases & MCP servers ---------------------------------- - (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update'), - (('/mcp/', '/config'), 'mcp_view', 'mcp_config'), - (('/mcp',), 'mcp_view', 'mcp_config'), + (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete'), + (('/mcp/', '/config'), 'mcp_view', 'mcp_config', None), + (('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete'), # --- Member management ---------------------------------------------- - (('/members',), 'member_view', 'member_role_update'), - (('/invitations',), 'member_view', 'member_invite'), + (('/members',), 'member_view', 'member_role_update', None), + (('/invitations',), 'member_view', 'member_invite', None), + # --- Agent-assistant session chatter (never traced) ------------------ + # Placed before the generic verbs so a conversation turn is not recorded as + # an opaque ``create`` on a nameless resource. + (('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session'), # --- Generic resource verbs ----------------------------------------- - (('/export',), 'export', 'export'), - (('/debug',), 'debug', 'debug'), - (('/execute',), 'execute', 'execute'), - (('/publish',), 'publish', 'publish'), + (('/export',), 'export', 'export', None), + (('/debug',), 'debug', 'debug', None), + (('/execute',), 'execute', 'execute', None), + (('/publish',), 'publish', 'publish', None), ) #: Refines the resource family for the generic verb rules. The route rules above @@ -583,19 +632,28 @@ def classify(method: str, route: str) -> ActionRule: upper_method = (method or 'GET').upper() lowered_route = (route or '').lower() is_read = upper_method in _READ_METHODS + is_delete = upper_method == 'DELETE' - for fragments, read_action, write_action in _ROUTE_RULES: + for fragments, read_action, write_action, delete_action in _ROUTE_RULES: if all(fragment in lowered_route for fragment in fragments): - return _with_resource_type(ACTION_RULES_BY_ACTION[read_action if is_read else write_action], route) + if is_read: + action = read_action + elif is_delete: + # Fall back to the generic delete verb when a rule has no + # dedicated destroy action (still a mutation, never a view). + action = delete_action or 'delete' + else: + action = write_action + return _with_resource_type(ACTION_RULES_BY_ACTION[action], route) if is_read: return _with_resource_type(ACTION_RULES_BY_ACTION['view'], route) + if is_delete: + return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) if upper_method == 'POST': return _with_resource_type(ACTION_RULES_BY_ACTION['create'], route) if upper_method in {'PUT', 'PATCH'}: return _with_resource_type(ACTION_RULES_BY_ACTION['update'], route) - if upper_method == 'DELETE': - return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) return _with_resource_type(ACTION_RULES_BY_ACTION['probe'], route) @@ -620,9 +678,13 @@ def bucket_allows(bucket: str, effective_level: int) -> bool: and ``audit`` are both *observation* buckets: viewing a resource and viewing the audit surface itself are reads, so a mutation-level Workspace must not fill its log with page views. They are only persisted once the - Workspace opts into the read level. + Workspace opts into the read level. ``skip`` is charter noise a Workspace + never needs traced (an assistant chat session's own turn traffic), so it is + dropped at every level instead of being mislabelled as a resource change. """ + if bucket == 'skip': + return False if effective_level <= OPERATION_LEVEL_NONE: return False if bucket in ('read', 'audit'): diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b36f78c88..c1fcb2a83 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2797,6 +2797,7 @@ const enUS = { member: 'Member', member_invitation: 'Member invitation', operation_log: 'Operation log', + assistant_conversation: 'Assistant conversation', plugin: 'Extension', plugin_page: 'Extension page', skill: 'Skill', @@ -2822,14 +2823,18 @@ const enUS = { plugin_view: 'View extension pages', plugin_config: 'Change extension config', plugin_install: 'Install extension', + plugin_uninstall: 'Uninstall extension', plugin_upgrade: 'Upgrade extension', page_view: 'View extension page', skill_view: 'View skill', skill_install: 'Install skill', + skill_uninstall: 'Uninstall skill', knowledge_base_view: 'View knowledge base', knowledge_base_update: 'Change knowledge base', + knowledge_base_delete: 'Delete knowledge base', mcp_view: 'View MCP server', mcp_config: 'Change MCP server', + mcp_delete: 'Delete MCP server', pipeline_extensions_update: 'Update pipeline extension bindings', export: 'Export data', execute: 'Execute task', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index a5a1b09e6..f88cc19c2 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2852,6 +2852,7 @@ const esES = { member: 'Miembro', member_invitation: 'Invitación de miembro', operation_log: 'Registro de operaciones', + assistant_conversation: 'Conversación del asistente', plugin: 'Extensión', plugin_page: 'Página de extensión', skill: 'Habilidad', @@ -2877,14 +2878,18 @@ const esES = { plugin_view: 'Ver páginas de extensión', plugin_config: 'Cambiar configuración de extensión', plugin_install: 'Instalar extensión', + plugin_uninstall: 'Desinstalar extensión', plugin_upgrade: 'Actualizar extensión', page_view: 'Ver página de extensión', skill_view: 'Ver habilidad', skill_install: 'Instalar habilidad', + skill_uninstall: 'Desinstalar habilidad', knowledge_base_view: 'Ver base de conocimiento', knowledge_base_update: 'Cambiar base de conocimiento', + knowledge_base_delete: 'Eliminar base de conocimiento', mcp_view: 'Ver servidor MCP', mcp_config: 'Cambiar servidor MCP', + mcp_delete: 'Eliminar servidor MCP', pipeline_extensions_update: 'Actualizar vinculaciones de extensiones del pipeline', export: 'Exportar datos', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 8dcf174ef..a3874a17f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2815,6 +2815,7 @@ const jaJP = { member: 'メンバー', member_invitation: 'メンバー招待', operation_log: '操作ログ', + assistant_conversation: 'アシスタント会話', plugin: '拡張機能', plugin_page: '拡張ページ', skill: 'スキル', @@ -2840,14 +2841,18 @@ const jaJP = { plugin_view: '拡張ページを閲覧', plugin_config: '拡張設定を変更', plugin_install: '拡張をインストール', + plugin_uninstall: '拡張をアンインストール', plugin_upgrade: '拡張をアップグレード', page_view: '拡張ページを閲覧', skill_view: 'スキルを閲覧', skill_install: 'スキルをインストール', + skill_uninstall: 'スキルをアンインストール', knowledge_base_view: 'ナレッジベースを閲覧', knowledge_base_update: 'ナレッジベースを変更', + knowledge_base_delete: 'ナレッジベースを削除', mcp_view: 'MCP サーバーを閲覧', mcp_config: 'MCP サーバーを変更', + mcp_delete: 'MCP サーバーを削除', pipeline_extensions_update: 'パイプライン拡張の紐付けを変更', export: 'データをエクスポート', execute: 'タスクを実行', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 069dd2f6a..f4f614621 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2822,6 +2822,7 @@ const ruRU = { member: 'Участник', member_invitation: 'Приглашение участника', operation_log: 'Журнал операций', + assistant_conversation: 'Сессия ассистента', plugin: 'Расширение', plugin_page: 'Страница расширения', skill: 'Навык', @@ -2847,14 +2848,18 @@ const ruRU = { plugin_view: 'Просмотр страниц расширения', plugin_config: 'Изменение настроек расширения', plugin_install: 'Установка расширения', + plugin_uninstall: 'Удаление расширения', plugin_upgrade: 'Обновление расширения', page_view: 'Просмотр страницы расширения', skill_view: 'Просмотр навыка', skill_install: 'Установка навыка', + skill_uninstall: 'Удаление навыка', knowledge_base_view: 'Просмотр базы знаний', knowledge_base_update: 'Изменение базы знаний', + knowledge_base_delete: 'Удаление базы знаний', mcp_view: 'Просмотр сервера MCP', mcp_config: 'Изменение сервера MCP', + mcp_delete: 'Удаление сервера MCP', pipeline_extensions_update: 'Изменение привязок расширений конвейера', export: 'Экспорт данных', execute: 'Выполнение задачи', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 149c7d822..779c65a0c 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2749,6 +2749,7 @@ const thTH = { member: 'สมาชิก', member_invitation: 'คำเชิญสมาชิก', operation_log: 'บันทึกการดำเนินการ', + assistant_conversation: 'บทสนทนาผู้ช่วย', plugin: 'ส่วนขยาย', plugin_page: 'หน้าส่วนขยาย', skill: 'ทักษะ', @@ -2774,14 +2775,18 @@ const thTH = { plugin_view: 'ดูหน้าส่วนขยาย', plugin_config: 'แก้ไขการตั้งค่าส่วนขยาย', plugin_install: 'ติดตั้งส่วนขยาย', + plugin_uninstall: 'ถอนการติดตั้งส่วนขยาย', plugin_upgrade: 'อัปเกรดส่วนขยาย', page_view: 'ดูหน้าส่วนขยาย', skill_view: 'ดูทักษะ', skill_install: 'ติดตั้งทักษะ', + skill_uninstall: 'ถอนการติดตั้งทักษะ', knowledge_base_view: 'ดูฐานความรู้', knowledge_base_update: 'แก้ไขฐานความรู้', + knowledge_base_delete: 'ลบฐานความรู้', mcp_view: 'ดูเซิร์ฟเวอร์ MCP', mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP', + mcp_delete: 'ลบเซิร์ฟเวอร์ MCP', pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์', export: 'ส่งออกข้อมูล', execute: 'รันงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 84dd9d7f8..a3615658d 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2784,6 +2784,7 @@ const viVN = { member: 'Thành viên', member_invitation: 'Lời mời thành viên', operation_log: 'Nhật ký thao tác', + assistant_conversation: 'Phiên trợ lý', plugin: 'Tiện ích mở rộng', plugin_page: 'Trang tiện ích', skill: 'Kỹ năng', @@ -2809,14 +2810,18 @@ const viVN = { plugin_view: 'Xem trang tiện ích', plugin_config: 'Thay đổi cấu hình tiện ích', plugin_install: 'Cài đặt tiện ích', + plugin_uninstall: 'Gỡ cài đặt tiện ích', plugin_upgrade: 'Nâng cấp tiện ích', page_view: 'Xem trang tiện ích', skill_view: 'Xem kỹ năng', skill_install: 'Cài đặt kỹ năng', + skill_uninstall: 'Gỡ cài đặt kỹ năng', knowledge_base_view: 'Xem cơ sở tri thức', knowledge_base_update: 'Thay đổi cơ sở tri thức', + knowledge_base_delete: 'Xóa cơ sở tri thức', mcp_view: 'Xem máy chủ MCP', mcp_config: 'Thay đổi máy chủ MCP', + mcp_delete: 'Xóa máy chủ MCP', pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline', export: 'Xuất dữ liệu', execute: 'Chạy tác vụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6b8929318..883c97bac 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2647,6 +2647,7 @@ const zhHans = { member: '成员', member_invitation: '成员邀请', operation_log: '操作日志', + assistant_conversation: '助手会话', plugin: '扩展', plugin_page: '扩展页面', skill: '技能', @@ -2672,14 +2673,18 @@ const zhHans = { plugin_view: '查看扩展页面', plugin_config: '修改扩展配置', plugin_install: '安装扩展', + plugin_uninstall: '卸载扩展', plugin_upgrade: '升级扩展', page_view: '查看扩展页面', skill_view: '查看技能', skill_install: '安装技能', + skill_uninstall: '卸载技能', knowledge_base_view: '查看知识库', knowledge_base_update: '修改知识库', + knowledge_base_delete: '删除知识库', mcp_view: '查看 MCP 服务器', mcp_config: '修改 MCP 服务器', + mcp_delete: '删除 MCP 服务器', pipeline_extensions_update: '修改管道扩展绑定', export: '导出数据', execute: '执行任务', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 135627fa0..3c0bd34b9 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2648,6 +2648,7 @@ const zhHant = { member: '成員', member_invitation: '成員邀請', operation_log: '操作日誌', + assistant_conversation: '助手會話', plugin: '擴充', plugin_page: '擴充頁面', skill: '技能', @@ -2673,14 +2674,18 @@ const zhHant = { plugin_view: '查看擴充頁面', plugin_config: '修改擴充設定', plugin_install: '安裝擴充', + plugin_uninstall: '解除安裝擴充', plugin_upgrade: '升級擴充', page_view: '查看擴充頁面', skill_view: '查看技能', skill_install: '安裝技能', + skill_uninstall: '解除安裝技能', knowledge_base_view: '查看知識庫', knowledge_base_update: '修改知識庫', + knowledge_base_delete: '刪除知識庫', mcp_view: '查看 MCP 伺服器', mcp_config: '修改 MCP 伺服器', + mcp_delete: '刪除 MCP 伺服器', pipeline_extensions_update: '修改管道擴展綁定', export: '匯出資料', execute: '執行任務', From 280be0d7a4649b6d0dadd44f571066422973f8cd Mon Sep 17 00:00:00 2001 From: TyperBody Date: Mon, 28 Sep 2026 02:17:50 +0800 Subject: [PATCH 20/20] fix(operation-trace): audit every route for misclassified mutations Systematically cross-checked all 263 route/method pairs against the classifier and fixed every case where a real change could be mislabelled: - POST /skills and PUT /skills/ were classified as skill_view (read bucket), so creating or editing a skill was silently unlogged at the mutation level. They now record skill_update. - The public webhook ingress (/bots/) and the embedded chat widget (/embed/*) are unauthenticated visitor traffic, not Workspace changes; they are dropped instead of logged as create/resource. - Uploading files and indexing documents (data flowing into a resource) are offered as observations so they never fill the log; deleting a knowledge-base file is destructive and now records file_delete. - A plugin's own config-file edit/delete is a plugin change, not an opaque resource delete; ordered before the /config rule that also matched it. - The bucket now comes from the action table (plus 'a read verb is always a read'), so a write action can no longer be forced into the read bucket. - Added agents and files resource families; localized every new action/resource type in all nine locales. --- src/langbot/pkg/operation_trace/service.py | 218 ++++++++++++++++----- web/src/i18n/locales/en-US.ts | 10 + web/src/i18n/locales/es-ES.ts | 10 + web/src/i18n/locales/ja-JP.ts | 10 + web/src/i18n/locales/ru-RU.ts | 10 + web/src/i18n/locales/th-TH.ts | 10 + web/src/i18n/locales/vi-VN.ts | 10 + web/src/i18n/locales/zh-Hans.ts | 10 + web/src/i18n/locales/zh-Hant.ts | 10 + 9 files changed, 252 insertions(+), 46 deletions(-) diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 09318d7c8..82be57fcc 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -393,6 +393,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='skill', ), + ActionRule( + action='skill_update', + category='extension', + bucket='write', + resource_type='skill', + ), ActionRule( action='skill_uninstall', category='extension', @@ -492,6 +498,51 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='read', resource_type='resource', ), + # --- Data-flow helpers (why a read: nothing to trace) ---------------- + # Uploading a file or indexing a document into a knowledge base is data + # flowing into a resource, not a change to the resource's definition. It is + # offered as a read verb so the persist gate drops it for every role, the + # same way a file view is dropped, keeping the log to definition changes. + ActionRule( + action='file_view', + category='resource', + bucket='read', + resource_type='file', + ), + ActionRule( + action='ingest', + category='resource', + bucket='read', + resource_type='knowledge_base', + ), + # Removing a document from a knowledge base is destructive and worth tracing, + # unlike the upload/index that put it there. + ActionRule( + action='file_delete', + category='knowledge', + bucket='write', + resource_type='knowledge_base', + ), + # --- Platform ingress (external traffic, no Workspace actor) --------- + ActionRule( + action='ingress', + category='runtime', + bucket='skip', + resource_type='bot', + ), + # --- Provider credential handshake ----------------------------------- + ActionRule( + action='codex_view', + category='integration', + bucket='read', + resource_type='model_provider', + ), + ActionRule( + action='codex_authorize', + category='integration', + bucket='write', + resource_type='model_provider', + ), ActionRule( action='probe', category='system', @@ -508,6 +559,14 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='skip', resource_type='assistant_conversation', ), + # Visitor traffic to an embedded public chat widget: unauthenticated, keyed + # only by a bot UUID, never a Workspace actor. Dropped rather than logged. + ActionRule( + action='embed', + category='runtime', + bucket='skip', + resource_type='bot', + ), ) ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule for rule in ACTION_RULE_TABLE} @@ -516,57 +575,86 @@ _READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'}) # Route rules evaluated in order; the first match wins, so the most specific # rule must precede its prefix. Each rule is ``(fragments, read_action, -# write_action, delete_action)``: every fragment must appear in the lowered -# route (a tuple expresses an AND, which lets ``/plugins///config`` -# be told apart from ``/plugins//``), and the action is selected by -# the method bucket -- read verb, ``DELETE``, or any other verb. +# write_action, delete_action, fallback_action)``: every fragment must appear in +# the lowered route (a tuple expresses an AND, which lets +# ``/plugins///config`` be told apart from +# ``/plugins//``). # -# ``delete_action`` is distinct because several resources register their read -# and their destroy on the *same* path (``GET``/``DELETE /plugins//``, -# ``/knowledge/...``, ``/mcp/servers/``). Keying only on fragments made a -# ``DELETE`` fall into the write bucket and record an uninstall as a view or an -# update, so the destructive operation left no readable trace. -_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str, str | None], ...]] = ( +# The action is selected by the method bucket: a read verb uses ``read_action``, +# ``DELETE`` uses ``delete_action``, a write verb uses ``write_action``. Two +# subtlety guards exist because keying only on fragments and then trusting the +# bucket silently mislabels real operations: +# +# * ``write_action`` may be ``None`` for a read-only surface (``/plugins/github``, +# the codex auth handshake, the public webhook ingress). A stray non-read verb +# then uses ``fallback_action`` instead of being recorded as a mutation. +# * the bucket is re-derived from the *actual* method and the resolved action, so +# a mismatch can never route a real mutation into the read bucket (which is +# dropped at the mutation level) or a page load into the write bucket. +_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str | None, str | None, str], ...]] = ( # --- Audit surface itself ------------------------------------------- - (('/settings/operation-logs/export',), 'export', 'export', None), - (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None), - (('/settings/operation-level',), 'settings_view', 'settings_update', None), - (('/settings/governance',), 'settings_view', 'settings_update', None), - (('/settings/limits',), 'settings_view', 'settings_update', None), + (('/settings/operation-logs/export',), 'export', 'export', None, 'export'), + (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None, 'audit_log_view'), + (('/settings/operation-level',), 'settings_view', 'settings_update', None, 'settings_view'), + (('/settings/governance',), 'settings_view', 'settings_update', None, 'settings_view'), + (('/settings/limits',), 'settings_view', 'settings_update', None, 'settings_view'), # --- Extension lifecycle: plugins ----------------------------------- # ``/plugins/install`` is an install; the bare ``/plugins//`` # is a read when fetched and an uninstall when deleted. - (('/plugins/install',), 'plugin_view', 'plugin_install', None), - (('/plugins/github',), 'plugin_view', 'plugin_view', None), - (('/plugins/', '/config'), 'plugin_view', 'plugin_config', None), - (('/plugins/', '/page-api'), 'page_view', 'page_view', None), - (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None), - (('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None), - (('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall'), + (('/plugins/install',), 'plugin_view', 'plugin_install', None, 'plugin_view'), + (('/plugins/github',), 'plugin_view', None, None, 'plugin_view'), + # Editing or deleting a plugin's own config file is a plugin change, not an + # opaque resource delete. Must precede the ``/config`` rule below, whose + # fragment also matches ``config-files``. + (('/plugins/', '/config-files'), 'plugin_view', 'plugin_config', 'plugin_config', 'plugin_view'), + (('/plugins/', '/config'), 'plugin_view', 'plugin_config', None, 'plugin_view'), + (('/plugins/', '/page-api'), 'page_view', 'page_view', None, 'page_view'), + (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None, 'plugin_view'), + (('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None, 'plugin_view'), + (('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall', 'plugin_view'), # The pipeline extension bindings (plugins / MCP servers / skills) live on # ``/pipelines//extensions``, not on a plugin. Without this rule the # generic fragment below would classify the change as a plugin config edit. # The read keeps the generic ``view`` for backwards-compatible labelling. - (('/extensions',), 'view', 'pipeline_extensions_update', None), + (('/extensions',), 'view', 'pipeline_extensions_update', None, 'view'), # --- Extension lifecycle: skills ------------------------------------ - (('/skills/', '/install'), 'skill_view', 'skill_install', None), - (('/skills',), 'skill_view', 'skill_view', 'skill_uninstall'), + # ``/skills/install/...`` is an install; the bare ``/skills`` collection is + # created with POST and the item is rewritten with PUT, so the write verb is + # an update -- not ``skill_view``, which used to drop a real skill edit into + # the read bucket where a mutation-level Workspace never persisted it. + (('/skills/', '/install'), 'skill_view', 'skill_install', None, 'skill_view'), + (('/skills',), 'skill_view', 'skill_update', 'skill_uninstall', 'skill_view'), + # --- Ingestion helpers (data flowing in, not a definition change) ---- + # Ordered before the knowledge-base rule so a file upload or an index is not + # mislabelled as a knowledge-base definition update. + (('/knowledge/', '/files'), 'file_view', 'ingest', 'file_delete', 'file_view'), # --- Knowledge bases & MCP servers ---------------------------------- - (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete'), - (('/mcp/', '/config'), 'mcp_view', 'mcp_config', None), - (('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete'), + (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete', 'knowledge_base_view'), + (('/mcp/', '/config'), 'mcp_view', 'mcp_config', None, 'mcp_view'), + (('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete', 'mcp_view'), # --- Member management ---------------------------------------------- - (('/members',), 'member_view', 'member_role_update', None), - (('/invitations',), 'member_view', 'member_invite', None), + (('/members',), 'member_view', 'member_role_update', None, 'member_view'), + (('/invitations',), 'member_view', 'member_invite', None, 'member_view'), # --- Agent-assistant session chatter (never traced) ------------------ # Placed before the generic verbs so a conversation turn is not recorded as # an opaque ``create`` on a nameless resource. - (('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session'), + (('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session', 'assistant_session'), + # --- Embedded public chat widget (visitor traffic, never traced) ------ + (('/embed/',), 'embed', 'embed', 'embed', 'embed'), + # --- Public inbound webhook ingress (external traffic, never traced) -- + # ``/bots/`` is unauthenticated platform traffic, not a Workspace + # mutation; recording it as ``create/resource`` was pure noise. + (('/bots/',), 'ingress', 'ingress', 'ingress', 'ingress'), + # --- Provider credential handshake (in-flight pairing state) --------- + (('/codex/',), 'codex_view', None, 'codex_authorize', 'codex_view'), + # --- Ingestion helpers: uploading a document is data flowing in, not a + # change to a resource definition. -------------------------------- + (('/files/',), 'file_view', 'ingest', 'ingest', 'file_view'), # --- Generic resource verbs ----------------------------------------- - (('/export',), 'export', 'export', None), - (('/debug',), 'debug', 'debug', None), - (('/execute',), 'execute', 'execute', None), - (('/publish',), 'publish', 'publish', None), + (('/export',), 'export', 'export', None, 'export'), + (('/debug',), 'debug', 'debug', None, 'debug'), + (('/execute',), 'execute', 'execute', None, 'execute'), + (('/publish',), 'publish', 'publish', None, 'publish'), ) #: Refines the resource family for the generic verb rules. The route rules above @@ -590,6 +678,11 @@ _RESOURCE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str], ...]] = ( (('/monitoring',), 'monitoring'), (('/webhooks',), 'webhook'), (('/apikeys',), 'api_key'), + (('/agents',), 'agent'), + (('/files/',), 'file'), + (('/skills',), 'skill'), + (('/extensions',), 'plugin'), + (('/assistant',), 'assistant_conversation'), # The sandbox, survey and generic system probes share the ``system`` family. (('/box/',), 'system'), (('/survey',), 'system'), @@ -622,6 +715,35 @@ def _with_resource_type(rule: ActionRule, route: str) -> ActionRule: return dataclasses.replace(rule, resource_type=resource_type) +#: Action names that describe an observation. A mutation can never be persisted +#: under one of these: the read bucket is dropped at the mutation level, so a +def _action_bucket(action: str, method: str) -> str: + """Return the capture bucket for a resolved action and the actual method. + + The action table is the single source of truth: an action declares whether it + is a traceable change (``write``), an observation (``read``), the audit + surface itself (``audit``) or noise (``skip``). The only adjustment made here + is that a read *method* is always an observation -- so a GET that somehow + resolved to a write action still cannot be persisted as a mutation. Because + the table now names a real write action for every mutating route, trusting it + no longer lets a skill edit hide in the read bucket the way it used to. + """ + + bucket = ACTION_RULES_BY_ACTION[action].bucket + if bucket in ('audit', 'skip'): + return bucket + if method in _READ_METHODS: + return 'read' + return bucket + + +def _resolve(rule: ActionRule, action: str, method: str, route: str) -> ActionRule: + """Attach the method-derived bucket to a classified action.""" + + refined = _with_resource_type(ACTION_RULES_BY_ACTION[action], route) + return dataclasses.replace(refined, bucket=_action_bucket(action, method)) + + def classify(method: str, route: str) -> ActionRule: """Map one HTTP request to its normalized action rule. @@ -634,7 +756,7 @@ def classify(method: str, route: str) -> ActionRule: is_read = upper_method in _READ_METHODS is_delete = upper_method == 'DELETE' - for fragments, read_action, write_action, delete_action in _ROUTE_RULES: + for fragments, read_action, write_action, delete_action, fallback_action in _ROUTE_RULES: if all(fragment in lowered_route for fragment in fragments): if is_read: action = read_action @@ -643,18 +765,22 @@ def classify(method: str, route: str) -> ActionRule: # dedicated destroy action (still a mutation, never a view). action = delete_action or 'delete' else: - action = write_action - return _with_resource_type(ACTION_RULES_BY_ACTION[action], route) + # A read-only surface declares no write action; never invent a + # mutation for it, use the observation action instead. + action = write_action or fallback_action + return _resolve(ACTION_RULES_BY_ACTION[action], action, upper_method, route) if is_read: - return _with_resource_type(ACTION_RULES_BY_ACTION['view'], route) - if is_delete: - return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) - if upper_method == 'POST': - return _with_resource_type(ACTION_RULES_BY_ACTION['create'], route) - if upper_method in {'PUT', 'PATCH'}: - return _with_resource_type(ACTION_RULES_BY_ACTION['update'], route) - return _with_resource_type(ACTION_RULES_BY_ACTION['probe'], route) + action = 'view' + elif is_delete: + action = 'delete' + elif upper_method == 'POST': + action = 'create' + elif upper_method in {'PUT', 'PATCH'}: + action = 'update' + else: + action = 'probe' + return _resolve(ACTION_RULES_BY_ACTION[action], action, upper_method, route) def level_cap_for_role(role: str | None) -> int: diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c1fcb2a83..2bded01d0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2798,6 +2798,8 @@ const enUS = { member_invitation: 'Member invitation', operation_log: 'Operation log', assistant_conversation: 'Assistant conversation', + agent: 'Agent', + file: 'File', plugin: 'Extension', plugin_page: 'Extension page', skill: 'Skill', @@ -2835,6 +2837,14 @@ const enUS = { mcp_view: 'View MCP server', mcp_config: 'Change MCP server', mcp_delete: 'Delete MCP server', + skill_update: 'Modify skill', + file_view: 'View file', + ingest: 'Ingest data', + file_delete: 'Delete knowledge base file', + ingress: 'External message ingress', + codex_view: 'View Codex authorization', + codex_authorize: 'Authorize Codex', + embed: 'Embedded session', pipeline_extensions_update: 'Update pipeline extension bindings', export: 'Export data', execute: 'Execute task', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index f88cc19c2..c444e4d0b 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2853,6 +2853,8 @@ const esES = { member_invitation: 'Invitación de miembro', operation_log: 'Registro de operaciones', assistant_conversation: 'Conversación del asistente', + agent: 'Agente', + file: 'Archivo', plugin: 'Extensión', plugin_page: 'Página de extensión', skill: 'Habilidad', @@ -2890,6 +2892,14 @@ const esES = { mcp_view: 'Ver servidor MCP', mcp_config: 'Cambiar servidor MCP', mcp_delete: 'Eliminar servidor MCP', + skill_update: 'Modificar habilidad', + file_view: 'Ver archivo', + ingest: 'Importar datos', + file_delete: 'Eliminar archivo de la base de conocimiento', + ingress: 'Entrada de mensajes externos', + codex_view: 'Ver autorización de Codex', + codex_authorize: 'Autorizar Codex', + embed: 'Sesión incrustada', pipeline_extensions_update: 'Actualizar vinculaciones de extensiones del pipeline', export: 'Exportar datos', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index a3874a17f..ef1c1d8f1 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2816,6 +2816,8 @@ const jaJP = { member_invitation: 'メンバー招待', operation_log: '操作ログ', assistant_conversation: 'アシスタント会話', + agent: 'エージェント', + file: 'ファイル', plugin: '拡張機能', plugin_page: '拡張ページ', skill: 'スキル', @@ -2853,6 +2855,14 @@ const jaJP = { mcp_view: 'MCP サーバーを閲覧', mcp_config: 'MCP サーバーを変更', mcp_delete: 'MCP サーバーを削除', + skill_update: 'スキルを変更', + file_view: 'ファイルを表示', + ingest: 'データを取り込み', + file_delete: 'ナレッジベースのファイルを削除', + ingress: '外部メッセージ受信', + codex_view: 'Codex 認証を表示', + codex_authorize: 'Codex を認証', + embed: '埋め込みセッション', pipeline_extensions_update: 'パイプライン拡張の紐付けを変更', export: 'データをエクスポート', execute: 'タスクを実行', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index f4f614621..a5db8d81e 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2823,6 +2823,8 @@ const ruRU = { member_invitation: 'Приглашение участника', operation_log: 'Журнал операций', assistant_conversation: 'Сессия ассистента', + agent: 'Агент', + file: 'Файл', plugin: 'Расширение', plugin_page: 'Страница расширения', skill: 'Навык', @@ -2860,6 +2862,14 @@ const ruRU = { mcp_view: 'Просмотр сервера MCP', mcp_config: 'Изменение сервера MCP', mcp_delete: 'Удаление сервера MCP', + skill_update: 'Изменение навыка', + file_view: 'Просмотр файла', + ingest: 'Импорт данных', + file_delete: 'Удаление файла базы знаний', + ingress: 'Внешний приём сообщений', + codex_view: 'Просмотр авторизации Codex', + codex_authorize: 'Авторизация Codex', + embed: 'Встроенная сессия', pipeline_extensions_update: 'Изменение привязок расширений конвейера', export: 'Экспорт данных', execute: 'Выполнение задачи', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 779c65a0c..522ffabde 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2750,6 +2750,8 @@ const thTH = { member_invitation: 'คำเชิญสมาชิก', operation_log: 'บันทึกการดำเนินการ', assistant_conversation: 'บทสนทนาผู้ช่วย', + agent: 'เอเจนต์', + file: 'ไฟล์', plugin: 'ส่วนขยาย', plugin_page: 'หน้าส่วนขยาย', skill: 'ทักษะ', @@ -2787,6 +2789,14 @@ const thTH = { mcp_view: 'ดูเซิร์ฟเวอร์ MCP', mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP', mcp_delete: 'ลบเซิร์ฟเวอร์ MCP', + skill_update: 'แก้ไขทักษะ', + file_view: 'ดูไฟล์', + ingest: 'นำเข้าข้อมูล', + file_delete: 'ลบไฟล์ฐานความรู้', + ingress: 'การรับข้อความภายนอก', + codex_view: 'ดูการอนุญาต Codex', + codex_authorize: 'อนุญาต Codex', + embed: 'เซสชันฝังตัว', pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์', export: 'ส่งออกข้อมูล', execute: 'รันงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index a3615658d..05bf02d0d 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2785,6 +2785,8 @@ const viVN = { member_invitation: 'Lời mời thành viên', operation_log: 'Nhật ký thao tác', assistant_conversation: 'Phiên trợ lý', + agent: 'Tác nhân', + file: 'Tệp', plugin: 'Tiện ích mở rộng', plugin_page: 'Trang tiện ích', skill: 'Kỹ năng', @@ -2822,6 +2824,14 @@ const viVN = { mcp_view: 'Xem máy chủ MCP', mcp_config: 'Thay đổi máy chủ MCP', mcp_delete: 'Xóa máy chủ MCP', + skill_update: 'Sửa kỹ năng', + file_view: 'Xem tệp', + ingest: 'Nhập dữ liệu', + file_delete: 'Xóa tệp cơ sở tri thức', + ingress: 'Nhận tin nhắn bên ngoài', + codex_view: 'Xem ủy quyền Codex', + codex_authorize: 'Ủy quyền Codex', + embed: 'Phiên nhúng', pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline', export: 'Xuất dữ liệu', execute: 'Chạy tác vụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 883c97bac..1fdc080c0 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2648,6 +2648,8 @@ const zhHans = { member_invitation: '成员邀请', operation_log: '操作日志', assistant_conversation: '助手会话', + agent: '智能体', + file: '文件', plugin: '扩展', plugin_page: '扩展页面', skill: '技能', @@ -2685,6 +2687,14 @@ const zhHans = { mcp_view: '查看 MCP 服务器', mcp_config: '修改 MCP 服务器', mcp_delete: '删除 MCP 服务器', + skill_update: '修改技能', + file_view: '查看文件', + ingest: '导入数据', + file_delete: '删除知识库文件', + ingress: '外部消息接入', + codex_view: '查看 Codex 授权', + codex_authorize: '授权 Codex', + embed: '嵌入式会话', pipeline_extensions_update: '修改管道扩展绑定', export: '导出数据', execute: '执行任务', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 3c0bd34b9..8c0526ff7 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2649,6 +2649,8 @@ const zhHant = { member_invitation: '成員邀請', operation_log: '操作日誌', assistant_conversation: '助手會話', + agent: '智慧代理', + file: '檔案', plugin: '擴充', plugin_page: '擴充頁面', skill: '技能', @@ -2686,6 +2688,14 @@ const zhHant = { mcp_view: '查看 MCP 伺服器', mcp_config: '修改 MCP 伺服器', mcp_delete: '刪除 MCP 伺服器', + skill_update: '修改技能', + file_view: '查看檔案', + ingest: '匯入資料', + file_delete: '刪除知識庫檔案', + ingress: '外部訊息接入', + codex_view: '查看 Codex 授權', + codex_authorize: '授權 Codex', + embed: '嵌入式工作階段', pipeline_extensions_update: '修改管道擴展綁定', export: '匯出資料', execute: '執行任務',