feat: enforce import provenance exposure policy (#2)

Merge BAT-017 / REQ-021 implementation after delivery-runner implementation, independent QC, and passing GitHub Actions CI.
This commit is contained in:
2026-07-09 18:44:19 +09:00
committed by GitHub
parent c9231c2685
commit d9182f28f2
13 changed files with 1168 additions and 47 deletions
+91 -14
View File
@@ -1,5 +1,13 @@
from __future__ import annotations
from ..provenance_policy import (
EXPOSURE_LEVEL_MINIMAL,
file_sha256,
redacted_source_file_label,
sanitize_csv_preview_rows,
sanitize_provenance_for_exposure,
)
from .shared import (
Any,
Path,
@@ -29,10 +37,9 @@ def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path:
return fail("not_found", f"CSV file not found: {path}")
with path.open("r", encoding="utf-8-sig", newline="") as f:
rows=list(csv.DictReader(f))
preview=[]
for r in rows[:20]:
preview.append(r)
return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
source_hash = file_sha256(path)
preview = sanitize_csv_preview_rows(rows, limit=20)
return ok(source=source, filename=redacted_source_file_label(str(path), source_hash), source_hash=source_hash, row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
@@ -56,7 +63,7 @@ def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int
preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
return ok(
source="kb_bank_pdf",
file_path=parsed["file_path"],
file_path=redacted_source_file_label(parsed["file_path"], source_hash),
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
@@ -137,7 +144,7 @@ def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip
(
batch_id,
"kb_bank_pdf",
parsed["file_path"],
redacted_source_file_label(parsed["file_path"], source_hash),
ts,
0,
"active",
@@ -387,7 +394,7 @@ def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_lim
preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
return ok(
source="seoulpay_xlsx",
file_path=parsed["file_path"],
file_path=redacted_source_file_label(parsed["file_path"], source_hash),
source_hash=source_hash,
sheet_name=parsed["sheet_name"],
metadata=parsed["metadata"],
@@ -431,7 +438,7 @@ def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "
(
batch_id,
"seoulpay_xlsx",
parsed["file_path"],
redacted_source_file_label(parsed["file_path"], source_hash),
ts,
0,
"active",
@@ -553,7 +560,7 @@ def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_pat
item["amount"] += int(tx.amount)
return ok(
source="toss_bank_pdf",
file_path=parsed["file_path"],
file_path=redacted_source_file_label(parsed["file_path"], source_hash),
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
@@ -597,7 +604,7 @@ def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates:
(
batch_id,
"toss_bank_pdf",
parsed["file_path"],
redacted_source_file_label(parsed["file_path"], source_hash),
ts,
0,
"active",
@@ -786,7 +793,7 @@ def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20,
item["amount"] += int(tx.amount)
return ok(
source="toss_securities_pdf",
file_path=parsed["file_path"],
file_path=redacted_source_file_label(parsed["file_path"], source_hash),
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
@@ -827,7 +834,7 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
(
batch_id,
"toss_securities_pdf",
parsed["file_path"],
redacted_source_file_label(parsed["file_path"], source_hash),
ts,
0,
"active",
@@ -916,7 +923,64 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
)
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
def list_ledger_import_batches(
source: str = "",
include_rolled_back: bool = True,
limit: int = 50,
provenance_exposure: str = EXPOSURE_LEVEL_MINIMAL,
debug_provenance_gate: bool = False,
db_path: str | None = None,
) -> dict[str, Any]:
return list_ledger_import_batches_with_provenance(
source=source,
include_rolled_back=include_rolled_back,
limit=limit,
provenance_exposure=provenance_exposure,
debug_provenance_gate=debug_provenance_gate,
db_path=db_path,
)
def _load_batch_memo(memo: Any) -> dict[str, Any]:
if isinstance(memo, dict):
return memo
if not isinstance(memo, str) or not memo.strip():
return {}
try:
parsed = json.loads(memo)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _batch_provenance(row: dict[str, Any]) -> dict[str, Any]:
memo = _load_batch_memo(row.get("memo"))
provenance = {
"source": row.get("source") or "",
"parser": row.get("source") or "",
"filename": row.get("filename") or "",
"import_batch_id": row.get("id") or "",
"source_hash": memo.get("source_hash") or "",
"validation": memo.get("validation") if isinstance(memo.get("validation"), dict) else {},
}
if memo.get("sheet_name"):
provenance["sheet_name"] = memo["sheet_name"]
return provenance
def list_ledger_import_batches_with_provenance(
source: str = "",
include_rolled_back: bool = True,
limit: int = 50,
provenance_exposure: str = EXPOSURE_LEVEL_MINIMAL,
debug_provenance_gate: bool = False,
db_path: str | None = None,
) -> dict[str, Any]:
if provenance_exposure == "debug-gated" and not debug_provenance_gate:
return fail(
"forbidden",
"debug-gated provenance requires explicit debug_provenance_gate=True",
)
clauses=[]; params=[]
if source:
clauses.append("source=?"); params.append(source)
@@ -926,7 +990,20 @@ def list_ledger_import_batches(source: str = "", include_rolled_back: bool = Tru
params.append(max(1, min(int(limit), 500)))
with db(db_path) as conn:
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows))
try:
items = []
for row in rows:
item = row_to_dict(row)
provenance = _batch_provenance(item)
item["memo"] = ""
item["provenance"] = sanitize_provenance_for_exposure(
provenance,
provenance_exposure,
)
items.append(item)
except ValueError as exc:
return fail("validation_error", str(exc))
return ok(import_batches=items, count=len(items))
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: