mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-29 21:06:41 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -67,13 +68,33 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
# Name the knowledge base and, on update, record which settings moved
|
||||
# instead of only "a knowledge base was changed".
|
||||
quart.g.operation_log_resource_id = knowledge_base_uuid
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.knowledge_service.get_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
include_secret=True,
|
||||
)
|
||||
except Exception:
|
||||
previous = None
|
||||
await self.ap.knowledge_service.update_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
json_data,
|
||||
)
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('knowledge_base_update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success(data={'uuid': knowledge_base_uuid})
|
||||
await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
|
||||
return self.success({})
|
||||
|
||||
@@ -4,6 +4,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ....service.secrets import redact_secrets
|
||||
from ... import group
|
||||
from ......pipeline.extension_preferences import (
|
||||
@@ -87,15 +88,30 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
quart.g.operation_log_resource_id = pipeline_uuid
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
except Exception:
|
||||
previous = None
|
||||
try:
|
||||
await self.ap.pipeline_service.update_pipeline(
|
||||
request_context,
|
||||
pipeline_uuid,
|
||||
await quart.request.json,
|
||||
json_data,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
else:
|
||||
await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
|
||||
return self.success()
|
||||
|
||||
@@ -18,6 +18,7 @@ from .....core import taskmgr
|
||||
from .....entity.persistence import plugin as persistence_plugin
|
||||
from ...authz import Permission
|
||||
from ...context import ExecutionContext, RequestContext
|
||||
from ...service import settings as settings_service
|
||||
from .. import group
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....plugin.github import validate_github_plugin_install_info
|
||||
@@ -526,15 +527,33 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
plugin_name,
|
||||
plugin,
|
||||
)
|
||||
try:
|
||||
incoming_config = await quart.request.json
|
||||
except Exception:
|
||||
incoming_config = None
|
||||
try:
|
||||
config = restore_plugin_secret_placeholders(
|
||||
await quart.request.json,
|
||||
incoming_config,
|
||||
current_config,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||
|
||||
# Record which plugin was reconfigured and which keys actually moved.
|
||||
# The diff is computed over the *requested* payload (secrets restored
|
||||
# from the stored config) so a masked ``***`` round-trip never shows
|
||||
# up as a change; sensitive keys are redacted by ``changed_fields``.
|
||||
config_changes = settings_service.changed_fields(
|
||||
current_config if isinstance(current_config, dict) else {},
|
||||
config if isinstance(config, dict) else {},
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('plugin_config')
|
||||
quart.g.operation_log_resource_id = f'{author}/{plugin_name}'
|
||||
quart.g.operation_log_changes = config_changes
|
||||
if rule is not None and config_changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, config_changes)
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from ... import group
|
||||
|
||||
@@ -62,6 +63,10 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
|
||||
if server_data is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
# Name the MCP server in the trace, and for an update record which
|
||||
# fields actually moved so the card answers "what changed" rather
|
||||
# than only "the MCP config was touched".
|
||||
quart.g.operation_log_resource_id = server_name
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
@@ -70,6 +75,15 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
changes = settings_service.changed_fields(
|
||||
server_data,
|
||||
data if isinstance(data, dict) else {},
|
||||
ignore=('uuid',),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('mcp_config')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
else:
|
||||
await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
|
||||
return self.success()
|
||||
|
||||
@@ -7,6 +7,7 @@ from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service import settings as settings_service
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -72,14 +73,31 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
# The skill name is the resource identity for both verbs; without it
|
||||
# the card could only say "a skill was updated".
|
||||
quart.g.operation_log_resource_id = skill_name
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError):
|
||||
previous = None
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
data if isinstance(data, dict) else {},
|
||||
ignore=('files', 'package_root', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('skill_view')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success(data={'skill': skill})
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
|
||||
@@ -631,9 +631,12 @@ export default function OperationTracePanel({
|
||||
<Badge variant="outline">L{record.level}</Badge>
|
||||
{record.resource_type && (
|
||||
<Badge variant="secondary">
|
||||
{t(`operationTrace.resourceTypes.${record.resource_type}`, {
|
||||
defaultValue: record.resource_type,
|
||||
})}
|
||||
{t(
|
||||
`operationTrace.resourceTypes.${record.resource_type}`,
|
||||
{
|
||||
defaultValue: record.resource_type,
|
||||
},
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{record.tampered ? (
|
||||
|
||||
Reference in New Issue
Block a user