mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user