mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
Systematically cross-checked all 263 route/method pairs against the classifier and fixed every case where a real change could be mislabelled: - POST /skills and PUT /skills/<name> were classified as skill_view (read bucket), so creating or editing a skill was silently unlogged at the mutation level. They now record skill_update. - The public webhook ingress (/bots/<uuid>) and the embedded chat widget (/embed/*) are unauthenticated visitor traffic, not Workspace changes; they are dropped instead of logged as create/resource. - Uploading files and indexing documents (data flowing into a resource) are offered as observations so they never fill the log; deleting a knowledge-base file is destructive and now records file_delete. - A plugin's own config-file edit/delete is a plugin change, not an opaque resource delete; ordered before the /config rule that also matched it. - The bucket now comes from the action table (plus 'a read verb is always a read'), so a write action can no longer be forced into the read bucket. - Added agents and files resource families; localized every new action/resource type in all nine locales.
2463 lines
99 KiB
Python
2463 lines
99 KiB
Python
"""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.<action>`` from the i18n locale files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import datetime
|
|
import enum
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
import typing
|
|
|
|
import sqlalchemy
|
|
|
|
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
|
|
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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'
|
|
|
|
#: Tracing is opt-in: a Workspace records nothing until an owner or admin
|
|
#: explicitly turns it on. This keeps auditing off the hot path by default and
|
|
#: avoids collecting administrative activity before the operator asked for it.
|
|
DEFAULT_OPERATION_LEVEL = OPERATION_LEVEL_NONE
|
|
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
|
|
|
|
#: How many newest records the integrity summary reads before the numbers
|
|
#: beside the list are served from an approximate scan. Verifying a record means
|
|
#: recomputing its HMAC, so an unbounded scan on every page/refresh would make the
|
|
#: panel latency grow with the history size. ``MAX_MAX_ROWS`` (500k) is the
|
|
#: configured ceiling for a Workspace, so a full-table verification at that scale
|
|
#: is exactly what this bound avoids.
|
|
MAX_INTEGRITY_SCAN_ROWS = 20000
|
|
|
|
#: Integrity verification is cached per Workspace for this long. Recomputing a
|
|
#: row's HMAC is the expensive part of the read path, so a result computed
|
|
#: seconds ago is served straight from the cache, which makes a burst of panel
|
|
#: opens, refreshes and page turns (all within this window) effectively free.
|
|
#:
|
|
#: The cache is a latency shield, never a correctness shortcut: a cache miss
|
|
#: re-verifies the whole window because an in-place edit to an *already
|
|
#: verified* row must still be caught. Skipping previously verified ids would
|
|
#: turn the incremental scan into a permanent blind spot for exactly the
|
|
#: tampering this feature exists to expose.
|
|
INTEGRITY_CACHE_TTL_SECONDS = 30.0
|
|
|
|
#: ``integrity`` query values accepted by :meth:`query_logs`. ``all`` keeps the
|
|
#: previous behaviour; the other two map to the two failure modes the panel
|
|
#: surfaces independently (hash mismatch vs. broken chain link).
|
|
INTEGRITY_FILTER_ALL = 'all'
|
|
INTEGRITY_FILTER_ISSUES = 'issues'
|
|
INTEGRITY_FILTER_HASH_MISMATCH = 'hash_mismatch'
|
|
INTEGRITY_FILTER_CHAIN_BROKEN = 'chain_broken'
|
|
_INTEGRITY_FILTERS = frozenset(
|
|
{
|
|
INTEGRITY_FILTER_ALL,
|
|
INTEGRITY_FILTER_ISSUES,
|
|
INTEGRITY_FILTER_HASH_MISMATCH,
|
|
INTEGRITY_FILTER_CHAIN_BROKEN,
|
|
}
|
|
)
|
|
|
|
#: 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
|
|
|
|
#: Bounded trace queue. The request path never blocks on it: when growing faster
|
|
#: than the single writer can drain, the oldest pending traces are dropped rather
|
|
#: than slowing the business request down. Dropping audit rows is acceptable;
|
|
#: stalling live traffic is not.
|
|
_QUEUE_MAX_SIZE = 2048
|
|
|
|
#: Seconds the writer waits for more work after draining a batch before flushing
|
|
#: the row-budget check. Batching turns N inserts into one budget query.
|
|
_WRITER_DRAIN_TIMEOUT = 0.2
|
|
|
|
_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',
|
|
)
|
|
|
|
|
|
def _integrity_scan_select(model: typing.Any, *conditions: typing.Any) -> typing.Any:
|
|
"""Build a projection-only scan query for integrity verification.
|
|
|
|
Verification only re-hashes the row content plus the two chain pointers, so
|
|
selecting the whole row would pull the ``changes`` / ``detail`` Text payloads
|
|
and the client fingerprint for every scanned record. On a cold pass that is
|
|
up to :data:`MAX_INTEGRITY_SCAN_ROWS` rows of payload the verifier never
|
|
reads; projecting the hash columns keeps the read proportional to the hash
|
|
instead of to the payload size.
|
|
"""
|
|
|
|
columns = [model.id, model.record_hash]
|
|
columns.extend(getattr(model, field) for field in _HASH_FIELDS)
|
|
return sqlalchemy.select(*columns).where(*conditions)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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='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',
|
|
),
|
|
# --- Extension lifecycle (plugins, pages, skills, MCP) --------------
|
|
ActionRule(
|
|
action='plugin_view',
|
|
category='extension',
|
|
bucket='read',
|
|
resource_type='plugin',
|
|
),
|
|
ActionRule(
|
|
action='plugin_config',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='plugin',
|
|
),
|
|
ActionRule(
|
|
action='plugin_install',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='plugin',
|
|
),
|
|
# Uninstalling is a mutation with its own verb. Without it the DELETE on the
|
|
# shared ``/plugins/<author>/<name>`` route falls through to the read rule
|
|
# and an uninstall is mislabelled as a view.
|
|
ActionRule(
|
|
action='plugin_uninstall',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='plugin',
|
|
),
|
|
ActionRule(
|
|
action='plugin_upgrade',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='plugin',
|
|
),
|
|
ActionRule(
|
|
action='page_view',
|
|
category='extension',
|
|
bucket='read',
|
|
resource_type='plugin_page',
|
|
),
|
|
ActionRule(
|
|
action='skill_view',
|
|
category='extension',
|
|
bucket='read',
|
|
resource_type='skill',
|
|
),
|
|
ActionRule(
|
|
action='skill_install',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='skill',
|
|
),
|
|
ActionRule(
|
|
action='skill_update',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='skill',
|
|
),
|
|
ActionRule(
|
|
action='skill_uninstall',
|
|
category='extension',
|
|
bucket='write',
|
|
resource_type='skill',
|
|
),
|
|
# --- Knowledge & MCP -------------------------------------------------
|
|
ActionRule(
|
|
action='knowledge_base_view',
|
|
category='knowledge',
|
|
bucket='read',
|
|
resource_type='knowledge_base',
|
|
),
|
|
ActionRule(
|
|
action='knowledge_base_update',
|
|
category='knowledge',
|
|
bucket='write',
|
|
resource_type='knowledge_base',
|
|
),
|
|
ActionRule(
|
|
action='knowledge_base_delete',
|
|
category='knowledge',
|
|
bucket='write',
|
|
resource_type='knowledge_base',
|
|
),
|
|
ActionRule(
|
|
action='mcp_view',
|
|
category='integration',
|
|
bucket='read',
|
|
resource_type='mcp_server',
|
|
),
|
|
ActionRule(
|
|
action='mcp_config',
|
|
category='integration',
|
|
bucket='write',
|
|
resource_type='mcp_server',
|
|
),
|
|
ActionRule(
|
|
action='mcp_delete',
|
|
category='integration',
|
|
bucket='write',
|
|
resource_type='mcp_server',
|
|
),
|
|
# --- Pipelines -------------------------------------------------------
|
|
ActionRule(
|
|
action='pipeline_extensions_update',
|
|
category='resource',
|
|
bucket='write',
|
|
resource_type='pipeline',
|
|
),
|
|
# --- 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',
|
|
),
|
|
# --- Data-flow helpers (why a read: nothing to trace) ----------------
|
|
# Uploading a file or indexing a document into a knowledge base is data
|
|
# flowing into a resource, not a change to the resource's definition. It is
|
|
# offered as a read verb so the persist gate drops it for every role, the
|
|
# same way a file view is dropped, keeping the log to definition changes.
|
|
ActionRule(
|
|
action='file_view',
|
|
category='resource',
|
|
bucket='read',
|
|
resource_type='file',
|
|
),
|
|
ActionRule(
|
|
action='ingest',
|
|
category='resource',
|
|
bucket='read',
|
|
resource_type='knowledge_base',
|
|
),
|
|
# Removing a document from a knowledge base is destructive and worth tracing,
|
|
# unlike the upload/index that put it there.
|
|
ActionRule(
|
|
action='file_delete',
|
|
category='knowledge',
|
|
bucket='write',
|
|
resource_type='knowledge_base',
|
|
),
|
|
# --- Platform ingress (external traffic, no Workspace actor) ---------
|
|
ActionRule(
|
|
action='ingress',
|
|
category='runtime',
|
|
bucket='skip',
|
|
resource_type='bot',
|
|
),
|
|
# --- Provider credential handshake -----------------------------------
|
|
ActionRule(
|
|
action='codex_view',
|
|
category='integration',
|
|
bucket='read',
|
|
resource_type='model_provider',
|
|
),
|
|
ActionRule(
|
|
action='codex_authorize',
|
|
category='integration',
|
|
bucket='write',
|
|
resource_type='model_provider',
|
|
),
|
|
ActionRule(
|
|
action='probe',
|
|
category='system',
|
|
bucket='read',
|
|
resource_type='system',
|
|
),
|
|
# A management-assistant chat session is not a Workspace resource change:
|
|
# creating a conversation and sending a turn produce one opaque
|
|
# ``create/resource`` row each. They carry no "what changed" answer, so the
|
|
# whole family is dropped rather than cluttering the log with noise.
|
|
ActionRule(
|
|
action='assistant_session',
|
|
category='runtime',
|
|
bucket='skip',
|
|
resource_type='assistant_conversation',
|
|
),
|
|
# Visitor traffic to an embedded public chat widget: unauthenticated, keyed
|
|
# only by a bot UUID, never a Workspace actor. Dropped rather than logged.
|
|
ActionRule(
|
|
action='embed',
|
|
category='runtime',
|
|
bucket='skip',
|
|
resource_type='bot',
|
|
),
|
|
)
|
|
|
|
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 rules evaluated in order; the first match wins, so the most specific
|
|
# rule must precede its prefix. Each rule is ``(fragments, read_action,
|
|
# write_action, delete_action, fallback_action)``: every fragment must appear in
|
|
# the lowered route (a tuple expresses an AND, which lets
|
|
# ``/plugins/<author>/<name>/config`` be told apart from
|
|
# ``/plugins/<author>/<name>``).
|
|
#
|
|
# The action is selected by the method bucket: a read verb uses ``read_action``,
|
|
# ``DELETE`` uses ``delete_action``, a write verb uses ``write_action``. Two
|
|
# subtlety guards exist because keying only on fragments and then trusting the
|
|
# bucket silently mislabels real operations:
|
|
#
|
|
# * ``write_action`` may be ``None`` for a read-only surface (``/plugins/github``,
|
|
# the codex auth handshake, the public webhook ingress). A stray non-read verb
|
|
# then uses ``fallback_action`` instead of being recorded as a mutation.
|
|
# * the bucket is re-derived from the *actual* method and the resolved action, so
|
|
# a mismatch can never route a real mutation into the read bucket (which is
|
|
# dropped at the mutation level) or a page load into the write bucket.
|
|
_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str | None, str | None, str], ...]] = (
|
|
# --- Audit surface itself -------------------------------------------
|
|
(('/settings/operation-logs/export',), 'export', 'export', None, 'export'),
|
|
(('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None, 'audit_log_view'),
|
|
(('/settings/operation-level',), 'settings_view', 'settings_update', None, 'settings_view'),
|
|
(('/settings/governance',), 'settings_view', 'settings_update', None, 'settings_view'),
|
|
(('/settings/limits',), 'settings_view', 'settings_update', None, 'settings_view'),
|
|
# --- Extension lifecycle: plugins -----------------------------------
|
|
# ``/plugins/install`` is an install; the bare ``/plugins/<author>/<name>``
|
|
# is a read when fetched and an uninstall when deleted.
|
|
(('/plugins/install',), 'plugin_view', 'plugin_install', None, 'plugin_view'),
|
|
(('/plugins/github',), 'plugin_view', None, None, 'plugin_view'),
|
|
# Editing or deleting a plugin's own config file is a plugin change, not an
|
|
# opaque resource delete. Must precede the ``/config`` rule below, whose
|
|
# fragment also matches ``config-files``.
|
|
(('/plugins/', '/config-files'), 'plugin_view', 'plugin_config', 'plugin_config', 'plugin_view'),
|
|
(('/plugins/', '/config'), 'plugin_view', 'plugin_config', None, 'plugin_view'),
|
|
(('/plugins/', '/page-api'), 'page_view', 'page_view', None, 'page_view'),
|
|
(('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None, 'plugin_view'),
|
|
(('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None, 'plugin_view'),
|
|
(('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall', 'plugin_view'),
|
|
# The pipeline extension bindings (plugins / MCP servers / skills) live on
|
|
# ``/pipelines/<uuid>/extensions``, not on a plugin. Without this rule the
|
|
# generic fragment below would classify the change as a plugin config edit.
|
|
# The read keeps the generic ``view`` for backwards-compatible labelling.
|
|
(('/extensions',), 'view', 'pipeline_extensions_update', None, 'view'),
|
|
# --- Extension lifecycle: skills ------------------------------------
|
|
# ``/skills/install/...`` is an install; the bare ``/skills`` collection is
|
|
# created with POST and the item is rewritten with PUT, so the write verb is
|
|
# an update -- not ``skill_view``, which used to drop a real skill edit into
|
|
# the read bucket where a mutation-level Workspace never persisted it.
|
|
(('/skills/', '/install'), 'skill_view', 'skill_install', None, 'skill_view'),
|
|
(('/skills',), 'skill_view', 'skill_update', 'skill_uninstall', 'skill_view'),
|
|
# --- Ingestion helpers (data flowing in, not a definition change) ----
|
|
# Ordered before the knowledge-base rule so a file upload or an index is not
|
|
# mislabelled as a knowledge-base definition update.
|
|
(('/knowledge/', '/files'), 'file_view', 'ingest', 'file_delete', 'file_view'),
|
|
# --- Knowledge bases & MCP servers ----------------------------------
|
|
(('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete', 'knowledge_base_view'),
|
|
(('/mcp/', '/config'), 'mcp_view', 'mcp_config', None, 'mcp_view'),
|
|
(('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete', 'mcp_view'),
|
|
# --- Member management ----------------------------------------------
|
|
(('/members',), 'member_view', 'member_role_update', None, 'member_view'),
|
|
(('/invitations',), 'member_view', 'member_invite', None, 'member_view'),
|
|
# --- Agent-assistant session chatter (never traced) ------------------
|
|
# Placed before the generic verbs so a conversation turn is not recorded as
|
|
# an opaque ``create`` on a nameless resource.
|
|
(('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session', 'assistant_session'),
|
|
# --- Embedded public chat widget (visitor traffic, never traced) ------
|
|
(('/embed/',), 'embed', 'embed', 'embed', 'embed'),
|
|
# --- Public inbound webhook ingress (external traffic, never traced) --
|
|
# ``/bots/<uuid>`` is unauthenticated platform traffic, not a Workspace
|
|
# mutation; recording it as ``create/resource`` was pure noise.
|
|
(('/bots/',), 'ingress', 'ingress', 'ingress', 'ingress'),
|
|
# --- Provider credential handshake (in-flight pairing state) ---------
|
|
(('/codex/',), 'codex_view', None, 'codex_authorize', 'codex_view'),
|
|
# --- Ingestion helpers: uploading a document is data flowing in, not a
|
|
# change to a resource definition. --------------------------------
|
|
(('/files/',), 'file_view', 'ingest', 'ingest', 'file_view'),
|
|
# --- Generic resource verbs -----------------------------------------
|
|
(('/export',), 'export', 'export', None, 'export'),
|
|
(('/debug',), 'debug', 'debug', None, 'debug'),
|
|
(('/execute',), 'execute', 'execute', None, 'execute'),
|
|
(('/publish',), 'publish', 'publish', None, 'publish'),
|
|
)
|
|
|
|
#: Refines the resource family for the generic verb rules. The route rules above
|
|
#: only cover the extension/tenant surfaces; everything else (bots, providers,
|
|
#: pipelines, users, monitoring...) falls back to a nameless ``resource``. This
|
|
#: table names the family from the registered route identity, so a read of
|
|
#: ``/api/v1/pipelines`` is reported as a Pipeline, not as an opaque Resource.
|
|
#: It is only consulted when the matched rule still carries the generic
|
|
#: ``resource`` type, so specific rules keep their own family. More specific
|
|
#: fragments must precede their prefixes because the first match wins.
|
|
_RESOURCE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str], ...]] = (
|
|
(('/platform/bots',), 'bot'),
|
|
(('/platform/',), 'adapter'),
|
|
(('/provider/providers',), 'model_provider'),
|
|
(('/provider/models',), 'llm_model'),
|
|
(('/provider/',), 'model_provider'),
|
|
(('/pipelines',), 'pipeline'),
|
|
(('/user/',), 'user'),
|
|
(('/workspaces/current',), 'workspace'),
|
|
(('/workspaces',), 'workspace'),
|
|
(('/monitoring',), 'monitoring'),
|
|
(('/webhooks',), 'webhook'),
|
|
(('/apikeys',), 'api_key'),
|
|
(('/agents',), 'agent'),
|
|
(('/files/',), 'file'),
|
|
(('/skills',), 'skill'),
|
|
(('/extensions',), 'plugin'),
|
|
(('/assistant',), 'assistant_conversation'),
|
|
# The sandbox, survey and generic system probes share the ``system`` family.
|
|
(('/box/',), 'system'),
|
|
(('/survey',), 'system'),
|
|
(('/system/',), 'system'),
|
|
)
|
|
|
|
|
|
def _resource_type_for(route: str) -> str | None:
|
|
"""Return the resource family for a route, or ``None`` when unknown."""
|
|
|
|
lowered = (route or '').lower()
|
|
for fragments, resource_type in _RESOURCE_RULES:
|
|
if all(fragment in lowered for fragment in fragments):
|
|
return resource_type
|
|
return None
|
|
|
|
|
|
def _with_resource_type(rule: ActionRule, route: str) -> ActionRule:
|
|
"""Refine a generic rule's resource family from the route identity.
|
|
|
|
Only the generic fallback (``resource``) is refined; a rule that already
|
|
names a family (plugin, skill, member...) is returned unchanged.
|
|
"""
|
|
|
|
if rule.resource_type != 'resource':
|
|
return rule
|
|
resource_type = _resource_type_for(route)
|
|
if resource_type is None:
|
|
return rule
|
|
return dataclasses.replace(rule, resource_type=resource_type)
|
|
|
|
|
|
#: Action names that describe an observation. A mutation can never be persisted
|
|
#: under one of these: the read bucket is dropped at the mutation level, so a
|
|
def _action_bucket(action: str, method: str) -> str:
|
|
"""Return the capture bucket for a resolved action and the actual method.
|
|
|
|
The action table is the single source of truth: an action declares whether it
|
|
is a traceable change (``write``), an observation (``read``), the audit
|
|
surface itself (``audit``) or noise (``skip``). The only adjustment made here
|
|
is that a read *method* is always an observation -- so a GET that somehow
|
|
resolved to a write action still cannot be persisted as a mutation. Because
|
|
the table now names a real write action for every mutating route, trusting it
|
|
no longer lets a skill edit hide in the read bucket the way it used to.
|
|
"""
|
|
|
|
bucket = ACTION_RULES_BY_ACTION[action].bucket
|
|
if bucket in ('audit', 'skip'):
|
|
return bucket
|
|
if method in _READ_METHODS:
|
|
return 'read'
|
|
return bucket
|
|
|
|
|
|
def _resolve(rule: ActionRule, action: str, method: str, route: str) -> ActionRule:
|
|
"""Attach the method-derived bucket to a classified action."""
|
|
|
|
refined = _with_resource_type(ACTION_RULES_BY_ACTION[action], route)
|
|
return dataclasses.replace(refined, bucket=_action_bucket(action, method))
|
|
|
|
|
|
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()
|
|
is_read = upper_method in _READ_METHODS
|
|
is_delete = upper_method == 'DELETE'
|
|
|
|
for fragments, read_action, write_action, delete_action, fallback_action in _ROUTE_RULES:
|
|
if all(fragment in lowered_route for fragment in fragments):
|
|
if is_read:
|
|
action = read_action
|
|
elif is_delete:
|
|
# Fall back to the generic delete verb when a rule has no
|
|
# dedicated destroy action (still a mutation, never a view).
|
|
action = delete_action or 'delete'
|
|
else:
|
|
# A read-only surface declares no write action; never invent a
|
|
# mutation for it, use the observation action instead.
|
|
action = write_action or fallback_action
|
|
return _resolve(ACTION_RULES_BY_ACTION[action], action, upper_method, route)
|
|
|
|
if is_read:
|
|
action = 'view'
|
|
elif is_delete:
|
|
action = 'delete'
|
|
elif upper_method == 'POST':
|
|
action = 'create'
|
|
elif upper_method in {'PUT', 'PATCH'}:
|
|
action = 'update'
|
|
else:
|
|
action = 'probe'
|
|
return _resolve(ACTION_RULES_BY_ACTION[action], action, upper_method, route)
|
|
|
|
|
|
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.
|
|
|
|
``write`` buckets are recorded from the mutation level upward. ``read``
|
|
and ``audit`` are both *observation* buckets: viewing a resource and
|
|
viewing the audit surface itself are reads, so a mutation-level Workspace
|
|
must not fill its log with page views. They are only persisted once the
|
|
Workspace opts into the read level. ``skip`` is charter noise a Workspace
|
|
never needs traced (an assistant chat session's own turn traffic), so it is
|
|
dropped at every level instead of being mislabelled as a resource change.
|
|
"""
|
|
|
|
if bucket == 'skip':
|
|
return False
|
|
if effective_level <= OPERATION_LEVEL_NONE:
|
|
return False
|
|
if bucket in ('read', 'audit'):
|
|
return effective_level >= OPERATION_LEVEL_READ
|
|
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
|
|
|
|
|
|
#: Ordered key groups that identify the resource a request acts on. Each group
|
|
#: is joined with ``/`` so ``author`` + ``plugin_name`` renders as
|
|
#: ``author/plugin_name`` — the same identity the UI already shows. Only these
|
|
#: keys are consulted: reading arbitrary payload fields would let a caller
|
|
#: inject unbounded, unattributed text into the audit trail.
|
|
_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',),
|
|
('server_uuid',),
|
|
('pipeline_uuid',),
|
|
('provider_uuid',),
|
|
('model_uuid',),
|
|
('account_uuid',),
|
|
('email',),
|
|
('name',),
|
|
('uuid',),
|
|
)
|
|
|
|
|
|
def resolve_resource_identity(
|
|
path_params: typing.Mapping[str, typing.Any] | None,
|
|
body: typing.Mapping[str, typing.Any] | None,
|
|
) -> str | None:
|
|
"""Best-effort identity of the resource one request acts on.
|
|
|
|
Path parameters describe the resource a route was registered for and are
|
|
therefore trusted; the request body is only consulted for install-style
|
|
endpoints that carry the identity in their payload (a marketplace install
|
|
names the plugin in the body, not in the URL). The result is bounded and
|
|
sensitive-looking keys are skipped so a secret can never be echoed back
|
|
through the trace.
|
|
"""
|
|
|
|
params = path_params or {}
|
|
payload = body or {}
|
|
for group in _IDENTITY_KEY_GROUPS:
|
|
parts: list[str] = []
|
|
for key in group:
|
|
value = params.get(key)
|
|
if value is None:
|
|
value = payload.get(key)
|
|
if value is None or value == '' or is_sensitive_field(key):
|
|
parts = []
|
|
break
|
|
parts.append(str(value))
|
|
if parts:
|
|
return typing.cast(str, truncate('/'.join(parts), _MAX_RESOURCE_ID_CHARS))
|
|
return None
|
|
|
|
|
|
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 dedupe_material(
|
|
*,
|
|
actor_account_uuid: str | None,
|
|
action: str,
|
|
route: str | None,
|
|
resource_id: str | None,
|
|
) -> str:
|
|
"""Return the raw material the dedupe key hashes.
|
|
|
|
The request path builds this cheap string; the background writer hashes it,
|
|
so enabling tracing never runs a hash on the request path.
|
|
"""
|
|
|
|
return '|'.join(
|
|
[
|
|
str(actor_account_uuid or ''),
|
|
str(action or ''),
|
|
str(route or ''),
|
|
str(resource_id or ''),
|
|
]
|
|
)
|
|
|
|
|
|
def hash_dedupe_material(material: str) -> str:
|
|
"""Hash the raw dedupe material into the stored key."""
|
|
|
|
return hashlib.sha256(material.encode('utf-8')).hexdigest()
|
|
|
|
|
|
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."""
|
|
|
|
return hash_dedupe_material(
|
|
dedupe_material(
|
|
actor_account_uuid=actor_account_uuid,
|
|
action=action,
|
|
route=route,
|
|
resource_id=resource_id,
|
|
)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Service
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WorkspaceSettingsService:
|
|
"""Governance settings + operation traceability for one Workspace."""
|
|
|
|
ap: app.Application
|
|
|
|
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
|
|
# 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] = {}
|
|
# Verification cache keyed by (Workspace UUID, listing filters). The
|
|
# verification result is shareable across whatever narrows the listing,
|
|
# so keying on the whole filter set (not just the Workspace) lets the
|
|
# panel's common views -- "everything" and "mutations only" -- both hit
|
|
# the cache instead of forcing a full rescan on every page turn.
|
|
# Single-writer queue. Trace recording must never run on the request
|
|
# path: when tracing is enabled the WebUI fires a burst of parallel
|
|
# requests (login alone touches a dozen endpoints) and doing several
|
|
# database round trips and a serialized write for each one stalls the
|
|
# whole service. Instead the request path only enqueues a plain dict and
|
|
# returns immediately; one background coroutine drains the queue and
|
|
# writes sequentially. Sequential writes also remove the read-newest-hash
|
|
# race that used to manufacture false "chain broken" reports.
|
|
self._queue: asyncio.Queue[dict[str, typing.Any]] = asyncio.Queue(maxsize=_QUEUE_MAX_SIZE)
|
|
self._writer_task: asyncio.Task[None] | None = None
|
|
# Incremental integrity verification cache, keyed by Workspace UUID. The
|
|
# chain is append-only, so once a prefix is verified it stays valid; a
|
|
# later read only has to verify the rows appended since. Without this the
|
|
# panel re-hashed up to MAX_INTEGRITY_SCAN_ROWS rows on every open,
|
|
# refresh and page turn, which is what made reading the log feel slow.
|
|
self._integrity_cache: dict[str, dict[str, typing.Any]] = {}
|
|
self._dropped_count = 0
|
|
# Set while the queue is empty; lets tests/shutdown await a drain.
|
|
self._idle = asyncio.Event()
|
|
self._idle.set()
|
|
|
|
# -- 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)
|
|
|
|
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(
|
|
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
|
|
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``."""
|
|
|
|
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)
|
|
if resolved_level > OPERATION_LEVEL_NONE:
|
|
self._activate()
|
|
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']
|
|
|
|
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.
|
|
|
|
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:
|
|
"""Queue one operation for traceability. Returns whether it was queued.
|
|
|
|
This method runs on the request path, so it must never touch the
|
|
database or await slow work: it only builds a bounded dict and pushes it
|
|
onto the writer queue. The background writer persists it later. When the
|
|
queue is full (traffic outruns the writer) the oldest pending trace is
|
|
dropped rather than slowing the request down.
|
|
"""
|
|
|
|
if level <= OPERATION_LEVEL_NONE:
|
|
return False
|
|
if not bucket_allows(rule.bucket, level):
|
|
return False
|
|
if level < OPERATION_LEVEL_MUTATION:
|
|
return False
|
|
|
|
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,
|
|
'request_id': str(request_id)[:128] if request_id 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 []),
|
|
'detail': json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str)
|
|
if detail
|
|
else None,
|
|
'client_ip': str(client_ip)[:64] if client_ip else None,
|
|
'user_agent': str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None,
|
|
'duration_ms': max(int(duration_ms), 0),
|
|
}
|
|
# Collapse repeated identical observations (e.g. a WebUI left open
|
|
# polling the same list) so page liveness cannot inflate the log. Only
|
|
# the cheap raw material is built here; the writer hashes it off the
|
|
# request path.
|
|
record_fields['_dedupe_material'] = dedupe_material(
|
|
actor_account_uuid=actor_account_uuid,
|
|
action=rule.action,
|
|
route=record_fields['route'],
|
|
resource_id=record_fields['resource_id'],
|
|
)
|
|
|
|
return self._enqueue(record_fields)
|
|
|
|
# -- background writer ------------------------------------------------
|
|
|
|
def _enqueue(self, record_fields: dict[str, typing.Any]) -> bool:
|
|
"""Push one trace onto the writer queue without blocking the request."""
|
|
|
|
self._ensure_writer()
|
|
self._idle.clear()
|
|
try:
|
|
self._queue.put_nowait(record_fields)
|
|
except asyncio.QueueFull:
|
|
# Drop the oldest pending trace to make room; never block the request.
|
|
try:
|
|
self._queue.get_nowait()
|
|
except asyncio.QueueEmpty: # pragma: no cover - race, harmless
|
|
pass
|
|
self._dropped_count += 1
|
|
if self._dropped_count % 100 == 1:
|
|
self.ap.logger.warning('Operation trace queue full; dropped %s traces so far', self._dropped_count)
|
|
try:
|
|
self._queue.put_nowait(record_fields)
|
|
except asyncio.QueueFull: # pragma: no cover - defensive
|
|
pass
|
|
return True
|
|
|
|
def _ensure_writer(self) -> None:
|
|
"""Start the single-writer task on first use (inside a running loop)."""
|
|
|
|
if self._writer_task is not None and not self._writer_task.done():
|
|
return
|
|
self._writer_task = asyncio.get_running_loop().create_task(self._writer_loop())
|
|
|
|
async def _writer_loop(self) -> None:
|
|
"""Drain the queue and persist traces sequentially.
|
|
|
|
A single writer keeps the hash chain correct without a lock: each row is
|
|
linked to the previously written row, so concurrent request producers can
|
|
never both link to the same predecessor.
|
|
"""
|
|
|
|
while True:
|
|
try:
|
|
record_fields = await self._queue.get()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|
|
batch = [record_fields]
|
|
while True:
|
|
try:
|
|
batch.append(self._queue.get_nowait())
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
|
|
touched: set[str] = set()
|
|
for fields in batch:
|
|
written = await self._persist(fields)
|
|
if written:
|
|
touched.add(fields['workspace_uuid'])
|
|
|
|
for workspace_uuid in touched:
|
|
await self._maybe_enforce_row_budget(workspace_uuid)
|
|
|
|
if self._queue.empty():
|
|
self._idle.set()
|
|
else:
|
|
# More work is already waiting; give the event loop a breath so a
|
|
# sustained burst cannot starve other tasks, then drain again.
|
|
await asyncio.sleep(_WRITER_DRAIN_TIMEOUT)
|
|
|
|
async def _persist(self, record_fields: dict[str, typing.Any]) -> bool:
|
|
"""Persist one queued trace. Never raises (auditing is best effort)."""
|
|
|
|
try:
|
|
workspace_uuid = record_fields['workspace_uuid']
|
|
# Off the request path: hash the dedupe material, resolve the actor
|
|
# display name, then link and insert this row.
|
|
record_fields['dedupe_key'] = hash_dedupe_material(record_fields.pop('_dedupe_material'))
|
|
if record_fields.get('actor_name') is None and record_fields.get('actor_account_uuid'):
|
|
await self._fill_actor_name(record_fields)
|
|
if await self._is_duplicate_observation(workspace_uuid, record_fields['dedupe_key']):
|
|
return False
|
|
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)
|
|
)
|
|
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 _fill_actor_name(self, record_fields: dict[str, typing.Any]) -> None:
|
|
"""Resolve the actor display name off the request path."""
|
|
|
|
if self.ap.user_service is None:
|
|
return
|
|
try:
|
|
account = await self.ap.user_service.get_user_by_uuid(record_fields['actor_account_uuid'])
|
|
if account is not None:
|
|
record_fields['actor_name'] = account.user
|
|
except Exception: # pragma: no cover - defensive
|
|
pass
|
|
|
|
async def flush_pending(self, timeout: float = 5.0) -> None:
|
|
"""Wait for the queue to drain. Used by tests and shutdown paths."""
|
|
|
|
if self._writer_task is None:
|
|
return
|
|
try:
|
|
await asyncio.wait_for(self._idle.wait(), timeout=timeout)
|
|
except asyncio.TimeoutError: # pragma: no cover - defensive
|
|
pass
|
|
|
|
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_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
|
|
|
|
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
|
|
|
|
# Enqueue directly: no database read on the request path. The writer
|
|
# resolves the actor display name and hashes the dedupe material later.
|
|
record_fields: dict[str, typing.Any] = {
|
|
'workspace_uuid': target_workspace,
|
|
'actor_account_uuid': actor_account_uuid,
|
|
'actor_name': None,
|
|
'actor_role': role,
|
|
'principal_type': principal_type,
|
|
'api_key_uuid': api_key_uuid,
|
|
'auth_type': auth_type,
|
|
'request_id': str(request_id)[:128] if request_id else None,
|
|
'http_method': (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': resolved_outcome,
|
|
'status_code': int(status_code) if status_code is not None else None,
|
|
'summary': summary,
|
|
'changes': changes_payload(changes or []),
|
|
'detail': json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str)
|
|
if detail
|
|
else None,
|
|
'client_ip': str(client_ip)[:64] if client_ip else None,
|
|
'user_agent': str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None,
|
|
'duration_ms': max(int(duration_ms), 0),
|
|
'_dedupe_material': dedupe_material(
|
|
actor_account_uuid=actor_account_uuid,
|
|
action=rule.action,
|
|
route=route,
|
|
resource_id=resource_id,
|
|
),
|
|
}
|
|
return self._enqueue(record_fields)
|
|
|
|
# -- 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,
|
|
integrity: str | None = None,
|
|
search: str | None = None,
|
|
) -> dict[str, typing.Any]:
|
|
"""Return one page of operation records plus a Workspace-wide summary.
|
|
|
|
The three verification counters describe the whole filtered history, not
|
|
just the returned page: an operator opening page 2 must still see that
|
|
39 records are tampered. Only ``total`` and ``records`` follow the
|
|
pagination window.
|
|
|
|
``integrity`` optionally narrows the listing to the records that failed
|
|
verification, which lets the panel make its counters actionable instead
|
|
of decorative.
|
|
|
|
``search`` is a case-insensitive substring match over the action,
|
|
resource type, resource id, actor name and summary. It exists so a
|
|
caller can ask a narrow question ("install", a plugin name, an account)
|
|
without pulling the whole history: the exact ``action`` filter compares
|
|
the stored key, so an approximate term like ``install`` would otherwise
|
|
return nothing and force a full dump.
|
|
"""
|
|
|
|
resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
|
|
resolved_offset = max(int(offset or 0), 0)
|
|
resolved_integrity = (integrity or INTEGRITY_FILTER_ALL).strip().lower()
|
|
if resolved_integrity not in _INTEGRITY_FILTERS:
|
|
resolved_integrity = INTEGRITY_FILTER_ALL
|
|
|
|
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 search:
|
|
needle = f'%{str(search).strip()}%'
|
|
filters.append(
|
|
sqlalchemy.or_(
|
|
model.action.ilike(needle),
|
|
model.resource_type.ilike(needle),
|
|
model.resource_id.ilike(needle),
|
|
model.actor_name.ilike(needle),
|
|
model.summary.ilike(needle),
|
|
)
|
|
)
|
|
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)
|
|
|
|
# Verify the filtered history once per request, keyed by the listing
|
|
# filters so a view (all / mutations only / a resource) can reuse an
|
|
# already computed result while still paging. ``integrity`` is not
|
|
# part of the key: it only narrows *which* records are returned, not
|
|
# what the verification finds, so its views share the result too.
|
|
summary = await self._integrity_summary(model, filters, resolved_integrity)
|
|
|
|
# ``total`` follows the active listing filter so the pagination badge
|
|
# and the pager stay consistent with what the operator asked to see.
|
|
visible_filters = list(filters)
|
|
if resolved_integrity == INTEGRITY_FILTER_ISSUES:
|
|
ids = summary['tampered_ids']
|
|
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
|
|
elif resolved_integrity == INTEGRITY_FILTER_HASH_MISMATCH:
|
|
ids = summary['integrity_failed_ids']
|
|
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
|
|
elif resolved_integrity == INTEGRITY_FILTER_CHAIN_BROKEN:
|
|
ids = summary['chain_failed_ids']
|
|
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
|
|
|
|
total_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*visible_filters)
|
|
)
|
|
total = int(total_result.scalar_one_or_none() or 0)
|
|
|
|
rows_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(model)
|
|
.where(*visible_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)
|
|
]
|
|
integrity_summary = summary['summary']
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
self.ap.logger.debug(f'Operation log query skipped: {exc}')
|
|
total = 0
|
|
records = []
|
|
integrity_summary = self._empty_integrity_summary()
|
|
|
|
return {
|
|
'records': records,
|
|
'total': total,
|
|
'limit': resolved_limit,
|
|
'offset': resolved_offset,
|
|
# Report the two failure modes separately so the panel can tell a
|
|
# content edit (integrity) apart from a dropped link (chain) instead
|
|
# of collapsing both into a single "tampered" signal. The counters
|
|
# cover the whole filtered history, not only this page.
|
|
'tampered_count': integrity_summary['tampered'],
|
|
'integrity_failed_count': integrity_summary['integrity_failed'],
|
|
'chain_failed_count': integrity_summary['chain_failed'],
|
|
'scanned_count': integrity_summary['scanned'],
|
|
'scan_truncated': integrity_summary['truncated'],
|
|
'integrity_filter': resolved_integrity,
|
|
}
|
|
|
|
@staticmethod
|
|
def _empty_integrity_summary() -> dict[str, typing.Any]:
|
|
return {
|
|
'tampered': 0,
|
|
'integrity_failed': 0,
|
|
'chain_failed': 0,
|
|
'scanned': 0,
|
|
'truncated': False,
|
|
}
|
|
|
|
def _invalidate_integrity_cache(self, workspace_uuid: str) -> None:
|
|
"""Drop every cached verification for one Workspace.
|
|
|
|
The cache is keyed by ``(Workspace, listing filters)`` so each view has
|
|
its own entry; a deletion invalidates all of them, not just the
|
|
unfiltered one.
|
|
"""
|
|
|
|
for key in [key for key in self._integrity_cache if key[0] == workspace_uuid]:
|
|
self._integrity_cache.pop(key, None)
|
|
|
|
@staticmethod
|
|
def _verified_lists(verified: dict[int, tuple[bool, bool]]) -> tuple[list[int], list[int], list[int]]:
|
|
"""Split a verification map into (tampered, hash-failed, chain-failed) ids."""
|
|
|
|
tampered = [rid for rid, (integ, chain) in verified.items() if not integ or not chain]
|
|
hash_failed = [rid for rid, (integ, _chain) in verified.items() if not integ]
|
|
chain_failed = [rid for rid, (_integ, chain) in verified.items() if not chain]
|
|
return tampered, hash_failed, chain_failed
|
|
|
|
def _cached_integrity_result(self, cached: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
"""Rebuild the summary payload from a cached verification map."""
|
|
|
|
verified: dict[int, tuple[bool, bool]] = cached['verified']
|
|
tampered, hash_failed, chain_failed = self._verified_lists(verified)
|
|
summary = {
|
|
'tampered': len(tampered),
|
|
'integrity_failed': len(hash_failed),
|
|
'chain_failed': len(chain_failed),
|
|
'scanned': min(len(verified), MAX_INTEGRITY_SCAN_ROWS),
|
|
'truncated': cached.get('truncated', False),
|
|
}
|
|
return {
|
|
'summary': summary,
|
|
'tampered_ids': tampered,
|
|
'integrity_failed_ids': hash_failed,
|
|
'chain_failed_ids': chain_failed,
|
|
}
|
|
|
|
async def _integrity_summary(
|
|
self,
|
|
model: typing.Any,
|
|
filters: list[typing.Any],
|
|
integrity_filter: str = INTEGRITY_FILTER_ALL,
|
|
) -> dict[str, typing.Any]:
|
|
"""Verify the filtered history and classify every failing record.
|
|
|
|
A result computed within :data:`INTEGRITY_CACHE_TTL_SECONDS` is served
|
|
from the cache, which is what keeps the panel responsive: opening,
|
|
refreshing and paging all land inside that window and pay nothing. The
|
|
cache key is ``(Workspace, listing filters)`` -- an integrity drill-down
|
|
reuses the very result it is drilling into, and both "everything" and
|
|
"mutations only" are cached independently -- so a cache miss is rare.
|
|
A miss re-verifies the *whole* window from scratch: the scan projects
|
|
only the hash columns and the verifier only computes two booleans, so
|
|
the cost is bounded, and an edit to a row verified on a previous pass is
|
|
still caught.
|
|
"""
|
|
|
|
workspace_uuid = None
|
|
for condition in filters:
|
|
try:
|
|
workspace_uuid = condition.right.value
|
|
except AttributeError:
|
|
continue
|
|
break
|
|
cache_key = (workspace_uuid, tuple(str(condition) for condition in filters))
|
|
cacheable = workspace_uuid is not None
|
|
now = time.monotonic()
|
|
|
|
if cacheable:
|
|
cached = self._integrity_cache.get(cache_key)
|
|
if cached is not None and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS:
|
|
return self._cached_integrity_result(cached)
|
|
|
|
started = time.monotonic()
|
|
try:
|
|
rows_result = await self.ap.persistence_mgr.execute_async(
|
|
_integrity_scan_select(model, *filters).order_by(model.id.desc()).limit(MAX_INTEGRITY_SCAN_ROWS)
|
|
)
|
|
rows = list(rows_result.all())
|
|
|
|
# Baseline for the oldest scanned row: its link points at a row that
|
|
# sits just outside the window. Reads are bounded by
|
|
# ``MAX_INTEGRITY_SCAN_ROWS``, so only a history larger than that
|
|
# window ever needs this extra row.
|
|
previous_row = None
|
|
oldest_id = rows[-1].id if rows else None
|
|
if oldest_id is not None:
|
|
older_result = await self.ap.persistence_mgr.execute_async(
|
|
_integrity_scan_select(model, model.workspace_uuid == rows[0].workspace_uuid, model.id < oldest_id)
|
|
.order_by(model.id.desc())
|
|
.limit(1)
|
|
)
|
|
previous_row = older_result.first()
|
|
|
|
# Every pass re-verifies every scanned row. The map is deliberately
|
|
# not merged with the previous cache: a row verified once is exactly
|
|
# what an attacker would edit afterwards.
|
|
new_verified: dict[int, tuple[bool, bool]] = {}
|
|
for index, row in enumerate(rows):
|
|
predecessor = rows[index + 1] if index + 1 < len(rows) else previous_row
|
|
new_verified[row.id] = self._verify_hash_and_chain(row, predecessor)
|
|
|
|
tampered_ids, integrity_failed_ids, chain_failed_ids = self._verified_lists(new_verified)
|
|
scanned = len(new_verified)
|
|
truncated = scanned >= MAX_INTEGRITY_SCAN_ROWS
|
|
summary = {
|
|
'tampered': len(tampered_ids),
|
|
'integrity_failed': len(integrity_failed_ids),
|
|
'chain_failed': len(chain_failed_ids),
|
|
'scanned': min(scanned, MAX_INTEGRITY_SCAN_ROWS),
|
|
'truncated': truncated,
|
|
}
|
|
result = {
|
|
'summary': summary,
|
|
'tampered_ids': tampered_ids,
|
|
'integrity_failed_ids': integrity_failed_ids,
|
|
'chain_failed_ids': chain_failed_ids,
|
|
}
|
|
if cacheable:
|
|
self._integrity_cache[cache_key] = {
|
|
'verified': new_verified,
|
|
'computed_at': now,
|
|
'truncated': truncated,
|
|
}
|
|
if summary['tampered'] or truncated:
|
|
logger.warning(
|
|
'Operation log integrity scan: %s tampered / %s hash / %s chain over %s rows (truncated=%s)',
|
|
summary['tampered'],
|
|
summary['integrity_failed'],
|
|
summary['chain_failed'],
|
|
summary['scanned'],
|
|
truncated,
|
|
)
|
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
if elapsed_ms >= 250:
|
|
logger.debug(
|
|
'Operation log integrity scan took %sms over %s rows',
|
|
elapsed_ms,
|
|
summary['scanned'],
|
|
)
|
|
return result
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
# Never take the whole log panel down because verification failed:
|
|
# fall back to zeroed counters and an unfiltered listing.
|
|
logger.debug(f'Operation log integrity summary skipped: {exc}')
|
|
return {
|
|
'summary': self._empty_integrity_summary(),
|
|
'tampered_ids': [],
|
|
'integrity_failed_ids': [],
|
|
'chain_failed_ids': [],
|
|
}
|
|
|
|
@staticmethod
|
|
def _verify_hash_and_chain(row: typing.Any, previous_row: typing.Any | None) -> tuple[bool, bool]:
|
|
"""Return ``(integrity_ok, chain_ok)`` for one row without serializing it.
|
|
|
|
The integrity scan runs over up to :data:`MAX_INTEGRITY_SCAN_ROWS` rows
|
|
and only needs the two booleans, so it must not build the full display
|
|
dict -- which decodes the ``changes`` / ``detail`` payloads and formats
|
|
timestamps -- for every scanned record. The hash payload here is exactly
|
|
:data:`_HASH_FIELDS`, the same single source of truth ``_serialize_log``
|
|
uses, so both paths can never drift apart.
|
|
"""
|
|
|
|
hash_payload = {field: getattr(row, field) for field in _HASH_FIELDS}
|
|
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 integrity_ok, chain_ok
|
|
|
|
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 '')
|
|
integrity_ok, chain_ok = self._verify_hash_and_chain(row, previous_row)
|
|
|
|
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))))
|
|
# Cached verification no longer matches the surviving prefix.
|
|
self._invalidate_integrity_cache(workspace_uuid)
|
|
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}')
|
|
|
|
if expired > 0:
|
|
# Age-based retention removed the oldest rows, so any cached
|
|
# verification -- including its boundary baseline link -- no longer
|
|
# describes the surviving chain.
|
|
self._invalidate_integrity_cache(workspace_uuid)
|
|
|
|
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}
|