mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 23:45:22 +08:00
refactor(operation-trace): isolate subsystem, fix install identity, merge migrations
- Move the operation-traceability service and its HTTP routes into a standalone pkg/operation_trace package; the Core controller package no longer depends on it (routes stay auto-discovered by the controller package scan). - Gate the hot path on one module-level boolean exposed as ap.operation_trace_active: while no Workspace has tracing enabled the route wrapper performs zero trace work and zero database round trips. The gate opens when a Workspace enables tracing and is primed once at startup for already-enabled Workspaces. - Record the installed extension identity for GitHub / marketplace / local / upload installs (owner/repo, author/name, filename) so the trace no longer shows a nameless "a plugin was installed" event and the card names the extension. - Merge migrations 0032 + 0033 into a single 0032_operation_traceability revision carrying the full table shape and an index set that matches the ORM. Tests: unit 5165 passed / integration 358 passed; ruff check + format clean.
This commit is contained in:
@@ -363,6 +363,13 @@ class RouterGroup(abc.ABC):
|
||||
database, so a disabled Workspace pays nothing at steady state.
|
||||
"""
|
||||
|
||||
# Cheap global gate: a single attribute read on the application object.
|
||||
# While no Workspace has enabled traceability this is ``False`` and the
|
||||
# isolated subsystem stays entirely off the hot request path, with no
|
||||
# import, service lookup, JSON parse or database round trip.
|
||||
if not getattr(self.ap, 'operation_trace_active', False):
|
||||
return
|
||||
|
||||
service = getattr(self.ap, 'workspace_settings_service', None)
|
||||
if service is None:
|
||||
return
|
||||
@@ -394,16 +401,15 @@ class RouterGroup(abc.ABC):
|
||||
# touching each handler. The body is read defensively: an audit path
|
||||
# must never fail (or block) because a request carries no JSON.
|
||||
try:
|
||||
# Imported lazily so the base controller never participates in an
|
||||
# import cycle with the service layer at module load time.
|
||||
from ..service import settings as settings_service
|
||||
|
||||
# Identity resolution lives in the isolated traceability service
|
||||
# and is reached through the handle, so the Core controller never
|
||||
# imports that subsystem.
|
||||
request_body: dict[str, typing.Any] | None = None
|
||||
if str(quart.request.method or '').upper() not in ('GET', 'HEAD', 'OPTIONS'):
|
||||
raw_body = await quart.request.get_json(silent=True)
|
||||
if isinstance(raw_body, dict):
|
||||
request_body = raw_body
|
||||
identity = settings_service.resolve_resource_identity(kwargs, request_body)
|
||||
identity = service.resolve_resource_identity(kwargs, request_body)
|
||||
if identity:
|
||||
resource_id = identity
|
||||
except Exception: # pragma: no cover - identity resolution is best effort
|
||||
|
||||
@@ -2,7 +2,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ......operation_trace import service as settings_service
|
||||
from ....service.secrets import redact_secrets
|
||||
from ... import group
|
||||
from ......pipeline.extension_preferences import (
|
||||
|
||||
@@ -18,7 +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 .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....plugin.github import validate_github_plugin_install_info
|
||||
@@ -872,6 +872,11 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
owner = install_info['owner']
|
||||
repo = install_info['repo']
|
||||
release_tag = install_info['release_tag']
|
||||
# Name the installed extension in the trace. GitHub installs carry
|
||||
# owner/repo in the body, which the audit identity resolver can also
|
||||
# read, but publishing it here keeps the trace correct regardless of
|
||||
# how the request was encoded.
|
||||
quart.g.operation_log_resource_id = f'{owner}/{repo}'
|
||||
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
@@ -912,6 +917,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
quart.g.operation_log_resource_id = f'{plugin_author}/{plugin_name}'
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
@@ -953,6 +959,9 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
file_bytes = file.read()
|
||||
form = await quart.request.form
|
||||
administrator_force = form.get('administrator_force', '').strip().lower() == 'true'
|
||||
# A local upload is multipart with no JSON body, so the archive
|
||||
# filename is the only identity available for the trace.
|
||||
quart.g.operation_log_resource_id = file.filename or 'local plugin'
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
data = {
|
||||
|
||||
@@ -3,7 +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 ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ....service import settings as settings_service
|
||||
from ......operation_trace import service as settings_service
|
||||
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from ... import group
|
||||
|
||||
|
||||
@@ -7,7 +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 .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -185,6 +185,9 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||
|
||||
# Name the installed skill in the trace (owner/repo from the body).
|
||||
quart.g.operation_log_resource_id = f'{data.get("owner", "")}/{data.get("repo", "")}'
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
@@ -228,6 +231,9 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
form = await quart.request.form
|
||||
# A zip upload is multipart with no JSON body, so the filename is
|
||||
# the only identity available for the trace.
|
||||
quart.g.operation_log_resource_id = file.filename or 'skill'
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_zip_upload(
|
||||
|
||||
@@ -12,7 +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 .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ from .groups import knowledge as groups_knowledge
|
||||
from .groups import resources as groups_resources
|
||||
from ...mcp.mount import MCPMount
|
||||
|
||||
# The traceability subsystem is self-contained and auto-registers its routes
|
||||
# through ``group.group_class``. Importing it here (instead of from within the
|
||||
# ``groups`` package) keeps the feature decoupled: the Core controller package
|
||||
# contains no code that depends on it, only this single bootstrap import.
|
||||
from ....operation_trace import routes as _operation_trace_routes # noqa: F401
|
||||
|
||||
importutil.import_modules_in_pkg(groups)
|
||||
importutil.import_modules_in_pkg(groups_provider)
|
||||
importutil.import_modules_in_pkg(groups_platform)
|
||||
|
||||
@@ -36,7 +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 ..operation_trace import service as workspace_settings_service
|
||||
from ..api.http.service import skill as skill_service
|
||||
from ..api.http.service import maintenance as maintenance_service
|
||||
from ..discover import engine as discover_engine
|
||||
|
||||
@@ -29,7 +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 ...operation_trace import service as operation_trace_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...skill import manager as skill_mgr
|
||||
from ...api.http.service import maintenance as maintenance_service
|
||||
@@ -128,7 +128,7 @@ 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)
|
||||
workspace_settings_service_inst = operation_trace_service.WorkspaceSettingsService(ap)
|
||||
ap.workspace_settings_service = workspace_settings_service_inst
|
||||
|
||||
apikey_service_inst = apikey_service.ApiKeyService(ap)
|
||||
@@ -162,6 +162,12 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# Persistence is ready now. Open the cheap global traceability gate if
|
||||
# any Workspace already has tracing enabled, so a restart resumes
|
||||
# recording without ever checking the database on the request path.
|
||||
if ap.workspace_settings_service is not None:
|
||||
await ap.workspace_settings_service.prime_global_flag()
|
||||
|
||||
if deployment.multi_workspace_enabled:
|
||||
directory_projection_service = DirectoryProjectionService(
|
||||
ap,
|
||||
|
||||
@@ -129,7 +129,7 @@ class WorkspaceOperationLog(Base):
|
||||
Linking rows this way makes silent edits or deletions detectable.
|
||||
"""
|
||||
|
||||
dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True)
|
||||
dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""Hash used to collapse repeated read observations into one row."""
|
||||
|
||||
# --- Client fingerprint ---------------------------------------------
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Isolated operation-traceability subsystem.
|
||||
|
||||
The "成员操作日志溯源" (member operation traceability) feature lives entirely in
|
||||
this package so it can be decoupled from the hot request path:
|
||||
|
||||
* :mod:`service` owns the capture levels, action catalog, append-only writer,
|
||||
tamper-evidence chain, dedupe window and read/prune APIs.
|
||||
* :mod:`routes` exposes the governance / operation-log HTTP surface and is
|
||||
auto-discovered by the HTTP controller's package scan, so nothing in the
|
||||
Core controller package needs to import it.
|
||||
|
||||
How the decoupling keeps tracing off the hot path
|
||||
-------------------------------------------------
|
||||
Tracing is opt-in per Workspace. The Core route wrapper asks
|
||||
:func:`operation_trace.service.is_globally_enabled` (a single module-level
|
||||
boolean, no I/O) before it queues any trace work. While no Workspace has ever
|
||||
turned tracing on, that flag is ``False`` and the wrapper performs zero
|
||||
database round trips. The first time an operator enables tracing the service
|
||||
flips the flag, and the per-Workspace level check then decides whether a given
|
||||
request is recorded. Recording itself always runs *after* the handler released
|
||||
its tenant scope, so auditing never extends a business transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['service', 'routes']
|
||||
+4
-4
@@ -25,10 +25,10 @@ import typing
|
||||
|
||||
import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service import settings as settings_service
|
||||
from .. import group
|
||||
from ..api.http.authz import Permission
|
||||
from ..api.http.context import RequestContext
|
||||
from ..api.http.controller import group
|
||||
from . import service as settings_service
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime.datetime | None:
|
||||
+90
-8
@@ -34,21 +34,46 @@ 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 (
|
||||
from ..api.http.authz import WorkspaceRole
|
||||
from ..api.http.context import PrincipalType, RequestContext
|
||||
from ..core import app
|
||||
from ..entity.persistence import metadata as persistence_metadata
|
||||
from ..entity.persistence import operation_log as persistence_operation_log
|
||||
from ..entity.persistence.operation_log import (
|
||||
OPERATION_LEVEL_MUTATION,
|
||||
OPERATION_LEVEL_NONE,
|
||||
OPERATION_LEVEL_READ,
|
||||
)
|
||||
from ....utils import constants
|
||||
from ..authz import WorkspaceRole
|
||||
from ..context import PrincipalType, RequestContext
|
||||
from ..utils import constants
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Cheap process-wide gate for the Core route wrapper. It is a module-level
|
||||
#: boolean, so checking it costs a load and a branch with no import, service
|
||||
#: lookup, JSON parse or database round trip. It stays ``False`` until some
|
||||
#: Workspace actually opts into tracing, which keeps the isolated subsystem
|
||||
#: entirely off the hot path for the default (tracing disabled) instance.
|
||||
_global_enabled = False
|
||||
|
||||
|
||||
def is_globally_enabled() -> bool:
|
||||
"""Return whether any Workspace may currently produce traces.
|
||||
|
||||
The Core route wrapper reads this before doing any trace work. ``False``
|
||||
means the whole subsystem is inert and costs the request path nothing.
|
||||
"""
|
||||
|
||||
return _global_enabled
|
||||
|
||||
|
||||
def enable_globally() -> None:
|
||||
"""Open the cheap gate. Idempotent; called when a Workspace turns tracing on."""
|
||||
|
||||
global _global_enabled
|
||||
_global_enabled = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage keys (WorkspaceMetadata.value is a short string column)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -595,6 +620,11 @@ def changed_fields(
|
||||
_IDENTITY_KEY_GROUPS: typing.Final[tuple[tuple[str, ...], ...]] = (
|
||||
('plugin_author', 'plugin_name'),
|
||||
('author', 'plugin_name'),
|
||||
('plugin_author', 'name'),
|
||||
# GitHub installs (plugins and skills) name the target as owner + repo in
|
||||
# the request body rather than in the URL, so without this group a
|
||||
# GitHub install would be traced as "a plugin was installed" with no name.
|
||||
('owner', 'repo'),
|
||||
('author', 'name'),
|
||||
('skill_uuid',),
|
||||
('knowledge_base_uuid',),
|
||||
@@ -814,6 +844,9 @@ class WorkspaceSettingsService:
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
# Publish the cheap gate on the app so the Core route wrapper can read
|
||||
# it as a plain attribute without importing this package.
|
||||
ap.operation_trace_active = is_globally_enabled()
|
||||
# Level cache keyed by Workspace UUID: {"level": int, "expires": float}
|
||||
self._level_cache: dict[str, tuple[int, float]] = {}
|
||||
self._level_cache_ttl = 5.0
|
||||
@@ -850,6 +883,34 @@ class WorkspaceSettingsService:
|
||||
self._level_cache.pop(workspace_uuid, None)
|
||||
self._policy_cache.pop(workspace_uuid, None)
|
||||
|
||||
def _activate(self) -> None:
|
||||
"""Open the cheap global gate once any Workspace enables tracing."""
|
||||
|
||||
enable_globally()
|
||||
self.ap.operation_trace_active = True
|
||||
|
||||
async def prime_global_flag(self) -> None:
|
||||
"""Open the cheap global gate at startup if a Workspace already opted in.
|
||||
|
||||
Runs one bounded query during application build, never per request, so a
|
||||
previously-enabled Workspace keeps recording after a restart while a
|
||||
disabled instance still pays nothing on the hot path.
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid)
|
||||
.where(
|
||||
persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY,
|
||||
persistence_metadata.WorkspaceMetadata.value != str(OPERATION_LEVEL_NONE),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if result.first() is not None:
|
||||
self._activate()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
self.ap.logger.debug(f'Operation trace global gate prime skipped: {exc}')
|
||||
|
||||
async def _read_metadata(self, workspace_uuid: str, key: str) -> str | None:
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -950,7 +1011,12 @@ class WorkspaceSettingsService:
|
||||
raw = await self._read_metadata(workspace_uuid, OPERATION_LEVEL_KEY)
|
||||
if raw is None:
|
||||
return DEFAULT_OPERATION_LEVEL
|
||||
return clamp_level(raw)
|
||||
configured = clamp_level(raw)
|
||||
if configured > OPERATION_LEVEL_NONE:
|
||||
# Reading an enabled Workspace re-opens the gate, so tracing resumes
|
||||
# on its own after a restart even before a write occurs.
|
||||
self._activate()
|
||||
return configured
|
||||
|
||||
async def effective_level(self, workspace_uuid: str, role: str | None) -> int:
|
||||
"""Return the level actually used for a request by ``role``."""
|
||||
@@ -979,6 +1045,8 @@ class WorkspaceSettingsService:
|
||||
"""
|
||||
|
||||
resolved_level = clamp_level(level)
|
||||
if resolved_level > OPERATION_LEVEL_NONE:
|
||||
self._activate()
|
||||
values = {
|
||||
OPERATION_LEVEL_KEY: str(resolved_level),
|
||||
}
|
||||
@@ -1006,6 +1074,20 @@ class WorkspaceSettingsService:
|
||||
|
||||
return (await self._operations_policy(workspace_uuid))['dedupe_window_seconds']
|
||||
|
||||
def resolve_resource_identity(
|
||||
self,
|
||||
path_params: typing.Mapping[str, typing.Any] | None,
|
||||
body: typing.Mapping[str, typing.Any] | None,
|
||||
) -> str | None:
|
||||
"""Identify the resource a request acts on.
|
||||
|
||||
Thin instance wrapper over the module-level resolver so the Core route
|
||||
wrapper can name a resource through the service handle without importing
|
||||
this package directly.
|
||||
"""
|
||||
|
||||
return resolve_resource_identity(path_params, body)
|
||||
|
||||
async def describe_governance(self, workspace_uuid: str) -> dict[str, typing.Any]:
|
||||
"""Return the governance payload rendered by the settings panel.
|
||||
|
||||
+34
-32
@@ -1,6 +1,6 @@
|
||||
"""add workspace operation logs for member/resource traceability
|
||||
"""add workspace operation traceability table
|
||||
|
||||
Revision ID: 0032_workspace_operation_logs
|
||||
Revision ID: 0032_operation_traceability
|
||||
Revises: 0031_merge_totp_assistant
|
||||
Create Date: 2026-09-25
|
||||
|
||||
@@ -8,6 +8,12 @@ The table is append-only and tenant-owned. Fresh installs already receive it
|
||||
from SQLAlchemy ``create_all``; this revision makes the change safe for
|
||||
existing databases and mirrors the shared Row Level Security contract so the
|
||||
Cloud release validation can enforce it.
|
||||
|
||||
It carries the full shape in one step: the base traceability columns, the
|
||||
tamper-evidence hash chain (``record_hash`` / ``prev_hash``) and the
|
||||
``dedupe_key`` used to collapse repeated read observations. The single
|
||||
composite index ``ix_workspace_operation_logs_workspace_dedupe`` covers the
|
||||
dedupe lookup, so no standalone ``dedupe_key`` index exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,7 +22,7 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0032_workspace_operation_logs'
|
||||
revision = '0032_operation_traceability'
|
||||
down_revision = '0031_merge_totp_assistant'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
@@ -26,6 +32,22 @@ _TABLE_NAME = 'workspace_operation_logs'
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
|
||||
#: Every index the ORM declares for this table. Kept in one place so the
|
||||
#: migration and ``Base.metadata.create_all`` stay in lockstep.
|
||||
_INDEXES: tuple[tuple[str, list[str]], ...] = (
|
||||
('ix_workspace_operation_logs_workspace_created', ['workspace_uuid', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_resource', ['workspace_uuid', 'resource_type', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_dedupe', ['workspace_uuid', 'dedupe_key', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_uuid', ['workspace_uuid']),
|
||||
('ix_workspace_operation_logs_created_at', ['created_at']),
|
||||
('ix_workspace_operation_logs_action', ['action']),
|
||||
('ix_workspace_operation_logs_resource_type', ['resource_type']),
|
||||
('ix_workspace_operation_logs_level', ['level']),
|
||||
('ix_workspace_operation_logs_actor_account_uuid', ['actor_account_uuid']),
|
||||
('ix_workspace_operation_logs_request_id', ['request_id']),
|
||||
('ix_workspace_operation_logs_route', ['route']),
|
||||
)
|
||||
|
||||
|
||||
def _setting(name: str) -> str:
|
||||
return f"NULLIF(current_setting('{name}', true), '')"
|
||||
@@ -66,6 +88,9 @@ def upgrade() -> None:
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('changes', sa.Text(), nullable=True),
|
||||
sa.Column('detail', sa.Text(), nullable=True),
|
||||
sa.Column('record_hash', sa.String(64), nullable=True),
|
||||
sa.Column('prev_hash', sa.String(64), nullable=True),
|
||||
sa.Column('dedupe_key', sa.String(64), nullable=True),
|
||||
sa.Column('client_ip', sa.String(64), nullable=True),
|
||||
sa.Column('user_agent', sa.String(512), nullable=True),
|
||||
sa.Column('duration_ms', sa.Integer(), nullable=False, server_default='0'),
|
||||
@@ -77,28 +102,8 @@ def upgrade() -> None:
|
||||
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)
|
||||
for index_name, columns in _INDEXES:
|
||||
op.create_index(index_name, _TABLE_NAME, columns, unique=False)
|
||||
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
@@ -119,15 +124,12 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()):
|
||||
return
|
||||
if conn.dialect.name == 'postgresql':
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
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)
|
||||
for index_name, _ in reversed(_INDEXES):
|
||||
op.drop_index(index_name, table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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)
|
||||
@@ -639,6 +639,18 @@ export default function OperationTracePanel({
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{record.resource_id && (
|
||||
// Which concrete resource was touched (author/name,
|
||||
// owner/repo, filename...). Rendered as data, not copy,
|
||||
// so it is not localized.
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-[16rem] truncate font-mono"
|
||||
title={record.resource_id}
|
||||
>
|
||||
{record.resource_id}
|
||||
</Badge>
|
||||
)}
|
||||
{record.tampered ? (
|
||||
<Badge variant="destructive">
|
||||
<ShieldAlert className="size-3" />
|
||||
|
||||
Reference in New Issue
Block a user