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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user