feat(operation-trace): record which resource was touched and what changed

Granularity follow-up: a record used to say only "installed an extension".

- Add resolve_resource_identity(): derive the acted-on resource from trusted route
  params or the install-style payload (plugin_author/plugin_name), so every
  resource family names its target. Unknown keys are ignored and sensitive keys
  are skipped, keeping the audit trail bounded and secret-free.
- The route wrapper applies it to every traced request; the identity only fills
  in when a handler did not publish a more precise one.
- Publish field-level before/after diffs for plugin config, skill, knowledge
  base, MCP server, pipeline, provider and model updates, reusing
  changed_fields()/build_summary() so secret-looking keys stay redacted and a
  masked round-trip never shows up as a phantom change.
This commit is contained in:
TyperBody
2026-09-26 03:46:20 +08:00
parent 6981b6643f
commit 475420956c
8 changed files with 133 additions and 7 deletions
@@ -3,6 +3,7 @@ import quart
from langbot.pkg.provider.modelmgr import errors as provider_errors
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ... import group
from .query import resolve_include_secret
@@ -85,14 +86,28 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
previous = await self.ap.llm_model_service.get_llm_model(request_context, model_uuid)
except Exception:
previous = None
try:
await self.ap.llm_model_service.update_llm_model(
request_context,
model_uuid,
await quart.request.json,
json_data,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success()
@self.route(
@@ -2,6 +2,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ... import group
from .query import resolve_include_secret
@@ -155,10 +156,29 @@ class ModelProvidersRouterGroup(group.RouterGroup):
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
previous = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=True,
)
except Exception:
previous = None
try:
await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
# Record which provider was reconfigured and which fields moved;
# credential-looking keys are redacted by ``changed_fields``.
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at', 'llm_count', 'embedding_count', 'rerank_count'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success()
@self.route(