diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 6126eebd4..181dbe806 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,127 @@ 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. + """ + + # Cheap global gate: a single attribute read on the application object. + # While no Workspace has enabled traceability this is ``False`` and the + # isolated subsystem stays entirely off the hot request path, with no + # import, service lookup, JSON parse or database round trip. + if not getattr(self.ap, 'operation_trace_active', False): + return + + 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) + + # 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: + # Identity resolution lives in the isolated traceability service + # and is reached through the handle, so the Core controller never + # imports that subsystem. + 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 = 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 + # request-local, so concurrent requests never share these values. + # The stored summary is derived by the service from ``changes`` and + # the classified action, so handlers only publish the diff itself. + 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) + 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/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py index 87dc2ac30..d8970d5ad 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py @@ -2,6 +2,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext +from ......operation_trace import service as settings_service from ... import group @@ -67,13 +68,33 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): knowledge_base_uuid: str, request_context: RequestContext, ) -> quart.Response: + # Name the knowledge base and, on update, record which settings moved + # instead of only "a knowledge base was changed". + quart.g.operation_log_resource_id = knowledge_base_uuid if quart.request.method == 'PUT': json_data = await quart.request.json + try: + previous = await self.ap.knowledge_service.get_knowledge_base( + request_context, + knowledge_base_uuid, + include_secret=True, + ) + except Exception: + previous = None await self.ap.knowledge_service.update_knowledge_base( request_context, knowledge_base_uuid, json_data, ) + changes = settings_service.changed_fields( + previous if isinstance(previous, dict) else {}, + json_data if isinstance(json_data, dict) else {}, + ignore=('uuid', 'created_at', 'updated_at'), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('knowledge_base_update') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) return self.success(data={'uuid': knowledge_base_uuid}) await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid) return self.success({}) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 016616623..33bc79ac8 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -4,6 +4,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext +from ......operation_trace import service as settings_service from ....service.secrets import redact_secrets from ....service.pipeline_run import run_pipeline from ... import group @@ -88,15 +89,30 @@ class PipelinesRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(pipeline_uuid: str, request_context: RequestContext) -> str: + quart.g.operation_log_resource_id = pipeline_uuid if quart.request.method == 'PUT': + json_data = await quart.request.json + try: + previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) + except Exception: + previous = None try: await self.ap.pipeline_service.update_pipeline( request_context, pipeline_uuid, - await quart.request.json, + json_data, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) + changes = settings_service.changed_fields( + previous if isinstance(previous, dict) else {}, + json_data if isinstance(json_data, dict) else {}, + ignore=('uuid', 'created_at', 'updated_at'), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('update') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) else: await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid) return self.success() @@ -159,7 +175,41 @@ class PipelinesRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(pipeline_uuid: str, request_context: RequestContext) -> str: + quart.g.operation_log_resource_id = pipeline_uuid json_data = await quart.request.json + # Capture "what was bound before" so the trace answers "what changed + # into what": without this the log could only say an extension was + # configured, never which binding was added or removed. + try: + previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) + except Exception: + previous = None + # The request uses ``bound_*`` keys while the stored preferences use + # the canonical names, so both sides are normalized before diffing. + requested = { + canonical: json_data[field] + for field, canonical in { + 'bound_plugins': 'plugins', + 'bound_mcp_servers': 'mcp_servers', + 'bound_skills': 'skills', + 'bound_mcp_resources': 'mcp_resources', + 'enable_all_plugins': 'enable_all_plugins', + 'enable_all_mcp_servers': 'enable_all_mcp_servers', + 'enable_all_skills': 'enable_all_skills', + 'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled', + }.items() + if field in json_data + } + if isinstance(previous, dict): + current = { + **normalize_extension_preferences(previous.get('extensions_preferences')), + **requested, + } + else: + current = requested + changes = settings_service.changed_fields(current, requested) + if changes: + quart.g.operation_log_changes = changes try: validate_extension_preferences( { diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index c7ed22efc..4bce79869 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -18,6 +18,7 @@ from .....core import taskmgr from .....entity.persistence import plugin as persistence_plugin from ...authz import Permission from ...context import ExecutionContext, RequestContext +from .....operation_trace import service as settings_service from .. import group from .....workspace.errors import WorkspaceNotFoundError from .....plugin.github import validate_github_plugin_install_info @@ -526,15 +527,33 @@ class PluginsRouterGroup(group.RouterGroup): plugin_name, plugin, ) + try: + incoming_config = await quart.request.json + except Exception: + incoming_config = None try: config = restore_plugin_secret_placeholders( - await quart.request.json, + incoming_config, current_config, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) await self._require_authenticated_plugin_runtime_context(request_context) await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config) + + # Record which plugin was reconfigured and which keys actually moved. + # The diff is computed over the *requested* payload (secrets restored + # from the stored config) so a masked ``***`` round-trip never shows + # up as a change; sensitive keys are redacted by ``changed_fields``. + config_changes = settings_service.changed_fields( + current_config if isinstance(current_config, dict) else {}, + config if isinstance(config, dict) else {}, + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('plugin_config') + quart.g.operation_log_resource_id = f'{author}/{plugin_name}' + quart.g.operation_log_changes = config_changes + if rule is not None and config_changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, config_changes) return self.success(data={}) @self.route( @@ -853,6 +872,11 @@ class PluginsRouterGroup(group.RouterGroup): owner = install_info['owner'] repo = install_info['repo'] release_tag = install_info['release_tag'] + # Name the installed extension in the trace. GitHub installs carry + # owner/repo in the body, which the audit identity resolver can also + # read, but publishing it here keeps the trace correct regardless of + # how the request was encoded. + quart.g.operation_log_resource_id = f'{owner}/{repo}' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) @@ -893,6 +917,7 @@ class PluginsRouterGroup(group.RouterGroup): plugin_author = data.get('plugin_author', '') plugin_name = data.get('plugin_name', '') + quart.g.operation_log_resource_id = f'{plugin_author}/{plugin_name}' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) ctx = taskmgr.TaskContext.new() @@ -934,6 +959,9 @@ class PluginsRouterGroup(group.RouterGroup): file_bytes = file.read() form = await quart.request.form administrator_force = form.get('administrator_force', '').strip().lower() == 'true' + # A local upload is multipart with no JSON body, so the archive + # filename is the only identity available for the trace. + quart.g.operation_log_resource_id = file.filename or 'local plugin' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) data = { diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py index 7ad970a2e..32a15e947 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -3,6 +3,7 @@ import quart from langbot.pkg.provider.modelmgr import errors as provider_errors from ....authz import Permission, has_permission from ....context import RequestContext +from ......operation_trace import service as settings_service from ... import group from .query import resolve_include_secret @@ -85,14 +86,28 @@ class LLMModelsRouterGroup(group.RouterGroup): permission=Permission.PROVIDER_SECRET_MANAGE, ) async def _(model_uuid: str, request_context: RequestContext) -> str: + json_data = await quart.request.json + try: + previous = await self.ap.llm_model_service.get_llm_model(request_context, model_uuid) + except Exception: + previous = None try: await self.ap.llm_model_service.update_llm_model( request_context, model_uuid, - await quart.request.json, + json_data, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) + changes = settings_service.changed_fields( + previous if isinstance(previous, dict) else {}, + json_data if isinstance(json_data, dict) else {}, + ignore=('uuid', 'created_at', 'updated_at'), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('update') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) return self.success() @self.route( diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py index 107cb9b7f..0a7302b58 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -2,6 +2,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext +from ......operation_trace import service as settings_service from ... import group from .query import resolve_include_secret @@ -155,10 +156,29 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) async def _(provider_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.json + try: + previous = await self.ap.provider_service.get_provider( + request_context, + provider_uuid, + include_secret=True, + ) + except Exception: + previous = None try: await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data) except ValueError as exc: return self.http_status(400, -1, str(exc)) + # Record which provider was reconfigured and which fields moved; + # credential-looking keys are redacted by ``changed_fields``. + changes = settings_service.changed_fields( + previous if isinstance(previous, dict) else {}, + json_data if isinstance(json_data, dict) else {}, + ignore=('uuid', 'created_at', 'updated_at', 'llm_count', 'embedding_count', 'rerank_count'), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('update') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) return self.success() @self.route( diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index 9633fa58c..56192506a 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -5,6 +5,7 @@ from urllib.parse import unquote from ....authz import Permission from ....context import RequestContext +from ......operation_trace import service as settings_service from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError from ... import group @@ -62,6 +63,10 @@ class MCPRouterGroup(group.RouterGroup): server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name) if server_data is None: return self.http_status(404, -1, 'Server not found') + # Name the MCP server in the trace, and for an update record which + # fields actually moved so the card answers "what changed" rather + # than only "the MCP config was touched". + quart.g.operation_log_resource_id = server_name if quart.request.method == 'PUT': data = await quart.request.json try: @@ -70,6 +75,15 @@ class MCPRouterGroup(group.RouterGroup): return self.http_status(403, exc.code, str(exc)) except ValueError as exc: return self.http_status(400, -1, str(exc)) + changes = settings_service.changed_fields( + server_data, + data if isinstance(data, dict) else {}, + ignore=('uuid',), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('mcp_config') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) else: await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid']) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 59c091917..6e6be90c6 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -7,6 +7,7 @@ from langbot_plugin.box.errors import BoxError from ...authz import Permission from ...context import RequestContext +from .....operation_trace import service as settings_service from .. import group @@ -72,14 +73,31 @@ class SkillsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response: + # The skill name is the resource identity for both verbs; without it + # the card could only say "a skill was updated". + quart.g.operation_log_resource_id = skill_name if quart.request.method == 'PUT': data = await quart.request.json + try: + previous = await self.ap.skill_service.get_skill(request_context, skill_name) + except (ValueError, BoxError): + previous = None try: skill = await self.ap.skill_service.update_skill(request_context, skill_name, data) - return self.success(data={'skill': skill}) except (ValueError, BoxError) as exc: return self.http_status(400, -1, str(exc)) + changes = settings_service.changed_fields( + previous if isinstance(previous, dict) else {}, + data if isinstance(data, dict) else {}, + ignore=('files', 'package_root', 'created_at', 'updated_at'), + ) + rule = settings_service.ACTION_RULES_BY_ACTION.get('skill_view') + quart.g.operation_log_changes = changes + if rule is not None and changes: + quart.g.operation_log_summary = settings_service.build_summary(rule, changes) + return self.success(data={'skill': skill}) + try: await self.ap.skill_service.delete_skill(request_context, skill_name) return self.success() @@ -167,6 +185,9 @@ class SkillsRouterGroup(group.RouterGroup): if not asset_url.endswith('skill.md') and not data.get('release_tag'): return self.http_status(400, -1, 'Missing required field: release_tag') + # Name the installed skill in the trace (owner/repo from the body). + quart.g.operation_log_resource_id = f'{data.get("owner", "")}/{data.get("repo", "")}' + try: skill = await self.ap.skill_service.install_from_github(request_context, data) return self.success(data={'skills': skill}) @@ -210,6 +231,9 @@ class SkillsRouterGroup(group.RouterGroup): if file is None: return self.http_status(400, -1, 'file is required') form = await quart.request.form + # A zip upload is multipart with no JSON body, so the filename is + # the only identity available for the trace. + quart.g.operation_log_resource_id = file.filename or 'skill' try: skill = await self.ap.skill_service.install_from_zip_upload( diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 4640596cd..7873e9f17 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 .....operation_trace import service 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/controller/main.py b/src/langbot/pkg/api/http/controller/main.py index 4cb11bdb4..e19552c65 100644 --- a/src/langbot/pkg/api/http/controller/main.py +++ b/src/langbot/pkg/api/http/controller/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import importlib import os import typing @@ -27,6 +28,13 @@ importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_knowledge) importutil.import_modules_in_pkg(groups_resources) +# The traceability subsystem is self-contained and auto-registers its routes +# through ``group.group_class``. Importing it here (instead of from within the +# ``groups`` package) keeps the feature decoupled: the Core controller package +# contains no code that depends on it, only this single bootstrap import. It +# runs after the package scan so its routes join the same registry. +importlib.import_module('langbot.pkg.operation_trace.routes') + class BoundedJSONRequest(quart.Request): """Parse bounded HTTP JSON bodies outside the shared event loop.""" 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..a850f428e 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,72 @@ 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, + '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, + ), '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 +246,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 +267,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/core/app.py b/src/langbot/pkg/core/app.py index 6b491362d..fa7a14f79 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 ..operation_trace import service 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,22 @@ 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 +460,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 +527,28 @@ class Application: self.logger.warning( f'Storage maintenance failed for Workspace {context.workspace_uuid}: {exc}' ) + if 'operation_logs' in due: + # Retention is per Workspace, so this must run + # inside the binding loop; left outside it only + # the last discovered Workspace would ever be + # pruned while the others grew unbounded. + 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..579f1049a 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 ...operation_trace import service as operation_trace_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 = operation_trace_service.WorkspaceSettingsService(ap) + ap.workspace_settings_service = workspace_settings_service_inst + apikey_service_inst = apikey_service.ApiKeyService(ap) ap.apikey_service = apikey_service_inst @@ -158,6 +162,12 @@ class BuildAppStage(stage.BootingStage): ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() + # Persistence is ready now. Open the cheap global traceability gate if + # any Workspace already has tracing enabled, so a restart resumes + # recording without ever checking the database on the request path. + if ap.workspace_settings_service is not None: + await ap.workspace_settings_service.prime_global_flag() + if deployment.multi_workspace_enabled: directory_projection_service = DirectoryProjectionService( ap, 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..f5305ac83 --- /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 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) + """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/operation_trace/__init__.py b/src/langbot/pkg/operation_trace/__init__.py new file mode 100644 index 000000000..018796ed2 --- /dev/null +++ b/src/langbot/pkg/operation_trace/__init__.py @@ -0,0 +1,26 @@ +"""Isolated operation-traceability subsystem. + +The "成员操作日志溯源" (member operation traceability) feature lives entirely in +this package so it can be decoupled from the hot request path: + +* :mod:`service` owns the capture levels, action catalog, append-only writer, + tamper-evidence chain, dedupe window and read/prune APIs. +* :mod:`routes` exposes the governance / operation-log HTTP surface and is + auto-discovered by the HTTP controller's package scan, so nothing in the + Core controller package needs to import it. + +How the decoupling keeps tracing off the hot path +------------------------------------------------- +Tracing is opt-in per Workspace. The Core route wrapper asks +:func:`operation_trace.service.is_globally_enabled` (a single module-level +boolean, no I/O) before it queues any trace work. While no Workspace has ever +turned tracing on, that flag is ``False`` and the wrapper performs zero +database round trips. The first time an operator enables tracing the service +flips the flag, and the per-Workspace level check then decides whether a given +request is recorded. Recording itself always runs *after* the handler released +its tenant scope, so auditing never extends a business transaction. +""" + +from __future__ import annotations + +__all__ = ['service', 'routes'] diff --git a/src/langbot/pkg/operation_trace/routes.py b/src/langbot/pkg/operation_trace/routes.py new file mode 100644 index 000000000..e408ae6eb --- /dev/null +++ b/src/langbot/pkg/operation_trace/routes.py @@ -0,0 +1,250 @@ +"""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 +* ``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 +future permission grant cannot silently widen who may reconfigure tracing. +""" + +from __future__ import annotations + +import datetime +import json +import typing + +import quart + +from ..api.http.authz import Permission +from ..api.http.context import RequestContext +from ..api.http.controller import group +from . import service as settings_service + + +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')), + # 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) + + @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/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py new file mode 100644 index 000000000..82be57fcc --- /dev/null +++ b/src/langbot/pkg/operation_trace/service.py @@ -0,0 +1,2462 @@ +"""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 asyncio +import dataclasses +import datetime +import enum +import hashlib +import hmac +import json +import logging +import time +import typing + +import sqlalchemy + +from ..api.http.authz import WorkspaceRole +from ..api.http.context import PrincipalType, RequestContext +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 + +logger = logging.getLogger(__name__) + + +#: Cheap process-wide gate for the Core route wrapper. It is a module-level +#: boolean, so checking it costs a load and a branch with no import, service +#: lookup, JSON parse or database round trip. It stays ``False`` until some +#: Workspace actually opts into tracing, which keeps the isolated subsystem +#: entirely off the hot path for the default (tracing disabled) instance. +_global_enabled = False + + +def is_globally_enabled() -> bool: + """Return whether any Workspace may currently produce traces. + + The Core route wrapper reads this before doing any trace work. ``False`` + means the whole subsystem is inert and costs the request path nothing. + """ + + return _global_enabled + + +def enable_globally() -> None: + """Open the cheap gate. Idempotent; called when a Workspace turns tracing on.""" + + global _global_enabled + _global_enabled = True + + +# --------------------------------------------------------------------------- +# 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' + +#: 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 + +#: 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 + +#: 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 verification is cached per Workspace for this long. Recomputing a +#: row's HMAC is the expensive part of the read path, so a result computed +#: seconds ago is served straight from the cache, which makes a burst of panel +#: opens, refreshes and page turns (all within this window) effectively free. +#: +#: The cache is a latency shield, never a correctness shortcut: a cache miss +#: re-verifies the whole window because an in-place edit to an *already +#: verified* row must still be caught. Skipping previously verified ids would +#: turn the incremental scan into a permanent blind spot for exactly the +#: tampering this feature exists to expose. +INTEGRITY_CACHE_TTL_SECONDS = 30.0 + +#: ``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 + +#: 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 + +#: Bounded trace queue. The request path never blocks on it: when growing faster +#: than the single writer can drain, the oldest pending traces are dropped rather +#: than slowing the business request down. Dropping audit rows is acceptable; +#: stalling live traffic is not. +_QUEUE_MAX_SIZE = 2048 + +#: Seconds the writer waits for more work after draining a batch before flushing +#: the row-budget check. Batching turns N inserts into one budget query. +_WRITER_DRAIN_TIMEOUT = 0.2 + +_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', +) + + +def _integrity_scan_select(model: typing.Any, *conditions: typing.Any) -> typing.Any: + """Build a projection-only scan query for integrity verification. + + Verification only re-hashes the row content plus the two chain pointers, so + selecting the whole row would pull the ``changes`` / ``detail`` Text payloads + and the client fingerprint for every scanned record. On a cold pass that is + up to :data:`MAX_INTEGRITY_SCAN_ROWS` rows of payload the verifier never + reads; projecting the hash columns keeps the read proportional to the hash + instead of to the payload size. + """ + + columns = [model.id, model.record_hash] + columns.extend(getattr(model, field) for field in _HASH_FIELDS) + return sqlalchemy.select(*columns).where(*conditions) + + +# --------------------------------------------------------------------------- +# 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='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', + ), + # --- 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', + ), + # 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', + 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', + ), + ActionRule( + action='skill_update', + category='extension', + bucket='write', + resource_type='skill', + ), + ActionRule( + action='skill_uninstall', + 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='knowledge_base_delete', + 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', + ), + ActionRule( + action='mcp_delete', + category='integration', + bucket='write', + resource_type='mcp_server', + ), + # --- Pipelines ------------------------------------------------------- + ActionRule( + action='pipeline_extensions_update', + category='resource', + bucket='write', + resource_type='pipeline', + ), + # --- 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', + ), + # --- 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', + 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', + ), + # 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} + +_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, fallback_action)``: every fragment must appear in +# the lowered route (a tuple expresses an AND, which lets +# ``/plugins///config`` be told apart from +# ``/plugins//``). +# +# 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, '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, '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, 'view'), + # --- Extension lifecycle: skills ------------------------------------ + # ``/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', '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, '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_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, '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 +#: only cover the extension/tenant surfaces; everything else (bots, providers, +#: pipelines, users, monitoring...) falls back to a nameless ``resource``. This +#: table names the family from the registered route identity, so a read of +#: ``/api/v1/pipelines`` is reported as a Pipeline, not as an opaque Resource. +#: It is only consulted when the matched rule still carries the generic +#: ``resource`` type, so specific rules keep their own family. More specific +#: fragments must precede their prefixes because the first match wins. +_RESOURCE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str], ...]] = ( + (('/platform/bots',), 'bot'), + (('/platform/',), 'adapter'), + (('/provider/providers',), 'model_provider'), + (('/provider/models',), 'llm_model'), + (('/provider/',), 'model_provider'), + (('/pipelines',), 'pipeline'), + (('/user/',), 'user'), + (('/workspaces/current',), 'workspace'), + (('/workspaces',), 'workspace'), + (('/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'), + (('/system/',), 'system'), +) + + +def _resource_type_for(route: str) -> str | None: + """Return the resource family for a route, or ``None`` when unknown.""" + + lowered = (route or '').lower() + for fragments, resource_type in _RESOURCE_RULES: + if all(fragment in lowered for fragment in fragments): + return resource_type + return None + + +def _with_resource_type(rule: ActionRule, route: str) -> ActionRule: + """Refine a generic rule's resource family from the route identity. + + Only the generic fallback (``resource``) is refined; a rule that already + names a family (plugin, skill, member...) is returned unchanged. + """ + + if rule.resource_type != 'resource': + return rule + resource_type = _resource_type_for(route) + if resource_type is None: + return rule + 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. + + ``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() + is_read = upper_method in _READ_METHODS + is_delete = upper_method == 'DELETE' + + 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 + 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: + # 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: + 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: + """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. + + ``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. ``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'): + return effective_level >= OPERATION_LEVEL_READ + 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 + + +#: 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'), + ('plugin_author', 'name'), + # GitHub installs (plugins and skills) name the target as owner + repo in + # the request body rather than in the URL, so without this group a + # GitHub install would be traced as "a plugin was installed" with no name. + ('owner', 'repo'), + ('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. + + 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 dedupe_material( + *, + actor_account_uuid: str | None, + action: str, + route: str | None, + resource_id: str | None, +) -> str: + """Return the raw material the dedupe key hashes. + + The request path builds this cheap string; the background writer hashes it, + so enabling tracing never runs a hash on the request path. + """ + + return '|'.join( + [ + str(actor_account_uuid or ''), + str(action or ''), + str(route or ''), + str(resource_id or ''), + ] + ) + + +def hash_dedupe_material(material: str) -> str: + """Hash the raw dedupe material into the stored key.""" + + return hashlib.sha256(material.encode('utf-8')).hexdigest() + + +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.""" + + return hash_dedupe_material( + dedupe_material( + actor_account_uuid=actor_account_uuid, + action=action, + route=route, + resource_id=resource_id, + ) + ) + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + + +class WorkspaceSettingsService: + """Governance settings + operation traceability for one Workspace.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + # Publish the cheap gate on the app so the Core route wrapper can read + # it as a plain attribute without importing this package. + ap.operation_trace_active = is_globally_enabled() + # 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] = {} + # Verification cache keyed by (Workspace UUID, listing filters). The + # verification result is shareable across whatever narrows the listing, + # so keying on the whole filter set (not just the Workspace) lets the + # panel's common views -- "everything" and "mutations only" -- both hit + # the cache instead of forcing a full rescan on every page turn. + # Single-writer queue. Trace recording must never run on the request + # path: when tracing is enabled the WebUI fires a burst of parallel + # requests (login alone touches a dozen endpoints) and doing several + # database round trips and a serialized write for each one stalls the + # whole service. Instead the request path only enqueues a plain dict and + # returns immediately; one background coroutine drains the queue and + # writes sequentially. Sequential writes also remove the read-newest-hash + # race that used to manufacture false "chain broken" reports. + self._queue: asyncio.Queue[dict[str, typing.Any]] = asyncio.Queue(maxsize=_QUEUE_MAX_SIZE) + self._writer_task: asyncio.Task[None] | None = None + # Incremental integrity verification cache, keyed by Workspace UUID. The + # chain is append-only, so once a prefix is verified it stays valid; a + # later read only has to verify the rows appended since. Without this the + # panel re-hashed up to MAX_INTEGRITY_SCAN_ROWS rows on every open, + # refresh and page turn, which is what made reading the log feel slow. + self._integrity_cache: dict[str, dict[str, typing.Any]] = {} + self._dropped_count = 0 + # Set while the queue is empty; lets tests/shutdown await a drain. + self._idle = asyncio.Event() + self._idle.set() + + # -- 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) + + def _activate(self) -> None: + """Open the cheap global gate once any Workspace enables tracing.""" + + enable_globally() + self.ap.operation_trace_active = True + + async def prime_global_flag(self) -> None: + """Open the cheap global gate at startup if a Workspace already opted in. + + Runs one bounded query during application build, never per request, so a + previously-enabled Workspace keeps recording after a restart while a + disabled instance still pays nothing on the hot path. + """ + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid) + .where( + persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY, + persistence_metadata.WorkspaceMetadata.value != str(OPERATION_LEVEL_NONE), + ) + .limit(1) + ) + if result.first() is not None: + self._activate() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation trace global gate prime skipped: {exc}') + + 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 + configured = clamp_level(raw) + if configured > OPERATION_LEVEL_NONE: + # Reading an enabled Workspace re-opens the gate, so tracing resumes + # on its own after a restart even before a write occurs. + self._activate() + return configured + + 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) + if resolved_level > OPERATION_LEVEL_NONE: + self._activate() + 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'] + + def resolve_resource_identity( + self, + path_params: typing.Mapping[str, typing.Any] | None, + body: typing.Mapping[str, typing.Any] | None, + ) -> str | None: + """Identify the resource a request acts on. + + Thin instance wrapper over the module-level resolver so the Core route + wrapper can name a resource through the service handle without importing + this package directly. + """ + + return resolve_resource_identity(path_params, body) + + 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: + """Queue one operation for traceability. Returns whether it was queued. + + This method runs on the request path, so it must never touch the + database or await slow work: it only builds a bounded dict and pushes it + onto the writer queue. The background writer persists it later. When the + queue is full (traffic outruns the writer) the oldest pending trace is + dropped rather than slowing the request down. + """ + + if level <= OPERATION_LEVEL_NONE: + return False + if not bucket_allows(rule.bucket, level): + return False + if level < OPERATION_LEVEL_MUTATION: + return False + + 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, + 'request_id': str(request_id)[:128] if request_id 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 []), + 'detail': json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str) + if detail + else None, + 'client_ip': str(client_ip)[:64] if client_ip else None, + 'user_agent': str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None, + 'duration_ms': max(int(duration_ms), 0), + } + # Collapse repeated identical observations (e.g. a WebUI left open + # polling the same list) so page liveness cannot inflate the log. Only + # the cheap raw material is built here; the writer hashes it off the + # request path. + record_fields['_dedupe_material'] = dedupe_material( + actor_account_uuid=actor_account_uuid, + action=rule.action, + route=record_fields['route'], + resource_id=record_fields['resource_id'], + ) + + return self._enqueue(record_fields) + + # -- background writer ------------------------------------------------ + + def _enqueue(self, record_fields: dict[str, typing.Any]) -> bool: + """Push one trace onto the writer queue without blocking the request.""" + + self._ensure_writer() + self._idle.clear() + try: + self._queue.put_nowait(record_fields) + except asyncio.QueueFull: + # Drop the oldest pending trace to make room; never block the request. + try: + self._queue.get_nowait() + except asyncio.QueueEmpty: # pragma: no cover - race, harmless + pass + self._dropped_count += 1 + if self._dropped_count % 100 == 1: + self.ap.logger.warning('Operation trace queue full; dropped %s traces so far', self._dropped_count) + try: + self._queue.put_nowait(record_fields) + except asyncio.QueueFull: # pragma: no cover - defensive + pass + return True + + def _ensure_writer(self) -> None: + """Start the single-writer task on first use (inside a running loop).""" + + if self._writer_task is not None and not self._writer_task.done(): + return + self._writer_task = asyncio.get_running_loop().create_task(self._writer_loop()) + + async def _writer_loop(self) -> None: + """Drain the queue and persist traces sequentially. + + A single writer keeps the hash chain correct without a lock: each row is + linked to the previously written row, so concurrent request producers can + never both link to the same predecessor. + """ + + while True: + try: + record_fields = await self._queue.get() + except asyncio.CancelledError: + raise + + batch = [record_fields] + while True: + try: + batch.append(self._queue.get_nowait()) + except asyncio.QueueEmpty: + break + + touched: set[str] = set() + for fields in batch: + written = await self._persist(fields) + if written: + touched.add(fields['workspace_uuid']) + + for workspace_uuid in touched: + await self._maybe_enforce_row_budget(workspace_uuid) + + if self._queue.empty(): + self._idle.set() + else: + # More work is already waiting; give the event loop a breath so a + # sustained burst cannot starve other tasks, then drain again. + await asyncio.sleep(_WRITER_DRAIN_TIMEOUT) + + async def _persist(self, record_fields: dict[str, typing.Any]) -> bool: + """Persist one queued trace. Never raises (auditing is best effort).""" + + try: + workspace_uuid = record_fields['workspace_uuid'] + # Off the request path: hash the dedupe material, resolve the actor + # display name, then link and insert this row. + record_fields['dedupe_key'] = hash_dedupe_material(record_fields.pop('_dedupe_material')) + if record_fields.get('actor_name') is None and record_fields.get('actor_account_uuid'): + await self._fill_actor_name(record_fields) + if await self._is_duplicate_observation(workspace_uuid, record_fields['dedupe_key']): + return False + 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) + ) + 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 _fill_actor_name(self, record_fields: dict[str, typing.Any]) -> None: + """Resolve the actor display name off the request path.""" + + if self.ap.user_service is None: + return + try: + account = await self.ap.user_service.get_user_by_uuid(record_fields['actor_account_uuid']) + if account is not None: + record_fields['actor_name'] = account.user + except Exception: # pragma: no cover - defensive + pass + + async def flush_pending(self, timeout: float = 5.0) -> None: + """Wait for the queue to drain. Used by tests and shutdown paths.""" + + if self._writer_task is None: + return + try: + await asyncio.wait_for(self._idle.wait(), timeout=timeout) + except asyncio.TimeoutError: # pragma: no cover - defensive + pass + + 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_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 + + 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 + + # Enqueue directly: no database read on the request path. The writer + # resolves the actor display name and hashes the dedupe material later. + record_fields: dict[str, typing.Any] = { + 'workspace_uuid': target_workspace, + 'actor_account_uuid': actor_account_uuid, + 'actor_name': None, + 'actor_role': role, + 'principal_type': principal_type, + 'api_key_uuid': api_key_uuid, + 'auth_type': auth_type, + 'request_id': str(request_id)[:128] if request_id else None, + 'http_method': (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': resolved_outcome, + 'status_code': int(status_code) if status_code is not None else None, + 'summary': summary, + 'changes': changes_payload(changes or []), + 'detail': json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str) + if detail + else None, + 'client_ip': str(client_ip)[:64] if client_ip else None, + 'user_agent': str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None, + 'duration_ms': max(int(duration_ms), 0), + '_dedupe_material': dedupe_material( + actor_account_uuid=actor_account_uuid, + action=rule.action, + route=route, + resource_id=resource_id, + ), + } + return self._enqueue(record_fields) + + # -- 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, + integrity: str | None = None, + search: str | None = None, + ) -> dict[str, typing.Any]: + """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. + + ``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)) + 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 + 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 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: + filters.append(model.created_at >= since) + if until is not None: + filters.append(model.created_at <= until) + + # Verify the filtered history once per request, keyed by the listing + # filters so a view (all / mutations only / a resource) can reuse an + # already computed result while still paging. ``integrity`` is not + # part of the key: it only narrows *which* records are returned, not + # what the verification finds, so its views share the result too. + summary = await self._integrity_summary(model, filters, resolved_integrity) + + # ``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(*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(*visible_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) + ] + 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, + '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. 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, + } + + def _invalidate_integrity_cache(self, workspace_uuid: str) -> None: + """Drop every cached verification for one Workspace. + + The cache is keyed by ``(Workspace, listing filters)`` so each view has + its own entry; a deletion invalidates all of them, not just the + unfiltered one. + """ + + for key in [key for key in self._integrity_cache if key[0] == workspace_uuid]: + self._integrity_cache.pop(key, None) + + @staticmethod + def _verified_lists(verified: dict[int, tuple[bool, bool]]) -> tuple[list[int], list[int], list[int]]: + """Split a verification map into (tampered, hash-failed, chain-failed) ids.""" + + tampered = [rid for rid, (integ, chain) in verified.items() if not integ or not chain] + hash_failed = [rid for rid, (integ, _chain) in verified.items() if not integ] + chain_failed = [rid for rid, (_integ, chain) in verified.items() if not chain] + return tampered, hash_failed, chain_failed + + def _cached_integrity_result(self, cached: dict[str, typing.Any]) -> dict[str, typing.Any]: + """Rebuild the summary payload from a cached verification map.""" + + verified: dict[int, tuple[bool, bool]] = cached['verified'] + tampered, hash_failed, chain_failed = self._verified_lists(verified) + summary = { + 'tampered': len(tampered), + 'integrity_failed': len(hash_failed), + 'chain_failed': len(chain_failed), + 'scanned': min(len(verified), MAX_INTEGRITY_SCAN_ROWS), + 'truncated': cached.get('truncated', False), + } + return { + 'summary': summary, + 'tampered_ids': tampered, + 'integrity_failed_ids': hash_failed, + 'chain_failed_ids': chain_failed, + } + + async def _integrity_summary( + self, + model: typing.Any, + filters: list[typing.Any], + integrity_filter: str = INTEGRITY_FILTER_ALL, + ) -> dict[str, typing.Any]: + """Verify the filtered history and classify every failing record. + + A result computed within :data:`INTEGRITY_CACHE_TTL_SECONDS` is served + from the cache, which is what keeps the panel responsive: opening, + refreshing and paging all land inside that window and pay nothing. The + cache key is ``(Workspace, listing filters)`` -- an integrity drill-down + reuses the very result it is drilling into, and both "everything" and + "mutations only" are cached independently -- so a cache miss is rare. + A miss re-verifies the *whole* window from scratch: the scan projects + only the hash columns and the verifier only computes two booleans, so + the cost is bounded, and an edit to a row verified on a previous pass is + still caught. + """ + + workspace_uuid = None + for condition in filters: + try: + workspace_uuid = condition.right.value + except AttributeError: + continue + break + cache_key = (workspace_uuid, tuple(str(condition) for condition in filters)) + cacheable = workspace_uuid is not None + now = time.monotonic() + + if cacheable: + cached = self._integrity_cache.get(cache_key) + if cached is not None and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS: + return self._cached_integrity_result(cached) + + started = time.monotonic() + try: + rows_result = await self.ap.persistence_mgr.execute_async( + _integrity_scan_select(model, *filters).order_by(model.id.desc()).limit(MAX_INTEGRITY_SCAN_ROWS) + ) + rows = list(rows_result.all()) + + # Baseline for the oldest scanned row: its link points at a row that + # sits just outside the window. Reads are bounded by + # ``MAX_INTEGRITY_SCAN_ROWS``, so only a history larger than that + # window ever needs this extra row. + previous_row = None + oldest_id = rows[-1].id if rows else None + if oldest_id is not None: + older_result = await self.ap.persistence_mgr.execute_async( + _integrity_scan_select(model, model.workspace_uuid == rows[0].workspace_uuid, model.id < oldest_id) + .order_by(model.id.desc()) + .limit(1) + ) + previous_row = older_result.first() + + # Every pass re-verifies every scanned row. The map is deliberately + # not merged with the previous cache: a row verified once is exactly + # what an attacker would edit afterwards. + new_verified: dict[int, tuple[bool, bool]] = {} + for index, row in enumerate(rows): + predecessor = rows[index + 1] if index + 1 < len(rows) else previous_row + new_verified[row.id] = self._verify_hash_and_chain(row, predecessor) + + tampered_ids, integrity_failed_ids, chain_failed_ids = self._verified_lists(new_verified) + scanned = len(new_verified) + truncated = scanned >= MAX_INTEGRITY_SCAN_ROWS + summary = { + 'tampered': len(tampered_ids), + 'integrity_failed': len(integrity_failed_ids), + 'chain_failed': len(chain_failed_ids), + 'scanned': min(scanned, MAX_INTEGRITY_SCAN_ROWS), + 'truncated': truncated, + } + result = { + 'summary': summary, + 'tampered_ids': tampered_ids, + 'integrity_failed_ids': integrity_failed_ids, + 'chain_failed_ids': chain_failed_ids, + } + if cacheable: + self._integrity_cache[cache_key] = { + 'verified': new_verified, + 'computed_at': now, + 'truncated': truncated, + } + if summary['tampered'] or truncated: + 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'], + 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 result + 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': [], + } + + @staticmethod + def _verify_hash_and_chain(row: typing.Any, previous_row: typing.Any | None) -> tuple[bool, bool]: + """Return ``(integrity_ok, chain_ok)`` for one row without serializing it. + + The integrity scan runs over up to :data:`MAX_INTEGRITY_SCAN_ROWS` rows + and only needs the two booleans, so it must not build the full display + dict -- which decodes the ``changes`` / ``detail`` payloads and formats + timestamps -- for every scanned record. The hash payload here is exactly + :data:`_HASH_FIELDS`, the same single source of truth ``_serialize_log`` + uses, so both paths can never drift apart. + """ + + hash_payload = {field: getattr(row, field) for field in _HASH_FIELDS} + 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 integrity_ok, chain_ok + + 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 '') + integrity_ok, chain_ok = self._verify_hash_and_chain(row, previous_row) + + 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)))) + # Cached verification no longer matches the surviving prefix. + self._invalidate_integrity_cache(workspace_uuid) + 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}') + + if expired > 0: + # Age-based retention removed the oldest rows, so any cached + # verification -- including its boundary baseline link -- no longer + # describes the surviving chain. + self._invalidate_integrity_cache(workspace_uuid) + + 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/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/0033_operation_traceability.py b/src/langbot/pkg/persistence/alembic/versions/0033_operation_traceability.py new file mode 100644 index 000000000..45394ea4d --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0033_operation_traceability.py @@ -0,0 +1,135 @@ +"""add workspace operation traceability table + +Revision ID: 0033_operation_traceability +Revises: 0032_cert_artifact_digest +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. + +It carries the full shape in one step: the base traceability columns, the +tamper-evidence hash chain (``record_hash`` / ``prev_hash``) and the +``dedupe_key`` used to collapse repeated read observations. The single +composite index ``ix_workspace_operation_logs_workspace_dedupe`` covers the +dedupe lookup, so no standalone ``dedupe_key`` index exists. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0033_operation_traceability' +down_revision = '0032_cert_artifact_digest' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'workspace_operation_logs' +_POLICY_NAME = 'langbot_workspace_isolation' +_TENANT_SETTING = 'langbot.workspace_uuid' + +#: Every index the ORM declares for this table. Kept in one place so the +#: migration and ``Base.metadata.create_all`` stay in lockstep. +_INDEXES: tuple[tuple[str, list[str]], ...] = ( + ('ix_workspace_operation_logs_workspace_created', ['workspace_uuid', 'created_at']), + ('ix_workspace_operation_logs_workspace_resource', ['workspace_uuid', 'resource_type', 'created_at']), + ('ix_workspace_operation_logs_workspace_dedupe', ['workspace_uuid', 'dedupe_key', 'created_at']), + ('ix_workspace_operation_logs_workspace_uuid', ['workspace_uuid']), + ('ix_workspace_operation_logs_created_at', ['created_at']), + ('ix_workspace_operation_logs_action', ['action']), + ('ix_workspace_operation_logs_resource_type', ['resource_type']), + ('ix_workspace_operation_logs_level', ['level']), + ('ix_workspace_operation_logs_actor_account_uuid', ['actor_account_uuid']), + ('ix_workspace_operation_logs_request_id', ['request_id']), + ('ix_workspace_operation_logs_route', ['route']), +) + + +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('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), + 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', + ), + ) + for index_name, columns in _INDEXES: + op.create_index(index_name, _TABLE_NAME, columns, 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 _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): + return + 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}')) + for index_name, _ in reversed(_INDEXES): + op.drop_index(index_name, table_name=_TABLE_NAME) + op.drop_table(_TABLE_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/tests/unit_tests/test_operation_trace_incremental.py b/tests/unit_tests/test_operation_trace_incremental.py new file mode 100644 index 000000000..2f2f29bdd --- /dev/null +++ b/tests/unit_tests/test_operation_trace_incremental.py @@ -0,0 +1,277 @@ +"""Integration tests for the operation-trace integrity cache. + +These drive the real service against an in-memory SQLite engine so the whole +read path -- scan projection, chain linking, cache reuse and invalidation -- is +exercised end to end rather than in isolation. The integrity summary is called +directly so the hashing counts are not diluted by the page serializer, which +re-verifies only the handful of rows it returns. +""" + +from __future__ import annotations + +import datetime +import logging +from types import SimpleNamespace + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence import operation_log as operation_log_module +from langbot.pkg.operation_trace import service as service_module + +pytestmark = pytest.mark.asyncio + +WORKSPACE = '00000000-0000-0000-0000-00000000000a' +MODEL = operation_log_module.WorkspaceOperationLog +FILTERS = [MODEL.workspace_uuid == WORKSPACE] + + +class _PersistenceManager: + """Minimal execute_async shim backed by one SQLite engine.""" + + def __init__(self, engine) -> None: + self.engine = engine + + async def execute_async(self, *args, **kwargs): + async with self.engine.connect() as connection: + result = await connection.execute(*args, **kwargs) + await connection.commit() + return result + + +def _build_row(index: int, prev_hash: str | None, *, created_at: datetime.datetime | None = None) -> dict: + """Build one append-only chain row, hash linked to ``prev_hash``.""" + + row: dict = { + 'workspace_uuid': WORKSPACE, + 'actor_account_uuid': None, + 'actor_name': None, + 'actor_role': None, + 'principal_type': 'account', + 'api_key_uuid': None, + 'auth_type': 'user_token', + 'http_method': 'GET', + 'route': '/api/v1/pipelines', + 'action': 'view', + 'resource_type': 'pipeline', + 'resource_id': None, + 'level': 2, + 'outcome': 'ok', + 'status_code': 200, + 'summary': f'row {index}', + 'changes': None, + 'client_ip': None, + 'prev_hash': prev_hash, + } + if created_at is not None: + row['created_at'] = created_at + row['record_hash'] = service_module.compute_record_hash(row) + return row + + +async def _append(service, engine, count: int, *, prev_hash: str | None = None) -> list[int]: + """Append ``count`` valid rows and return every row id in insertion order.""" + + for _ in range(count): + row = _build_row(0, prev_hash) + async with engine.begin() as connection: + await connection.execute(sqlalchemy.insert(MODEL).values(**row)) + prev_hash = row['record_hash'] + async with engine.begin() as connection: + result = await connection.execute(sqlalchemy.select(MODEL.id).order_by(MODEL.id.asc())) + return [row[0] for row in result.all()] + + +async def _tail_hash(engine) -> str | None: + async with engine.begin() as connection: + result = await connection.execute(sqlalchemy.select(MODEL.record_hash).order_by(MODEL.id.desc()).limit(1)) + return result.scalar_one_or_none() + + +def _cached_views(service) -> list: + """Return the cache keys currently held for the test Workspace.""" + + return [key for key in service._integrity_cache if key[0] == WORKSPACE] + + +def _counting_verifier(service, monkeypatch): + """Replace the per-row verifier with one that records the ids it hashes.""" + + calls: list[int] = [] + original = service_module.WorkspaceSettingsService._verify_hash_and_chain + + def counting(row, previous_row): + calls.append(row.id) + return original(row, previous_row) + + monkeypatch.setattr(service, '_verify_hash_and_chain', counting) + return calls + + +@pytest.fixture +async def trace_env(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "trace.db"}') + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + application = SimpleNamespace( + persistence_mgr=_PersistenceManager(engine), + logger=logging.getLogger('operation-trace-test'), + ) + try: + yield service_module.WorkspaceSettingsService(application), engine + finally: + await engine.dispose() + + +async def test_cached_read_within_ttl_reuses_without_rehashing(trace_env, monkeypatch): + service, engine = trace_env + ids = await _append(service, engine, 5) + calls = _counting_verifier(service, monkeypatch) + + first = await service._integrity_summary(MODEL, FILTERS) + assert first['summary']['scanned'] == 5 + assert first['summary']['tampered'] == 0 + assert len(calls) == 5 + views = _cached_views(service) + assert len(views) == 1 + assert set(service._integrity_cache[views[0]]['verified']) == set(ids) + + # A warm read inside the TTL must not re-hash anything at all: this is the + # shield that makes a burst of panel opens, refreshes and page turns cheap. + calls.clear() + second = await service._integrity_summary(MODEL, FILTERS) + assert second['summary']['scanned'] == 5 + assert calls == [] + + +async def test_cache_miss_reverifies_every_row(trace_env, monkeypatch): + service, engine = trace_env + await _append(service, engine, 6) + + # A zero TTL forces every read past the cache short circuit. + monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0) + await service._integrity_summary(MODEL, FILTERS) + + calls = _counting_verifier(service, monkeypatch) + await service._integrity_summary(MODEL, FILTERS) + + # Every row is re-verified on each miss. Skipping previously verified ids + # would make an edit to an older row permanently invisible. + assert len(calls) == 6 + + +async def test_edit_to_already_verified_row_is_re_detected(trace_env, monkeypatch): + service, engine = trace_env + monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0) + ids = await _append(service, engine, 5) + + clean = await service._integrity_summary(MODEL, FILTERS) + assert clean['summary']['tampered'] == 0 + + # Edit a row that was already verified on the previous pass, without + # re-signing it: the whole point of tamper evidence is to catch this. + async with engine.begin() as connection: + await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='EDITED-OLD')) + + after = await service._integrity_summary(MODEL, FILTERS) + assert after['summary']['integrity_failed'] == 1 + assert after['integrity_failed_ids'] == [ids[1]] + + +async def test_content_edit_is_flagged_as_hash_mismatch(trace_env, monkeypatch): + service, engine = trace_env + monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0) + ids = await _append(service, engine, 4) + async with engine.begin() as connection: + await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='silently edited')) + + result = await service.query_logs(WORKSPACE) + assert result['integrity_failed_count'] == 1 + assert result['chain_failed_count'] == 0 + assert result['tampered_count'] == 1 + + +async def test_resigned_link_is_flagged_as_chain_break(trace_env, monkeypatch): + service, engine = trace_env + monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0) + ids = await _append(service, engine, 4) + + # Re-sign the row from its own content with a forged prev_hash, so its own + # hash still matches and only the link is wrong. The successor's prev_hash + # no longer points at this row's record_hash either, so both are flagged. + async with engine.begin() as connection: + result = await connection.execute( + sqlalchemy.select(*[getattr(MODEL, field) for field in service_module._HASH_FIELDS]).where( + MODEL.id == ids[2] + ) + ) + content = dict(zip(service_module._HASH_FIELDS, result.first())) + content['prev_hash'] = 'not-the-predecessor-hash' + forged_hash = service_module.compute_record_hash(content) + assert service_module.verify_record_hash(content, forged_hash) + + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(MODEL) + .where(MODEL.id == ids[2]) + .values(prev_hash=content['prev_hash'], record_hash=forged_hash) + ) + + result = await service._integrity_summary(MODEL, FILTERS) + assert result['summary']['integrity_failed'] == 0 + assert result['summary']['chain_failed'] == 2 # the row and its successor + # Ids come back newest-first; compare as a set to stay order-agnostic. + assert set(result['chain_failed_ids']) == {ids[2], ids[3]} + + +async def test_age_prune_drops_the_stale_integrity_cache(trace_env): + service, engine = trace_env + await _append(service, engine, 2) + old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1)) + async with engine.begin() as connection: + await connection.execute(sqlalchemy.insert(MODEL).values(**old_row)) + + # Warm the cache: its verified map now records the row retention will drop. + await service.query_logs(WORKSPACE) + assert _cached_views(service) + + await service.prune(WORKSPACE, retention_days=1) + + # Deleting the oldest rows invalidated the cached prefix. + assert _cached_views(service) == [] + assert 'expired' in (await service.prune(WORKSPACE, retention_days=1)) + + +async def test_each_listing_view_is_cached_independently(trace_env, monkeypatch): + service, engine = trace_env + await _append(service, engine, 4) + + # Two different listing filters must not collide: each gets its own entry, + # and a view that was never scanned must still be verified from scratch. + all_view = [MODEL.workspace_uuid == WORKSPACE] + mutation_view = [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1] + await service._integrity_summary(MODEL, all_view) + await service._integrity_summary(MODEL, mutation_view) + + views = _cached_views(service) + assert len(views) == 2 + assert len({key[1] for key in views}) == 2 + + +async def test_invalidation_clears_every_cached_view(trace_env): + service, engine = trace_env + await _append(service, engine, 2) + old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1)) + async with engine.begin() as connection: + await connection.execute(sqlalchemy.insert(MODEL).values(**old_row)) + + await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE]) + await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1]) + assert len(_cached_views(service)) == 2 + + await service.prune(WORKSPACE, retention_days=1) + + # A deletion invalidates *all* views for the Workspace, not just one. + assert _cached_views(service) == [] diff --git a/tests/unit_tests/test_operation_trace_integrity.py b/tests/unit_tests/test_operation_trace_integrity.py new file mode 100644 index 000000000..b0768458d --- /dev/null +++ b/tests/unit_tests/test_operation_trace_integrity.py @@ -0,0 +1,131 @@ +"""Unit tests for the operation-trace integrity scan optimizations. + +The read path must stay fast while proving a record is untampered. These tests +pin the two invariants that make that safe: + +* the integrity scan projects only the hash columns (never the whole row), and +* the lightweight verifier returns exactly what the full serializer would, so + the two verification paths can never drift apart. +""" + +from __future__ import annotations + +import types + +from langbot.pkg.entity.persistence import operation_log as persistence_operation_log +from langbot.pkg.operation_trace import service as operation_trace_service + + +def _make_row(**overrides: object) -> types.SimpleNamespace: + """Build a row exposing every column the verifier and serializer touch.""" + + fields: dict[str, object] = { + 'id': 1, + 'workspace_uuid': 'ws-1', + 'actor_account_uuid': 'acc-1', + 'actor_name': 'Ada', + 'actor_role': 'owner', + 'principal_type': 'account', + 'api_key_uuid': None, + 'auth_type': 'user_token', + 'request_id': 'req-1', + 'http_method': 'PUT', + 'route': '/api/v1/settings/governance', + 'action': 'settings_update', + 'resource_type': 'workspace_settings', + 'resource_id': None, + 'level': 1, + 'outcome': 'ok', + 'status_code': 200, + 'summary': 'level: 0 → 1', + 'changes': '[{"field": "level", "before": 0, "after": 1}]', + 'detail': '{}', + 'client_ip': '127.0.0.1', + 'user_agent': 'pytest', + 'duration_ms': 3, + 'prev_hash': None, + 'record_hash': None, + 'created_at': None, + } + fields.update(overrides) + row = types.SimpleNamespace(**fields) + if row.record_hash is None: + payload = {field: getattr(row, field) for field in operation_trace_service._HASH_FIELDS} + row.record_hash = operation_trace_service.compute_record_hash(payload) + return row + + +class TestIntegrityScanProjection: + """The scan must not load payload columns it never inspects.""" + + def test_projection_selects_exactly_the_hash_columns(self): + model = persistence_operation_log.WorkspaceOperationLog + statement = operation_trace_service._integrity_scan_select(model) + keys = {column.key for column in statement.selected_columns} + + expected = {'id', 'record_hash', *operation_trace_service._HASH_FIELDS} + assert keys == expected + + def test_projection_skips_expensive_payload_columns(self): + model = persistence_operation_log.WorkspaceOperationLog + statement = operation_trace_service._integrity_scan_select(model) + keys = {column.key for column in statement.selected_columns} + + # ``detail`` and the client fingerprint are never hashed, so the cold + # scan must not page them in for up to MAX_INTEGRITY_SCAN_ROWS rows. + assert 'detail' not in keys + assert 'user_agent' not in keys + assert 'duration_ms' not in keys + # ``changes`` IS part of the hash, so it must remain selected. + assert 'changes' in keys + + +class TestLightweightVerifier: + """The fast verifier and the full serializer must agree byte for byte.""" + + def test_verifier_matches_serializer_for_a_valid_row(self): + service = operation_trace_service.WorkspaceSettingsService.__new__( + operation_trace_service.WorkspaceSettingsService + ) + previous = _make_row(id=1) + row = _make_row(id=2, prev_hash=previous.record_hash) + + fast = service._verify_hash_and_chain(row, previous) + serialized = service._serialize_log(row, previous_row=previous) + + assert fast == (serialized['integrity_ok'], serialized['chain_ok']) + assert fast == (True, True) + + def test_verifier_detects_a_content_edit(self): + service = operation_trace_service.WorkspaceSettingsService.__new__( + operation_trace_service.WorkspaceSettingsService + ) + row = _make_row(id=2) + # Simulate a silent edit after the hash was computed. + row.summary = 'tampered summary' + + integrity_ok, chain_ok = service._verify_hash_and_chain(row, None) + assert integrity_ok is False + assert chain_ok is True + + def test_verifier_detects_a_broken_chain_link(self): + service = operation_trace_service.WorkspaceSettingsService.__new__( + operation_trace_service.WorkspaceSettingsService + ) + previous = _make_row(id=1) + row = _make_row(id=2, prev_hash='not-the-previous-hash') + + integrity_ok, chain_ok = service._verify_hash_and_chain(row, previous) + # The row content itself is intact; only the link is wrong. + assert integrity_ok is True + assert chain_ok is False + + def test_missing_baseline_leaves_chain_ok(self): + service = operation_trace_service.WorkspaceSettingsService.__new__( + operation_trace_service.WorkspaceSettingsService + ) + row = _make_row(id=1, prev_hash=None) + + integrity_ok, chain_ok = service._verify_hash_and_chain(row, None) + assert integrity_ok is True + assert chain_ok is True 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/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' && ( string, +): string { + if (value === REDACTED_SENTINEL) return redactedLabel; + if (value === null || value === undefined || value === '') return '—'; + const text = + typeof value === 'string' ? value : (JSON.stringify(value) ?? ''); + if (text.length > COLLAPSED_VALUE_CHARS) return sizeLabel(text.length); + return text; +} + +/** Render one before → after pair in a compact, readable row. */ +function ChangeRow({ + change, + redactedLabel, + sizeLabel, + collapsed = false, +}: { + change: OperationChangeField; + redactedLabel: string; + sizeLabel: (count: number) => string; + collapsed?: boolean; +}) { + const render = (value: unknown): string => + collapsed + ? summarizeValue(value, redactedLabel, sizeLabel) + : formatValue(value, redactedLabel); + return ( +
    + {change.field} + + {render(change.before)} + + + + {render(change.after)} + +
    + ); +} + +function outcomeVariant( + outcome: OperationLogRecord['outcome'], +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (outcome === 'error') return 'destructive'; + if (outcome === 'denied') return 'outline'; + return 'secondary'; +} + +/** + * How many changes a collapsed row spells out before summarizing the rest. + * Three covers the common case (a name + a couple of flags) so the vast + * majority of rows answer "what changed" without a click. + */ +const COLLAPSED_CHANGE_LIMIT = 3; + +/** + * Render a record's before → after diff inline. + * + * "What was changed into what" is the reason this log exists, and the diff + * already arrived with the page, so it is shown on the collapsed row instead + * of behind a click. Only the fields that differ are listed; the rest fold + * into a single "+N" note that the expanded row spells out in full. + */ +function ChangeList({ + changes, + redactedLabel, + sizeLabel, + limit, + moreLabel, + collapsed = false, +}: { + changes: OperationChangeField[]; + redactedLabel: string; + sizeLabel: (count: number) => string; + limit?: number; + moreLabel: (hidden: number) => string; + collapsed?: boolean; +}) { + const shown = limit === undefined ? changes : changes.slice(0, limit); + const hidden = changes.length - shown.length; + return ( +
    + {shown.map((change, index) => ( + + ))} + {hidden > 0 && ( + + {moreLabel(hidden)} + + )} +
    + ); +} + +/** A record paired with how many times it repeated on the current page. */ +interface GroupedRecord { + key: string; + record: OperationLogRecord; + count: number; +} + +/** + * Collapse consecutive identical observations into a single row. + * + * An open WebUI or a refresh burst otherwise fills the log with the same + * "view X" line and buries the operations that actually changed something. + * The identity includes the summary and the change digest, so two rows are + * merged only when nothing about them differs; a differing change is kept as + * its own row. + */ +function groupRecords(records: OperationLogRecord[]): GroupedRecord[] { + const grouped: GroupedRecord[] = []; + for (const record of records) { + const key = [ + record.action, + record.resource_type, + record.resource_id, + record.actor_account_uuid, + record.outcome, + record.status_code, + record.summary ?? '', + record.changes.map((change) => change.field).join(','), + ].join('|'); + const last = grouped[grouped.length - 1]; + if (last && last.key === key) { + last.count += 1; + continue; + } + grouped.push({ key, record, count: 1 }); + } + return grouped; +} + +/** + * Build a compact page list: the first and last page are always shown, with a + * one-page window around the current page and gaps collapsed to an ellipsis. + * ``'…'`` entries are rendered as non-clickable separators. + */ +function pageWindow( + current: number, + count: number, + span = 1, +): (number | '…')[] { + if (count <= 0) return []; + const pages = new Set([1, count]); + for (let page = current - span; page <= current + span; page += 1) { + if (page >= 1 && page <= count) pages.add(page); + } + const sorted = [...pages].sort((a, b) => a - b); + const result: (number | '…')[] = []; + let previous = 0; + for (const page of sorted) { + if (previous && page - previous > 1) result.push('…'); + result.push(page); + previous = page; + } + return result; +} + +/** Compact timestamp: time-only for today, date+time otherwise. */ +function formatTimestamp(value: string | null): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const time = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + return sameDay ? time : `${date.toLocaleDateString()} ${time}`; +} + +/** + * Local calendar day a record belongs to. "today"/"yesterday" are named so the + * timeline reads relatively at the top and absolutely further back. + */ +function dayKey(value: string | null): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return 'unknown'; + const iso = (candidate: Date) => + `${candidate.getFullYear()}-${candidate.getMonth()}-${candidate.getDate()}`; + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (iso(date) === iso(now)) return 'today'; + if (iso(date) === iso(yesterday)) return 'yesterday'; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}`; +} + +/** A day's worth of records, so a long history reads as a timeline. */ +interface DayGroup { + key: string; + records: GroupedRecord[]; +} + +/** Header for a day group; absolute dates pass through unchanged. */ +function dayHeading( + key: string, + labels: { today: string; yesterday: string; unknown: string }, +): string { + if (key === 'today') return labels.today; + if (key === 'yesterday') return labels.yesterday; + if (key === 'unknown') return labels.unknown; + return key; +} + +/** + * Bucket records by calendar day, preserving the newest-first order. + * + * The backend returns a flat stream newest-first; without day headers an + * operator paging through hundreds of rows cannot tell where "today" ends and + * "last week" begins, which is the first thing they look for. + */ +function groupByDay(records: GroupedRecord[]): DayGroup[] { + const groups: DayGroup[] = []; + for (const record of records) { + const key = dayKey(record.record.created_at); + const last = groups[groups.length - 1]; + if (last && last.key === key) { + last.records.push(record); + continue; + } + groups.push({ key, records: [record] }); + } + return groups; +} + +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); + // 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); + + 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; + const pageCount = Math.max(Math.ceil(total / PAGE_SIZE), 1); + const pageIndex = Math.floor(offset / PAGE_SIZE) + 1; + + // Collapse consecutive identical rows so a refresh burst reads as one line + // with a count instead of twenty identical entries. + const groupedRecords = useMemo( + () => groupRecords(page?.records ?? []), + [page], + ); + + // A timeline reads top-down: "today", then "yesterday", then dated buckets. + const dayGroups = useMemo(() => groupByDay(groupedRecords), [groupedRecords]); + + const integrityFilter: OperationIntegrityFilter = query.integrity ?? 'all'; + // "Mutations only" is the default answer to "who changed something"; the + // level filter already exists, so this is a one-click view rather than new + // query API surface. Level >= 1 drops the pure "view" observations that + // otherwise dominate an L2 (read-level) log. + const mutationsOnly = query.level === 1; + + // 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); + 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, + integrity: query.integrity, + }); + 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')} +

    +
    + {/* Keep the actions in one right-aligned cluster: the panel toolbar + spreads direct children apart, so they are grouped instead. */} +
    + {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')} +

    + {!retentionOpen && ( + + {t('operationTrace.retentionSummary', { + days: retentionDays, + rows: maxRows, + })} + + )} +
    + +
    + + + + +
    +
    +
    +
    + +
    +
    +
    + +

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

    + {total} + {/* 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, + })} + + )} +
    +
    + + + + {/* The log is an answer to "who changed something", so the pure + "view" observations can be hidden in one click. It reuses the + level filter (mutations are level >= 1) rather than adding new + query API. */} + +
    +
    + +
    + {groupedRecords.length === 0 && ( +

    + {integrityFilter === 'all' + ? t('operationTrace.empty') + : t('operationTrace.emptyFiltered')} +

    + )} + {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} + + · + {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. */} + + + {/* 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)} + + )} +
    + )} +
    +
    + ); + })} +
    + ))} +
    + +
    + + {t('operationTrace.pageInfo', { + from: total === 0 ? 0 : offset + 1, + to: Math.min(offset + PAGE_SIZE, total), + total, + })} + +
    + + {/* 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 === '…' ? ( + + … + + ) : ( + + ), + )} + +
    +
    +
    +
    + + ); +} 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..651b2f957 --- /dev/null +++ b/web/src/app/infra/entities/operation-log.ts @@ -0,0 +1,122 @@ +/** + * 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; + /** Registered Core route identity, e.g. ``/api/v1/settings/governance``. */ + route: 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; +} + +/** Verification filter accepted by the listing endpoint. */ +export type OperationIntegrityFilter = + | 'all' + | 'issues' + | 'hash_mismatch' + | 'chain_broken'; + +export interface OperationLogPage { + records: OperationLogRecord[]; + /** Records matching the filters and the active verification filter. */ + total: number; + limit: number; + offset: number; + /** + * Workspace-wide counters over the filtered history, not just this page. + * They stay constant while paging so an operator opening page 2 still sees + * the total number of tampered records. + */ + tampered_count: number; + /** Records whose stored hash no longer matches their content. */ + integrity_failed_count: number; + /** Records whose predecessor link is broken. */ + chain_failed_count: number; + /** How many records the verification scan actually inspected. */ + scanned_count: number; + /** The scan stopped at the row cap, so the counters are approximate. */ + scan_truncated: boolean; + /** Which verification filter produced this page. */ + integrity_filter: OperationIntegrityFilter; +} + +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; + /** Narrow the listing to records that failed verification. */ + integrity?: OperationIntegrityFilter; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a66418e65..71812449f 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,55 @@ 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(); + // 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 f448dfd83..2bded01d0 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', @@ -22,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', @@ -99,7 +99,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 +726,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 +1125,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 +1166,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 +1628,6 @@ const enUS = { runnerUsageAll: 'All', runnerUsageAgent: 'Agent / Pipeline', runnerUsageEvent: 'Plugin processor', - searchPlaceholder: 'Search plugins...', searchPlaceholderCount: 'Search {{count}} extensions, capabilities, or use cases...', @@ -2719,12 +2713,150 @@ 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 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)', + records: 'Operation records', + changesCount: '{{count}} change(s)', + hideDetails: 'Hide details', + 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', + integrityFailedCount: '{{count}} hash mismatches', + 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', + }, + mutation: { + label: 'Mutations only', + }, + read: { + label: 'Everything (incl. views)', + }, + }, + levelNames: { + off: 'Off', + mutation: 'Mutations', + read: 'Everything', + }, + resourceTypes: { + bot: 'Bot', + adapter: 'Adapter', + model_provider: 'Model provider', + llm_model: 'Model', + pipeline: 'Pipeline', + user: 'User', + workspace: 'Workspace', + monitoring: 'Monitoring', + webhook: 'Webhook', + api_key: 'API key', + workspace_settings: 'Workspace settings', + member: 'Member', + member_invitation: 'Member invitation', + operation_log: 'Operation log', + assistant_conversation: 'Assistant conversation', + agent: 'Agent', + file: 'File', + plugin: 'Extension', + plugin_page: 'Extension page', + skill: 'Skill', + knowledge_base: 'Knowledge base', + mcp_server: 'MCP server', + runtime: 'Runtime', + system: 'System', + resource: 'Resource', + }, + outcomes: { + ok: 'Succeeded', + denied: 'Denied', + error: 'Failed', + }, + actions: { + audit_log_view: 'View operation logs', + 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', + 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', + 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', + 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 +3314,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..c444e4d0b 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', @@ -2767,12 +2768,151 @@ 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 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)', + records: 'Registros de operación', + changesCount: '{{count}} cambio(s)', + hideDetails: 'Ocultar detalles', + 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', + integrityFailedCount: '{{count}} hashes no coinciden', + 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', + }, + mutation: { + label: 'Solo cambios', + }, + read: { + label: 'Todo (incl. consultas)', + }, + }, + levelNames: { + off: 'Desactivado', + mutation: 'Solo cambios', + read: 'Todo', + }, + resourceTypes: { + bot: 'Bot', + adapter: 'Adaptador', + model_provider: 'Proveedor de modelos', + llm_model: 'Modelo', + pipeline: 'Pipeline', + user: 'Usuario', + workspace: 'Espacio de trabajo', + monitoring: 'Monitorización', + webhook: 'Webhook', + api_key: 'Clave de API', + workspace_settings: 'Ajustes del espacio de trabajo', + member: 'Miembro', + 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', + knowledge_base: 'Base de conocimiento', + mcp_server: 'Servidor MCP', + runtime: 'Entorno de ejecución', + system: 'Sistema', + resource: 'Recurso', + }, + outcomes: { + ok: 'Correcto', + denied: 'Denegado', + error: 'Fallido', + }, + actions: { + audit_log_view: 'Ver registros de operación', + 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', + 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', + 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', + 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..ef1c1d8f1 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: 'チャットモデル検索', @@ -2730,12 +2731,150 @@ const jaJP = { title: '設定', nav: { workspace: 'ワークスペース', + operationTrace: '操作トレーサビリティ', models: 'モデル', api: 'API', storage: 'ストレージ', account: 'アカウント', }, }, + operationTrace: { + title: '操作トレーサビリティ', + description: '管理者とオーナーの操作を追跡します。', + captureLevel: 'キャプチャレベル', + retention: '保持ポリシー', + retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件', + retentionDays: '保持日数', + maxRows: '最大レコード数', + dedupeWindow: '重複排除ウィンドウ(秒)', + records: '操作レコード', + changesCount: '変更 {{count}} 件', + hideDetails: '詳細を隠す', + 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: '検証済み', + integrityFailedCount: '{{count}} 件のハッシュ不一致', + chainFailedCount: '{{count}} 件のリンク断絶', + scanTruncated: '最新 {{count}} 件のみ検証', + emptyFiltered: '現在のフィルターでは検証が必要な記録はありません', + mutationsOnly: '変更のみ', + today: '今日', + yesterday: '昨日', + unknownDay: '不明な日時', + showDetails: '詳細', + moreChanges: '他 {{count}} 件', + elidedValue: '{{count}} 文字', + levels: { + off: { + label: 'トレース無効', + }, + mutation: { + label: '変更のみ', + }, + read: { + label: '最上位(閲覧含む)', + }, + }, + levelNames: { + off: '無効', + mutation: '変更のみ', + read: '最上位', + }, + resourceTypes: { + bot: 'ボット', + adapter: 'アダプター', + model_provider: 'モデルプロバイダー', + llm_model: 'モデル', + pipeline: 'パイプライン', + user: 'ユーザー', + workspace: 'ワークスペース', + monitoring: 'モニタリング', + webhook: 'Webhook', + api_key: 'API キー', + workspace_settings: 'ワークスペース設定', + member: 'メンバー', + member_invitation: 'メンバー招待', + operation_log: '操作ログ', + assistant_conversation: 'アシスタント会話', + agent: 'エージェント', + file: 'ファイル', + plugin: '拡張機能', + plugin_page: '拡張ページ', + skill: 'スキル', + knowledge_base: 'ナレッジベース', + mcp_server: 'MCP サーバー', + runtime: 'ランタイム', + system: 'システム', + resource: 'リソース', + }, + outcomes: { + ok: '成功', + denied: '拒否', + error: '失敗', + }, + actions: { + audit_log_view: '操作ログを閲覧', + settings_update: 'トレーサビリティ設定を変更', + settings_view: 'トレーサビリティ設定を閲覧', + member_invite: 'メンバーを招待', + member_role_update: 'メンバーのロールを変更', + member_remove: 'メンバーを削除', + member_view: 'メンバーを閲覧', + 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 サーバーを削除', + skill_update: 'スキルを変更', + file_view: 'ファイルを表示', + ingest: 'データを取り込み', + file_delete: 'ナレッジベースのファイルを削除', + ingress: '外部メッセージ受信', + codex_view: 'Codex 認証を表示', + codex_authorize: 'Codex を認証', + embed: '埋め込みセッション', + pipeline_extensions_update: 'パイプライン拡張の紐付けを変更', + 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..a5db8d81e 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: 'Найти чат-модели', @@ -2737,12 +2738,150 @@ const ruRU = { title: 'Настройки', nav: { workspace: 'Workspace', + operationTrace: 'Трассировка', models: 'Модели', api: 'API', storage: 'Хранилище', account: 'Аккаунт', }, }, + operationTrace: { + title: 'Трассировка операций', + description: 'Отслеживание изменений и просмотров администраторов', + captureLevel: 'Уровень записи', + retention: 'Хранение', + retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей', + retentionDays: 'Дней хранения', + maxRows: 'Максимум записей', + dedupeWindow: 'Окно дедупликации (с)', + records: 'Записи операций', + changesCount: 'Изменений: {{count}}', + hideDetails: 'Скрыть детали', + 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: 'Проверено', + integrityFailedCount: 'Несовпадение хэша: {{count}}', + chainFailedCount: 'Разрыв связи: {{count}}', + scanTruncated: 'Проверены только последние {{count}}', + emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки', + mutationsOnly: 'Только изменения', + today: 'Сегодня', + yesterday: 'Вчера', + unknownDay: 'Неизвестное время', + showDetails: 'Подробнее', + moreChanges: 'ещё {{count}}', + elidedValue: '{{count}} символов', + levels: { + off: { + label: 'Трассировка выключена', + }, + mutation: { + label: 'Только изменения', + }, + read: { + label: 'Всё (включая просмотры)', + }, + }, + levelNames: { + off: 'Выкл', + mutation: 'Изменения', + read: 'Всё', + }, + resourceTypes: { + bot: 'Бот', + adapter: 'Адаптер', + model_provider: 'Провайдер моделей', + llm_model: 'Модель', + pipeline: 'Конвейер', + user: 'Пользователь', + workspace: 'Рабочее пространство', + monitoring: 'Мониторинг', + webhook: 'Webhook', + api_key: 'API-ключ', + workspace_settings: 'Настройки пространства', + member: 'Участник', + member_invitation: 'Приглашение участника', + operation_log: 'Журнал операций', + assistant_conversation: 'Сессия ассистента', + agent: 'Агент', + file: 'Файл', + plugin: 'Расширение', + plugin_page: 'Страница расширения', + skill: 'Навык', + knowledge_base: 'База знаний', + mcp_server: 'MCP-сервер', + runtime: 'Среда выполнения', + system: 'Система', + resource: 'Ресурс', + }, + outcomes: { + ok: 'Успех', + denied: 'Отказано', + error: 'Ошибка', + }, + actions: { + audit_log_view: 'Просмотр журнала операций', + settings_update: 'Изменение настроек трассировки', + settings_view: 'Просмотр настроек трассировки', + member_invite: 'Приглашение участника', + member_role_update: 'Изменение роли участника', + member_remove: 'Удаление участника', + member_view: 'Просмотр участников', + 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', + skill_update: 'Изменение навыка', + file_view: 'Просмотр файла', + ingest: 'Импорт данных', + file_delete: 'Удаление файла базы знаний', + ingress: 'Внешний приём сообщений', + codex_view: 'Просмотр авторизации Codex', + codex_authorize: 'Авторизация Codex', + embed: 'Встроенная сессия', + pipeline_extensions_update: 'Изменение привязок расширений конвейера', + 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..522ffabde 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: 'ค้นหาโมเดลแชต', @@ -2664,12 +2665,150 @@ const thTH = { title: 'การตั้งค่า', nav: { workspace: 'Workspace', + operationTrace: 'การติดตาม', models: 'โมเดล', api: 'API', storage: 'พื้นที่จัดเก็บ', account: 'บัญชี', }, }, + operationTrace: { + title: 'การติดตามการดำเนินการ', + description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ', + captureLevel: 'ระดับการบันทึก', + retention: 'นโยบายการเก็บรักษา', + retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ', + retentionDays: 'จำนวนวันเก็บรักษา', + maxRows: 'จำนวนบันทึกสูงสุด', + dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)', + records: 'บันทึกการดำเนินการ', + changesCount: '{{count}} การเปลี่ยนแปลง', + hideDetails: 'ซ่อนรายละเอียด', + 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: 'ตรวจสอบผ่าน', + integrityFailedCount: 'แฮชไม่ตรงกัน {{count}} รายการ', + chainFailedCount: 'ลิงก์ขาด {{count}} รายการ', + scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด', + emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน', + mutationsOnly: 'เฉพาะการแก้ไข', + today: 'วันนี้', + yesterday: 'เมื่อวาน', + unknownDay: 'ไม่ทราบเวลา', + showDetails: 'รายละเอียด', + moreChanges: 'อีก {{count}} รายการ', + elidedValue: '{{count}} ตัวอักษร', + levels: { + off: { + label: 'ปิดการติดตาม', + }, + mutation: { + label: 'เฉพาะการแก้ไข', + }, + read: { + label: 'สูงสุด (รวมการดู)', + }, + }, + levelNames: { + off: 'ปิด', + mutation: 'เฉพาะแก้ไข', + read: 'สูงสุด', + }, + resourceTypes: { + bot: 'บอท', + adapter: 'อะแดปเตอร์', + model_provider: 'ผู้ให้บริการโมเดล', + llm_model: 'โมเดล', + pipeline: 'ไปป์ไลน์', + user: 'ผู้ใช้', + workspace: 'เวิร์กสเปซ', + monitoring: 'การมอนิเตอร์', + webhook: 'Webhook', + api_key: 'คีย์ API', + workspace_settings: 'การตั้งค่าพื้นที่ทำงาน', + member: 'สมาชิก', + member_invitation: 'คำเชิญสมาชิก', + operation_log: 'บันทึกการดำเนินการ', + assistant_conversation: 'บทสนทนาผู้ช่วย', + agent: 'เอเจนต์', + file: 'ไฟล์', + plugin: 'ส่วนขยาย', + plugin_page: 'หน้าส่วนขยาย', + skill: 'ทักษะ', + knowledge_base: 'ฐานความรู้', + mcp_server: 'เซิร์ฟเวอร์ MCP', + runtime: 'รันไทม์', + system: 'ระบบ', + resource: 'ทรัพยากร', + }, + outcomes: { + ok: 'สำเร็จ', + denied: 'ถูกปฏิเสธ', + error: 'ล้มเหลว', + }, + actions: { + audit_log_view: 'ดูบันทึกการดำเนินการ', + settings_update: 'แก้ไขการตั้งค่าการติดตาม', + settings_view: 'ดูการตั้งค่าการติดตาม', + member_invite: 'เชิญสมาชิก', + member_role_update: 'แก้ไขบทบาทสมาชิก', + member_remove: 'ลบสมาชิก', + member_view: 'ดูสมาชิก', + 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', + skill_update: 'แก้ไขทักษะ', + file_view: 'ดูไฟล์', + ingest: 'นำเข้าข้อมูล', + file_delete: 'ลบไฟล์ฐานความรู้', + ingress: 'การรับข้อความภายนอก', + codex_view: 'ดูการอนุญาต Codex', + codex_authorize: 'อนุญาต Codex', + embed: 'เซสชันฝังตัว', + pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์', + 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..05bf02d0d 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', @@ -2699,12 +2700,150 @@ 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', + 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)', + records: 'Bản ghi thao tác', + changesCount: '{{count}} thay đổi', + hideDetails: 'Ẩn chi tiết', + 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', + integrityFailedCount: '{{count}} mã băm không khớp', + 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', + }, + 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', + }, + resourceTypes: { + bot: 'Bot', + adapter: 'Bộ chuyển đổi', + model_provider: 'Nhà cung cấp mô hình', + llm_model: 'Mô hình', + pipeline: 'Đường ống', + user: 'Người dùng', + workspace: 'Workspace', + monitoring: 'Giám sát', + webhook: 'Webhook', + api_key: 'Khóa API', + workspace_settings: 'Cài đặt không gian làm việc', + 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ý', + 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', + knowledge_base: 'Cơ sở tri thức', + mcp_server: 'Máy chủ MCP', + runtime: 'Môi trường chạy', + system: 'Hệ thống', + resource: 'Tài nguyên', + }, + 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', + 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', + 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', + 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ụ', + 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..1fdc080c0 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: '查询聊天模型', @@ -2562,12 +2563,150 @@ const zhHans = { title: '设置', nav: { workspace: '工作区', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '存储', account: '账户', }, }, + operationTrace: { + title: '操作溯源', + description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。', + captureLevel: '操作等级', + retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条', + retentionDays: '保留天数', + maxRows: '最大记录数', + dedupeWindow: '去重窗口(秒)', + records: '操作记录', + changesCount: '{{count}} 项变更', + hideDetails: '收起详情', + 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: '校验通过', + integrityFailedCount: '{{count}} 条哈希不匹配', + chainFailedCount: '{{count}} 条链路断裂', + scanTruncated: '仅校验最近 {{count}} 条', + emptyFiltered: '当前筛选下没有需要校验的记录', + mutationsOnly: '只看修改', + today: '今天', + yesterday: '昨天', + unknownDay: '未知时间', + showDetails: '详情', + moreChanges: '还有 {{count}} 项', + elidedValue: '{{count}} 字符', + levels: { + off: { + label: '关闭溯源', + }, + mutation: { + label: '仅修改操作', + }, + read: { + label: '最高级(含查看)', + }, + }, + levelNames: { + off: '关闭', + mutation: '仅修改', + read: '最高级', + }, + resourceTypes: { + bot: '机器人', + adapter: '适配器', + model_provider: '模型提供商', + llm_model: '模型', + pipeline: '管道', + user: '用户', + workspace: '工作区', + monitoring: '监控', + webhook: 'Webhook', + api_key: 'API 密钥', + workspace_settings: '工作区设置', + member: '成员', + member_invitation: '成员邀请', + operation_log: '操作日志', + assistant_conversation: '助手会话', + agent: '智能体', + file: '文件', + plugin: '扩展', + plugin_page: '扩展页面', + skill: '技能', + knowledge_base: '知识库', + mcp_server: 'MCP 服务器', + runtime: '运行时', + system: '系统', + resource: '资源', + }, + outcomes: { + ok: '成功', + denied: '被拒绝', + error: '失败', + }, + actions: { + audit_log_view: '查看操作日志', + settings_update: '修改溯源设置', + settings_view: '查看溯源设置', + member_invite: '邀请成员', + member_role_update: '修改成员角色', + member_remove: '移除成员', + member_view: '查看成员', + 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 服务器', + skill_update: '修改技能', + file_view: '查看文件', + ingest: '导入数据', + file_delete: '删除知识库文件', + ingress: '外部消息接入', + codex_view: '查看 Codex 授权', + codex_authorize: '授权 Codex', + embed: '嵌入式会话', + pipeline_extensions_update: '修改管道扩展绑定', + 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..8c0526ff7 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: '查詢聊天模型', @@ -2563,12 +2564,150 @@ const zhHant = { title: '設定', nav: { workspace: 'Workspace', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '儲存', account: '帳戶', }, }, + operationTrace: { + title: '操作溯源', + description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。', + captureLevel: '操作等級', + retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條', + retentionDays: '保留天數', + maxRows: '最大記錄數', + dedupeWindow: '去重視窗(秒)', + records: '操作記錄', + changesCount: '{{count}} 項變更', + hideDetails: '收合詳情', + 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: '驗證通過', + integrityFailedCount: '{{count}} 筆雜湊不符', + chainFailedCount: '{{count}} 筆鏈結中斷', + scanTruncated: '僅校驗最近 {{count}} 筆', + emptyFiltered: '目前篩選下沒有需要校驗的記錄', + mutationsOnly: '只看修改', + today: '今天', + yesterday: '昨天', + unknownDay: '未知時間', + showDetails: '詳情', + moreChanges: '還有 {{count}} 項', + elidedValue: '{{count}} 字元', + levels: { + off: { + label: '關閉溯源', + }, + mutation: { + label: '僅修改操作', + }, + read: { + label: '最高級(含查看)', + }, + }, + levelNames: { + off: '關閉', + mutation: '僅修改', + read: '最高級', + }, + resourceTypes: { + bot: '機器人', + adapter: '適配器', + model_provider: '模型提供商', + llm_model: '模型', + pipeline: '管道', + user: '使用者', + workspace: '工作區', + monitoring: '監控', + webhook: 'Webhook', + api_key: 'API 金鑰', + workspace_settings: '工作區設定', + member: '成員', + member_invitation: '成員邀請', + operation_log: '操作日誌', + assistant_conversation: '助手會話', + agent: '智慧代理', + file: '檔案', + plugin: '擴充', + plugin_page: '擴充頁面', + skill: '技能', + knowledge_base: '知識庫', + mcp_server: 'MCP 伺服器', + runtime: '執行階段', + system: '系統', + resource: '資源', + }, + outcomes: { + ok: '成功', + denied: '被拒絕', + error: '失敗', + }, + actions: { + audit_log_view: '查看操作日誌', + settings_update: '修改溯源設定', + settings_view: '查看溯源設定', + member_invite: '邀請成員', + member_role_update: '修改成員角色', + member_remove: '移除成員', + member_view: '查看成員', + 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 伺服器', + skill_update: '修改技能', + file_view: '查看檔案', + ingest: '匯入資料', + file_delete: '刪除知識庫檔案', + ingress: '外部訊息接入', + codex_view: '查看 Codex 授權', + codex_authorize: '授權 Codex', + embed: '嵌入式工作階段', + pipeline_extensions_update: '修改管道擴展綁定', + export: '匯出資料', + execute: '執行任務', + debug: '偵錯任務', + publish: '發佈資源', + create: '建立資源', + update: '修改資源', + delete: '刪除資源', + view: '查看資源', + probe: '探測介面', + }, + }, storageAnalysis: { title: '儲存分析', description: '查看儲存占用和可清理檔案',