even more performance benefits

This commit is contained in:
Ludwig Lehnert
2026-08-12 13:26:31 +00:00
parent 0ea17c6401
commit 29340d778d
5 changed files with 353 additions and 92 deletions
+4 -3
View File
@@ -54,6 +54,7 @@ The database contains:
- `shares`: AD group-to-folder lifecycle and ACL reconciliation state; - `shares`: AD group-to-folder lifecycle and ACL reconciliation state;
- `audit_events`: normalized read, write, move, and delete events; - `audit_events`: normalized read, write, move, and delete events;
- `audit_sources`: Samba log inode/offset checkpoints; - `audit_sources`: Samba log inode/offset checkpoints;
- `audit_read_dedup`: bounded, persistent fingerprints for restart-safe read deduplication;
- `audit_daily_totals`, `audit_daily_counts`, and `audit_daily_facets`: compact materialized metadata for fast activity counts and filters; - `audit_daily_totals`, `audit_daily_counts`, and `audit_daily_facets`: compact materialized metadata for fast activity counts and filters;
- `audit_paths`, `audit_paths_fts`, and `audit_path_events`: deduplicated trigram path search with an incrementally maintained event mapping; - `audit_paths`, `audit_paths_fts`, and `audit_path_events`: deduplicated trigram path search with an incrementally maintained event mapping;
- `audit_rollup_state`: bounded legacy-event backfill progress; - `audit_rollup_state`: bounded legacy-event backfill progress;
@@ -180,7 +181,7 @@ The E2E suite verifies:
- SMB allow/deny behavior and real file operations; - SMB allow/deny behavior and real file operations;
- Data, Private, and FSLogix usage aggregation; - Data, Private, and FSLogix usage aggregation;
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination; - high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
- shared SQLite schema, integrity, indexes, legacy-log removal, and ordered read deduplication; - shared SQLite schema, integrity, indexes, legacy-log removal, and read deduplication across interleaved events and collector polls;
- real rsync transfer progress, completed backup status, log output, remote snapshot marker, and per-group encrypted non-solid 7z archives; - real rsync transfer progress, completed backup status, log output, remote snapshot marker, and per-group encrypted non-solid 7z archives;
- anonymous action rejection plus authenticated manual backup and reconciliation actions, terminal progress, and live reconciliation output; - anonymous action rejection plus authenticated manual backup and reconciliation actions, terminal progress, and live reconciliation output;
- overview and system-health aggregation; - overview and system-health aggregation;
@@ -399,7 +400,7 @@ Samba emits selected high-level `full_audit` operations for Private, Data, and F
- each event records a UTC timestamp, user, client address/name, share, result, path, and one of `read`, `write`, `move`, or `delete`; - each event records a UTC timestamp, user, client address/name, share, result, path, and one of `read`, `write`, `move`, or `delete`;
- directory listings, sessions, metadata access, file-open/create noise, and all other VFS operations are discarded; users ending in a configured `AUDIT_SKIP_USER_SUFFIXES` value are also discarded; - directory listings, sessions, metadata access, file-open/create noise, and all other VFS operations are discarded; users ending in a configured `AUDIT_SKIP_USER_SUFFIXES` value are also discarded;
- FSLogix profile-container events are retained and queried through their own partial indexes, API route, and UI log instead of appearing in the main activity stream; - FSLogix profile-container events are retained and queried through their own partial indexes, API route, and UI log instead of appearing in the main activity stream;
- immediately consecutive identical reads within the same UTC second are collapsed into one event, including across collector polling cycles; a different event interrupts the sequence and preserves later reads; - identical reads within the same UTC second are collapsed into one event regardless of intervening events, log source, or collector polling cycle; the persistent fingerprint cache covers the latest 48 hours and can be tuned with `AUDIT_READ_DEDUP_WINDOW_SECONDS`;
- activity pages read only `limit + 1` indexed rows and use a stable time/id cursor; - activity pages read only `limit + 1` indexed rows and use a stable time/id cursor;
- exact counts for date, user, share, action, and result filters and all facet lists come from daily rollups; - exact counts for date, user, share, action, and result filters and all facet lists come from daily rollups;
- selective substring path searches use the deduplicated trigram index and skip an expensive exact count while more pages exist; - selective substring path searches use the deduplicated trigram index and skip an expensive exact count while more pages exist;
@@ -407,7 +408,7 @@ Samba emits selected high-level `full_audit` operations for Private, Data, and F
- collector inserts and rollup updates are batched in one transaction; - collector inserts and rollup updates are batched in one transaction;
- no activity retention deletion is performed. - no activity retention deletion is performed.
Collection starts even when the web UI is disabled. Existing databases remain queryable while the collector backfills rollups, path IDs, and path-event mappings in bounded chunks after prioritizing each live append. On the first collector start after the legacy archive migration, recognized daily `.jsonl`/`.jsonl.gz` files and the old collector state file under `/state/audit` are deleted without import. Existing raw Samba log content is then processed using the current action, suffix, and deduplication policy. Collection starts even when the web UI is disabled. Existing databases remain queryable while the collector backfills rollups, path IDs, path-event mappings, and recent read deduplication in bounded chunks after prioritizing each live append. On the first collector start after the legacy archive migration, recognized daily `.jsonl`/`.jsonl.gz` files and the old collector state file under `/state/audit` are deleted without import. Existing raw Samba log content is then processed using the current action, suffix, and deduplication policy.
## Backups ## Backups
+37 -7
View File
@@ -2,10 +2,10 @@
"""Shared policy for the small set of user-facing audit events.""" """Shared policy for the small set of user-facing audit events."""
import datetime as dt import datetime as dt
import hashlib
import os import os
from typing import Mapping, Optional, Tuple from typing import Mapping, Optional, Tuple
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"}) AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
OPERATION_ACTIONS = { OPERATION_ACTIONS = {
@@ -30,11 +30,9 @@ OPERATION_ACTIONS = {
"rmdir": "delete", "rmdir": "delete",
} }
def action_for(operation: str) -> Optional[str]: def action_for(operation: str) -> Optional[str]:
return OPERATION_ACTIONS.get(operation.strip().casefold()) return OPERATION_ACTIONS.get(operation.strip().casefold())
def skipped_user_suffixes() -> Tuple[str, ...]: def skipped_user_suffixes() -> Tuple[str, ...]:
raw = os.getenv("AUDIT_SKIP_USER_SUFFIXES", "_svc,_ServiceAcc") raw = os.getenv("AUDIT_SKIP_USER_SUFFIXES", "_svc,_ServiceAcc")
return tuple( return tuple(
@@ -43,20 +41,16 @@ def skipped_user_suffixes() -> Tuple[str, ...]:
if suffix.strip() if suffix.strip()
) )
def account_name(user: str) -> str: def account_name(user: str) -> str:
account = user.strip().rsplit("\\", 1)[-1] account = user.strip().rsplit("\\", 1)[-1]
return account.split("@", 1)[0] return account.split("@", 1)[0]
def skip_user(user: str) -> bool: def skip_user(user: str) -> bool:
account = account_name(user).casefold() account = account_name(user).casefold()
return any(account.endswith(suffix) for suffix in skipped_user_suffixes()) return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
ReadEventKey = Tuple[str, ...] ReadEventKey = Tuple[str, ...]
def read_deduplication_key(event: Mapping[str, object]) -> Optional[ReadEventKey]: def read_deduplication_key(event: Mapping[str, object]) -> Optional[ReadEventKey]:
if str(event.get("action", "")).casefold() != "read": if str(event.get("action", "")).casefold() != "read":
return None return None
@@ -84,3 +78,39 @@ def read_deduplication_key(event: Mapping[str, object]) -> Optional[ReadEventKey
"success" if success else "failure", "success" if success else "failure",
"" if success else str(event.get("result", "")), "" if success else str(event.get("result", "")),
) )
def read_deduplication_fingerprint(
event: Mapping[str, object]
) -> Optional[bytes]:
key = read_deduplication_key(event)
if key is None:
return None
digest = hashlib.blake2b(digest_size=16)
for value in key:
encoded = value.encode("utf-8", errors="surrogatepass")
digest.update(len(encoded).to_bytes(4, "big"))
digest.update(encoded)
return digest.digest()
def read_deduplication_fingerprint_values(
timestamp: object,
user: object,
client_ip: object,
share: object,
path: object,
success: object,
result: object,
) -> bytes:
"""Fingerprint legacy SQLite columns with the live-ingest policy."""
fingerprint = read_deduplication_fingerprint({
"action": "read",
"timestamp": timestamp,
"user": user,
"clientIp": client_ip,
"share": share,
"path": path,
"success": bool(success),
"result": result,
})
assert fingerprint is not None
return fingerprint
+163 -29
View File
@@ -11,14 +11,16 @@ from typing import Dict, Iterable, List, Optional, Set, Tuple
try: try:
from .audit_policy import ( from .audit_policy import (
account_name, account_name,
read_deduplication_key, read_deduplication_fingerprint,
read_deduplication_fingerprint_values,
skipped_user_suffixes, skipped_user_suffixes,
) )
from .state_db import connect_state_db from .state_db import connect_state_db
except ImportError: except ImportError:
from audit_policy import ( from audit_policy import (
account_name, account_name,
read_deduplication_key, read_deduplication_fingerprint,
read_deduplication_fingerprint_values,
skipped_user_suffixes, skipped_user_suffixes,
) )
from state_db import connect_state_db from state_db import connect_state_db
@@ -47,6 +49,13 @@ CREATE TABLE IF NOT EXISTS audit_sources (
inode INTEGER NOT NULL, inode INTEGER NOT NULL,
offset INTEGER NOT NULL offset INTEGER NOT NULL
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS audit_read_dedup (
fingerprint BLOB PRIMARY KEY,
occurred_second INTEGER NOT NULL,
event_id INTEGER
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS audit_read_dedup_time
ON audit_read_dedup (occurred_second);
CREATE INDEX IF NOT EXISTS audit_events_main_time CREATE INDEX IF NOT EXISTS audit_events_main_time
ON audit_events (occurred_second DESC, id DESC) ON audit_events (occurred_second DESC, id DESC)
WHERE share <> 'FSLogix' COLLATE NOCASE; WHERE share <> 'FSLogix' COLLATE NOCASE;
@@ -138,7 +147,7 @@ CREATE TABLE IF NOT EXISTS audit_rollup_state (
backfill_next_id INTEGER NOT NULL, backfill_next_id INTEGER NOT NULL,
backfill_max_id INTEGER NOT NULL, backfill_max_id INTEGER NOT NULL,
ready INTEGER NOT NULL CHECK (ready IN (0, 1)), ready INTEGER NOT NULL CHECK (ready IN (0, 1)),
policy_version INTEGER NOT NULL DEFAULT 3 policy_version INTEGER NOT NULL DEFAULT 4
); );
""" """
@@ -212,7 +221,7 @@ def ensure_audit_schema(conn: sqlite3.Connection) -> None:
WHERE singleton = 1 WHERE singleton = 1
""" """
).fetchone() ).fetchone()
if state is not None and int(state["policy_version"]) < 3: if state is not None and int(state["policy_version"]) < 4:
for table in ( for table in (
"audit_daily_totals", "audit_daily_totals",
"audit_daily_counts", "audit_daily_counts",
@@ -223,7 +232,7 @@ def ensure_audit_schema(conn: sqlite3.Connection) -> None:
""" """
UPDATE audit_rollup_state UPDATE audit_rollup_state
SET backfill_next_id = 1, backfill_max_id = ?, ready = ?, SET backfill_next_id = 1, backfill_max_id = ?, ready = ?,
policy_version = 3 policy_version = 4
WHERE singleton = 1 WHERE singleton = 1
""", """,
(max_id, int(max_id == 0)), (max_id, int(max_id == 0)),
@@ -231,6 +240,21 @@ def ensure_audit_schema(conn: sqlite3.Connection) -> None:
conn.commit() conn.commit()
READ_DEDUP_DEFAULT_WINDOW_SECONDS = 2 * 86400
def read_deduplication_cutoff() -> int:
try:
window = int(os.getenv(
"AUDIT_READ_DEDUP_WINDOW_SECONDS",
str(READ_DEDUP_DEFAULT_WINDOW_SECONDS),
))
except ValueError:
window = READ_DEDUP_DEFAULT_WINDOW_SECONDS
now = int(dt.datetime.now(dt.timezone.utc).timestamp())
return now - max(1, window)
def parse_event_second(timestamp: object) -> int: def parse_event_second(timestamp: object) -> int:
parsed = dt.datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")) parsed = dt.datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
if parsed.tzinfo is None: if parsed.tzinfo is None:
@@ -257,22 +281,13 @@ def row_to_event(row: sqlite3.Row) -> Dict[str, object]:
} }
def last_event(conn: sqlite3.Connection) -> Optional[Dict[str, object]]:
row = conn.execute(
"""
SELECT id, occurred_at, ingested_at, user, client_ip, client, share,
action, result, success, path, source
FROM audit_events
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
return row_to_event(row) if row is not None else None
class AuditStore: class AuditStore:
def __init__(self, database_path: Optional[str] = None): def __init__(self, database_path: Optional[str] = None):
self.conn = connect_state_db(database_path) self.conn = connect_state_db(database_path)
self.conn.create_function(
"audit_read_fingerprint", 7,
read_deduplication_fingerprint_values, deterministic=True,
)
ensure_audit_schema(self.conn) ensure_audit_schema(self.conn)
def close(self) -> None: def close(self) -> None:
@@ -299,10 +314,13 @@ class AuditStore:
totals = Counter() totals = Counter()
counts = Counter() counts = Counter()
facets = set() facets = set()
previous_key = read_deduplication_key(last_event(self.conn) or {}) seen_read_fingerprints = set()
for event in events: for event in events:
current_key = read_deduplication_key(event) read_fingerprint = read_deduplication_fingerprint(event)
if current_key is not None and current_key == previous_key: if (
read_fingerprint is not None
and read_fingerprint in seen_read_fingerprints
):
continue continue
occurred_second = parse_event_second(event["timestamp"]) occurred_second = parse_event_second(event["timestamp"])
user = str(event["user"]) user = str(event["user"])
@@ -311,7 +329,6 @@ class AuditStore:
action = str(event["action"]) action = str(event["action"])
result = str(event["result"]) result = str(event["result"])
success = int(bool(event["success"])) success = int(bool(event["success"]))
day = occurred_second // 86400
event_rows.append( event_rows.append(
( (
str(event["timestamp"]), str(event["timestamp"]),
@@ -327,17 +344,62 @@ class AuditStore:
success, success,
str(event["path"]), str(event["path"]),
str(event["source"]), str(event["source"]),
read_fingerprint,
) )
) )
totals[(day, account)] += 1 if read_fingerprint is not None:
counts[(day, account, user, share, action, result, success)] += 1 seen_read_fingerprints.add(read_fingerprint)
for kind, value in (("user", user), ("share", share), ("action", action)):
if value:
facets.add((kind, day, account, value))
previous_key = current_key
self.conn.execute("BEGIN IMMEDIATE") self.conn.execute("BEGIN IMMEDIATE")
try: try:
fingerprints = [
row[13] for row in event_rows if row[13] is not None
]
existing_fingerprints = set()
for offset in range(0, len(fingerprints), 500):
chunk = fingerprints[offset : offset + 500]
placeholders = ",".join("?" for _ in chunk)
existing_fingerprints.update(
bytes(row[0])
for row in self.conn.execute(
f"""
SELECT fingerprint
FROM audit_read_dedup
WHERE fingerprint IN ({placeholders})
""",
chunk,
)
)
if existing_fingerprints:
event_rows = [
row
for row in event_rows
if (
row[13] is None
or row[13] not in existing_fingerprints
)
]
for row in event_rows:
day = int(row[1]) // 86400
account = str(row[4])
user = str(row[3])
share = str(row[7])
action = str(row[8])
result = str(row[9])
success = int(row[10])
totals[(day, account)] += 1
counts[(
day, account, user, share, action, result, success
)] += 1
for kind, value in (
("user", user),
("share", share),
("action", action),
):
if value:
facets.add((kind, day, account, value))
if event_rows: if event_rows:
paths = sorted({str(row[11]) for row in event_rows}) paths = sorted({str(row[11]) for row in event_rows})
self.conn.executemany( self.conn.executemany(
@@ -383,6 +445,24 @@ class AuditStore:
""", """,
(len(indexed_rows),), (len(indexed_rows),),
) )
last_inserted_id = int(
self.conn.execute(
"SELECT max(id) FROM audit_events"
).fetchone()[0]
)
first_inserted_id = last_inserted_id - len(event_rows) + 1
self.conn.executemany(
"""
INSERT INTO audit_read_dedup (
fingerprint, occurred_second, event_id
) VALUES (?, ?, ?)
""",
(
(row[13], int(row[1]), first_inserted_id + offset)
for offset, row in enumerate(event_rows)
if row[13] is not None
),
)
self.conn.executemany( self.conn.executemany(
ROLLUP_TOTAL_SQL, ROLLUP_TOTAL_SQL,
((*key, count) for key, count in totals.items()), ((*key, count) for key, count in totals.items()),
@@ -393,6 +473,11 @@ class AuditStore:
) )
self.conn.executemany(ROLLUP_FACET_SQL, facets) self.conn.executemany(ROLLUP_FACET_SQL, facets)
self.conn.execute(
"DELETE FROM audit_read_dedup WHERE occurred_second < ?",
(read_deduplication_cutoff(),),
)
if source_updates: if source_updates:
self.conn.executemany( self.conn.executemany(
""" """
@@ -422,7 +507,7 @@ class AuditStore:
raise raise
return len(event_rows) return len(event_rows)
def backfill_rollups(self, limit: int = 50000) -> int: def backfill_rollups(self, limit: int = 5000) -> int:
"""Backfill one bounded legacy-event chunk without delaying live appends.""" """Backfill one bounded legacy-event chunk without delaying live appends."""
state = self.conn.execute( state = self.conn.execute(
""" """
@@ -459,6 +544,55 @@ class AuditStore:
end_id = int(chunk[1]) end_id = int(chunk[1])
self.conn.execute("BEGIN IMMEDIATE") self.conn.execute("BEGIN IMMEDIATE")
try: try:
dedup_cutoff = read_deduplication_cutoff()
self.conn.execute(
"DELETE FROM audit_read_dedup WHERE occurred_second < ?",
(dedup_cutoff,),
)
self.conn.execute(
"""
INSERT INTO audit_read_dedup (
fingerprint, occurred_second, event_id
)
SELECT audit_read_fingerprint(
e.occurred_at, e.user, e.client_ip, e.share, e.path,
e.success, e.result
), e.occurred_second, e.id
FROM audit_events AS e
WHERE e.id BETWEEN ? AND ?
AND e.action = 'read'
AND e.occurred_second >= ?
ORDER BY e.id
ON CONFLICT (fingerprint) DO UPDATE SET
event_id = coalesce(
audit_read_dedup.event_id, excluded.event_id
)
""",
(start_id, end_id, dedup_cutoff),
)
duplicate_read_sql = """
SELECT e.id
FROM audit_events AS e
JOIN audit_read_dedup AS d
ON d.fingerprint = audit_read_fingerprint(
e.occurred_at, e.user, e.client_ip, e.share, e.path,
e.success, e.result
)
WHERE e.id BETWEEN ? AND ?
AND e.action = 'read'
AND e.occurred_second >= ?
AND (d.event_id IS NULL OR d.event_id <> e.id)
"""
self.conn.execute(
f"DELETE FROM audit_path_events "
f"WHERE event_id IN ({duplicate_read_sql})",
(start_id, end_id, dedup_cutoff),
)
self.conn.execute(
f"DELETE FROM audit_events "
f"WHERE id IN ({duplicate_read_sql})",
(start_id, end_id, dedup_cutoff),
)
self.conn.execute( self.conn.execute(
""" """
INSERT OR IGNORE INTO audit_paths (path) INSERT OR IGNORE INTO audit_paths (path)
+8 -2
View File
@@ -341,6 +341,7 @@ async function renderBackup() {
'<button id="start-backup" type="button">Sicherung jetzt starten</button>' '<button id="start-backup" type="button">Sicherung jetzt starten</button>'
) + '<div id="backup-body"></div>'; ) + '<div id="backup-body"></div>';
const button = document.querySelector("#start-backup"); const button = document.querySelector("#start-backup");
const body = document.querySelector("#backup-body");
let logLines = []; let logLines = [];
let previousActive = false; let previousActive = false;
let polling = false; let polling = false;
@@ -349,9 +350,10 @@ async function renderBackup() {
const currentLog = document.querySelector("#backup-log"); const currentLog = document.querySelector("#backup-log");
const previousScrollTop = currentLog?.scrollTop || 0; const previousScrollTop = currentLog?.scrollTop || 0;
const data = await api(includeLog ? "/api/backup" : "/api/backup?log=0"); const data = await api(includeLog ? "/api/backup" : "/api/backup?log=0");
if (!body.isConnected || !button.isConnected) return null;
if (Array.isArray(data.log)) logLines = data.log; if (Array.isArray(data.log)) logLines = data.log;
data.log = logLines; data.log = logLines;
document.querySelector("#backup-body").innerHTML = backupMarkup(data); body.innerHTML = backupMarkup(data);
button.disabled = !data.manualEnabled || processIsActive(data); button.disabled = !data.manualEnabled || processIsActive(data);
button.title = data.manualEnabled ? "" : "Es ist kein Sicherungsziel eingerichtet."; button.title = data.manualEnabled ? "" : "Es ist kein Sicherungsziel eingerichtet.";
const logView = document.querySelector("#backup-log"); const logView = document.querySelector("#backup-log");
@@ -394,6 +396,7 @@ async function renderBackup() {
} }
}); });
const initial = await refresh(true); const initial = await refresh(true);
if (!body.isConnected || !button.isConnected) return;
previousActive = processIsActive(initial); previousActive = processIsActive(initial);
state.timer = window.setInterval(() => { void poll(); }, 2000); state.timer = window.setInterval(() => { void poll(); }, 2000);
} }
@@ -449,6 +452,7 @@ async function renderReconciliation() {
'<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>' '<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>'
) + '<div id="reconciliation-body"></div>'; ) + '<div id="reconciliation-body"></div>';
const button = document.querySelector("#start-reconciliation"); const button = document.querySelector("#start-reconciliation");
const body = document.querySelector("#reconciliation-body");
let logLines = []; let logLines = [];
let previousActive = false; let previousActive = false;
let polling = false; let polling = false;
@@ -459,9 +463,10 @@ async function renderReconciliation() {
const data = await api( const data = await api(
includeLog ? "/api/reconciliation" : "/api/reconciliation?log=0" includeLog ? "/api/reconciliation" : "/api/reconciliation?log=0"
); );
if (!body.isConnected || !button.isConnected) return null;
if (Array.isArray(data.log)) logLines = data.log; if (Array.isArray(data.log)) logLines = data.log;
data.log = logLines; data.log = logLines;
document.querySelector("#reconciliation-body").innerHTML = reconciliationMarkup(data); body.innerHTML = reconciliationMarkup(data);
button.disabled = processIsActive(data); button.disabled = processIsActive(data);
const logView = document.querySelector("#reconciliation-log"); const logView = document.querySelector("#reconciliation-log");
if (logView) { if (logView) {
@@ -503,6 +508,7 @@ async function renderReconciliation() {
} }
}); });
const initial = await refresh(true); const initial = await refresh(true);
if (!body.isConnected || !button.isConnected) return;
previousActive = processIsActive(initial); previousActive = processIsActive(initial);
state.timer = window.setInterval(() => { void poll(); }, 1500); state.timer = window.setInterval(() => { void poll(); }, 1500);
} }
+141 -51
View File
@@ -466,8 +466,9 @@ class AuditParsingTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
active = os.path.join(tmpdir, "log.pc01") active = os.path.join(tmpdir, "log.pc01")
database = os.path.join(tmpdir, "state.db") database = os.path.join(tmpdir, "state.db")
today = dt.datetime.now(dt.timezone.utc).strftime("%Y/%m/%d")
read = ( read = (
"[2026/07/31 12:34:56.000000, 1] smbd_audit: " f"[{today} 12:34:56.000000, 1] smbd_audit: "
"x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n" "x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n"
) )
write = read.replace("pread|OK|a.txt", "pwrite|OK|changed.txt") write = read.replace("pread|OK|a.txt", "pwrite|OK|changed.txt")
@@ -499,65 +500,87 @@ class AuditParsingTests(unittest.TestCase):
finally: finally:
store.close() store.close()
def test_deduplicates_only_uninterrupted_identical_reads_in_one_second(self):
def test_deduplicates_reads_per_second_across_interleaved_events(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
active = os.path.join(tmpdir, "log.pc01")
database = os.path.join(tmpdir, "state.db") database = os.path.join(tmpdir, "state.db")
def line(operation, path, second="12:34:56"):
return (
f"[2026/07/31 {second}.000000, 1] smbd_audit: "
f"x|alice|192.0.2.5|PC01|Data|{operation}|OK|{path}\n"
)
with open(active, "w", encoding="utf-8") as handle:
handle.writelines(
[
line("pread", "a.txt"),
line("pread", "a.txt"),
line("pread", "b.txt"),
line("pread", "a.txt"),
line("pread", "a.txt"),
]
)
store = audit_store.AuditStore(database) store = audit_store.AuditStore(database)
try:
with mock.patch.object(
audit_collector,
"SAMBA_LOG_GLOB",
os.path.join(tmpdir, "log.*"),
):
self.assertEqual(audit_collector.collect_once(store), 3)
with open(active, "a", encoding="utf-8") as handle:
handle.write(line("pread", "a.txt"))
self.assertEqual(audit_collector.collect_once(store), 0)
with open(active, "a", encoding="utf-8") as handle:
handle.write(line("pwrite", "changed.txt"))
handle.write(line("pread", "a.txt"))
self.assertEqual(audit_collector.collect_once(store), 2)
with open(active, "a", encoding="utf-8") as handle:
handle.write(line("pread", "a.txt", "12:34:57"))
self.assertEqual(audit_collector.collect_once(store), 1)
rows = store.conn.execute( def event(
"SELECT action, path, occurred_at FROM audit_events ORDER BY id" action="read",
).fetchall() path="a.txt",
self.assertEqual( second="12:34:56",
[(row["action"], row["path"]) for row in rows], share="Data",
source="log.pc01",
):
timestamp = f"{dt.datetime.now(dt.timezone.utc).date()}T{second}+00:00"
return {
"timestamp": timestamp,
"ingestedAt": timestamp,
"user": "LOCAL\\silvia.mueller",
"clientIp": "10.100.0.22",
"client": "PC01",
"share": share,
"operation": "pread" if action == "read" else "pwrite",
"action": action,
"path": path,
"result": "OK",
"success": True,
"source": source,
}
try:
inserted = store.append_batch(
[ [
("read", "a.txt"), event(source="log.pc01"),
("read", "b.txt"), event(path="profile.vhdx", share="FSLogix"),
("read", "a.txt"), event(action="write", path="changed.txt"),
("write", "changed.txt"), event(action="write", path="changed.txt", source="log.pc02"),
("read", "a.txt"), event(source="log.pc02"),
("read", "a.txt"), event(path="b.txt"),
event(source="log.pc03"),
], ],
{},
set(),
) )
self.assertTrue(rows[-1]["occurred_at"].endswith("12:34:57+00:00")) repeated_poll = store.append_batch(
[event(source="log.pc04")], {}, set()
)
next_second = store.append_batch(
[event(second="12:34:57")], {}, set()
)
rows = store.conn.execute(
"""
SELECT action, share, path, occurred_second
FROM audit_events
ORDER BY id
"""
).fetchall()
fingerprints = store.conn.execute(
"""
SELECT count(*) FROM audit_read_dedup
"""
).fetchone()[0]
finally: finally:
store.close() store.close()
self.assertEqual(inserted, 5)
self.assertEqual(repeated_poll, 0)
self.assertEqual(next_second, 1)
self.assertEqual(len(rows), 6)
self.assertEqual(fingerprints, 4)
self.assertEqual(
[(row["action"], row["share"], row["path"]) for row in rows],
[
("read", "Data", "a.txt"),
("read", "FSLogix", "profile.vhdx"),
("write", "Data", "changed.txt"),
("write", "Data", "changed.txt"),
("read", "Data", "b.txt"),
("read", "Data", "a.txt"),
],
)
class AuditQueryTests(unittest.TestCase): class AuditQueryTests(unittest.TestCase):
def make_event(self, timestamp, user, success=True): def make_event(self, timestamp, user, success=True):
@@ -946,7 +969,69 @@ class AuditQueryTests(unittest.TestCase):
self.assertEqual(main["matched"], 1) self.assertEqual(main["matched"], 1)
self.assertEqual(fslogix["matched"], 1) self.assertEqual(fslogix["matched"], 1)
self.assertEqual(total, 2) self.assertEqual(total, 2)
self.assertEqual(policy_version, 3) self.assertEqual(policy_version, 4)
def test_upgrade_collapses_recent_legacy_read_duplicates(self):
with tempfile.TemporaryDirectory() as tmpdir:
today = dt.datetime.now(dt.timezone.utc).date()
database = os.path.join(tmpdir, "state.db")
store = audit_store.AuditStore(database)
event = self.make_event(f"{today}T12:00:00+00:00", "alice")
store.append_batch([event], {}, set())
for source in ("log.pc02", "log.pc03"):
store.conn.execute(
"""
INSERT INTO audit_events (
occurred_at, occurred_second, ingested_at, user, account,
client_ip, client, share, action, result, success, path,
path_id, source
)
SELECT occurred_at, occurred_second, ingested_at, user,
account, client_ip, client, share, action, result,
success, path, path_id, ?
FROM audit_events
WHERE id = 1
""",
(source,),
)
store.conn.execute("DELETE FROM audit_read_dedup")
store.conn.execute(
"UPDATE audit_rollup_state SET policy_version = 3"
)
store.conn.commit()
store.close()
store = audit_store.AuditStore(database)
try:
live = dict(event, source="log.live")
live_inserted = store.append_batch([live], {}, set())
while store.backfill_rollups(limit=1):
pass
event_count = store.conn.execute(
"SELECT count(*) FROM audit_events"
).fetchone()[0]
rolled_up = store.conn.execute(
"SELECT sum(event_count) FROM audit_daily_totals"
).fetchone()[0]
dedup_keys = store.conn.execute(
"SELECT count(*) FROM audit_read_dedup"
).fetchone()[0]
policy_version = store.conn.execute(
"SELECT policy_version FROM audit_rollup_state"
).fetchone()[0]
retained_source = store.conn.execute(
"SELECT source FROM audit_events"
).fetchone()[0]
finally:
store.close()
self.assertEqual(event_count, 1)
self.assertEqual(rolled_up, 1)
self.assertEqual(live_inserted, 1)
self.assertEqual(retained_source, "log.live")
self.assertEqual(dedup_keys, 1)
self.assertEqual(policy_version, 4)
def test_schema_has_filter_indexes_and_rollup_tables(self): def test_schema_has_filter_indexes_and_rollup_tables(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -981,6 +1066,7 @@ class AuditQueryTests(unittest.TestCase):
"audit_events_fslogix_user_time", "audit_events_fslogix_user_time",
"audit_events_fslogix_account_time", "audit_events_fslogix_account_time",
"audit_events_fslogix_result_time", "audit_events_fslogix_result_time",
"audit_read_dedup_time",
}.issubset(indexes) }.issubset(indexes)
) )
self.assertTrue( self.assertTrue(
@@ -992,6 +1078,7 @@ class AuditQueryTests(unittest.TestCase):
"audit_paths", "audit_paths",
"audit_paths_fts", "audit_paths_fts",
"audit_path_events", "audit_path_events",
"audit_read_dedup",
}.issubset(tables) }.issubset(tables)
) )
@@ -1082,6 +1169,9 @@ class WebPresentationTests(unittest.TestCase):
self.assertIn('"/api/backup?log=0"', script) self.assertIn('"/api/backup?log=0"', script)
self.assertIn('"/api/reconciliation?log=0"', script) self.assertIn('"/api/reconciliation?log=0"', script)
self.assertEqual(script.count("if (active || previousActive)"), 2) self.assertEqual(script.count("if (active || previousActive)"), 2)
self.assertIn('const body = document.querySelector("#reconciliation-body")', script)
self.assertIn("if (!body.isConnected || !button.isConnected)", script)
self.assertNotIn('document.querySelector("#reconciliation-body").innerHTML', script)
self.assertNotIn("BACKUP_AUTO_ENABLED", script) self.assertNotIn("BACKUP_AUTO_ENABLED", script)
self.assertIn("data.interrupted", script) self.assertIn("data.interrupted", script)