From 2faf284a0eca1dd784578abf56c4188ddde40eb6 Mon Sep 17 00:00:00 2001 From: AIIrondev Date: Fri, 10 Apr 2026 20:32:56 +0200 Subject: [PATCH] Implement tamper-evident audit logging and invoice correction features - Introduced a new audit logging system that creates a tamper-evident chain of events in MongoDB. - Added functions for appending audit events, ensuring index integrity, and verifying the audit chain. - Implemented soft-delete functionality for inventory and borrowing records to comply with GoBD regulations. - Added UI components for displaying invoice corrections and audit dashboard, including mismatch reporting. - Created a CLI utility for verifying the integrity of the audit chain. - Enhanced invoice management by preventing overwrites and allowing correction entries with reasons and optional deltas. --- Legal/GOBD_CHANGE_REPORT_2026-04-10.md | 180 ++++++++++ Web/__pycache__/app.cpython-312.pyc | Bin 348116 -> 357959 bytes Web/__pycache__/audit_log.cpython-312.pyc | Bin 0 -> 5409 bytes .../verify_audit_chain.cpython-312.pyc | Bin 0 -> 1625 bytes Web/app.py | 322 ++++++++++++++++-- Web/audit_log.py | 149 ++++++++ Web/ausleihung.py | 30 +- Web/items.py | 67 ++-- Web/templates/admin_audit.html | 89 +++++ Web/templates/admin_borrowings.html | 10 + Web/templates/base.html | 1 + Web/templates/library_borrowings_admin.html | 11 + Web/verify_audit_chain.py | 31 ++ 13 files changed, 825 insertions(+), 65 deletions(-) create mode 100644 Legal/GOBD_CHANGE_REPORT_2026-04-10.md create mode 100644 Web/__pycache__/audit_log.cpython-312.pyc create mode 100644 Web/__pycache__/verify_audit_chain.cpython-312.pyc create mode 100644 Web/audit_log.py create mode 100644 Web/templates/admin_audit.html create mode 100644 Web/verify_audit_chain.py diff --git a/Legal/GOBD_CHANGE_REPORT_2026-04-10.md b/Legal/GOBD_CHANGE_REPORT_2026-04-10.md new file mode 100644 index 0000000..8f8c2cd --- /dev/null +++ b/Legal/GOBD_CHANGE_REPORT_2026-04-10.md @@ -0,0 +1,180 @@ +# GoBD Hardening Change Report (2026-04-10) + +## Scope +This change set implements core hardening measures for GoBD-oriented operation: + +1. Soft-delete instead of hard-delete for inventory and borrowing records. +2. Tamper-evident audit log chain for critical accounting/inventory events. +3. Invoice immutability model with correction entries instead of overwrite. + +## Implemented Changes + +### 1) Tamper-evident audit chain + +- New module: `Web/audit_log.py` +- Audit entries are chained by hash (`prev_hash` -> `entry_hash`) with monotonic `chain_index`. +- Canonical JSON serialization is used to make hashing deterministic. + +Current fields per audit event: +- `event_type` +- `actor` +- `source` +- `ip` +- `payload` +- `timestamp` +- `created_at` +- `prev_hash` +- `entry_hash` +- `chain_index` + +Integrated event writes in: +- Item soft-delete flow +- Invoice creation +- Invoice paid marking +- Invoice finalize+repair +- Invoice correction entry creation + +### 1b) Audit operational controls (phase 2) + +- Added index management in `Web/audit_log.py`: + - Unique index on `chain_index` + - Additional indexes on `created_at` and `event_type` +- Added chain verification function in `Web/audit_log.py`. +- Added CLI verification tool: `Web/verify_audit_chain.py`. +- Added admin verification endpoint: + - `GET /admin/audit/verify` + - Returns chain integrity result (`200` if valid, `409` on mismatch). +- Added lazy index provisioning helper invoked in admin borrowing views. + +### 2) Soft-delete conversion + +#### Inventory items + +- Deletion endpoint now marks records logically deleted: + - `Deleted: true` + - `DeletedAt` + - `DeletedBy` + - `LastUpdated` + - `Verfuegbar: false` +- Item-linked borrow records are also logically deleted (`Status: deleted`) instead of physically removed. +- Image files are no longer physically deleted in this flow. + +#### Borrow records + +- `remove_ausleihung` changed to set `Status: deleted` + timestamps. +- Retrieval helpers now exclude deleted records by default. + +#### Item reads + +- Item helper queries now exclude `Deleted: true` records. +- Grouped item lookups and appointment queries also exclude deleted records. +- Code uniqueness checks ignore deleted records, allowing controlled code reuse. + +### 3) Invoice immutability and correction flow + +- Invoice creation now blocks overwrite if an invoice already exists for the borrow record. +- New lock marker on invoice creation/update path: `InvoiceLocked: true`. +- New correction endpoint: + - `POST /admin/borrowings//invoice/correction` + - Appends entries to `InvoiceCorrections` + - Does not mutate existing `InvoiceData` body + - Requires correction reason + - Supports optional amount delta + +### 3b) UI integration for correction flow (phase 2) + +- Added correction action forms in: + - `Web/templates/admin_borrowings.html` + - `Web/templates/library_borrowings_admin.html` +- Added correction count display (`invoice_corrections_count`) in both admin tables. + +## Detailed File-Level Review + +### `Web/audit_log.py` + +- Introduces chain-based audit persistence. +- Uses last chain entry to calculate next `chain_index` and hash. +- Adds explicit index provisioning and full chain verification routine. +- Tradeoff: application-level sequencing is improved by unique index, but concurrent peak writes may still require retry logic around duplicate key conflicts. + +### `Web/verify_audit_chain.py` + +- New CLI operational tool for manual/cron verification. +- Returns non-zero exit code on chain mismatch. + +### `Web/app.py` + +- Added `_append_audit_event(...)` helper and integrated it at critical event boundaries. +- Inventory API routes now hide soft-deleted items. +- `delete_item` changed from destructive deletion to soft-delete semantics. +- Invoice route now rejects overwrite and requires correction route for changes. +- Added correction route with immutable invoice core. +- Added admin audit verification route and lazy audit index initialization helper. + +Behavioral impact: +- Deleted items no longer disappear from DB; they are hidden from normal views. +- Existing UI actions for delete continue to work, but now preserve evidence. +- Invoice re-creation attempts now return warning and redirect. + +### `Web/items.py` + +- Introduced `_active_record_query(...)` and applied it across item retrieval APIs. +- Converted `remove_item` to soft-delete update. +- Updated maintenance reset (`unstuck_item`) to status updates instead of `delete_many`. + +Behavioral impact: +- Item-level DB history is preserved. +- Legacy scripts relying on hard delete semantics may need adaptation. + +### `Web/ausleihung.py` + +- Converted `remove_ausleihung` to soft-delete by status. +- Default retrieval paths now exclude deleted records. + +Behavioral impact: +- Borrowing history remains in DB for traceability. + +## Validation Performed + +- Static diagnostics reported no errors in modified files: + - `Web/app.py` + - `Web/items.py` + - `Web/ausleihung.py` + - `Web/audit_log.py` + +- Additional phase-2 checks: + - `python3 -m py_compile Web/app.py Web/audit_log.py Web/verify_audit_chain.py` + - No syntax errors + - Template diagnostics: + - `Web/templates/admin_borrowings.html` no errors + - `Web/templates/library_borrowings_admin.html` no errors + +## Residual Risks / Open Points + +1. Concurrency hardening for audit chain: + - Current chain append may produce collisions under parallel writes. + - Recommendation: transaction or optimistic retry with unique index on `chain_index`. + +2. Broader query coverage: + - Some direct Mongo queries outside helper paths may still need explicit `Deleted != true` filters. + +3. Formal GoBD process requirements beyond code: + - Verfahrensdokumentation must be updated. + - WORM/immutable storage for exported archives should be enforced externally. + - Operational controls (RBAC review, periodic reconciliation, restore drills) should be documented. + +## Recommended Next Steps + +1. Add retry strategy for audit write conflicts: + - Retry `append_audit_event` on duplicate `chain_index` key errors. + +2. Add admin UI page for chain status and recent audit events: + - Human-readable inspection view on top of `/admin/audit/verify`. + +3. Add policy enforcement tests: + - Ensure invoice overwrite is blocked. + - Ensure soft-deleted records are hidden in APIs. + - Ensure deletion endpoints never call physical delete operators. + +4. Infrastructure-level immutability: + - Push periodic audit snapshots and invoice archives to immutable/WORM storage. diff --git a/Web/__pycache__/app.cpython-312.pyc b/Web/__pycache__/app.cpython-312.pyc index 568642c4526a33eb5bc02696aadb5333849efa85..243e26050c4e341903bb0ef4fad088a6b7c57d0c 100644 GIT binary patch delta 54714 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

P{*r`}*WAXI8hC-D6*;X{jh8$H!40IgaN- z)Z5dzNncLW2f2_vzgsKlv6pJYkh{=_m~VyV&sUkN|;0fJYQja}I8;Zh{eTb3qBC{deE4kyZ(1$;x ztYf>i_#V4c8-%P0KE!-!W>zX^d*1fMjmh0wVUK;HHUU`%`w;Vyu$4JDUCz{QEv?5M zYW5`EhnO$LU)i+jdJ(m&dAbfO{C7>~p?}f*`Oti+&(JgTsUefLPv1TUbq(pUH%4e9 zP}xu)M}2eC{pD57&_5}ABIFs#d+$woN3$FZC`aj{4gD+0_0SJc^&2KQS|Tgh$QfmzSA8H_p_vsSA(I)H5ze1BP^KLwoGW z=Aab&5c7=*)Ha#7qmDt>;|H!_lo}_Sk1><`Q-JIO-!M%-&&e?HIWuAHy)bhd5xo zL`V1#^A-5>&;(R&AB`+UJ@z4HmLWdGe00HJuAOYTo!gSL|7g@@jkRZT@_7C?Dd!HP&p7 z+7qwN&ek&v(TIr$9eb|EM{~LB+^Ko=5e-9XpzkWaia}#y6)(YtDjw9IS21KgTE&z4 zrup)3ReCy&yR(`HytG=+orG!#`X}_*!?b*?u!%mz0ua*W%Y$wcBy47nee1?x48hX&Q?8JQ@5P`8%fV-MB_ql5wW$ z&k#KbKtG`uWT`nLYAdpz9jqZ delta 48404 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 OBk<--sEKeS()<%RO5{2K diff --git a/Web/__pycache__/audit_log.cpython-312.pyc b/Web/__pycache__/audit_log.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0acb8912d9f786942627e32a628dfc5404ee19b8 GIT binary patch literal 5409 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 literal 0 HcmV?d00001 diff --git a/Web/__pycache__/verify_audit_chain.cpython-312.pyc b/Web/__pycache__/verify_audit_chain.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a4fc60c433da8d24c390d7403f910ae12fdd68d GIT binary patch literal 1625 zcma)6O>7%Q6rS1HwRasm?Ivy@O^Z!aRk$V138|=vLTpMJQX7#7R9{xEcjB&Vd(G^) zu`3$|sS=3*Rh5v^Lk~F?C5K9I;7oHs>ZPVeF_T=Xh(m5^!vUonn6bTeXfB!6&U^Fb zn|c59C=l=gKH!-@t$!r|HpRjna);TWoLK}WNCFd@QVu3zi%Lm}QTo|5#m z_Pj~ely8BghJB`T{Vf2<>k`0HcEG*7OSxQI?ktyKn}MJrRiudL<~z~JOW_i=au%(G zsU5z7i8aHyYdB0(`64DG_=aWT0u851rbWZ)YbmR6c1zq6k3iOQ*)C-4XwKq@bVo;v zXnaQe-)9jju*;pZHBjA)RQDqXxu^zdkGqrg zyIhx6VK*jwkiA`m%XN`8iR!MD_Pbm*=yF|J6&aN1%dD)R<&M8ytyL*241HyovWp;+ zsTBAv8uypzRgfKe1qg#{ zLvTf87JY>dVp&D4sBQEtoCRfc16r}kUrxk7PQ=EH@x;`0 zclBxFvuWmY_hMsAO66G4)ht@V5Q@v#VSVl1FJseMPhNI;c0}ET`Pd!&0$&~7uj_2F z!sI;fP7>nC0RJ8F+RTAwK%cv~E%}w94gJ`?skP%H_2VNe&f4gO`sjrx`Umr#W}yFv z=wkd%ydD^ymo`-2ni{IBp(UxI4sYoFYx=3Werkz4(NE8NHq^j(=I!i4_U^!a{L6={ zPNVmehC0~{9$lQcGjW$Tf+rWgy#MB7^~^IL@b@+SeYYnUCclqwBfk3QZw>g5JO`4Z zzr1Hj`58Tk{-o9i-~CN#^u`-%V*SwJr#<@rkG|N}_^B7q)P0xkI;Xjxx5h$a=fJP$ zw3rV)gm1?b^l(Iu1;DDJaLK9<2|jQnb^?`5$tS2T?vsBK-V+^f+MW literal 0 HcmV?d00001 diff --git a/Web/app.py b/Web/app.py index fe5cc0a..cffe365 100755 --- a/Web/app.py +++ b/Web/app.py @@ -30,6 +30,7 @@ from werkzeug.utils import secure_filename import user as us import items as it import ausleihung as au +import audit_log as al import datetime from apscheduler.schedulers.background import BackgroundScheduler from pymongo import MongoClient @@ -133,6 +134,43 @@ def _get_current_module(path): return 'inventory' +def _append_audit_event(db, event_type, payload): + """Write an audit entry; never break business flow on audit failures.""" + try: + al.append_audit_event( + db=db, + event_type=event_type, + actor=session.get('username', 'system'), + payload=payload, + request_ip=request.remote_addr, + source='web', + ) + except Exception as exc: + app.logger.warning(f"Audit write failed for {event_type}: {exc}") + + +_AUDIT_INDEXES_READY = False + + +def _ensure_audit_indexes_once(): + """Ensure audit indexes exist once per process.""" + global _AUDIT_INDEXES_READY + if _AUDIT_INDEXES_READY: + return + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + al.ensure_audit_indexes(db) + _AUDIT_INDEXES_READY = True + except Exception as exc: + app.logger.warning(f"Could not ensure audit indexes: {exc}") + finally: + if client: + client.close() + + def _parse_money_value(value): """Parse a user-facing money value into a float when possible.""" if value is None: @@ -1134,6 +1172,8 @@ def library_loans_admin(): flash('Bibliotheks-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) + _ensure_audit_indexes_once() + def fmt_dt(dt): try: return dt.strftime('%d.%m.%Y %H:%M') if dt else '' @@ -1151,7 +1191,7 @@ def library_loans_admin(): ausleihungen_col = db['ausleihungen'] library_items = list(items_col.find( - {'ItemType': {'$in': LIBRARY_ITEM_TYPES}}, + {'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}}, {'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1} )) item_map = {str(item['_id']): item for item in library_items if item.get('_id')} @@ -1197,6 +1237,7 @@ def library_loans_admin(): 'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_doc.get('Anschaffungskosten')), 'invoice_paid': bool(invoice_data.get('paid', False)), 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', + 'invoice_corrections_count': len(record.get('InvoiceCorrections', []) or []), 'has_damage': item_has_damage, 'damage_count': len(damage_reports), 'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''), @@ -2375,14 +2416,16 @@ def get_items(): items_col = db['items'] items_cur = items_col.find({ 'IsGroupedSubItem': {'$ne': True}, - 'ItemType': {'$nin': LIBRARY_ITEM_TYPES} + 'ItemType': {'$nin': LIBRARY_ITEM_TYPES}, + 'Deleted': {'$ne': True}, }) items = [] for itm in items_cur: item_id_str = str(itm['_id']) grouped_children = list(items_col.find({ 'ParentItemId': item_id_str, - 'IsGroupedSubItem': True + 'IsGroupedSubItem': True, + 'Deleted': {'$ne': True}, })) grouped_count = 1 + len(grouped_children) @@ -2422,7 +2465,7 @@ def get_item_json(id): try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] - item = db['items'].find_one({'_id': ObjectId(id)}) + item = db['items'].find_one({'_id': ObjectId(id), 'Deleted': {'$ne': True}}) if not item: return jsonify({'error': 'not found'}), 404 item['_id'] = str(item['_id']) @@ -3655,43 +3698,68 @@ def delete_item(id): flash('Element nicht gefunden.', 'error') return redirect(url_for('home_admin')) - # Collect all referenced images once to avoid duplicate deletion attempts - image_filenames = [] - seen_images = set() - for group_item in group_items: - for filename in group_item.get('Images', []): - if filename and filename not in seen_images: - seen_images.add(filename) - image_filenames.append(filename) - - # Attempt to delete image files - stats = {'originals': 0, 'thumbnails': 0, 'previews': 0, 'errors': 0} - try: - stats = delete_item_images(image_filenames) - app.logger.info(f"Item group deletion ({len(group_item_ids)} IDs) - Images removed: " + - f"originals={stats['originals']}, thumbnails={stats['thumbnails']}, " + - f"previews={stats['previews']}, errors={stats['errors']}") - except Exception as e: - app.logger.error(f"Error deleting images for item group {id}: {str(e)}") - - # Delete all items in the group and related borrow entries + # GoBD hardening: keep files and records immutable, use soft-delete markers only. delete_success = True - for group_item_id in group_item_ids: - if not it.remove_item(group_item_id): - delete_success = False + soft_deleted_items = 0 + soft_deleted_borrows = 0 + now = datetime.datetime.now() + client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] - db['ausleihungen'].delete_many({'Item': {'$in': group_item_ids}}) - client.close() + + for group_item_id in group_item_ids: + result = db['items'].update_one( + {'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}}, + {'$set': { + 'Deleted': True, + 'DeletedAt': now, + 'DeletedBy': session.get('username', ''), + 'LastUpdated': now, + 'Verfuegbar': False, + }} + ) + if result.modified_count > 0: + soft_deleted_items += 1 + elif result.matched_count == 0: + delete_success = False + + borrow_result = db['ausleihungen'].update_many( + { + 'Item': {'$in': group_item_ids}, + 'Status': {'$ne': 'deleted'} + }, + {'$set': { + 'Status': 'deleted', + 'DeletedAt': now, + 'DeletedBy': session.get('username', ''), + 'LastUpdated': now, + }} + ) + soft_deleted_borrows = borrow_result.modified_count + + _append_audit_event( + db, + 'inventory_item_soft_deleted', + { + 'root_item_id': id, + 'group_item_ids': group_item_ids, + 'soft_deleted_items': soft_deleted_items, + 'soft_deleted_borrow_records': soft_deleted_borrows, + } + ) except Exception as e: - app.logger.error(f"Error deleting borrowing records for item group {id}: {str(e)}") + app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}") + delete_success = False + finally: + if client: + client.close() if delete_success: - flash(f'Elementgruppe erfolgreich gelöscht ({len(group_item_ids)} Versionen). {stats["originals"]} Bilder entfernt.', 'success') + flash(f'Elementgruppe revisionssicher deaktiviert ({soft_deleted_items}/{len(group_item_ids)} Versionen).', 'success') else: - flash('Fehler beim Löschen des Elements aus der Datenbank.', 'error') + flash('Fehler beim revisionssicheren Deaktivieren des Elements.', 'error') return redirect(url_for('home_admin')) @@ -5075,6 +5143,8 @@ def admin_borrowings(): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) + _ensure_audit_indexes_once() + client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] @@ -5127,6 +5197,7 @@ def admin_borrowings(): 'invoice_created_at': fmt_dt(invoice_data.get('created_at')) if isinstance(invoice_data.get('created_at'), datetime.datetime) else '', 'invoice_paid': bool(invoice_data.get('paid', False)), 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', + 'invoice_corrections_count': len(r.get('InvoiceCorrections', []) or []), 'has_damage': has_damage, }) @@ -5140,6 +5211,75 @@ def admin_borrowings(): ) +@app.route('/admin/audit/verify', methods=['GET']) +def admin_verify_audit_chain(): + """Admin endpoint to verify audit chain integrity.""" + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'ok': False, 'error': 'forbidden'}), 403 + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + al.ensure_audit_indexes(db) + result = al.verify_audit_chain(db) + status_code = 200 if result.get('ok') else 409 + return jsonify(result), status_code + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 500 + finally: + if client: + client.close() + + +@app.route('/admin/audit', methods=['GET']) +def admin_audit_dashboard(): + """Admin dashboard for audit chain status and recent events.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + al.ensure_audit_indexes(db) + verify_result = al.verify_audit_chain(db) + + audit_rows = list( + db['audit_log'].find( + {}, + { + 'chain_index': 1, + 'event_type': 1, + 'actor': 1, + 'source': 1, + 'ip': 1, + 'timestamp': 1, + 'created_at': 1, + 'entry_hash': 1, + 'prev_hash': 1, + 'payload': 1, + } + ).sort('chain_index', -1).limit(200) + ) + + return render_template( + 'admin_audit.html', + verify_result=verify_result, + audit_rows=audit_rows, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + ) + except Exception as exc: + app.logger.error(f"Error loading audit dashboard: {exc}") + flash('Fehler beim Laden des Audit-Dashboards.', 'error') + return redirect(url_for('home_admin')) + finally: + if client: + client.close() + + @app.route('/admin/reset_borrowing/', methods=['POST']) def admin_reset_borrowing(borrow_id): """ @@ -5241,6 +5381,10 @@ def admin_create_invoice(borrow_id): now = datetime.datetime.now() existing_invoice = borrow_doc.get('InvoiceData') or {} + if existing_invoice.get('invoice_number'): + flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning') + return redirect(url_for('admin_borrowings')) + invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now) borrower = borrow_doc.get('User', '') item_name = item_doc.get('Name', '') @@ -5266,6 +5410,7 @@ def admin_create_invoice(borrow_id): update_fields = { 'InvoiceData': invoice_data, + 'InvoiceLocked': True, 'LastUpdated': now, } if close_borrowing: @@ -5313,6 +5458,19 @@ def admin_create_invoice(borrow_id): except Exception as log_err: app.logger.warning(f"Damage invoice log write failed for borrow {borrow_id}: {log_err}") + _append_audit_event( + db, + 'invoice_created', + { + 'borrow_id': borrow_id, + 'invoice_number': invoice_number, + 'amount': round(amount_value, 2), + 'mark_destroyed': mark_destroyed, + 'close_borrowing': close_borrowing, + 'item_id': str(item_doc.get('_id')), + } + ) + pdf_buffer = _build_invoice_pdf(invoice_data) return send_file( pdf_buffer, @@ -5387,6 +5545,16 @@ def admin_mark_invoice_paid(borrow_id): except Exception as log_err: app.logger.warning(f"Damage invoice paid log write failed for borrow {borrow_id}: {log_err}") + _append_audit_event( + db, + 'invoice_marked_paid', + { + 'borrow_id': borrow_id, + 'invoice_number': invoice_data.get('invoice_number', ''), + 'amount': invoice_data.get('amount'), + } + ) + flash('Rechnung wurde als bezahlt markiert.', 'success') return redirect(url_for('admin_borrowings')) except Exception as e: @@ -5492,6 +5660,19 @@ def admin_finalize_invoice_and_repair(borrow_id): except Exception as log_err: app.logger.warning(f"Damage invoice finalize log write failed for borrow {borrow_id}: {log_err}") + _append_audit_event( + db, + 'invoice_finalized_and_repaired', + { + 'borrow_id': borrow_id, + 'item_id': str(item_doc.get('_id')) if item_doc else '', + 'invoice_number': invoice_data.get('invoice_number', ''), + 'amount': invoice_data.get('amount'), + 'repaired': repaired, + 'resolved_damage_reports': resolved_count, + } + ) + if repaired: flash('Rechnung als bezahlt markiert und Element als repariert abgeschlossen.', 'success') else: @@ -5556,6 +5737,83 @@ def admin_view_invoice_pdf(borrow_id): client.close() +@app.route('/admin/borrowings//invoice/correction', methods=['POST']) +def admin_add_invoice_correction(borrow_id): + """Append an invoice correction entry without mutating the original invoice body.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + + borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1}) + if not borrow_doc: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + invoice_data = borrow_doc.get('InvoiceData') or {} + if not invoice_data: + flash('Korrektur nicht möglich: Es existiert noch keine Rechnung.', 'warning') + return redirect(url_for('admin_borrowings')) + + correction_reason = str(request.form.get('correction_reason', '')).strip() + if not correction_reason: + flash('Bitte eine Begründung für die Korrektur angeben.', 'error') + return redirect(url_for('admin_borrowings')) + + delta_raw = request.form.get('amount_delta') + delta_value = _parse_money_value(delta_raw) if delta_raw not in (None, '') else 0.0 + if delta_value is None: + flash('Ungültiger Korrekturbetrag.', 'error') + return redirect(url_for('admin_borrowings')) + + now = datetime.datetime.now() + correction_number = f"CORR-{now.strftime('%Y%m%d-%H%M%S')}-{str(borrow_doc.get('_id'))[-6:].upper()}" + correction_entry = { + 'correction_number': correction_number, + 'reason': correction_reason, + 'amount_delta': round(delta_value, 2), + 'amount_delta_text': _format_money_value(delta_value), + 'created_at': now, + 'created_by': session.get('username', ''), + 'invoice_number': invoice_data.get('invoice_number', ''), + } + + ausleihungen.update_one( + {'_id': borrow_doc['_id']}, + { + '$push': {'InvoiceCorrections': {'$each': [correction_entry], '$position': 0}}, + '$set': {'LastUpdated': now, 'InvoiceLocked': True} + } + ) + + _append_audit_event( + db, + 'invoice_correction_added', + { + 'borrow_id': borrow_id, + 'invoice_number': invoice_data.get('invoice_number', ''), + 'correction_number': correction_number, + 'amount_delta': round(delta_value, 2), + 'reason': correction_reason, + } + ) + + flash('Korrekturbuchung wurde revisionssicher ergänzt.', 'success') + return redirect(url_for('admin_borrowings')) + except Exception as e: + app.logger.error(f"Error creating invoice correction for borrow {borrow_id}: {e}") + flash('Fehler beim Anlegen der Korrekturbuchung.', 'error') + return redirect(url_for('admin_borrowings')) + finally: + if client: + client.close() + + @app.route('/admin/library/items//invoices', methods=['GET']) def library_item_invoices(item_id): """Show all stored invoices for one specific library item.""" diff --git a/Web/audit_log.py b/Web/audit_log.py new file mode 100644 index 0000000..508d1a6 --- /dev/null +++ b/Web/audit_log.py @@ -0,0 +1,149 @@ +""" +Tamper-evident audit logging helpers. + +The audit chain stores each entry with a hash of the previous entry. +Any mutation in history breaks the chain verification. +""" + +import datetime +import hashlib +import json +import random +import time + +from pymongo.errors import DuplicateKeyError + + +def _stable_json(value): + """Serialize dictionaries in a stable way for deterministic hashing.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str) + + +def _entry_hash(prev_hash, payload): + """Build the chained entry hash from previous hash + canonical payload.""" + base = f"{prev_hash}|{_stable_json(payload)}" + return hashlib.sha256(base.encode("utf-8")).hexdigest() + + +def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5): + """ + Append an audit event to a tamper-evident chain. + + Args: + db: MongoDB database handle. + event_type (str): Event category. + actor (str): User/system who performed the action. + payload (dict): Event details. + request_ip (str, optional): Request origin. + source (str): Source subsystem. + + Returns: + dict: Inserted audit entry. + """ + logs = db["audit_log"] + attempts = 0 + + while attempts <= max_retries: + previous = logs.find_one(sort=[("chain_index", -1)]) + prev_hash = previous.get("entry_hash", "") if previous else "" + chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1 + + timestamp = datetime.datetime.utcnow() + entry_payload = { + "event_type": event_type, + "actor": actor or "system", + "source": source, + "ip": request_ip or "", + "payload": payload or {}, + "timestamp": timestamp.isoformat() + "Z", + } + + entry_hash = _entry_hash(prev_hash, entry_payload) + + entry = { + **entry_payload, + "created_at": timestamp, + "prev_hash": prev_hash, + "entry_hash": entry_hash, + "chain_index": chain_index, + } + + try: + logs.insert_one(entry) + return entry + except DuplicateKeyError: + attempts += 1 + if attempts > max_retries: + raise + # Exponential backoff with jitter to avoid retry storms. + delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01) + time.sleep(delay) + + +def ensure_audit_indexes(db): + """Create indexes required for fast and safe audit operations.""" + logs = db["audit_log"] + logs.create_index("chain_index", unique=True, name="audit_chain_index_unique") + logs.create_index("created_at", name="audit_created_at_idx") + logs.create_index("event_type", name="audit_event_type_idx") + + +def verify_audit_chain(db): + """Verify hash chain integrity across all stored audit entries.""" + logs = db["audit_log"] + entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1)) + + previous_hash = "" + previous_index = 0 + mismatches = [] + + for entry in entries: + chain_index = int(entry.get("chain_index", 0)) + prev_hash = entry.get("prev_hash", "") + entry_hash = entry.get("entry_hash", "") + + payload = { + "event_type": entry.get("event_type", ""), + "actor": entry.get("actor", ""), + "source": entry.get("source", ""), + "ip": entry.get("ip", ""), + "payload": entry.get("payload", {}), + "timestamp": entry.get("timestamp", ""), + } + + expected_hash = _entry_hash(previous_hash, payload) + + if chain_index != previous_index + 1: + mismatches.append({ + "chain_index": chain_index, + "error": "chain_index_gap", + "expected": previous_index + 1, + "found": chain_index, + }) + + if prev_hash != previous_hash: + mismatches.append({ + "chain_index": chain_index, + "error": "prev_hash_mismatch", + "expected": previous_hash, + "found": prev_hash, + }) + + if entry_hash != expected_hash: + mismatches.append({ + "chain_index": chain_index, + "error": "entry_hash_mismatch", + "expected": expected_hash, + "found": entry_hash, + }) + + previous_hash = entry_hash + previous_index = chain_index + + return { + "ok": len(mismatches) == 0, + "count": len(entries), + "last_chain_index": previous_index, + "last_hash": previous_hash, + "mismatches": mismatches, + } diff --git a/Web/ausleihung.py b/Web/ausleihung.py index a9a5df6..8f0bb37 100755 --- a/Web/ausleihung.py +++ b/Web/ausleihung.py @@ -365,8 +365,7 @@ def cancel_ausleihung(id): def remove_ausleihung(id): """ - Entfernt einen Ausleihungsdatensatz aus der Datenbank. - Hinweis: Normalerweise ist es besser, Datensätze zu markieren als sie zu löschen. + Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete). Args: id (str): ID des zu entfernenden Ausleihungsdatensatzes @@ -378,9 +377,17 @@ def remove_ausleihung(id): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] ausleihungen = db['ausleihungen'] - result = ausleihungen.delete_one({'_id': ObjectId(id)}) + now = datetime.datetime.now() + result = ausleihungen.update_one( + {'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}}, + {'$set': { + 'Status': 'deleted', + 'DeletedAt': now, + 'LastUpdated': now, + }} + ) client.close() - return result.deleted_count > 0 + return result.modified_count > 0 except Exception as e: # print(f"Error removing ausleihung: {e}") # Log the error return False @@ -429,14 +436,15 @@ def get_ausleihungen(status=None, start=None, end=None, date_filter='overlap'): collection = db['ausleihungen'] # Query erstellen - query = {} + query = {'Status': {'$ne': 'deleted'}} # Status-Filter hinzufügen if status is not None: if isinstance(status, list): - query['Status'] = {'$in': status} + allowed_status = [s for s in status if s != 'deleted'] + query['Status'] = {'$in': allowed_status} else: - query['Status'] = status + query['Status'] = status if status != 'deleted' else '__blocked_deleted_status__' # Datum parsen, wenn als String angegeben if start is not None and isinstance(start, str): @@ -561,7 +569,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr db = client[cfg.MONGODB_DB] ausleihungen = db['ausleihungen'] - query = {'User': user_id} + query = {'User': user_id, 'Status': {'$ne': 'deleted'}} # Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen # und filtern später clientseitig @@ -610,12 +618,12 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr # Status-Matching if isinstance(status, list): - if current_status in status: + if current_status in status and current_status != 'deleted': # Status aktualisieren und zur Ergebnismenge hinzufügen ausleihung['VerifiedStatus'] = current_status filtered_results.append(ausleihung) else: - if current_status == status: + if current_status == status and current_status != 'deleted': # Status aktualisieren und zur Ergebnismenge hinzufügen ausleihung['VerifiedStatus'] = current_status filtered_results.append(ausleihung) @@ -644,7 +652,7 @@ def get_ausleihung_by_item(item_id, status=None, include_history=False): ausleihungen = db['ausleihungen'] # Build query - query = {'Item': item_id} + query = {'Item': item_id, 'Status': {'$ne': 'deleted'}} if status and not include_history: query['Status'] = status diff --git a/Web/items.py b/Web/items.py index 0f860c4..4e0edb3 100755 --- a/Web/items.py +++ b/Web/items.py @@ -43,6 +43,14 @@ def _non_library_query(extra_query=None): return base_query +def _active_record_query(extra_query=None): + """Build a query that excludes logically deleted records.""" + base_query = {'Deleted': {'$ne': True}} + if extra_query: + base_query.update(extra_query) + return base_query + + # === ITEM MANAGEMENT === def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None, @@ -118,7 +126,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi def remove_item(id): """ - Remove an item from the inventory. + Soft-delete an item from the inventory. Args: id (str): ID of the item to remove @@ -130,9 +138,17 @@ def remove_item(id): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - result = items.delete_one({'_id': ObjectId(id)}) + result = items.update_one( + {'_id': ObjectId(id), 'Deleted': {'$ne': True}}, + {'$set': { + 'Deleted': True, + 'DeletedAt': datetime.datetime.now(), + 'LastUpdated': datetime.datetime.now(), + 'Verfuegbar': False, + }} + ) client.close() - return result.deleted_count > 0 + return result.modified_count > 0 except Exception as e: print(f"Error removing item: {e}") return False @@ -155,7 +171,7 @@ def get_group_item_ids(id): db = client[cfg.MONGODB_DB] items = db['items'] - base_item = items.find_one({'_id': ObjectId(id)}) + base_item = items.find_one(_active_record_query({'_id': ObjectId(id)})) if not base_item: client.close() return [] @@ -165,7 +181,7 @@ def get_group_item_ids(id): # Prefer SeriesGroupId because it represents the full logical group. series_group_id = base_item.get('SeriesGroupId') if series_group_id: - for group_item in items.find({'SeriesGroupId': series_group_id}, {'_id': 1}): + for group_item in items.find(_active_record_query({'SeriesGroupId': series_group_id}), {'_id': 1}): resolved_ids.add(str(group_item['_id'])) else: resolved_ids.add(str(base_item['_id'])) @@ -173,10 +189,10 @@ def get_group_item_ids(id): parent_item_id = base_item.get('ParentItemId') if parent_item_id: resolved_ids.add(str(parent_item_id)) - for sibling in items.find({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}, {'_id': 1}): + for sibling in items.find(_active_record_query({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}), {'_id': 1}): resolved_ids.add(str(sibling['_id'])) else: - for child in items.find({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}, {'_id': 1}): + for child in items.find(_active_record_query({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}), {'_id': 1}): resolved_ids.add(str(child['_id'])) client.close() @@ -342,7 +358,7 @@ def is_code_unique(code_4, exclude_id=None): items = db['items'] # Build query to find items with this code - query = {'Code_4': code_4} + query = {'Code_4': code_4, 'Deleted': {'$ne': True}} # If we're editing an item, exclude it from the uniqueness check if exclude_id: @@ -368,7 +384,7 @@ def get_items(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - items_return = items.find(_non_library_query()) + items_return = items.find(_active_record_query(_non_library_query())) items_list = [] for item in items_return: item['_id'] = str(item['_id']) @@ -391,7 +407,7 @@ def get_available_items(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - items_return = items.find(_non_library_query({'Verfuegbar': True})) + items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': True}))) items_list = [] for item in items_return: item['_id'] = str(item['_id']) @@ -414,7 +430,7 @@ def get_borrowed_items(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - items_return = items.find(_non_library_query({'Verfuegbar': False})) + items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': False}))) items_list = [] for item in items_return: item['_id'] = str(item['_id']) @@ -440,7 +456,7 @@ def get_item(id): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - item = items.find_one({'_id': ObjectId(id)}) + item = items.find_one(_active_record_query({'_id': ObjectId(id)})) client.close() return item except Exception as e: @@ -462,7 +478,7 @@ def get_item_by_name(name): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - item = items.find_one({'Name': name}) + item = items.find_one(_active_record_query({'Name': name})) client.close() return item except Exception as e: @@ -486,13 +502,13 @@ def get_items_by_filter(filter_value): items = db['items'] # Use $or to find matches in any filter field - query = _non_library_query({ + query = _active_record_query(_non_library_query({ '$or': [ {'Filter': filter_value}, {'Filter2': filter_value}, {'Filter3': filter_value} ] - }) + })) results = list(items.find(query)) client.close() @@ -518,7 +534,7 @@ def get_filters(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - non_library = _non_library_query() + non_library = _active_record_query(_non_library_query()) filters = items.distinct('Filter', non_library) filters2 = items.distinct('Filter2', non_library) filters3 = items.distinct('Filter3', non_library) @@ -550,7 +566,7 @@ def get_primary_filters(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - filters = [f for f in items.distinct('Filter', _non_library_query()) if f] + filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f] client.close() return filters except Exception as e: @@ -569,7 +585,7 @@ def get_secondary_filters(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - filters = [f for f in items.distinct('Filter2', _non_library_query()) if f] + filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f] client.close() return filters except Exception as e: @@ -588,7 +604,7 @@ def get_tertiary_filters(): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - filters = [f for f in items.distinct('Filter3', _non_library_query()) if f] + filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f] client.close() return filters except Exception as e: @@ -610,7 +626,7 @@ def get_item_by_code_4(code_4): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] items = db['items'] - results = list(items.find(_non_library_query({"Code_4": code_4}))) + results = list(items.find(_active_record_query(_non_library_query({"Code_4": code_4})))) # Convert ObjectId to string for item in results: @@ -640,7 +656,14 @@ def unstuck_item(id): client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] ausleihungen = db['ausleihungen'] - result = ausleihungen.delete_many({'Item': id}) + result = ausleihungen.update_many( + {'Item': id, 'Status': {'$nin': ['cancelled', 'deleted']}}, + {'$set': { + 'Status': 'cancelled', + 'CancelledReason': 'unstuck_reset', + 'LastUpdated': datetime.datetime.now() + }} + ) # Also reset the item status items = db['items'] @@ -980,7 +1003,7 @@ def get_items_with_appointments(): db = client[cfg.MONGODB_DB] items = db['items'] - items_return = items.find({'NextAppointment': {'$exists': True}}) + items_return = items.find({'NextAppointment': {'$exists': True}, 'Deleted': {'$ne': True}}) items_list = [] for item in items_return: item['_id'] = str(item['_id']) diff --git a/Web/templates/admin_audit.html b/Web/templates/admin_audit.html new file mode 100644 index 0000000..ee667af --- /dev/null +++ b/Web/templates/admin_audit.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %} +{% block content %} +

+

Audit Dashboard

+

Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).

+ +
+
+
Chain Status
+ {% if verify_result.ok %} +
OK
+ {% else %} +
FEHLER
+ {% endif %} +
+
+
Einträge
+
{{ verify_result.count }}
+
+
+
Letzter Index
+
{{ verify_result.last_chain_index }}
+
+
+
Mismatch Count
+
{{ verify_result.mismatches|length }}
+
+
+ + {% if verify_result.mismatches %} +
+

Integritätsabweichungen

+
+ + + + + + + + + + + {% for m in verify_result.mismatches %} + + + + + + + {% endfor %} + +
IndexTypExpectedFound
{{ m.chain_index }}{{ m.error }}{{ m.expected }}{{ m.found }}
+
+
+ {% endif %} + +
+

Letzte Audit-Ereignisse

+
+ + + + + + + + + + + + + {% for row in audit_rows %} + + + + + + + + + {% endfor %} + +
IndexZeitEventActorHashPayload
{{ row.chain_index }}{{ row.timestamp or row.created_at }}{{ row.event_type }}{{ row.actor }}{{ row.entry_hash }}
{{ row.payload }}
+
+
+
+{% endblock %} diff --git a/Web/templates/admin_borrowings.html b/Web/templates/admin_borrowings.html index 100dc89..f6b165c 100644 --- a/Web/templates/admin_borrowings.html +++ b/Web/templates/admin_borrowings.html @@ -100,6 +100,9 @@ {% if e.invoice_number %}
{{ e.invoice_number }}
{{ e.invoice_amount }}
+ {% if e.invoice_corrections_count %} +
{{ e.invoice_corrections_count }} Korrektur(en)
+ {% endif %} {% if e.invoice_paid %}
Bezahlt
{% if e.invoice_paid_at %} @@ -129,6 +132,13 @@ {% endif %} + {% if e.invoice_number %} +
+ + + +
+ {% endif %}
diff --git a/Web/templates/base.html b/Web/templates/base.html index 6cf44a5..7ae8258 100755 --- a/Web/templates/base.html +++ b/Web/templates/base.html @@ -418,6 +418,7 @@
  • Filter verwalten
  • Orte verwalten
  • Ausleihen
  • +
  • Audit Dashboard
  • Logs
  • diff --git a/Web/templates/library_borrowings_admin.html b/Web/templates/library_borrowings_admin.html index 293b3af..85c58af 100644 --- a/Web/templates/library_borrowings_admin.html +++ b/Web/templates/library_borrowings_admin.html @@ -282,6 +282,9 @@ {% if e.invoice_number %}
    {{ e.invoice_number }}
    {{ e.invoice_amount }}
    + {% if e.invoice_corrections_count %} +
    {{ e.invoice_corrections_count }} Korrektur(en)
    + {% endif %} @@ -326,6 +329,14 @@ {% endif %} + {% if e.invoice_number %} +
    + + + +
    + {% endif %} + {% if e.status in ['active', 'planned'] %}
    diff --git a/Web/verify_audit_chain.py b/Web/verify_audit_chain.py new file mode 100644 index 0000000..22f6680 --- /dev/null +++ b/Web/verify_audit_chain.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""CLI utility to verify the tamper-evident audit chain.""" + +import json +import sys + +from pymongo import MongoClient + +import settings as cfg +import audit_log as al + + +def main(): + client = None + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + al.ensure_audit_indexes(db) + result = al.verify_audit_chain(db) + print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) + return 0 if result.get("ok") else 2 + except Exception as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + finally: + if client: + client.close() + + +if __name__ == "__main__": + sys.exit(main())