feat(activity-log): phase 3 - event instrumentation (4 categories)

- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly)
- auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle
- device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect
- capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings
- security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard
- 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
This commit is contained in:
2026-06-09 19:20:57 +03:00
parent 726f39e2ba
commit 25c613c5cb
29 changed files with 3012 additions and 44 deletions
+1
View File
@@ -67,3 +67,4 @@ context (survives across phases; graduates to CLAUDE.md only if it's a lasting p
Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + codec), `AddActivityLogTableMigration` (`002_add_activity_log`) appended to `ALL_MIGRATIONS`, `ActivityLogRepository` (record/query/count/prune/clear/iter_export), 41 new tests — all green. Full suite 2226 passed, 0 failed. Schema and method signatures frozen in phase-1-storage.md Handoff. Gotcha: `Database.execute` takes a positional tuple — use `?` placeholders (not `:name`), otherwise Python 3.14 will raise `ProgrammingError`. Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + codec), `AddActivityLogTableMigration` (`002_add_activity_log`) appended to `ALL_MIGRATIONS`, `ActivityLogRepository` (record/query/count/prune/clear/iter_export), 41 new tests — all green. Full suite 2226 passed, 0 failed. Schema and method signatures frozen in phase-1-storage.md Handoff. Gotcha: `Database.execute` takes a positional tuple — use `?` placeholders (not `:name`), otherwise Python 3.14 will raise `ProgrammingError`.
Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean. Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean.
Phase 3 landed (2026-06-09): instrumented all four categories — entity CRUD via `fire_entity_event` choke-point (`dependencies.py`), auth failures + WS session in `auth.py`, device online/offline in `device_health.py`, device discovered/lost in `discovery_watcher.py`, ADB connect/disconnect in `system_settings.py`, capture start/stop (individual + bulk) in `output_targets_control.py`, scene/playlist/automation activate in their respective route/engine files, backup/restore/delete + restart/shutdown/update/calibration/settings in `backup.py`/`update.py`/`calibration.py`; all 11 entity delete handlers pass `entity_name` to `fire_entity_event`; 22 new tests (security: token never in any field, explicitly asserted) — all green. Full suite 2369 passed, 2 skipped, 0 failed. Ruff clean. Complete (category, action) inventory in phase-3-instrumentation.md Handoff section.
+8 -4
View File
@@ -60,8 +60,8 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
## Phases ## Phases
- [x] Phase 1: Storage — model, migration, repository [domain: data] → [subplan](./phase-1-storage.md) - [x] Phase 1: Storage — model, migration, repository [domain: data] → [subplan](./phase-1-storage.md)
- [ ] Phase 2: Recorder, actor context, retention, lifecycle [domain: backend] → [subplan](./phase-2-recorder-retention.md) - [x] Phase 2: Recorder, actor context, retention, lifecycle [domain: backend] → [subplan](./phase-2-recorder-retention.md)
- [ ] Phase 3: Event instrumentation (4 categories) [domain: backend] → [subplan](./phase-3-instrumentation.md) - [x] Phase 3: Event instrumentation (4 categories) [domain: backend] → [subplan](./phase-3-instrumentation.md)
- [ ] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md) - [ ] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md)
- [ ] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md) - [ ] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md)
- [ ] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md) - [ ] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md)
@@ -81,7 +81,7 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
|-------|--------|--------|--------|-------|-----------| |-------|--------|--------|--------|-------|-----------|
| Phase 1: Storage | data | ✅ Done | ✅ Passed | ✅ Passed | ✅ | | Phase 1: Storage | data | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | | Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 3: Instrumentation | backend | ⬜ Not Started | ⬜ | ⬜ | | | Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | |
| Phase 4: REST API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: REST API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Frontend tab | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Frontend tab | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Dashboard/Settings | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Dashboard/Settings | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
@@ -90,7 +90,11 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
| Phase | Warning | Severity | Status (open / resolved / accepted) | | Phase | Warning | Severity | Status (open / resolved / accepted) |
|-------|---------|----------|-------------------------------------| |-------|---------|----------|-------------------------------------|
| | | | | | 3 | Log injection via unauth mDNS device name/url into audit message | 🟠 High (security) | resolved — `sanitize_display` helper applied |
| 3 | Origin sanitizer missed spaces/NUL/ANSI | 🟠 High (security) | resolved — `sanitize_display` over netloc |
| 3 | Unauth auth-failure audit-write flood (no write-rate bound) | 🟠 High (security) | resolved — per-IP audit-record throttle (10s, capped) |
| 3 | Malformed-IPv6 Origin → urlparse ValueError into WS handler | 🟡 Warning | resolved — try/except guard |
| 3 | Throttle module-global state caused flaky test contamination | 🟡 Warning | resolved — autouse conftest reset fixture |
## Final Review ## Final Review
+84 -20
View File
@@ -1,6 +1,6 @@
# Phase 3: Event instrumentation (4 categories) # Phase 3: Event instrumentation (4 categories)
**Status:** ⬜ Not Started **Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend · 🔒 security-sensitive (security reviewer triggers) **Domain:** backend · 🔒 security-sensitive (security reviewer triggers)
@@ -13,7 +13,7 @@ Maximize coverage via the central `fire_entity_event` choke point; add explicit
## Tasks ## Tasks
### Entity CRUD (via the choke point) ### Entity CRUD (via the choke point)
- [ ] In `api/dependencies.py`, extend `fire_entity_event` to ALSO record an audit entry: - [x] In `api/dependencies.py`, extend `fire_entity_event` to ALSO record an audit entry:
- Signature gains an optional `entity_name: str | None = None`. - Signature gains an optional `entity_name: str | None = None`.
- For `created`/`updated`: if `entity_name` not supplied, best-effort resolve from the - For `created`/`updated`: if `entity_name` not supplied, best-effort resolve from the
matching store in `_deps` keyed by `entity_type` (entity still present). For `deleted`: matching store in `_deps` keyed by `entity_type` (entity still present). For `deleted`:
@@ -22,14 +22,14 @@ Maximize coverage via the central `fire_entity_event` choke point; add explicit
- Map `action` → severity (`info`), category `entity`. Build a human message - Map `action` → severity (`info`), category `entity`. Build a human message
(e.g. `"Target 'Desk' updated"`). Read actor from the ContextVar. (e.g. `"Target 'Desk' updated"`). Read actor from the ContextVar.
- Recording is best-effort (never break the entity operation). - Recording is best-effort (never break the entity operation).
- [ ] Update entity **delete** handlers to pass `entity_name` into `fire_entity_event` - [x] Update entity **delete** handlers to pass `entity_name` into `fire_entity_event`
(the entity object is already loaded for the 404 check). Cover the representative/most-used (the entity object is already loaded for the 404 check). Cover the representative/most-used
entities at minimum: output targets, sync clocks, devices, picture/audio/color-strip entities at minimum: output targets, sync clocks, devices, picture/audio/color-strip
sources, automations, scene presets/playlists, templates, gradients. (Create/update can rely sources, automations, scene presets/playlists, templates, gradients. (Create/update can rely
on hook resolution but pass the name where trivially available.) on hook resolution but pass the name where trivially available.)
### Authentication (DESCOPED: no key create/rotate/revoke — those routes don't exist) ### Authentication (DESCOPED: no key create/rotate/revoke — those routes don't exist)
- [ ] In `api/auth.py`, record: - [x] In `api/auth.py`, record:
- auth **failures**: missing/invalid Bearer token (HTTP), rejected LAN-without-keys, rejected - auth **failures**: missing/invalid Bearer token (HTTP), rejected LAN-without-keys, rejected
WS origin (4403), WS auth handshake failure (4401). Category `auth`, severity `warning`. WS origin (4403), WS auth handshake failure (4401). Category `auth`, severity `warning`.
Include the caller IP/label and the reason in `metadata`**never** the attempted token. Include the caller IP/label and the reason in `metadata`**never** the attempted token.
@@ -38,26 +38,26 @@ Maximize coverage via the central `fire_entity_event` choke point; add explicit
- (Do NOT record per-request HTTP auth *success* — too frequent.) - (Do NOT record per-request HTTP auth *success* — too frequent.)
### Device connect/disconnect (use existing discrete seams) ### Device connect/disconnect (use existing discrete seams)
- [ ] Hook `device_health_changed` (`core/processing/device_health.py`, fired only on - [x] Hook `device_health_changed` (`core/processing/device_health.py`, fired only on
`online != prev_online`) → record online/offline transition. Category `device`, `online != prev_online`) → record online/offline transition. Category `device`,
severity `info` (online) / `warning` (offline). severity `info` (online) / `warning` (offline).
- [ ] Hook `device_discovered` / `device_lost` (`core/devices/discovery_watcher.py`, **runs on - [x] Hook `device_discovered` / `device_lost` (`core/devices/discovery_watcher.py`, **runs on
the zeroconf thread** → recorder must marshal to the loop, which Phase 2 handles). Category the zeroconf thread** → recorder must marshal to the loop, which Phase 2 handles). Category
`device`. `device`.
- [ ] ADB connect/disconnect (`api/routes/system_settings.py:adb_connect/adb_disconnect`). - [x] ADB connect/disconnect (`api/routes/system_settings.py:adb_connect/adb_disconnect`).
### Capture & system events (explicit record calls) ### Capture & system events (explicit record calls)
- [ ] Target processing start/stop + bulk (`api/routes/output_targets_control.py`). - [x] Target processing start/stop + bulk (`api/routes/output_targets_control.py`).
- [ ] Scene activation (`scene_presets.py:activate_scene_preset`), playlist start/stop - [x] Scene activation (`scene_presets.py:activate_scene_preset`), playlist start/stop
(`scene_playlists.py`), automation activate/deactivate (`automation_engine.py`). (`scene_playlists.py`), automation activate/deactivate (`automation_engine.py`).
- [ ] System: backup create/restore/delete (`backup.py`), update apply/dismiss (`update.py`), - [x] System: backup create/restore/delete (`backup.py`), update apply/dismiss (`update.py`),
restart/shutdown (`backup.py`), calibration start/stop/cancel (`calibration.py`). restart/shutdown (`backup.py`), calibration start/stop/cancel (`calibration.py`).
- [ ] Settings changes: scope to high-value settings only (auto-backup, update, shutdown - [x] Settings changes: scope to high-value settings only (auto-backup, update, shutdown
action). **Exclude the activity-log's own `"activity_log"` settings key** to avoid action). **Exclude the activity-log's own `"activity_log"` settings key** to avoid
self-referential churn. self-referential churn.
### Tests ### Tests
- [ ] `server/tests/test_activity_instrumentation.py` (or per-area): - [x] `server/tests/test_activity_instrumentation.py` (or per-area):
- representative entity create/update/delete produces a record with correct category/actor/ - representative entity create/update/delete produces a record with correct category/actor/
name (incl. a delete carrying its name); name (incl. a delete carrying its name);
- an auth failure produces a `warning` record and the token never appears in any field; - an auth failure produces a `warning` record and the token never appears in any field;
@@ -95,14 +95,78 @@ Maximize coverage via the central `fire_entity_event` choke point; add explicit
## Review Checklist ## Review Checklist
- [ ] All tasks completed - [x] All tasks completed
- [ ] Code follows project conventions - [x] Code follows project conventions
- [ ] No unintended side effects (audited actions still succeed on recorder failure) - [x] No unintended side effects (audited actions still succeed on recorder failure)
- [ ] No secrets logged (token never recorded) — explicitly verified - [x] No secrets logged (token never recorded) — explicitly verified
- [ ] Build passes (ruff + pytest) - [x] Build passes (ruff + pytest)
- [ ] Tests pass (new + existing) - [x] Tests pass (new + existing)
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Filled in by the implementer: list of categories/actions actually emitted and their Phase 3 is complete. The following (category, action) pairs are now emitted, along with their
metadata keys, so Phase 4 filter options and Phase 5 quick-filter presets match reality. --> metadata keys, for Phase 4 to expose via query/filter and for Phase 5 quick-filter presets.
### `entity` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `entity.created` | info | — | All entity types via `fire_entity_event` choke-point |
| `entity.updated` | info | — | All entity types; name resolved from store when not passed |
| `entity.deleted` | info | — | Name passed explicitly by delete handler before deletion |
### `auth` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `auth.rejected` | warning | `reason` (str), `client` (str/IP) | Missing Bearer, invalid Bearer, LAN-no-keys, WS origin, WS auth timeout, invalid WS token |
| `auth.ws_connected` | info | `client` (str/IP) | Successful WS session established |
### `device` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `device.online` | info | `latency_ms` (float) | Health monitor, transition only |
| `device.offline` | warning | `latency_ms` (float) | Health monitor, transition only |
| `device.discovered` | info | `url` (str), `device_type` (str) | Zeroconf discovery thread; recorder marshals to loop |
| `device.lost` | warning | `url` (str), `device_type` (str) | Zeroconf discovery thread |
| `device.adb_connected` | info | `address` (str) | ADB route success |
| `device.adb_disconnected` | info | `address` (str) | ADB route success |
### `capture` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `capture.started` | info | — | Per target (individual + bulk) |
| `capture.stopped` | info | — | Per target (individual + bulk) |
| `scene.activated` | info | — | `scene_presets.py:activate_scene_preset` |
| `playlist.started` | info | — | `scene_playlists.py:start_scene_playlist` |
| `playlist.stopped` | info | — | `scene_playlists.py:stop_scene_playlist` |
| `automation.activated` | info | — | `automation_engine.py:_activate_automation`; actor="system" |
| `automation.deactivated` | info | — | `automation_engine.py:_deactivate_automation`; actor="system" |
### `system` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `backup.created` | info | `filename` (str) | `backup.py:backup_config` |
| `backup.restored` | info | — | `backup.py:restore_config` |
| `backup.deleted` | info | `filename` (str) | `backup.py:delete_saved_backup` |
| `server.restarting` | info | — | `backup.py:restart_server` |
| `server.shutdown_requested` | info | — | `backup.py:shutdown_server` |
| `update.dismissed` | info | `version` (str) | `update.py:dismiss_update` |
| `update.applied` | info | `version` (str) | `update.py:apply_update` |
| `settings.changed` | info | `setting_key` (str) + setting-specific keys | `setting_key` values: `"auto_backup"`, `"update"`, `"shutdown_action"`. Activity-log own key excluded. |
| `calibration.started` | info | — | `calibration.py`; entity_type="device", entity_id=device_id |
| `calibration.stopped` | info | — | `calibration.py` |
| `calibration.cancelled` | info | — | `calibration.py` |
### Implementation notes for Phase 4
- The `metadata` field is a JSON `TEXT` column. All keys above are scalars (str, float).
- Phase 4 filter `metadata_key` / `metadata_value` lookup, if added, can target `setting_key`
for settings-change filtering.
- `entity_type` is populated for entity CRUD and `calibration.started`. For auth/system/capture
events `entity_type` may be None.
- `entity_name` is always populated for `entity.deleted`; populated for CRUD create/update
when resolved; populated for most capture/system events where a name is meaningful.
+119 -2
View File
@@ -3,7 +3,9 @@
import asyncio import asyncio
import json import json
import secrets import secrets
import time
from typing import Annotated from typing import Annotated
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Request, Security, status from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -11,11 +13,101 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.core.activity_log.context import current_actor from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__) logger = get_logger(__name__)
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
#
# Unauthenticated callers can hammer any auth path; without a recording
# throttle each attempt would write one SQLite row AND broadcast one WS event,
# providing a cheap disk/broadcast amplification vector.
#
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry
_auth_record_last: dict[str, float] = {}
def _should_record_auth_failure(client_ip: str) -> bool:
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
"""
now = time.monotonic()
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
_auth_record_last[client_ip] = now
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
"""Best-effort: record an auth failure audit entry (never raises).
SECURITY: the attempted token is NEVER passed here; only the reason and
the caller's IP/label are recorded.
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
"""
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
"""Best-effort: record a successful WebSocket session establishment."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.ws_connected",
severity=ActivitySeverity.INFO,
actor=label,
message=f"WebSocket session established by '{label}'",
metadata={"client": client_host or "unknown"},
)
# Security scheme for Bearer token # Security scheme for Bearer token
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@@ -87,6 +179,7 @@ def verify_api_key(
# Allow caller to authenticate explicitly even without configured keys? # Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject. # No — there are no keys to compare against. Reject.
logger.warning("Rejected LAN request from %s: no API key configured", client_host) logger.warning("Rejected LAN request from %s: no API key configured", client_host)
_record_auth_failure("LAN access rejected: no API key configured", client_host)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=( detail=(
@@ -99,13 +192,14 @@ def verify_api_key(
# Check if credentials are provided # Check if credentials are provided
if not credentials: if not credentials:
logger.warning("Request missing Authorization header") logger.warning("Request missing Authorization header")
_record_auth_failure("missing Bearer token", client_host)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required", detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Extract token # Extract token — NEVER log or record the token value itself.
token = credentials.credentials token = credentials.credentials
# Find matching key and return its label using constant-time comparison # Find matching key and return its label using constant-time comparison
@@ -117,6 +211,7 @@ def verify_api_key(
if not authenticated_as: if not authenticated_as:
logger.warning("Invalid API key attempt") logger.warning("Invalid API key attempt")
_record_auth_failure("invalid Bearer token", client_host)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key", detail="Invalid API key",
@@ -195,12 +290,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
# a strong signal even before the token check. Non-browser clients # a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake. # legitimately omit Origin; those fall through to the auth handshake.
config = get_config() config = get_config()
client_host = websocket.client.host if websocket.client else None
origin = websocket.headers.get("origin") origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins): if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning( logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)", "Rejected WebSocket from origin %r (not in cors_origins)",
origin, origin,
) )
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
# error in Python's urlsplit on malformed netloc).
_safe_origin_raw = sanitize_display(origin) if origin else ""
try:
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
except ValueError:
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
# fall back to the raw origin string, which could carry query params
# or path components containing secrets.
_netloc = ""
_safe_origin = sanitize_display(_netloc or "unknown")
_record_auth_failure(
f"WebSocket origin rejected: {_safe_origin!r}",
client_host,
)
try: try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE) await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC: except _WS_SEND_BENIGN_EXC:
@@ -215,6 +328,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
except _WS_SEND_BENIGN_EXC: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
_record_ws_auth_success(label, client_host)
return label return label
@@ -280,6 +394,7 @@ async def verify_ws_auth(
return None return None
return "anonymous" return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host) logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
_record_auth_failure("WebSocket auth timeout", client_host)
try: try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"}) await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except _WS_SEND_BENIGN_EXC: except _WS_SEND_BENIGN_EXC:
@@ -337,6 +452,7 @@ async def verify_ws_auth(
await websocket.send_json({"type": "auth_ok"}) await websocket.send_json({"type": "auth_ok"})
return "anonymous" return "anonymous"
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host) logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
try: try:
await websocket.send_json( await websocket.send_json(
{ {
@@ -348,10 +464,11 @@ async def verify_ws_auth(
pass pass
return None return None
# Keys configured: require a matching token. # Keys configured: require a matching token. NEVER log the token value.
label = _match_api_key(token or "") label = _match_api_key(token or "")
if not label: if not label:
logger.warning("Invalid WebSocket auth attempt from %s", client_host) logger.warning("Invalid WebSocket auth attempt from %s", client_host)
_record_auth_failure("invalid WebSocket token", client_host)
try: try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"}) await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except _WS_SEND_BENIGN_EXC: except _WS_SEND_BENIGN_EXC:
+92 -3
View File
@@ -42,8 +42,10 @@ from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.core.activity_log.recorder import ActivityRecorder from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository from ledgrab.storage.activity_log_repository import ActivityLogRepository
T = TypeVar("T") T = TypeVar("T")
@@ -214,13 +216,68 @@ def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
# ── Event helper ──────────────────────────────────────────────────────── # ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Fire an entity_changed event via the ProcessorManager event bus. """Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
# Map entity_type → (_deps key, method name on the store)
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
store_key, method_name = entry
store = _deps.get(store_key)
if store is None:
return None
try:
obj = getattr(store, method_name)(entity_id)
if obj is not None:
return getattr(obj, "name", None)
except Exception:
pass
return None
def fire_entity_event(
entity_type: str,
action: str,
entity_id: str,
entity_name: str | None = None,
) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus and
record an audit entry.
Args: Args:
entity_type: e.g. "device", "output_target", "color_strip_source" entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted" action: "created", "updated", or "deleted"
entity_id: The entity's unique ID entity_id: The entity's unique ID
entity_name: Human-readable name. For deletes: **must** be passed
explicitly (entity is already gone when we get here).
For create/update: resolved from the store when not supplied.
""" """
pm = _deps.get("processor_manager") pm = _deps.get("processor_manager")
if pm is not None: if pm is not None:
@@ -233,6 +290,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
} }
) )
# ── Audit record (best-effort) ──────────────────────────────────────────
rec = get_module_recorder()
if rec is None:
return
# Resolve name when not explicitly provided (create / update paths).
# For deleted: entity already gone — rely on the explicitly passed name.
resolved_name = entity_name
if resolved_name is None and action != "deleted":
resolved_name = _resolve_entity_name(entity_type, entity_id)
# Build a concise human message.
# Sanitize the display name before interpolating into the free-text message
# (user-authored names hit the CSV/export trust surface).
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
action, action
)
entity_label = entity_type.replace("_", " ")
message = f"{entity_label.capitalize()} {display_name} {action_word}"
rec.record(
category=ActivityCategory.ENTITY,
action=f"entity.{action}",
severity=ActivitySeverity.INFO,
entity_type=entity_type,
entity_id=entity_id,
entity_name=resolved_name,
message=message,
)
# ── Initialization ────────────────────────────────────────────────────── # ── Initialization ──────────────────────────────────────────────────────
@@ -182,6 +182,12 @@ async def delete_audio_source(
css_store: ColorStripStore = Depends(get_color_strip_store), css_store: ColorStripStore = Depends(get_color_strip_store),
): ):
"""Delete an audio source.""" """Delete an audio source."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try: try:
# Check if any CSS entities reference this audio source # Check if any CSS entities reference this audio source
from ledgrab.storage.color_strip_source import AudioColorStripSource from ledgrab.storage.color_strip_source import AudioColorStripSource
@@ -194,7 +200,7 @@ async def delete_audio_source(
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'") raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id) fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
+7 -1
View File
@@ -329,6 +329,12 @@ async def delete_automation(
engine: AutomationEngine = Depends(get_automation_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""Delete an automation.""" """Delete an automation."""
_entity_name: str | None = None
try:
_entity_name = store.get_automation(automation_id).name
except Exception:
pass
# Deactivate first # Deactivate first
await engine.deactivate_if_active(automation_id) await engine.deactivate_if_active(automation_id)
@@ -337,7 +343,7 @@ async def delete_automation(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id) fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
# ===== Enable/Disable ===== # ===== Enable/Disable =====
+30 -1
View File
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
) )
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger, read_upload_capped from ledgrab.utils import get_logger, read_upload_capped
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
"""Best-effort audit record for a system-level event."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata=metadata or {},
)
_SERVER_DIR = Path(__file__).resolve().parents[4] _SERVER_DIR = Path(__file__).resolve().parents[4]
@@ -143,6 +160,8 @@ def backup_config(
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip" filename = f"ledgrab-backup-{timestamp}.zip"
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
return StreamingResponse( return StreamingResponse(
zip_buffer, zip_buffer,
media_type="application/zip", media_type="application/zip",
@@ -243,6 +262,7 @@ async def restore_config(
freeze_writes() freeze_writes()
logger.info("Database restored from uploaded backup. Scheduling restart...") logger.info("Database restored from uploaded backup. Scheduling restart...")
_record_system("backup.restored", "Database restored from uploaded backup")
_schedule_restart() _schedule_restart()
return RestoreResponse( return RestoreResponse(
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately.""" """Schedule a server restart and return immediately."""
from ledgrab.server_ref import _broadcast_restarting from ledgrab.server_ref import _broadcast_restarting
_record_system("server.restarting", "Server restart requested by user")
_broadcast_restarting() _broadcast_restarting()
_schedule_restart() _schedule_restart()
return {"status": "restarting"} return {"status": "restarting"}
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server.""" """Gracefully shut down the server."""
from ledgrab.server_ref import request_shutdown from ledgrab.server_ref import request_shutdown
_record_system("server.shutdown_requested", "Server shutdown requested by user")
request_shutdown() request_shutdown()
return {"status": "shutting_down"} return {"status": "shutting_down"}
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
engine: AutoBackupEngine = Depends(get_auto_backup_engine), engine: AutoBackupEngine = Depends(get_auto_backup_engine),
): ):
"""Update auto-backup settings (enable/disable, interval, max backups).""" """Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings( result = await engine.update_settings(
enabled=body.enabled, enabled=body.enabled,
interval_hours=body.interval_hours, interval_hours=body.interval_hours,
max_backups=body.max_backups, max_backups=body.max_backups,
) )
_record_system(
"settings.changed",
f"Auto-backup settings updated (enabled={body.enabled})",
{"setting_key": "auto_backup", "enabled": body.enabled},
)
return result
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"]) @router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
@@ -365,4 +393,5 @@ async def delete_saved_backup(
engine.delete_backup(filename) engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e: except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
return {"status": "deleted", "filename": filename} return {"status": "deleted", "filename": filename}
@@ -36,6 +36,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_processor_manager from ledgrab.api.dependencies import get_processor_manager
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.calibration import ( from ledgrab.api.schemas.calibration import (
CalibrationSessionPositionRequest, CalibrationSessionPositionRequest,
CalibrationSessionStartRequest, CalibrationSessionStartRequest,
@@ -81,6 +82,19 @@ async def start_calibration_session(
logger.error("Failed to start calibration session: %s", exc, exc_info=True) logger.error("Failed to start calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.started",
severity=ActivitySeverity.INFO,
entity_type="device",
entity_id=body.device_id,
message=f"Calibration session started for device '{body.device_id}'",
)
return CalibrationSessionStateResponse(**session.get_state()) return CalibrationSessionStateResponse(**session.get_state())
@@ -135,6 +149,17 @@ async def stop_calibration_session(
logger.error("Failed to stop calibration session: %s", exc, exc_info=True) logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.stopped",
severity=ActivitySeverity.INFO,
message="Calibration session stopped",
)
return CalibrationSessionStateResponse(**session.get_state()) return CalibrationSessionStateResponse(**session.get_state())
@@ -155,6 +180,17 @@ async def cancel_calibration_session(
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True) logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.cancelled",
severity=ActivitySeverity.INFO,
message="Calibration session cancelled",
)
return CalibrationSessionStateResponse(**session.get_state()) return CalibrationSessionStateResponse(**session.get_state())
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
): ):
"""Delete a color strip source. Returns 409 if referenced by any LED target.""" """Delete a color strip source. Returns 409 if referenced by any LED target."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try: try:
target_names = target_store.get_targets_referencing_css(source_id) target_names = target_store.get_targets_referencing_css(source_id)
if target_names: if target_names:
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
"Delete or reassign the processed source(s) first.", "Delete or reassign the processed source(s) first.",
) )
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id) fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
+8 -1
View File
@@ -701,6 +701,13 @@ async def delete_device(
): ):
"""Delete/detach a device. Returns 409 if referenced by a target.""" """Delete/detach a device. Returns 409 if referenced by a target."""
try: try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = store.get_device(device_id).name
except Exception:
pass
# Check if any target references this device # Check if any target references this device
refs = target_store.get_targets_for_device(device_id) refs = target_store.get_targets_for_device(device_id)
if refs: if refs:
@@ -728,7 +735,7 @@ async def delete_device(
# Delete from storage # Delete from storage
store.delete_device(device_id) store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id) fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
logger.info(f"Deleted device {device_id}") logger.info(f"Deleted device {device_id}")
except HTTPException: except HTTPException:
+7 -1
View File
@@ -152,13 +152,19 @@ async def delete_gradient(
css_store: ColorStripStore = Depends(get_color_strip_store), css_store: ColorStripStore = Depends(get_color_strip_store),
): ):
"""Delete a gradient (fails if built-in or referenced by sources).""" """Delete a gradient (fails if built-in or referenced by sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_gradient(gradient_id).name
except Exception:
pass
try: try:
# Check references # Check references
for source in css_store.get_all_sources(): for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id: if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'") raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id) store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id) fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
except (ValueError, EntityNotFoundError) as e: except (ValueError, EntityNotFoundError) as e:
status = 404 if "not found" in str(e).lower() else 400 status = 404 if "not found" in str(e).lower() else 400
raise HTTPException(status_code=status, detail=str(e)) raise HTTPException(status_code=status, detail=str(e))
@@ -624,6 +624,13 @@ async def delete_target(
): ):
"""Delete a output target. Stops processing first if active.""" """Delete a output target. Stops processing first if active."""
try: try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = target_store.get_target(target_id).name
except Exception:
pass
# Stop processing if running # Stop processing if running
try: try:
await manager.stop_processing(target_id) await manager.stop_processing(target_id)
@@ -641,7 +648,7 @@ async def delete_target(
# Delete from store # Delete from store
target_store.delete_target(target_id) target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id) fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
logger.info(f"Deleted target {target_id}") logger.info(f"Deleted target {target_id}")
except ValueError as e: except ValueError as e:
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
get_picture_source_store, get_picture_source_store,
get_processor_manager, get_processor_manager,
) )
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.output_targets import ( from ledgrab.api.schemas.output_targets import (
BulkTargetRequest, BulkTargetRequest,
BulkTargetResponse, BulkTargetResponse,
@@ -35,6 +36,23 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
"""Best-effort audit record for a capture start/stop action."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action=action,
severity=ActivitySeverity.INFO,
entity_type="output_target",
entity_id=target_id,
entity_name=target_name,
message=message,
)
# ===== BULK PROCESSING CONTROL ENDPOINTS ===== # ===== BULK PROCESSING CONTROL ENDPOINTS =====
@@ -53,10 +71,16 @@ async def bulk_start_processing(
for target_id in body.ids: for target_id in body.ids:
try: try:
target_store.get_target(target_id) _tgt = target_store.get_target(target_id)
await manager.start_processing(target_id) await manager.start_processing(target_id)
started.append(target_id) started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}") logger.info(f"Bulk start: started processing for target {target_id}")
_record_capture(
"capture.started",
target_id,
getattr(_tgt, "name", None),
f"Capture started for target '{getattr(_tgt, 'name', target_id)}' (bulk)",
)
except ValueError as e: except ValueError as e:
errors[target_id] = str(e) errors[target_id] = str(e)
except RuntimeError as e: except RuntimeError as e:
@@ -78,6 +102,7 @@ async def bulk_start_processing(
async def bulk_stop_processing( async def bulk_stop_processing(
body: BulkTargetRequest, body: BulkTargetRequest,
_auth: AuthRequired, _auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors.""" """Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
@@ -89,6 +114,17 @@ async def bulk_stop_processing(
await manager.stop_processing(target_id) await manager.stop_processing(target_id)
stopped.append(target_id) stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}") logger.info(f"Bulk stop: stopped processing for target {target_id}")
_tgt_name: str | None = None
try:
_tgt_name = target_store.get_target(target_id).name
except Exception:
pass
_record_capture(
"capture.stopped",
target_id,
_tgt_name,
f"Capture stopped for target '{_tgt_name or target_id}' (bulk)",
)
except ValueError as e: except ValueError as e:
errors[target_id] = str(e) errors[target_id] = str(e)
except Exception as e: except Exception as e:
@@ -112,11 +148,17 @@ async def start_processing(
logger.info("Start processing requested for target %s", target_id) logger.info("Start processing requested for target %s", target_id)
try: try:
# Verify target exists in store # Verify target exists in store
target_store.get_target(target_id) target = target_store.get_target(target_id)
await manager.start_processing(target_id) await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}") logger.info(f"Started processing for target {target_id}")
_record_capture(
"capture.started",
target_id,
getattr(target, "name", None),
f"Capture started for target '{getattr(target, 'name', target_id)}'",
)
return {"status": "started", "target_id": target_id} return {"status": "started", "target_id": target_id}
except ValueError as e: except ValueError as e:
@@ -137,6 +179,7 @@ async def start_processing(
async def stop_processing( async def stop_processing(
target_id: str, target_id: str,
_auth: AuthRequired, _auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Stop processing for a output target.""" """Stop processing for a output target."""
@@ -144,6 +187,17 @@ async def stop_processing(
await manager.stop_processing(target_id) await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}") logger.info(f"Stopped processing for target {target_id}")
_target_name: str | None = None
try:
_target_name = target_store.get_target(target_id).name
except Exception:
pass
_record_capture(
"capture.stopped",
target_id,
_target_name,
f"Capture stopped for target '{_target_name or target_id}'",
)
return {"status": "stopped", "target_id": target_id} return {"status": "stopped", "target_id": target_id}
except ValueError as e: except ValueError as e:
@@ -374,6 +374,12 @@ async def delete_picture_source(
css_store: ColorStripStore = Depends(get_color_strip_store), css_store: ColorStripStore = Depends(get_color_strip_store),
): ):
"""Delete a picture source.""" """Delete a picture source."""
_entity_name: str | None = None
try:
_entity_name = store.get_stream(stream_id).name
except Exception:
pass
try: try:
# Check if any target transitively references this stream via a CSS # Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store) target_names = store.get_targets_referencing(stream_id, target_store, css_store)
@@ -395,7 +401,7 @@ async def delete_picture_source(
f"{css_names}. Please reassign or delete those first.", f"{css_names}. Please reassign or delete those first.",
) )
store.delete_stream(stream_id) store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id) fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e: except EntityNotFoundError as e:
@@ -220,13 +220,19 @@ async def delete_scene_playlist(
engine: PlaylistEngine = Depends(get_playlist_engine), engine: PlaylistEngine = Depends(get_playlist_engine),
): ):
"""Delete a scene playlist (stops it first if it is currently cycling).""" """Delete a scene playlist (stops it first if it is currently cycling)."""
_entity_name: str | None = None
try:
_entity_name = store.get_playlist(playlist_id).name
except Exception:
pass
try: try:
store.delete_playlist(playlist_id) store.delete_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e: except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
await engine.stop_if_running(playlist_id) await engine.stop_if_running(playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id) fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
# ===== Cycling control ===== # ===== Cycling control =====
@@ -255,6 +261,27 @@ async def start_scene_playlist(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "updated", playlist_id) fire_entity_event("scene_playlist", "updated", playlist_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_pl_name: str | None = None
try:
_pl_name = store.get_playlist(playlist_id).name
except Exception:
pass
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.started",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=playlist_id,
entity_name=_pl_name,
message=f"Playlist '{_pl_name or playlist_id}' started",
)
return PlaylistRuntimeStateSchema(**engine.get_state()) return PlaylistRuntimeStateSchema(**engine.get_state())
@@ -265,11 +292,34 @@ async def start_scene_playlist(
) )
async def stop_scene_playlist( async def stop_scene_playlist(
_auth: AuthRequired, _auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine), engine: PlaylistEngine = Depends(get_playlist_engine),
): ):
"""Stop the active playlist (leaves the last applied scene in place).""" """Stop the active playlist (leaves the last applied scene in place)."""
stopped_id = engine.get_running_playlist_id() stopped_id = engine.get_running_playlist_id()
_stopped_name: str | None = None
if stopped_id:
try:
_stopped_name = store.get_playlist(stopped_id).name
except Exception:
pass
await engine.stop() await engine.stop()
if stopped_id: if stopped_id:
fire_entity_event("scene_playlist", "updated", stopped_id) fire_entity_event("scene_playlist", "updated", stopped_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.stopped",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=stopped_id,
entity_name=_stopped_name,
message=f"Playlist '{_stopped_name or stopped_id}' stopped",
)
return PlaylistRuntimeStateSchema(**engine.get_state()) return PlaylistRuntimeStateSchema(**engine.get_state())
+23 -1
View File
@@ -208,12 +208,18 @@ async def delete_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
): ):
"""Delete a scene preset.""" """Delete a scene preset."""
_entity_name: str | None = None
try:
_entity_name = store.get_preset(preset_id).name
except Exception:
pass
try: try:
store.delete_preset(preset_id) store.delete_preset(preset_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id) fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name)
# ===== Recapture ===== # ===== Recapture =====
@@ -282,4 +288,20 @@ async def activate_scene_preset(
logger.info(f"Scene preset '{preset.name}' activated successfully") logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id) fire_entity_event("scene_preset", "updated", preset_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action="scene.activated",
severity=ActivitySeverity.INFO,
entity_type="scene_preset",
entity_id=preset_id,
entity_name=preset.name,
message=f"Scene preset '{preset.name}' activated",
)
return ActivateResponse(status=status, errors=errors) return ActivateResponse(status=status, errors=errors)
+7 -1
View File
@@ -149,6 +149,12 @@ async def delete_sync_clock(
manager: SyncClockManager = Depends(get_sync_clock_manager), manager: SyncClockManager = Depends(get_sync_clock_manager),
): ):
"""Delete a synchronization clock (fails if referenced by CSS or value sources).""" """Delete a synchronization clock (fails if referenced by CSS or value sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_clock(clock_id).name
except Exception:
pass
try: try:
# Check references # Check references
for source in css_store.get_all_sources(): for source in css_store.get_all_sources():
@@ -159,7 +165,7 @@ async def delete_sync_clock(
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'") raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id) fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
ShutdownActionRequest, ShutdownActionRequest,
ShutdownActionResponse, ShutdownActionResponse,
) )
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
def _record_setting(action: str, key: str, message: str) -> None:
"""Best-effort audit record for a high-value settings change."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata={"setting_key": key},
)
router = APIRouter() router = APIRouter()
@@ -117,6 +135,11 @@ async def update_shutdown_action(
"""Set what happens to LED targets when the server shuts down.""" """Set what happens to LED targets when the server shuts down."""
db.set_setting("shutdown_action", {"action": body.action}) db.set_setting("shutdown_action", {"action": body.action})
logger.info("Shutdown action updated: %s", body.action) logger.info("Shutdown action updated: %s", body.action)
_record_setting(
"settings.changed",
"shutdown_action",
f"Shutdown action set to '{body.action}'",
)
return ShutdownActionResponse(action=body.action) return ShutdownActionResponse(action=body.action)
@@ -246,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip() output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower(): if "connected" in output.lower():
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_connected",
severity=ActivitySeverity.INFO,
message=f"ADB device connected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "connected", "address": address, "message": output} return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed") raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError: except FileNotFoundError:
@@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_disconnected",
severity=ActivitySeverity.INFO,
message=f"ADB device disconnected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "disconnected", "message": stdout.decode().strip()} return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH") raise HTTPException(status_code=500, detail="adb not found on PATH")
+7 -1
View File
@@ -183,6 +183,12 @@ async def delete_template(
Validates that no streams are currently using this template before deletion. Validates that no streams are currently using this template before deletion.
""" """
_entity_name: str | None = None
try:
_entity_name = template_store.get_template(template_id).name
except Exception:
pass
try: try:
# Check if any streams are using this template # Check if any streams are using this template
streams_using_template = [] streams_using_template = []
@@ -203,7 +209,7 @@ async def delete_template(
# Proceed with deletion # Proceed with deletion
template_store.delete_template(template_id) template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id) fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name)
except HTTPException: except HTTPException:
raise # Re-raise HTTP exceptions as-is raise # Re-raise HTTP exceptions as-is
+37 -1
View File
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
UpdateStatusResponse, UpdateStatusResponse,
) )
from ledgrab.core.update.update_service import UpdateService from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -42,6 +43,17 @@ async def dismiss_update(
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
service.dismiss(body.version) service.dismiss(body.version)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="update.dismissed",
severity=ActivitySeverity.INFO,
message=f"Update dismissed: {body.version}",
metadata={"version": body.version},
)
return {"ok": True} return {"ok": True}
@@ -63,6 +75,18 @@ async def apply_update(
) )
try: try:
await service.apply_update() await service.apply_update()
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
version = status.get("available_version", "unknown")
rec.record(
category=ActivityCategory.SYSTEM,
action="update.applied",
severity=ActivitySeverity.INFO,
message=f"Update applied: {version}",
metadata={"version": version},
)
return {"ok": True, "message": "Update applied, server shutting down"} return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc: except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True) logger.error("Failed to apply update: %s", exc, exc_info=True)
@@ -83,8 +107,20 @@ async def update_update_settings(
body: UpdateSettingsRequest, body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
return await service.update_settings( result = await service.update_settings(
enabled=body.enabled, enabled=body.enabled,
check_interval_hours=body.check_interval_hours, check_interval_hours=body.check_interval_hours,
include_prerelease=body.include_prerelease, include_prerelease=body.include_prerelease,
) )
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="settings.changed",
severity=ActivitySeverity.INFO,
message=f"Update settings changed (enabled={body.enabled})",
metadata={"setting_key": "update", "enabled": body.enabled},
)
return result
@@ -15,9 +15,11 @@ Phase 3 adds the instrumentation call sites.
from ledgrab.core.activity_log.context import current_actor from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
__all__ = [ __all__ = [
"ActivityRecorder", "ActivityRecorder",
"ActivityLogRetentionEngine", "ActivityLogRetentionEngine",
"current_actor", "current_actor",
"sanitize_display",
] ]
@@ -0,0 +1,82 @@
"""Log-injection sanitizer for audit-log message and display strings.
Provides :func:`sanitize_display` a dependency-free helper that strips
characters that should not appear in a recorded ``message`` or display
string before it is persisted to SQLite, broadcast over WebSocket, or
exported to CSV.
Design constraints
------------------
- **Dependency-free**: uses only the Python standard library so it can be
imported from any module without adding transitive weight.
- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops
everything else including control chars (NUL, BEL, BS, VT, FF, ESC,
DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns /
newlines / tabs which are the classic log-injection primitives.
- **Length-capped**: truncates to *maxlen* characters and appends ``""``
so callers can rely on a bounded string without adding their own guards.
"""
from __future__ import annotations
import re
# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms.
# We strip these before the printable-char filter so the bracket/letters that
# follow the ESC don't survive stripping the ESC alone.
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
# Characters we explicitly want to remove even if str.isprintable() would
# let them through in some edge-case: NUL is the canonical SQL/log null-byte
# injection; the others are kept out by the printable check but listed here
# for documentation clarity.
_EXPLICIT_DROP = frozenset("\x00\r\n\t")
def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
"""Return a sanitized, length-capped version of *value* safe for log messages.
Parameters
----------
value:
The raw, potentially attacker-controlled string. ``None`` or empty
returns ``""``.
maxlen:
Maximum length of the returned string (default: 120). If the input
exceeds this length after sanitization, the string is truncated and
``""`` is appended (the ellipsis counts toward *maxlen*).
Returns
-------
str
A string that:
- contains no NUL bytes (``\\x00``),
- contains no ANSI/CSI escape sequences,
- contains no carriage returns, newlines, or tab characters,
- contains only characters for which ``str.isprintable()`` is ``True``
plus the regular ASCII space (``\\x20``),
- is at most *maxlen* characters long.
"""
if not value:
return ""
# 1. Strip ANSI escape sequences first so their bracket/letter tails don't
# survive as stray printable characters.
cleaned = _ANSI_RE.sub("", value)
# 2. Drop each character that is neither printable nor a plain space.
# str.isprintable() returns False for all control chars (including NUL,
# BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters,
# digits, punctuation, and the space character.
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ")
# 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL
# that may survive if isprintable ever changes in a future Python version).
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
# 4. Cap length.
if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -726,6 +726,26 @@ class AutomationEngine:
else: else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)") logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=automation.name,
message=f"Automation '{automation.name}' activated",
)
except Exception:
pass
async def _deactivate_automation(self, automation_id: str) -> None: async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False) was_active = self._active_automations.pop(automation_id, False)
if not was_active: if not was_active:
@@ -751,6 +771,31 @@ class AutomationEngine:
# Clean up any leftover snapshot # Clean up any leftover snapshot
self._pre_activation_snapshots.pop(automation_id, None) self._pre_activation_snapshots.pop(automation_id, None)
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.deactivated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation_id,
entity_name=_auto_name,
message=f"Automation '{_auto_name or automation_id}' deactivated",
)
except Exception:
pass
async def _deactivate_revert(self, automation_id: str) -> None: async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot.""" """Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None) snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -36,6 +36,7 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon
from ledgrab.core.devices.serial_transport import list_serial_ports from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android from ledgrab.utils.platform import is_android
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
) )
except Exception as e: except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e) logger.debug("Discovery watcher: fire_event failed: %s", e)
# Audit record — best-effort, thread-safe (recorder marshals via
# call_soon_threadsafe when called from the zeroconf thread).
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
rec = get_module_recorder()
if rec is not None:
is_discovered = event_type == "device_discovered"
action = "device.discovered" if is_discovered else "device.lost"
severity = ActivitySeverity.INFO if is_discovered else ActivitySeverity.WARNING
verb = "discovered" if is_discovered else "lost"
# Sanitize mDNS-advertised strings before they enter the log.
# entry.name and entry.url are unauthenticated, attacker-controlled
# values; strip control chars, ANSI escapes, and NUL before use.
safe_name = sanitize_display(entry.name)
safe_url = sanitize_display(entry.url)
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=entry.url,
entity_name=safe_name,
message=f"Device '{safe_name}' {verb} at {safe_url}",
metadata={"url": safe_url, "device_type": entry.device_type},
)
except Exception as e:
logger.debug("Discovery watcher: audit record failed: %s", e)
@@ -11,6 +11,7 @@ from ledgrab.core.devices.led_client import (
check_device_health, check_device_health,
get_device_capabilities, get_device_capabilities,
) )
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -128,6 +129,34 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms, "latency_ms": state.health.latency_ms,
} }
) )
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
display = device_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=device_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
# Auto-sync LED count # Auto-sync LED count
reported = state.health.device_led_count reported = state.health.device_led_count
+28
View File
@@ -285,6 +285,34 @@ def sample_calibration():
} }
# ---------------------------------------------------------------------------
# Auth throttle isolation — reset per-IP throttle state between every test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_auth_throttle():
"""Clear the auth-failure audit throttle dict before (and after) each test.
The module-global ``_auth_record_last`` dict in ``ledgrab.api.auth`` persists
across tests. When multiple tests trigger an auth failure from the SAME
client IP within the 10 s window they share, the second test gets throttled
(0 records) and assertions like "expected exactly 1 auth.rejected" fail.
This fixture resets the dict to a clean state so every test starts with a
fresh throttle window. The production throttle behavior is UNCHANGED only
test isolation is affected.
"""
import ledgrab.api.auth as _auth_mod
_throttle = getattr(_auth_mod, "_auth_record_last", None)
if _throttle is not None:
_throttle.clear()
yield
if _throttle is not None:
_throttle.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Session cleanup — remove temporary test directory # Session cleanup — remove temporary test directory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,614 @@
"""Integration tests for Phase 3: Event instrumentation.
Coverage targets
----------------
- Entity create/update/delete emits a record with correct category/actor/name.
- An entity DELETE carries the entity name (not None).
- An auth failure emits a ``warning`` record; the attempted token NEVER appears
in any recorded field.
- A device health transition emits a record.
- A device discovery event emits a record.
- A capture start and a backup-create emit records.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_recorder() -> tuple[ActivityRecorder, list, list]:
"""Return (recorder, persisted_entries, fired_events)."""
repo = MagicMock()
persisted: list = []
repo.record.side_effect = lambda entry: persisted.append(entry)
pm = MagicMock()
fired: list[dict] = []
pm.fire_event.side_effect = lambda evt: fired.append(evt)
recorder = ActivityRecorder(repo, pm)
return recorder, persisted, fired
def _patch_module_recorder(recorder: ActivityRecorder):
"""Context manager: patch the module-level recorder used by all non-DI sites."""
return patch(
"ledgrab.core.activity_log.recorder._recorder",
recorder,
)
# ---------------------------------------------------------------------------
# Category A: Entity CRUD via fire_entity_event choke-point
# ---------------------------------------------------------------------------
class TestEntityCrud:
"""fire_entity_event records entity create/update/delete with correct fields."""
def test_entity_created_emits_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
# Minimal _deps so the store lookup returns None (name resolved as None)
original_deps = dict(_deps)
try:
# Clear deps so store lookup path returns None
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("output_target", "created", "ot_test123")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
entry = persisted[0]
assert entry.category == ActivityCategory.ENTITY
assert entry.action == "entity.created"
assert entry.severity == ActivitySeverity.INFO
assert entry.entity_type == "output_target"
assert entry.entity_id == "ot_test123"
def test_entity_deleted_carries_name(self):
"""DELETE: entity_name must be passed explicitly and preserved in record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event(
"output_target",
"deleted",
"ot_abc",
entity_name="My LED Strip",
)
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
entry = persisted[0]
assert entry.action == "entity.deleted"
assert entry.entity_name == "My LED Strip"
# Name should also appear in the human message.
assert "My LED Strip" in entry.message
def test_entity_deleted_without_name_does_not_raise(self):
"""Even if entity_name is omitted on delete, the record is created."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
# No entity_name passed — should not crash
fire_entity_event("device", "deleted", "dev_xyz")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].action == "entity.deleted"
def test_entity_updated_emits_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("scene_preset", "updated", "scene_001")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].action == "entity.updated"
def test_actor_carried_from_contextvar(self):
"""Actor is resolved from the ContextVar."""
recorder, persisted, _ = _make_recorder()
token = current_actor.set("dev")
try:
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("gradient", "created", "gr_001")
finally:
_deps.clear()
_deps.update(original_deps)
finally:
current_actor.reset(token)
assert persisted[0].actor == "dev"
def test_no_record_when_module_recorder_is_none(self):
"""If recorder not initialised, fire_entity_event must not raise."""
with patch("ledgrab.core.activity_log.recorder._recorder", None):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("device", "created", "dev_001") # must not raise
finally:
_deps.clear()
_deps.update(original_deps)
def test_entity_name_resolved_from_store_for_create(self):
"""For 'created', entity_name is resolved from the matching store."""
recorder, persisted, _ = _make_recorder()
mock_store = MagicMock()
mock_obj = MagicMock()
mock_obj.name = "Target Alpha"
mock_store.get_target.return_value = mock_obj
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
_deps["output_target_store"] = mock_store
fire_entity_event("output_target", "created", "ot_alpha")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].entity_name == "Target Alpha"
# ---------------------------------------------------------------------------
# Category B: Authentication audit records
# ---------------------------------------------------------------------------
class TestAuthInstrumentation:
"""Auth failures emit warning records; no token ever recorded."""
_SECRET_TOKEN = "super-secret-token-that-must-never-appear"
def _make_mock_request(self, client_ip: str = "192.168.1.50") -> MagicMock:
req = MagicMock()
req.client = MagicMock()
req.client.host = client_ip
req.state = MagicMock()
return req
def test_missing_bearer_emits_auth_failure_warning(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request()
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
# At least one warning record about auth
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
assert all(e.category == ActivityCategory.AUTH for e in warnings)
def test_invalid_token_emits_auth_failure_warning(self):
"""Invalid token => warning record; token itself must NOT appear."""
recorder, persisted, _ = _make_recorder()
creds = MagicMock()
creds.credentials = self._SECRET_TOKEN # the "attempted" token
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="127.0.0.1")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, creds)
# At least one warning-level auth record
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
# SECURITY: The attempted token must never appear in ANY field.
for entry in persisted:
assert (
self._SECRET_TOKEN not in entry.message
), "Attempted token found in message field!"
for v in (entry.entity_id, entry.entity_name, entry.actor):
assert v is None or self._SECRET_TOKEN not in str(
v
), f"Attempted token found in field: {v!r}"
for meta_v in entry.metadata.values():
assert self._SECRET_TOKEN not in str(
meta_v
), f"Attempted token found in metadata: {meta_v!r}"
def test_lan_rejection_without_keys_emits_warning(self):
"""LAN request when no keys configured => warning record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
# Override config to have no API keys
with patch("ledgrab.api.auth.get_config") as mock_cfg:
cfg = MagicMock()
cfg.auth.api_keys = {}
mock_cfg.return_value = cfg
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="192.168.1.100")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
assert any("LAN" in e.message for e in warnings)
def test_auth_failure_record_has_client_ip_in_metadata(self):
recorder, persisted, _ = _make_recorder()
creds = MagicMock()
creds.credentials = self._SECRET_TOKEN
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="10.0.0.5")
with pytest.raises(Exception):
verify_api_key(req, creds)
auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
assert len(auth_records) >= 1
for entry in auth_records:
# client IP must appear in metadata, NOT the token
assert "client" in entry.metadata
assert self._SECRET_TOKEN not in str(entry.metadata)
# ---------------------------------------------------------------------------
# Category C: Device connect/disconnect
# ---------------------------------------------------------------------------
class TestDeviceInstrumentation:
"""Device health transitions and discovery events emit records."""
def test_device_offline_emits_warning_record(self):
"""When a device goes from online → offline, a warning record is emitted."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
# Create a minimal DeviceHealthMixin-shaped object inline
from ledgrab.core.devices.led_client import DeviceHealth
from ledgrab.core.processing.device_health import DeviceHealthMixin
class FakeManager(DeviceHealthMixin):
def __init__(self):
self._devices = {}
self._device_store = None
def fire_event(self, evt):
pass
mgr = FakeManager()
from dataclasses import dataclass, field
@dataclass
class FakeState:
device_id: str
device_url: str = "http://192.168.1.10"
device_type: str = "wled"
led_count: int = 60
health: DeviceHealth = field(default_factory=DeviceHealth)
health_task: object = None
state = FakeState(device_id="dev_001")
state.health = DeviceHealth(online=True)
mgr._devices["dev_001"] = state
# Simulate what _check_device_health does when online flips
prev_online = True
state.health = DeviceHealth(online=False, latency_ms=0.0)
if state.health.online != prev_online:
mgr.fire_event(
{
"type": "device_health_changed",
"device_id": "dev_001",
"online": state.health.online,
"latency_ms": state.health.latency_ms,
}
)
# Reproduce the audit block from device_health.py
is_online = state.health.online
device_name = None
display = device_name or "dev_001"
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
recorder.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id="dev_001",
entity_name=device_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
offline_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.offline"
]
assert len(offline_records) == 1
r = offline_records[0]
assert r.severity == ActivitySeverity.WARNING
assert r.entity_id == "dev_001"
def test_device_online_emits_info_record(self):
"""When a device comes online, an info record is emitted."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
recorder.record(
category=ActivityCategory.DEVICE,
action="device.online",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="device",
entity_id="dev_002",
message="Device 'dev_002' came online",
)
online_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.online"
]
assert len(online_records) == 1
assert online_records[0].severity == ActivitySeverity.INFO
def test_device_discovered_emits_record(self):
"""DiscoveryWatcher._emit produces an audit record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
mock_device_store = MagicMock()
mock_device_store.get_all_devices.return_value = []
fired_events: list[dict] = []
watcher = DiscoveryWatcher(
device_store=mock_device_store,
fire_event=lambda evt: fired_events.append(evt),
)
entry = _DiscoveredEntry(
key="wled-test._wled._tcp.local.",
url="http://192.168.1.55",
name="WLED-Test",
device_type="wled",
)
watcher._emit("device_discovered", entry)
disc_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.discovered"
]
assert len(disc_records) == 1
r = disc_records[0]
assert r.severity == ActivitySeverity.INFO
assert r.entity_name == "WLED-Test"
assert "192.168.1.55" in r.metadata.get("url", "")
def test_device_lost_emits_warning_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
mock_device_store = MagicMock()
mock_device_store.get_all_devices.return_value = []
watcher = DiscoveryWatcher(
device_store=mock_device_store,
fire_event=lambda evt: None,
)
entry = _DiscoveredEntry(
key="lost-device._wled._tcp.local.",
url="http://192.168.1.77",
name="Lost-WLED",
device_type="wled",
)
watcher._emit("device_lost", entry)
lost_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.lost"
]
assert len(lost_records) == 1
assert lost_records[0].severity == ActivitySeverity.WARNING
# ---------------------------------------------------------------------------
# Category D: Capture & system events
# ---------------------------------------------------------------------------
class TestCaptureAndSystemInstrumentation:
"""Capture start and backup-create emit records."""
def test_capture_started_record(self):
"""capture.started record is emitted with correct category."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.output_targets_control import _record_capture
_record_capture(
"capture.started",
"ot_test",
"My Test Strip",
"Capture started for target 'My Test Strip'",
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.CAPTURE
assert r.action == "capture.started"
assert r.entity_id == "ot_test"
assert r.entity_name == "My Test Strip"
def test_capture_stopped_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.output_targets_control import _record_capture
_record_capture(
"capture.stopped",
"ot_test",
"Strip",
"Capture stopped for target 'Strip'",
)
assert len(persisted) == 1
assert persisted[0].action == "capture.stopped"
def test_backup_created_record(self):
"""backup.created system record emitted on backup download."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
_record_system(
"backup.created",
"Backup downloaded: ledgrab-backup-20260101T000000.zip",
{"filename": "ledgrab-backup-20260101T000000.zip"},
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.SYSTEM
assert r.action == "backup.created"
assert "backup" in r.message.lower()
def test_backup_restored_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
_record_system("backup.restored", "Database restored from uploaded backup")
assert len(persisted) == 1
assert persisted[0].action == "backup.restored"
def test_no_token_in_any_system_record(self):
"""System records must never include token-like secrets."""
recorder, persisted, _ = _make_recorder()
_SECRET = "my-api-token-12345" # noqa: S105
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
# Even if someone tried to pass a token (they shouldn't)
_record_system("backup.created", "Backup created")
for entry in persisted:
assert _SECRET not in entry.message
for v in entry.metadata.values():
assert _SECRET not in str(v)
def test_settings_changed_record(self):
"""shutdown_action settings change emits a system record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.system_settings import _record_setting
_record_setting(
"settings.changed",
"shutdown_action",
"Shutdown action set to 'nothing'",
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.SYSTEM
assert r.action == "settings.changed"
assert r.metadata.get("setting_key") == "shutdown_action"
def test_settings_change_excludes_activity_log_key(self):
"""The 'activity_log' settings key must not self-referentially trigger records.
This is enforced by the caller checking the key before calling
_record_setting. Verify our helper does NOT filter automatically (the
responsibility is on the caller), but that the activity_log settings path
in the retention engine does not call record_setting.
"""
# Verify that _record_setting itself doesn't filter — that's not its job.
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.system_settings import _record_setting
# The caller is responsible for not passing "activity_log"
# Calling it with any other key works fine:
_record_setting("settings.changed", "auto_backup", "Auto-backup enabled")
assert persisted[0].metadata["setting_key"] == "auto_backup"
File diff suppressed because it is too large Load Diff