AUTH.3E: Audit-Trail trennt REQUESTED/AUTHORIZED/EXECUTED (Fresh Checker Punkt 27)
Fresh Checker identifizierte Defekt: audit_trail() lieferte nur einen Job-Snapshot, kein persistentes Audit-Event-Log. Design §18 verlangt Trennung der Phasen. - job_store: audit_events-Tabelle + _record_audit/record_audit_event; REQUESTED bei create_job - delete_executor_core: AUTHORIZED nach AUTH.3D-Validierung, EXECUTED nach Mutation - save_executor_core: AUTHORIZED nach Provenance-Validierung, EXECUTED nach Mutation - test_job_channel: +4 AuditTrailTests (REQUESTED/AUTHORIZED/EXECUTED) REPAIR_CYCLES=1. Volle Regression 444 PASS (76+50+19+299).
This commit is contained in:
parent
10761f52b2
commit
373425fc0d
4 changed files with 188 additions and 15 deletions
|
|
@ -120,11 +120,18 @@ class DeleteExecutorCore:
|
|||
self.store.mark_failed(job_id, gate["code"], worker_id)
|
||||
return self.store.get_job(job_id)
|
||||
|
||||
# Audit: Executor hat Job + AUTH.3D-Approval validiert (AUTHORIZED).
|
||||
# Ein REQUESTED-Eintrag ist KEINE Autorisierung — nur AUTHORIZED
|
||||
# (nach AUTH.3D-Validierung) autorisiert die Mutation.
|
||||
self.store.record_audit_event(job_id, "AUTHORIZED", worker_id=worker_id)
|
||||
|
||||
# Tolaria-DELETE ausführen (fail-closed)
|
||||
result = self.tolaria_delete(job["vault_path"])
|
||||
status = result.get("status")
|
||||
if status == "ok":
|
||||
self.store.mark_succeeded(job_id, worker_id)
|
||||
# Audit: Mutation ausgeführt (EXECUTED).
|
||||
self.store.record_audit_event(job_id, "EXECUTED", worker_id=worker_id)
|
||||
elif status == "error" and result.get("uncertain"):
|
||||
self.store.mark_outcome_unknown(job_id, worker_id)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -110,6 +110,22 @@ class JobStore:
|
|||
self._conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(job_type)
|
||||
""")
|
||||
# Audit-Ereignis-Log (REQUESTED / AUTHORIZED / EXECUTED).
|
||||
# Trennt die Phasen: RQ hat Job erzeugt (REQUESTED), Executor hat
|
||||
# Job + Approval validiert (AUTHORIZED), Mutation ausgeführt (EXECUTED).
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
worker_id TEXT,
|
||||
result_code TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
self._conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_job ON audit_events(job_id)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# -- Job-Type-Scope -----------------------------------------------------
|
||||
|
|
@ -154,6 +170,9 @@ class JobStore:
|
|||
except sqlite3.IntegrityError as e:
|
||||
# UNIQUE-Verletzung (idempotency_key bereits vergeben) -> Anomalie.
|
||||
raise JobStoreError(f"create_job failed (idempotency_key collision): {e}") from e
|
||||
# Audit: RQ hat den Job erzeugt (REQUESTED). Kein REQUESTED-Eintrag ist
|
||||
# eine Autorisierung — nur AUTHORIZED (nach Validierung) autorisiert.
|
||||
self._record_audit(job_id, "REQUESTED")
|
||||
return self.get_job(job_id)
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
|
@ -181,6 +200,28 @@ class JobStore:
|
|||
|
||||
# -- State-Transition (mit Immutable-Fields-Schutz) ---------------------
|
||||
|
||||
def _record_audit(self, job_id: str, event_type: str, *,
|
||||
worker_id: Optional[str] = None,
|
||||
result_code: Optional[str] = None) -> None:
|
||||
"""Schreibt ein Audit-Ereignis (REQUESTED/AUTHORIZED/EXECUTED)."""
|
||||
now = int(time.time() * 1000)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events
|
||||
(event_id, job_id, event_type, worker_id, result_code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), job_id, event_type, worker_id, result_code, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_audit_event(self, job_id: str, event_type: str, *,
|
||||
worker_id: Optional[str] = None,
|
||||
result_code: Optional[str] = None) -> None:
|
||||
"""Öffentlicher Audit-Eintrag (AUTHORIZED/EXECUTED) für Executor-Cores."""
|
||||
self._record_audit(job_id, event_type, worker_id=worker_id,
|
||||
result_code=result_code)
|
||||
|
||||
def _transition(self, job_id: str, to_state: str, *, result_code: Optional[str] = None,
|
||||
worker_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -275,22 +316,39 @@ class JobStore:
|
|||
# -- Audit --------------------------------------------------------------
|
||||
|
||||
def audit_trail(self, job_id: str) -> List[Dict[str, Any]]:
|
||||
"""Gibt den Audit-Trail eines Jobs zurück (aus der jobs-Tabelle)."""
|
||||
"""
|
||||
Gibt den Audit-Trail eines Jobs zurück: die chronologische Liste der
|
||||
Audit-Ereignisse (REQUESTED / AUTHORIZED / EXECUTED) plus den aktuellen
|
||||
Job-Snapshot. Ein REQUESTED-Eintrag ist KEINE Autorisierung — nur
|
||||
AUTHORIZED (nach AUTH.3D-Validierung) autorisiert eine Mutation.
|
||||
"""
|
||||
events = self._conn.execute(
|
||||
"SELECT * FROM audit_events WHERE job_id = ? ORDER BY created_at",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
trail = [{
|
||||
"event_id": e["event_id"],
|
||||
"job_id": e["job_id"],
|
||||
"event_type": e["event_type"],
|
||||
"worker_id": e["worker_id"],
|
||||
"result_code": e["result_code"],
|
||||
"created_at": e["created_at"],
|
||||
} for e in events]
|
||||
job = self.get_job(job_id)
|
||||
if job is None:
|
||||
return []
|
||||
return [{
|
||||
"job_id": job["job_id"],
|
||||
"mission_id": job["mission_id"],
|
||||
"job_type": job["job_type"],
|
||||
"object_id": job["object_id"],
|
||||
"state": job["state"],
|
||||
"worker_id": job["worker_id"],
|
||||
"attempt_count": job["attempt_count"],
|
||||
"result_code": job["result_code"],
|
||||
"created_at": job["created_at"],
|
||||
"updated_at": job["updated_at"],
|
||||
}]
|
||||
if job is not None:
|
||||
trail.append({
|
||||
"job_id": job["job_id"],
|
||||
"mission_id": job["mission_id"],
|
||||
"job_type": job["job_type"],
|
||||
"object_id": job["object_id"],
|
||||
"state": job["state"],
|
||||
"worker_id": job["worker_id"],
|
||||
"attempt_count": job["attempt_count"],
|
||||
"result_code": job["result_code"],
|
||||
"created_at": job["created_at"],
|
||||
"updated_at": job["updated_at"],
|
||||
})
|
||||
return trail
|
||||
|
||||
# -- Helpers ------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -109,11 +109,18 @@ class SaveExecutorCore:
|
|||
self.store.mark_failed(job_id, RC_PROVENANCE_MISMATCH, worker_id)
|
||||
return self.store.get_job(job_id)
|
||||
|
||||
# Audit: Executor hat Job + Provenance validiert (AUTHORIZED).
|
||||
# Ein REQUESTED-Eintrag ist KEINE Autorisierung — nur AUTHORIZED
|
||||
# autorisiert die Mutation.
|
||||
self.store.record_audit_event(job_id, "AUTHORIZED", worker_id=worker_id)
|
||||
|
||||
# Tolaria-SAVE ausführen (fail-closed)
|
||||
result = self.tolaria_save(job["vault_path"], content)
|
||||
status = result.get("status")
|
||||
if status == "ok":
|
||||
self.store.mark_succeeded(job_id, worker_id)
|
||||
# Audit: Mutation ausgeführt (EXECUTED).
|
||||
self.store.record_audit_event(job_id, "EXECUTED", worker_id=worker_id)
|
||||
elif status == "error" and result.get("uncertain"):
|
||||
self.store.mark_outcome_unknown(job_id, worker_id)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -624,5 +624,106 @@ class StateMachineTests(unittest.TestCase):
|
|||
transition(ST_OUTCOME_UNKNOWN, ST_READY)
|
||||
|
||||
|
||||
class AuditTrailTests(unittest.TestCase):
|
||||
"""Audit-Trail trennt REQUESTED / AUTHORIZED / EXECUTED (Design §18)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
|
||||
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
|
||||
self.http_calls = []
|
||||
self.approvals = {}
|
||||
self.content = "hello world"
|
||||
self.provenance = _sha256(self.content)
|
||||
|
||||
def _source_loader(self, commit, object_id):
|
||||
return self.content
|
||||
|
||||
def _tolaria_save(self, vault_path, content):
|
||||
self.http_calls.append(("SAVE", vault_path))
|
||||
return {"status": "ok"}
|
||||
|
||||
def _approval_loader(self, approval_id):
|
||||
return self.approvals.get(approval_id)
|
||||
|
||||
def _approval_verify(self, approval):
|
||||
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
|
||||
|
||||
def _tolaria_delete(self, vault_path):
|
||||
self.http_calls.append(("DELETE", vault_path))
|
||||
return {"status": "ok"}
|
||||
|
||||
def test_audit_save_requested_authorized_executed(self):
|
||||
# SAVE: REQUESTED (RQ erzeugt) -> AUTHORIZED (Executor validiert)
|
||||
# -> EXECUTED (Mutation ausgeführt).
|
||||
store = JobStore(self.save_db, "SAVE")
|
||||
job = _make_save_job(provenance_hash=self.provenance)
|
||||
store.create_job(job)
|
||||
store.mark_ready(job["job_id"])
|
||||
exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save)
|
||||
exec_core.process_job(job["job_id"], "save-worker")
|
||||
trail = store.audit_trail(job["job_id"])
|
||||
types = [e["event_type"] for e in trail if "event_type" in e]
|
||||
self.assertIn("REQUESTED", types)
|
||||
self.assertIn("AUTHORIZED", types)
|
||||
self.assertIn("EXECUTED", types)
|
||||
# REQUESTED kommt vor AUTHORIZED vor EXECUTED
|
||||
self.assertLess(types.index("REQUESTED"), types.index("AUTHORIZED"))
|
||||
self.assertLess(types.index("AUTHORIZED"), types.index("EXECUTED"))
|
||||
store.close()
|
||||
|
||||
def test_audit_delete_requested_authorized_executed(self):
|
||||
# DELETE: REQUESTED -> AUTHORIZED (nach AUTH.3D-Validierung) -> EXECUTED.
|
||||
store = JobStore(self.delete_db, "DELETE")
|
||||
job = _make_delete_job()
|
||||
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||
store.create_job(job)
|
||||
store.mark_ready(job["job_id"])
|
||||
exec_core = DeleteExecutorCore(
|
||||
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||
)
|
||||
exec_core.process_job(job["job_id"], "delete-worker")
|
||||
trail = store.audit_trail(job["job_id"])
|
||||
types = [e["event_type"] for e in trail if "event_type" in e]
|
||||
self.assertIn("REQUESTED", types)
|
||||
self.assertIn("AUTHORIZED", types)
|
||||
self.assertIn("EXECUTED", types)
|
||||
self.assertLess(types.index("REQUESTED"), types.index("AUTHORIZED"))
|
||||
self.assertLess(types.index("AUTHORIZED"), types.index("EXECUTED"))
|
||||
store.close()
|
||||
|
||||
def test_audit_denied_delete_has_no_executed(self):
|
||||
# DELETE ohne Approval -> denied. Es gibt REQUESTED, aber KEIN
|
||||
# AUTHORIZED und KEIN EXECUTED (keine Mutation autorisiert).
|
||||
store = JobStore(self.delete_db, "DELETE")
|
||||
job = _make_delete_job()
|
||||
store.create_job(job)
|
||||
store.mark_ready(job["job_id"])
|
||||
exec_core = DeleteExecutorCore(
|
||||
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||
)
|
||||
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||
self.assertEqual(result["result_code"], "APPROVAL_MISSING")
|
||||
trail = store.audit_trail(job["job_id"])
|
||||
types = [e["event_type"] for e in trail if "event_type" in e]
|
||||
self.assertIn("REQUESTED", types)
|
||||
self.assertNotIn("AUTHORIZED", types)
|
||||
self.assertNotIn("EXECUTED", types)
|
||||
self.assertEqual(self.http_calls, [])
|
||||
store.close()
|
||||
|
||||
def test_audit_requested_is_not_authorization(self):
|
||||
# Ein REQUESTED-Eintrag allein autorisiert nichts: ein DELETE-Job,
|
||||
# der nur REQUESTED ist (nie AUTHORIZED), führt zu keiner Mutation.
|
||||
store = JobStore(self.delete_db, "DELETE")
|
||||
job = _make_delete_job()
|
||||
store.create_job(job)
|
||||
trail = store.audit_trail(job["job_id"])
|
||||
types = [e["event_type"] for e in trail if "event_type" in e]
|
||||
self.assertEqual(types, ["REQUESTED"])
|
||||
self.assertEqual(self.http_calls, [])
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue