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

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

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

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

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

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

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

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

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

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

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

+ {record.summary} +

+ )} + {record.changes.length > 0 && ( +
+ {record.changes.map((change, index) => ( + + ))} +
+ )} +
+ +
+ + {record.duration_ms}ms + + {record.record_hash && ( + + + {record.record_hash.slice(0, 8)} + + )} +
+
+
+ ))} +
+ +
+ + {t('operationTrace.pageInfo', { + from: total === 0 ? 0 : offset + 1, + to: Math.min(offset + PAGE_SIZE, total), + total, + })} + +
+ + +
+
+
+
+ + ); +} diff --git a/web/src/app/infra/entities/operation-log.ts b/web/src/app/infra/entities/operation-log.ts new file mode 100644 index 000000000..cbeb75647 --- /dev/null +++ b/web/src/app/infra/entities/operation-log.ts @@ -0,0 +1,96 @@ +/** + * Types for the operation traceability surface. + * + * The backend stores one append-only row per traced operation and answers + * "what was changed into what" through the ``changes`` diff. All + * human-readable strings arrive as i18n keys so the panel owns the copy. + */ + +/** Capture levels, ordered by increasing scope. */ +export type OperationLevel = 0 | 1 | 2; + +export interface OperationLevelOption { + level: OperationLevel; + /** Frontend translation key, e.g. ``operationTrace.levels.read``. */ + i18n_key: string; +} + +export interface OperationLogLimits { + min_retention_days: number; + max_retention_days: number; + min_max_rows: number; + max_max_rows: number; + min_dedupe_window_seconds: number; + max_dedupe_window_seconds: number; +} + +export interface OperationGovernance { + configured_level: OperationLevel; + configured_level_name: string; + retention_days: number; + max_rows: number; + /** Seconds during which an identical observation is collapsed into one row. */ + dedupe_window_seconds: number; + supported_levels: OperationLevelOption[]; + limits: OperationLogLimits; +} + +export interface OperationChangeField { + field: string; + before: unknown; + after: unknown; +} + +export interface OperationLogRecord { + id: number; + /** Whether the stored hash still matches the row content. */ + integrity_ok: boolean; + /** Whether this row still links to its predecessor correctly. */ + chain_ok: boolean; + /** Either check failed: the card is flagged as possibly tampered. */ + tampered: boolean; + record_hash: string | null; + actor_account_uuid: string | null; + actor_name: string | null; + actor_role: string | null; + http_method: string | null; + action: string | null; + /** Translation key resolved by the panel, e.g. ``operationTrace.actions.view``. */ + action_i18n_key: string; + resource_type: string | null; + resource_id: string | null; + level: OperationLevel; + level_name: string; + outcome: 'ok' | 'denied' | 'error'; + status_code: number | null; + summary: string | null; + changes: OperationChangeField[]; + duration_ms: number; + created_at: string | null; +} + +export interface OperationLogPage { + records: OperationLogRecord[]; + total: number; + limit: number; + offset: number; + /** How many records on this page failed hash or chain verification. */ + tampered_count: number; +} + +export interface OperationLogFilters { + actions: string[]; + resource_types: string[]; + actors: { account_uuid: string; name: string }[]; +} + +export interface OperationLogQuery { + limit?: number; + offset?: number; + action?: string; + resource_type?: string; + actor?: string; + level?: OperationLevel; + since?: string; + until?: string; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a66418e65..f87b851d7 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -89,6 +89,13 @@ import type { WorkspaceRole, WorkspaceSpaceBilling, } from '@/app/infra/entities/workspace'; +import type { + OperationGovernance, + OperationLevel, + OperationLogFilters, + OperationLogPage, + OperationLogQuery, +} from '@/app/infra/entities/operation-log'; /** * 后端服务客户端 @@ -1824,6 +1831,48 @@ export class BackendClient extends BaseHttpClient { ); } + // ============ Workspace governance / operation traceability ============ + + public getOperationGovernance(): Promise { + return this.get('/api/v1/settings/governance'); + } + + public updateOperationGovernance(request: { + level: OperationLevel; + retention_days?: number; + max_rows?: number; + dedupe_window_seconds?: number; + }): Promise { + return this.put('/api/v1/settings/governance', request); + } + + public getOperationLogs( + query: OperationLogQuery = {}, + ): Promise { + return this.get('/api/v1/settings/operation-logs', query); + } + + public getOperationLogFilters(): Promise { + return this.get('/api/v1/settings/operation-logs/filters'); + } + + /** + * Build the export URL so the browser can download the CSV attachment + * directly; axios would try to parse the response body as JSON. + */ + public buildOperationLogExportURL(query: OperationLogQuery = {}): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + params.set(key, String(value)); + } + const suffix = params.toString(); + return `${this.getBaseUrl()}/api/v1/settings/operation-logs/export${ + suffix ? `?${suffix}` : '' + }`; + } + + public setPassword( newPassword: string, currentPassword?: string, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f448dfd83..e73341c75 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,5 +1,4 @@ import pipelineMigration from './pipeline-migration/en-US'; - const enUS = { assistant: { modelHint: 'Switch assistant model for your next message', @@ -99,7 +98,6 @@ const enUS = { guidedTour: { eventDebugDescription: 'Choose a supported event, enter test data, and review the output and tool results. Platform actions are simulated; models and other tools run as configured.', - pipeline: { trigger: { title: 'Choose which messages to answer', @@ -727,7 +725,6 @@ const enUS = { internalErrorHint: 'An unexpected error occurred. Check the backend logs using the reference below.', errorReference: 'Error reference: {{id}}', - title: 'Bots', description: 'Create and manage bots, which are the entry points for LangBot to connect with various platforms', @@ -1127,13 +1124,11 @@ const enUS = { pluginSettings: 'Plugin settings', pluginSettingsDescription: 'Parameters declared by this plugin.', selectToDebug: 'Select a plugin above to start debugging.', - debugOutput: 'Processor output', debugDescription: 'Input events, plugin logs and action results for this test session.', debugNotice: 'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.', - create: 'Create plugin processor', type: 'Plugin processor', description: @@ -1170,7 +1165,6 @@ const enUS = { debugData: { chatId: 'Chat ID', feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)', - title: 'Event data', form: 'Common fields', json: 'Full JSON', @@ -1633,7 +1627,6 @@ const enUS = { runnerUsageAll: 'All', runnerUsageAgent: 'Agent / Pipeline', runnerUsageEvent: 'Plugin processor', - searchPlaceholder: 'Search plugins...', searchPlaceholderCount: 'Search {{count}} extensions, capabilities, or use cases...', @@ -2719,12 +2712,88 @@ const enUS = { title: 'Settings', nav: { workspace: 'Workspace', + operationTrace: 'Traceability', models: 'Models', api: 'API', storage: 'Storage', account: 'Account', }, }, + operationTrace: { + title: 'Operation traceability', + description: + 'Trace admin and owner changes and views, recording what was changed into what.', + captureLevel: 'Capture level', + retention: 'Retention', + retentionDays: 'Retention days', + maxRows: 'Max records', + dedupeWindow: 'Dedupe window (s)', + records: 'Operation records', + levelUpdated: 'Capture level updated', + levelUpdateFailed: 'Failed to update capture level', + retentionUpdated: 'Retention updated', + retentionUpdateFailed: 'Failed to update retention', + export: 'Download logs', + exportFailed: 'Failed to download operation logs', + empty: 'No operation records yet', + systemActor: 'System', + pageInfo: '{{from}}-{{to}} of {{total}}', + previousPage: 'Previous page', + nextPage: 'Next page', + filterAction: 'Action', + filterAllActions: 'All actions', + filterResource: 'Resource', + filterAllResources: 'All resources', + filterActor: 'Actor', + filterAllActors: 'All actors', + loadFailed: 'Failed to load traceability data', + refresh: 'Refresh', + save: 'Save', + redacted: 'Redacted', + tamperedBadge: 'Possibly tampered', + verifiedBadge: 'Verified', + tamperedCount: '{{count}} records failed verification', + levels: { + off: { + label: 'Tracing off', + }, + mutation: { + label: 'Mutations only', + }, + read: { + label: 'Everything (incl. views)', + }, + }, + levelNames: { + off: 'Off', + mutation: 'Mutations', + read: 'Everything', + }, + outcomes: { + ok: 'Succeeded', + denied: 'Denied', + error: 'Failed', + }, + actions: { + audit_log_view: 'View operation logs', + clear_prune: 'Prune records by policy', + settings_update: 'Update tracing settings', + settings_view: 'View tracing settings', + member_invite: 'Invite member', + member_role_update: 'Update member role', + member_remove: 'Remove member', + member_view: 'View members', + export: 'Export data', + execute: 'Execute task', + debug: 'Debug task', + publish: 'Publish resource', + create: 'Create resource', + update: 'Update resource', + delete: 'Delete resource', + view: 'View resource', + probe: 'Probe endpoint', + }, + }, storageAnalysis: { title: 'Storage Analysis', description: 'Inspect storage usage and cleanup candidates', @@ -3182,5 +3251,4 @@ const enUS = { invalidPage: 'Invalid plugin page', }, }; - export default enUS; diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 41b813442..78f4b0ec6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2767,12 +2767,88 @@ const esES = { title: 'Configuración', nav: { workspace: 'Workspace', + operationTrace: 'Trazabilidad', models: 'Modelos', api: 'API', storage: 'Almacenamiento', account: 'Cuenta', }, }, + operationTrace: { + title: 'Trazabilidad de operaciones', + description: + 'Rastrea los cambios y las consultas de administradores y propietarios, registrando qué se cambió y a qué.', + captureLevel: 'Nivel de captura', + retention: 'Retención', + retentionDays: 'Días de retención', + maxRows: 'Registros máximos', + dedupeWindow: 'Ventana de deduplicación (s)', + records: 'Registros de operación', + levelUpdated: 'Nivel de captura actualizado', + levelUpdateFailed: 'No se pudo actualizar el nivel de captura', + retentionUpdated: 'Retención actualizada', + retentionUpdateFailed: 'No se pudo actualizar la retención', + export: 'Descargar registros', + exportFailed: 'No se pudieron descargar los registros', + empty: 'Aún no hay registros de operación', + systemActor: 'Sistema', + pageInfo: '{{from}}-{{to}} de {{total}}', + previousPage: 'Página anterior', + nextPage: 'Página siguiente', + filterAction: 'Acción', + filterAllActions: 'Todas las acciones', + filterResource: 'Recurso', + filterAllResources: 'Todos los recursos', + filterActor: 'Actor', + filterAllActors: 'Todos los actores', + loadFailed: 'No se pudieron cargar los datos de trazabilidad', + refresh: 'Actualizar', + save: 'Guardar', + redacted: 'Oculto', + tamperedBadge: 'Posible manipulación', + verifiedBadge: 'Verificado', + tamperedCount: '{{count}} registros fallaron la verificación', + levels: { + off: { + label: 'Sin trazabilidad', + }, + mutation: { + label: 'Solo cambios', + }, + read: { + label: 'Todo (incl. consultas)', + }, + }, + levelNames: { + off: 'Desactivado', + mutation: 'Solo cambios', + read: 'Todo', + }, + outcomes: { + ok: 'Correcto', + denied: 'Denegado', + error: 'Fallido', + }, + actions: { + audit_log_view: 'Ver registros de operación', + clear_prune: 'Depurar registros según la política', + settings_update: 'Actualizar ajustes de trazabilidad', + settings_view: 'Ver ajustes de trazabilidad', + member_invite: 'Invitar miembro', + member_role_update: 'Actualizar rol del miembro', + member_remove: 'Eliminar miembro', + member_view: 'Ver miembros', + export: 'Exportar datos', + execute: 'Ejecutar tarea', + debug: 'Depurar tarea', + publish: 'Publicar recurso', + create: 'Crear recurso', + update: 'Actualizar recurso', + delete: 'Eliminar recurso', + view: 'Ver recurso', + probe: 'Sondear endpoint', + }, + }, storageAnalysis: { title: 'Análisis de almacenamiento', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 78a7778f0..7f205da2d 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2730,12 +2730,88 @@ const jaJP = { title: '設定', nav: { workspace: 'ワークスペース', + operationTrace: '操作トレーサビリティ', models: 'モデル', api: 'API', storage: 'ストレージ', account: 'アカウント', }, }, + operationTrace: { + title: '操作トレーサビリティ', + description: + '管理者とオーナーの変更・閲覧操作を追跡し、「何が何に変更されたか」を記録します。', + captureLevel: 'キャプチャレベル', + retention: '保持ポリシー', + retentionDays: '保持日数', + maxRows: '最大レコード数', + dedupeWindow: '重複排除ウィンドウ(秒)', + records: '操作レコード', + levelUpdated: 'キャプチャレベルを更新しました', + levelUpdateFailed: 'キャプチャレベルの更新に失敗しました', + retentionUpdated: '保持ポリシーを更新しました', + retentionUpdateFailed: '保持ポリシーの更新に失敗しました', + export: 'ログをダウンロード', + exportFailed: '操作ログのダウンロードに失敗しました', + empty: '操作レコードはまだありません', + systemActor: 'システム', + pageInfo: '{{from}}-{{to}} / 全 {{total}} 件', + previousPage: '前のページ', + nextPage: '次のページ', + filterAction: '操作', + filterAllActions: 'すべての操作', + filterResource: 'リソース', + filterAllResources: 'すべてのリソース', + filterActor: '実行者', + filterAllActors: 'すべての実行者', + loadFailed: 'トレーサビリティデータの読み込みに失敗しました', + refresh: '更新', + save: '保存', + redacted: 'マスク済み', + tamperedBadge: '改ざんの可能性', + verifiedBadge: '検証済み', + tamperedCount: '{{count}} 件が検証に失敗', + levels: { + off: { + label: 'トレース無効', + }, + mutation: { + label: '変更のみ', + }, + read: { + label: '最上位(閲覧含む)', + }, + }, + levelNames: { + off: '無効', + mutation: '変更のみ', + read: '最上位', + }, + outcomes: { + ok: '成功', + denied: '拒否', + error: '失敗', + }, + actions: { + audit_log_view: '操作ログを閲覧', + clear_prune: 'ポリシーで記録を整理', + settings_update: 'トレーサビリティ設定を変更', + settings_view: 'トレーサビリティ設定を閲覧', + member_invite: 'メンバーを招待', + member_role_update: 'メンバーのロールを変更', + member_remove: 'メンバーを削除', + member_view: 'メンバーを閲覧', + export: 'データをエクスポート', + execute: 'タスクを実行', + debug: 'タスクをデバッグ', + publish: 'リソースを公開', + create: 'リソースを作成', + update: 'リソースを変更', + delete: 'リソースを削除', + view: 'リソースを閲覧', + probe: 'エンドポイントを確認', + }, + }, storageAnalysis: { title: 'ストレージ分析', description: 'ストレージ使用量とクリーンアップ候補を確認します', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index faa6bf053..cf98777d2 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2737,12 +2737,88 @@ const ruRU = { title: 'Настройки', nav: { workspace: 'Workspace', + operationTrace: 'Трассировка', models: 'Модели', api: 'API', storage: 'Хранилище', account: 'Аккаунт', }, }, + operationTrace: { + title: 'Трассировка операций', + description: + 'Отслеживание изменений и просмотров администраторов и владельцев с записью того, что и на что было изменено.', + captureLevel: 'Уровень записи', + retention: 'Хранение', + retentionDays: 'Дней хранения', + maxRows: 'Максимум записей', + dedupeWindow: 'Окно дедупликации (с)', + records: 'Записи операций', + levelUpdated: 'Уровень записи обновлён', + levelUpdateFailed: 'Не удалось обновить уровень записи', + retentionUpdated: 'Хранение обновлено', + retentionUpdateFailed: 'Не удалось обновить хранение', + export: 'Скачать журнал', + exportFailed: 'Не удалось скачать журнал операций', + empty: 'Записей операций пока нет', + systemActor: 'Система', + pageInfo: '{{from}}-{{to}} из {{total}}', + previousPage: 'Предыдущая страница', + nextPage: 'Следующая страница', + filterAction: 'Действие', + filterAllActions: 'Все действия', + filterResource: 'Ресурс', + filterAllResources: 'Все ресурсы', + filterActor: 'Исполнитель', + filterAllActors: 'Все исполнители', + loadFailed: 'Не удалось загрузить данные трассировки', + refresh: 'Обновить', + save: 'Сохранить', + redacted: 'Скрыто', + tamperedBadge: 'Возможна подмена', + verifiedBadge: 'Проверено', + tamperedCount: 'Записей с ошибкой проверки: {{count}}', + levels: { + off: { + label: 'Трассировка выключена', + }, + mutation: { + label: 'Только изменения', + }, + read: { + label: 'Всё (включая просмотры)', + }, + }, + levelNames: { + off: 'Выкл', + mutation: 'Изменения', + read: 'Всё', + }, + outcomes: { + ok: 'Успех', + denied: 'Отказано', + error: 'Ошибка', + }, + actions: { + audit_log_view: 'Просмотр журнала операций', + clear_prune: 'Очистка записей по политике', + settings_update: 'Изменение настроек трассировки', + settings_view: 'Просмотр настроек трассировки', + member_invite: 'Приглашение участника', + member_role_update: 'Изменение роли участника', + member_remove: 'Удаление участника', + member_view: 'Просмотр участников', + export: 'Экспорт данных', + execute: 'Выполнение задачи', + debug: 'Отладка задачи', + publish: 'Публикация ресурса', + create: 'Создание ресурса', + update: 'Изменение ресурса', + delete: 'Удаление ресурса', + view: 'Просмотр ресурса', + probe: 'Проверка эндпоинта', + }, + }, storageAnalysis: { title: 'Анализ хранилища', description: 'Проверьте использование хранилища и кандидатов на очистку', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index f4f83777c..63710062f 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2664,12 +2664,88 @@ const thTH = { title: 'การตั้งค่า', nav: { workspace: 'Workspace', + operationTrace: 'การติดตาม', models: 'โมเดล', api: 'API', storage: 'พื้นที่จัดเก็บ', account: 'บัญชี', }, }, + operationTrace: { + title: 'การติดตามการดำเนินการ', + description: + 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ โดยบันทึกว่าอะไรเปลี่ยนเป็นอะไร', + captureLevel: 'ระดับการบันทึก', + retention: 'นโยบายการเก็บรักษา', + retentionDays: 'จำนวนวันเก็บรักษา', + maxRows: 'จำนวนบันทึกสูงสุด', + dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)', + records: 'บันทึกการดำเนินการ', + levelUpdated: 'อัปเดตระดับการบันทึกแล้ว', + levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ', + retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว', + retentionUpdateFailed: 'อัปเดตนโยบายการเก็บรักษาไม่สำเร็จ', + export: 'ดาวน์โหลดบันทึก', + exportFailed: 'ดาวน์โหลดบันทึกการดำเนินการไม่สำเร็จ', + empty: 'ยังไม่มีบันทึกการดำเนินการ', + systemActor: 'ระบบ', + pageInfo: '{{from}}-{{to}} จาก {{total}}', + previousPage: 'หน้าก่อนหน้า', + nextPage: 'หน้าถัดไป', + filterAction: 'การดำเนินการ', + filterAllActions: 'ทุกการดำเนินการ', + filterResource: 'ทรัพยากร', + filterAllResources: 'ทุกทรัพยากร', + filterActor: 'ผู้ดำเนินการ', + filterAllActors: 'ผู้ดำเนินการทั้งหมด', + loadFailed: 'โหลดข้อมูลการติดตามไม่สำเร็จ', + refresh: 'รีเฟรช', + save: 'บันทึก', + redacted: 'ปิดบังแล้ว', + tamperedBadge: 'อาจถูกแก้ไข', + verifiedBadge: 'ตรวจสอบผ่าน', + tamperedCount: 'มี {{count}} บันทึกที่ตรวจสอบไม่ผ่าน', + levels: { + off: { + label: 'ปิดการติดตาม', + }, + mutation: { + label: 'เฉพาะการแก้ไข', + }, + read: { + label: 'สูงสุด (รวมการดู)', + }, + }, + levelNames: { + off: 'ปิด', + mutation: 'เฉพาะแก้ไข', + read: 'สูงสุด', + }, + outcomes: { + ok: 'สำเร็จ', + denied: 'ถูกปฏิเสธ', + error: 'ล้มเหลว', + }, + actions: { + audit_log_view: 'ดูบันทึกการดำเนินการ', + clear_prune: 'ล้างบันทึกตามนโยบาย', + settings_update: 'แก้ไขการตั้งค่าการติดตาม', + settings_view: 'ดูการตั้งค่าการติดตาม', + member_invite: 'เชิญสมาชิก', + member_role_update: 'แก้ไขบทบาทสมาชิก', + member_remove: 'ลบสมาชิก', + member_view: 'ดูสมาชิก', + export: 'ส่งออกข้อมูล', + execute: 'รันงาน', + debug: 'ดีบักงาน', + publish: 'เผยแพร่ทรัพยากร', + create: 'สร้างทรัพยากร', + update: 'แก้ไขทรัพยากร', + delete: 'ลบทรัพยากร', + view: 'ดูทรัพยากร', + probe: 'ตรวจสอบปลายทาง', + }, + }, storageAnalysis: { title: 'วิเคราะห์พื้นที่จัดเก็บ', description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 48307dc34..1bb4209ca 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2699,12 +2699,88 @@ const viVN = { title: 'Cài đặt', nav: { workspace: 'Workspace', + operationTrace: 'Truy vết', models: 'Mô hình', api: 'API', storage: 'Lưu trữ', account: 'Tài khoản', }, }, + operationTrace: { + title: 'Truy vết thao tác', + description: + 'Theo dõi thao tác sửa và xem của quản trị viên và chủ sở hữu, ghi lại "cái gì đã đổi thành cái gì".', + captureLevel: 'Mức ghi nhận', + retention: 'Chính sách lưu trữ', + retentionDays: 'Số ngày lưu', + maxRows: 'Số bản ghi tối đa', + dedupeWindow: 'Khoảng khử trùng lặp (giây)', + records: 'Bản ghi thao tác', + levelUpdated: 'Đã cập nhật mức ghi nhận', + levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại', + retentionUpdated: 'Đã cập nhật chính sách lưu trữ', + retentionUpdateFailed: 'Cập nhật chính sách lưu trữ thất bại', + export: 'Tải nhật ký', + exportFailed: 'Tải nhật ký thao tác thất bại', + empty: 'Chưa có bản ghi thao tác', + systemActor: 'Hệ thống', + pageInfo: '{{from}}-{{to}} trên {{total}}', + previousPage: 'Trang trước', + nextPage: 'Trang sau', + filterAction: 'Thao tác', + filterAllActions: 'Tất cả thao tác', + filterResource: 'Tài nguyên', + filterAllResources: 'Tất cả tài nguyên', + filterActor: 'Người thực hiện', + filterAllActors: 'Tất cả người thực hiện', + loadFailed: 'Tải dữ liệu truy vết thất bại', + refresh: 'Làm mới', + save: 'Lưu', + redacted: 'Đã ẩn', + tamperedBadge: 'Có thể bị sửa', + verifiedBadge: 'Đã xác minh', + tamperedCount: '{{count}} bản ghi không vượt qua kiểm tra', + levels: { + off: { + label: 'Tắt truy vết', + }, + mutation: { + label: 'Chỉ thao tác sửa', + }, + read: { + label: 'Cao nhất (gồm xem)', + }, + }, + levelNames: { + off: 'Tắt', + mutation: 'Chỉ sửa', + read: 'Cao nhất', + }, + outcomes: { + ok: 'Thành công', + denied: 'Bị từ chối', + error: 'Thất bại', + }, + actions: { + audit_log_view: 'Xem nhật ký thao tác', + clear_prune: 'Dọn bản ghi theo chính sách', + settings_update: 'Cập nhật cài đặt truy vết', + settings_view: 'Xem cài đặt truy vết', + member_invite: 'Mời thành viên', + member_role_update: 'Cập nhật vai trò thành viên', + member_remove: 'Xóa thành viên', + member_view: 'Xem thành viên', + export: 'Xuất dữ liệu', + execute: 'Chạy tác vụ', + debug: 'Gỡ lỗi tác vụ', + publish: 'Xuất bản tài nguyên', + create: 'Tạo tài nguyên', + update: 'Cập nhật tài nguyên', + delete: 'Xóa tài nguyên', + view: 'Xem tài nguyên', + probe: 'Dò endpoint', + }, + }, storageAnalysis: { title: 'Phân tích lưu trữ', description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 3a22387e2..968ba5f1f 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2562,12 +2562,87 @@ const zhHans = { title: '设置', nav: { workspace: '工作区', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '存储', account: '账户', }, }, + operationTrace: { + title: '操作溯源', + description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。', + captureLevel: '操作等级', + retention: '保留策略', + retentionDays: '保留天数', + maxRows: '最大记录数', + dedupeWindow: '去重窗口(秒)', + records: '操作记录', + levelUpdated: '操作等级已更新', + levelUpdateFailed: '操作等级更新失败', + retentionUpdated: '保留策略已更新', + retentionUpdateFailed: '保留策略更新失败', + export: '下载日志', + exportFailed: '下载操作日志失败', + empty: '暂无操作记录', + systemActor: '系统', + pageInfo: '第 {{from}}-{{to}} 条,共 {{total}} 条', + previousPage: '上一页', + nextPage: '下一页', + filterAction: '操作类型', + filterAllActions: '全部操作', + filterResource: '资源类型', + filterAllResources: '全部资源', + filterActor: '操作者', + filterAllActors: '全部操作者', + loadFailed: '加载操作溯源数据失败', + refresh: '刷新', + save: '保存', + redacted: '已脱敏', + tamperedBadge: '可能被篡改', + verifiedBadge: '校验通过', + tamperedCount: '{{count}} 条记录校验异常', + levels: { + off: { + label: '关闭溯源', + }, + mutation: { + label: '仅修改操作', + }, + read: { + label: '最高级(含查看)', + }, + }, + levelNames: { + off: '关闭', + mutation: '仅修改', + read: '最高级', + }, + outcomes: { + ok: '成功', + denied: '被拒绝', + error: '失败', + }, + actions: { + audit_log_view: '查看操作日志', + clear_prune: '按策略清理记录', + settings_update: '修改溯源设置', + settings_view: '查看溯源设置', + member_invite: '邀请成员', + member_role_update: '修改成员角色', + member_remove: '移除成员', + member_view: '查看成员', + export: '导出数据', + execute: '执行任务', + debug: '调试任务', + publish: '发布资源', + create: '创建资源', + update: '修改资源', + delete: '删除资源', + view: '查看资源', + probe: '探测接口', + }, + }, storageAnalysis: { title: '存储分析', description: '查看存储占用和可清理文件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 652c1ab70..7491691bc 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2563,12 +2563,87 @@ const zhHant = { title: '設定', nav: { workspace: 'Workspace', + operationTrace: '操作溯源', models: '模型', api: 'API', storage: '儲存', account: '帳戶', }, }, + operationTrace: { + title: '操作溯源', + description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。', + captureLevel: '操作等級', + retention: '保留策略', + retentionDays: '保留天數', + maxRows: '最大記錄數', + dedupeWindow: '去重視窗(秒)', + records: '操作記錄', + levelUpdated: '操作等級已更新', + levelUpdateFailed: '操作等級更新失敗', + retentionUpdated: '保留策略已更新', + retentionUpdateFailed: '保留策略更新失敗', + export: '下載日誌', + exportFailed: '下載操作日誌失敗', + empty: '暫無操作記錄', + systemActor: '系統', + pageInfo: '第 {{from}}-{{to}} 條,共 {{total}} 條', + previousPage: '上一頁', + nextPage: '下一頁', + filterAction: '操作類型', + filterAllActions: '全部操作', + filterResource: '資源類型', + filterAllResources: '全部資源', + filterActor: '操作者', + filterAllActors: '全部操作者', + loadFailed: '載入操作溯源資料失敗', + refresh: '重新整理', + save: '儲存', + redacted: '已遮罩', + tamperedBadge: '可能被竄改', + verifiedBadge: '驗證通過', + tamperedCount: '{{count}} 筆記錄驗證異常', + levels: { + off: { + label: '關閉溯源', + }, + mutation: { + label: '僅修改操作', + }, + read: { + label: '最高級(含查看)', + }, + }, + levelNames: { + off: '關閉', + mutation: '僅修改', + read: '最高級', + }, + outcomes: { + ok: '成功', + denied: '被拒絕', + error: '失敗', + }, + actions: { + audit_log_view: '查看操作日誌', + clear_prune: '依策略清理記錄', + settings_update: '修改溯源設定', + settings_view: '查看溯源設定', + member_invite: '邀請成員', + member_role_update: '修改成員角色', + member_remove: '移除成員', + member_view: '查看成員', + export: '匯出資料', + execute: '執行任務', + debug: '偵錯任務', + publish: '發佈資源', + create: '建立資源', + update: '修改資源', + delete: '刪除資源', + view: '查看資源', + probe: '探測介面', + }, + }, storageAnalysis: { title: '儲存分析', description: '查看儲存占用和可清理檔案',