AUDIT-WP-0005-T04. Disposition: there are no pre-production records. No audit-core SQLite store on this host, no mock-file-backend output, and no audit-core pod, deployment or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit device. Consistent with the history: WP-0003-T03 was cancelled before the receiver was ever deployed, so every SQLite store that has existed was a test fixture. Nothing is being discarded because nothing was ever accepted outside tests. The tool is built anyway because the SQLite path stays reachable - the entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that fallback is ever used in anger the records are audit records, and writing the migration afterwards under pressure is the wrong time. audit_core.migrate_store and `python -m audit_core migrate-store` transfer events, dead letters and secret-finding counters. Records keep their original event_id, payload_hash and accepted_at, which is why this bypasses accept(): that stamps acceptance with the current time, and a migration that rewrote acceptance times would destroy the evidence it exists to preserve. Idempotent, and verification reads back from the destination rather than trusting the write path. A destination record with a differing payload hash is reported as a conflict and left untouched - silently overwriting a stored audit record is the same class of failure as losing it. Conflicts and failed verification exit non-zero; a partial migration is not a success. Tests 71 -> 77. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
"""Command line helpers for the development audit backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from typing import Any
|
|
|
|
from audit_core.interface import AuditEvent
|
|
from audit_core.mock_file_backend import MockFileAuditBackend
|
|
|
|
|
|
def parse_detail(values: list[str]) -> dict[str, Any]:
|
|
details: dict[str, Any] = {}
|
|
for item in values:
|
|
if "=" not in item:
|
|
raise SystemExit(f"detail must be key=value: {item}")
|
|
key, value = item.split("=", 1)
|
|
details[key] = value
|
|
return details
|
|
|
|
|
|
def emit(args: argparse.Namespace) -> int:
|
|
details = parse_detail(args.detail)
|
|
event = AuditEvent(
|
|
tenant=args.tenant,
|
|
scope=args.scope,
|
|
source=args.source,
|
|
actor=args.actor,
|
|
action=args.action,
|
|
resource=args.resource,
|
|
outcome=args.outcome,
|
|
reason=args.reason,
|
|
details=details,
|
|
)
|
|
backend = MockFileAuditBackend(base_dir=args.dir, retention_days=args.retention_days)
|
|
path = backend.emit(event)
|
|
print(json.dumps({"ok": True, "path": path, "event_id": event.event_id}, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
def cleanup(args: argparse.Namespace) -> int:
|
|
backend = MockFileAuditBackend(base_dir=args.dir, retention_days=args.retention_days)
|
|
removed = backend.cleanup_old_files()
|
|
print(json.dumps({"ok": True, "removed": removed}, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Audit Core development CLI")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
emit_parser = sub.add_parser("emit", help="Write one event to the mock file backend.")
|
|
emit_parser.add_argument("--tenant", default="platform")
|
|
emit_parser.add_argument("--scope", default="platform-control-plane")
|
|
emit_parser.add_argument("--source", required=True)
|
|
emit_parser.add_argument("--actor")
|
|
emit_parser.add_argument("--action", required=True)
|
|
emit_parser.add_argument("--resource", required=True)
|
|
emit_parser.add_argument("--outcome", default="success")
|
|
emit_parser.add_argument("--reason")
|
|
emit_parser.add_argument("--detail", action="append", default=[])
|
|
emit_parser.add_argument("--dir", default=None)
|
|
emit_parser.add_argument("--retention-days", type=int, default=None)
|
|
emit_parser.set_defaults(func=emit)
|
|
|
|
cleanup_parser = sub.add_parser("cleanup", help="Remove mock audit files older than retention.")
|
|
cleanup_parser.add_argument("--dir", default=None)
|
|
cleanup_parser.add_argument("--retention-days", type=int, default=None)
|
|
cleanup_parser.set_defaults(func=cleanup)
|
|
|
|
migrate_parser = sub.add_parser(
|
|
"migrate-store", help="Move audit records from a SQLite store into PostgreSQL."
|
|
)
|
|
migrate_parser.add_argument("--from-sqlite", required=True, dest="source")
|
|
migrate_parser.add_argument(
|
|
"--to-postgres", dest="destination", default=None,
|
|
help="DSN; defaults to AUDIT_CORE_DATABASE_URL",
|
|
)
|
|
migrate_parser.add_argument("--schema", default="audit_core")
|
|
migrate_parser.add_argument(
|
|
"--dry-run", action="store_true",
|
|
help="Report what would move without writing anything.",
|
|
)
|
|
migrate_parser.set_defaults(func=migrate_store)
|
|
|
|
return parser
|
|
|
|
|
|
def migrate_store(args: argparse.Namespace) -> int:
|
|
import os
|
|
|
|
from audit_core.migrate_store import migrate
|
|
|
|
if args.dry_run:
|
|
import sqlite3
|
|
|
|
with sqlite3.connect(f"file:{args.source}?mode=ro", uri=True) as source:
|
|
count = source.execute("SELECT count(*) FROM events").fetchone()[0]
|
|
print(json.dumps({"dry_run": True, "events_in_source": count}, sort_keys=True))
|
|
return 0
|
|
|
|
from audit_core.postgres_backend import PostgresAuditBackend
|
|
|
|
dsn = args.destination or os.environ.get("AUDIT_CORE_DATABASE_URL")
|
|
if not dsn:
|
|
raise SystemExit("--to-postgres or AUDIT_CORE_DATABASE_URL is required")
|
|
|
|
backend = PostgresAuditBackend(dsn, schema=args.schema)
|
|
try:
|
|
report = migrate(args.source, backend)
|
|
finally:
|
|
backend.close()
|
|
print(report.summary())
|
|
# A conflict or a failed verification is not a partial success.
|
|
return 0 if report.ok else 1
|
|
|
|
|
|
def main() -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|