PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
audit_repository.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
13
14#include <chrono>
15#include <ctime>
16#include <iomanip>
17#include <sstream>
18
21
22#ifdef PACS_WITH_DATABASE_SYSTEM
23
24#include <database/query_builder.h>
25
26namespace kcenon::pacs::storage {
27
28using kcenon::common::make_error;
29using kcenon::common::ok;
30
31namespace {
32
33auto get_string(const database_row& row, const std::string& key) -> std::string {
34 auto it = row.find(key);
35 return (it != row.end()) ? it->second : std::string{};
36}
37
38auto get_int64(const database_row& row, const std::string& key) -> int64_t {
39 auto it = row.find(key);
40 if (it == row.end() || it->second.empty()) {
41 return 0;
42 }
43
44 try {
45 return std::stoll(it->second);
46 } catch (...) {
47 return 0;
48 }
49}
50
51} // namespace
52
53audit_repository::audit_repository(std::shared_ptr<pacs_database_adapter> db)
54 : base_repository(std::move(db), "audit_log", "audit_pk") {}
55
56auto audit_repository::to_like_pattern(std::string_view pattern)
57 -> std::string {
58 std::string result;
59 result.reserve(pattern.size());
60
61 for (char c : pattern) {
62 if (c == '*') {
63 result += '%';
64 } else if (c == '?') {
65 result += '_';
66 } else if (c == '%' || c == '_') {
67 result += '\\';
68 result += c;
69 } else {
70 result += c;
71 }
72 }
73
74 return result;
75}
76
77auto audit_repository::parse_timestamp(const std::string& str) const
78 -> std::chrono::system_clock::time_point {
79 if (str.empty()) {
80 return {};
81 }
82
83 std::tm tm{};
84 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d", &tm.tm_year, &tm.tm_mon,
85 &tm.tm_mday, &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
86 return {};
87 }
88
89 tm.tm_year -= 1900;
90 tm.tm_mon -= 1;
91 return std::chrono::system_clock::from_time_t(std::mktime(&tm));
92}
93
94auto audit_repository::format_timestamp(
95 std::chrono::system_clock::time_point tp) const -> std::string {
96 if (tp == std::chrono::system_clock::time_point{}) {
97 return "";
98 }
99
100 auto time = std::chrono::system_clock::to_time_t(tp);
101 std::tm tm{};
102#ifdef _WIN32
103 gmtime_s(&tm, &time);
104#else
105 gmtime_r(&time, &tm);
106#endif
107
108 char buf[32];
109 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
110 return buf;
111}
112
113auto audit_repository::add_audit_log(const audit_record& record)
114 -> Result<int64_t> {
115 if (!db() || !db()->is_connected()) {
116 return make_error<int64_t>(-1, "Database not connected", "storage");
117 }
118
119 std::map<std::string, database::core::database_value> insert_data{
120 {"event_type", record.event_type},
121 {"user_id", record.user_id},
122 {"source_ae", record.source_ae},
123 {"target_ae", record.target_ae},
124 {"source_ip", record.source_ip},
125 {"patient_id", record.patient_id},
126 {"study_uid", record.study_uid},
127 {"message", record.message},
128 {"details", record.details},
129 {"outcome", record.outcome.empty() ? std::string("SUCCESS")
130 : record.outcome}};
131 if (record.timestamp != std::chrono::system_clock::time_point{}) {
132 insert_data["timestamp"] = format_timestamp(record.timestamp);
133 }
134
135 auto builder = query_builder();
136 builder.insert_into(table_name()).values(insert_data);
137
138 auto insert_result = db()->insert(builder.build());
139 if (insert_result.is_err()) {
140 return make_error<int64_t>(
141 -1,
142 kcenon::pacs::compat::format("Failed to insert audit log: {}",
143 insert_result.error().message),
144 "storage");
145 }
146
147 return db()->last_insert_rowid();
148}
149
150auto audit_repository::query_audit_log(const audit_query& query)
151 -> Result<std::vector<audit_record>> {
152 if (!db() || !db()->is_connected()) {
153 return make_error<std::vector<audit_record>>(-1,
154 "Database not connected",
155 "storage");
156 }
157
158 auto builder = query_builder();
159 builder.select(select_columns()).from(table_name());
160
161 if (query.event_type.has_value()) {
162 builder.where("event_type", "=", *query.event_type);
163 }
164 if (query.outcome.has_value()) {
165 builder.where("outcome", "=", *query.outcome);
166 }
167 if (query.user_id.has_value()) {
168 builder.where("user_id", "LIKE", to_like_pattern(*query.user_id));
169 }
170 if (query.source_ae.has_value()) {
171 builder.where("source_ae", "=", *query.source_ae);
172 }
173 if (query.patient_id.has_value()) {
174 builder.where("patient_id", "=", *query.patient_id);
175 }
176 if (query.study_uid.has_value()) {
177 builder.where("study_uid", "=", *query.study_uid);
178 }
179 if (query.date_from.has_value()) {
180 builder.where("date(timestamp)", ">=", *query.date_from);
181 }
182 if (query.date_to.has_value()) {
183 builder.where("date(timestamp)", "<=", *query.date_to);
184 }
185
186 builder.order_by("timestamp", database::sort_order::desc);
187
188 if (query.limit > 0) {
189 builder.limit(query.limit);
190 }
191 if (query.offset > 0) {
192 builder.offset(query.offset);
193 }
194
195 auto result = db()->select(builder.build());
196 if (result.is_err()) {
197 return Result<std::vector<audit_record>>::err(result.error());
198 }
199
200 std::vector<audit_record> items;
201 items.reserve(result.value().size());
202 for (const auto& row : result.value()) {
203 items.push_back(map_row_to_entity(row));
204 }
205
206 return ok(std::move(items));
207}
208
209auto audit_repository::find_audit_by_pk(int64_t pk)
210 -> std::optional<audit_record> {
211 auto result = find_by_id(pk);
212 if (result.is_err()) {
213 return std::nullopt;
214 }
215 return result.value();
216}
217
218auto audit_repository::audit_count() -> Result<size_t> {
219 return count();
220}
221
222auto audit_repository::cleanup_old_audit_logs(std::chrono::hours age)
223 -> Result<size_t> {
224 auto cutoff = std::chrono::system_clock::now() - age;
225 auto cutoff_time = std::chrono::system_clock::to_time_t(cutoff);
226 std::tm tm{};
227 kcenon::pacs::compat::gmtime_safe(&cutoff_time, &tm);
228
229 std::ostringstream oss;
230 oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
231 auto cutoff_str = oss.str();
232
233 if (!db() || !db()->is_connected()) {
234 return make_error<size_t>(-1, "Database not connected", "storage");
235 }
236
237 auto builder = query_builder();
238 builder.delete_from(table_name())
239 .where("timestamp", "<", cutoff_str);
240
241 auto result = db()->remove(builder.build());
242 if (result.is_err()) {
243 return make_error<size_t>(
244 -1,
245 kcenon::pacs::compat::format("Failed to cleanup old audit logs: {}",
246 result.error().message),
247 "storage");
248 }
249
250 return ok(static_cast<size_t>(result.value()));
251}
252
253auto audit_repository::map_row_to_entity(const database_row& row) const
254 -> audit_record {
255 audit_record record;
256 record.pk = get_int64(row, "audit_pk");
257 record.event_type = get_string(row, "event_type");
258 record.outcome = get_string(row, "outcome");
259
260 auto timestamp = get_string(row, "timestamp");
261 if (!timestamp.empty()) {
262 record.timestamp = parse_timestamp(timestamp);
263 }
264
265 record.user_id = get_string(row, "user_id");
266 record.source_ae = get_string(row, "source_ae");
267 record.target_ae = get_string(row, "target_ae");
268 record.source_ip = get_string(row, "source_ip");
269 record.patient_id = get_string(row, "patient_id");
270 record.study_uid = get_string(row, "study_uid");
271 record.message = get_string(row, "message");
272 record.details = get_string(row, "details");
273 return record;
274}
275
276auto audit_repository::entity_to_row(const audit_record& entity) const
277 -> std::map<std::string, database_value> {
278 return {
279 {"event_type", entity.event_type},
280 {"outcome", entity.outcome},
281 {"timestamp", format_timestamp(entity.timestamp)},
282 {"user_id", entity.user_id},
283 {"source_ae", entity.source_ae},
284 {"target_ae", entity.target_ae},
285 {"source_ip", entity.source_ip},
286 {"patient_id", entity.patient_id},
287 {"study_uid", entity.study_uid},
288 {"message", entity.message},
289 {"details", entity.details},
290 };
291}
292
293auto audit_repository::get_pk(const audit_record& entity) const -> int64_t {
294 return entity.pk;
295}
296
297auto audit_repository::has_pk(const audit_record& entity) const -> bool {
298 return entity.pk > 0;
299}
300
301auto audit_repository::select_columns() const -> std::vector<std::string> {
302 return {"audit_pk", "event_type", "outcome", "timestamp",
303 "user_id", "source_ae", "target_ae", "source_ip",
304 "patient_id","study_uid", "message", "details"};
305}
306
307} // namespace kcenon::pacs::storage
308
309#else // !PACS_WITH_DATABASE_SYSTEM
310
311#include <sqlite3.h>
312
313namespace kcenon::pacs::storage {
314
315using kcenon::common::make_error;
316using kcenon::common::ok;
317
318namespace {
319
320auto parse_datetime(const char* str) -> std::chrono::system_clock::time_point {
321 if (!str || *str == '\0') {
322 return std::chrono::system_clock::time_point{};
323 }
324
325 std::tm tm{};
326 std::istringstream ss(str);
327 ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
328 if (ss.fail()) {
329 return std::chrono::system_clock::time_point{};
330 }
331
332 return std::chrono::system_clock::from_time_t(std::mktime(&tm));
333}
334
335auto get_text(sqlite3_stmt* stmt, int col) -> std::string {
336 const auto* text =
337 reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
338 return text ? std::string(text) : std::string{};
339}
340
341} // namespace
342
343audit_repository::audit_repository(sqlite3* db) : db_(db) {}
344
346
348 : db_(other.db_) {
349 other.db_ = nullptr;
350}
351
353 -> audit_repository& {
354 if (this != &other) {
355 db_ = other.db_;
356 other.db_ = nullptr;
357 }
358 return *this;
359}
360
361auto audit_repository::to_like_pattern(std::string_view pattern)
362 -> std::string {
363 std::string result;
364 result.reserve(pattern.size());
365
366 for (char c : pattern) {
367 if (c == '*') {
368 result += '%';
369 } else if (c == '?') {
370 result += '_';
371 } else if (c == '%' || c == '_') {
372 result += '\\';
373 result += c;
374 } else {
375 result += c;
376 }
377 }
378
379 return result;
380}
381
382auto audit_repository::parse_timestamp(const std::string& str)
383 -> std::chrono::system_clock::time_point {
384 return parse_datetime(str.c_str());
385}
386
387auto audit_repository::parse_audit_row(void* stmt_ptr) const -> audit_record {
388 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
389 audit_record record;
390
391 record.pk = sqlite3_column_int64(stmt, 0);
392 record.event_type = get_text(stmt, 1);
393 record.outcome = get_text(stmt, 2);
394 record.timestamp = parse_datetime(get_text(stmt, 3).c_str());
395 record.user_id = get_text(stmt, 4);
396 record.source_ae = get_text(stmt, 5);
397 record.target_ae = get_text(stmt, 6);
398 record.source_ip = get_text(stmt, 7);
399 record.patient_id = get_text(stmt, 8);
400 record.study_uid = get_text(stmt, 9);
401 record.message = get_text(stmt, 10);
402 record.details = get_text(stmt, 11);
403 return record;
404}
405
407 -> Result<int64_t> {
408 const char* sql = R"(
409 INSERT INTO audit_log (
410 event_type, outcome, user_id, source_ae, target_ae,
411 source_ip, patient_id, study_uid, message, details
412 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
413 )";
414
415 sqlite3_stmt* stmt = nullptr;
416 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
417 if (rc != SQLITE_OK) {
418 return make_error<int64_t>(
419 rc,
420 kcenon::pacs::compat::format("Failed to prepare audit insert: {}",
421 sqlite3_errmsg(db_)),
422 "storage");
423 }
424
425 sqlite3_bind_text(stmt, 1, record.event_type.c_str(), -1, SQLITE_TRANSIENT);
426 sqlite3_bind_text(stmt, 2, record.outcome.c_str(), -1, SQLITE_TRANSIENT);
427 sqlite3_bind_text(stmt, 3, record.user_id.c_str(), -1, SQLITE_TRANSIENT);
428 sqlite3_bind_text(stmt, 4, record.source_ae.c_str(), -1, SQLITE_TRANSIENT);
429 sqlite3_bind_text(stmt, 5, record.target_ae.c_str(), -1, SQLITE_TRANSIENT);
430 sqlite3_bind_text(stmt, 6, record.source_ip.c_str(), -1, SQLITE_TRANSIENT);
431 sqlite3_bind_text(stmt, 7, record.patient_id.c_str(), -1, SQLITE_TRANSIENT);
432 sqlite3_bind_text(stmt, 8, record.study_uid.c_str(), -1, SQLITE_TRANSIENT);
433 sqlite3_bind_text(stmt, 9, record.message.c_str(), -1, SQLITE_TRANSIENT);
434 sqlite3_bind_text(stmt, 10, record.details.c_str(), -1, SQLITE_TRANSIENT);
435
436 rc = sqlite3_step(stmt);
437 sqlite3_finalize(stmt);
438
439 if (rc != SQLITE_DONE) {
440 return make_error<int64_t>(
441 rc,
442 kcenon::pacs::compat::format("Failed to insert audit log: {}",
443 sqlite3_errmsg(db_)),
444 "storage");
445 }
446
447 return sqlite3_last_insert_rowid(db_);
448}
449
452 std::vector<audit_record> results;
453 std::ostringstream sql;
454 sql << "SELECT audit_pk, event_type, outcome, timestamp, user_id, "
455 << "source_ae, target_ae, source_ip, patient_id, study_uid, "
456 << "message, details FROM audit_log WHERE 1=1";
457
458 std::vector<std::string> params;
459
460 if (query.event_type) {
461 sql << " AND event_type = ?";
462 params.push_back(*query.event_type);
463 }
464 if (query.outcome) {
465 sql << " AND outcome = ?";
466 params.push_back(*query.outcome);
467 }
468 if (query.user_id) {
469 sql << " AND user_id LIKE ?";
470 params.push_back(to_like_pattern(*query.user_id));
471 }
472 if (query.source_ae) {
473 sql << " AND source_ae = ?";
474 params.push_back(*query.source_ae);
475 }
476 if (query.patient_id) {
477 sql << " AND patient_id = ?";
478 params.push_back(*query.patient_id);
479 }
480 if (query.study_uid) {
481 sql << " AND study_uid = ?";
482 params.push_back(*query.study_uid);
483 }
484 if (query.date_from) {
485 sql << " AND date(timestamp) >= date(?)";
486 params.push_back(*query.date_from);
487 }
488 if (query.date_to) {
489 sql << " AND date(timestamp) <= date(?)";
490 params.push_back(*query.date_to);
491 }
492
493 sql << " ORDER BY timestamp DESC";
494
495 if (query.limit > 0) {
496 sql << " LIMIT " << query.limit;
497 }
498 if (query.offset > 0) {
499 sql << " OFFSET " << query.offset;
500 }
501 sql << ";";
502
503 sqlite3_stmt* stmt = nullptr;
504 auto rc = sqlite3_prepare_v2(db_, sql.str().c_str(), -1, &stmt, nullptr);
505 if (rc != SQLITE_OK) {
506 return make_error<std::vector<audit_record>>(
507 rc,
508 kcenon::pacs::compat::format("Failed to prepare query: {}",
509 sqlite3_errmsg(db_)),
510 "storage");
511 }
512
513 for (size_t i = 0; i < params.size(); ++i) {
514 sqlite3_bind_text(stmt, static_cast<int>(i + 1), params[i].c_str(), -1,
515 SQLITE_TRANSIENT);
516 }
517
518 while (sqlite3_step(stmt) == SQLITE_ROW) {
519 results.push_back(parse_audit_row(stmt));
520 }
521
522 sqlite3_finalize(stmt);
523 return ok(std::move(results));
524}
525
527 -> std::optional<audit_record> {
528 const char* sql =
529 "SELECT audit_pk, event_type, outcome, timestamp, user_id, "
530 "source_ae, target_ae, source_ip, patient_id, study_uid, "
531 "message, details FROM audit_log WHERE audit_pk = ?;";
532
533 sqlite3_stmt* stmt = nullptr;
534 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
535 if (rc != SQLITE_OK) {
536 return std::nullopt;
537 }
538
539 sqlite3_bind_int64(stmt, 1, pk);
540
541 std::optional<audit_record> result;
542 if (sqlite3_step(stmt) == SQLITE_ROW) {
543 result = parse_audit_row(stmt);
544 }
545
546 sqlite3_finalize(stmt);
547 return result;
548}
549
550auto audit_repository::audit_count() const -> Result<size_t> {
551 const char* sql = "SELECT COUNT(*) FROM audit_log;";
552 sqlite3_stmt* stmt = nullptr;
553 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
554 if (rc != SQLITE_OK) {
555 return make_error<size_t>(
556 rc,
557 kcenon::pacs::compat::format("Failed to prepare query: {}",
558 sqlite3_errmsg(db_)),
559 "storage");
560 }
561
562 size_t count = 0;
563 if (sqlite3_step(stmt) == SQLITE_ROW) {
564 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
565 }
566 sqlite3_finalize(stmt);
567 return ok(count);
568}
569
571 -> Result<size_t> {
572 auto cutoff = std::chrono::system_clock::now() - age;
573 auto cutoff_time = std::chrono::system_clock::to_time_t(cutoff);
574 std::ostringstream oss;
575 oss << std::put_time(std::gmtime(&cutoff_time), "%Y-%m-%d %H:%M:%S");
576 auto cutoff_str = oss.str();
577
578 const char* sql = "DELETE FROM audit_log WHERE timestamp < ?;";
579 sqlite3_stmt* stmt = nullptr;
580 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
581 if (rc != SQLITE_OK) {
582 return make_error<size_t>(
583 rc,
584 kcenon::pacs::compat::format("Failed to prepare audit cleanup: {}",
585 sqlite3_errmsg(db_)),
586 "storage");
587 }
588
589 sqlite3_bind_text(stmt, 1, cutoff_str.c_str(), -1, SQLITE_TRANSIENT);
590 rc = sqlite3_step(stmt);
591 sqlite3_finalize(stmt);
592
593 if (rc != SQLITE_DONE) {
594 return make_error<size_t>(
595 rc,
596 kcenon::pacs::compat::format("Failed to cleanup old audit logs: {}",
597 sqlite3_errmsg(db_)),
598 "storage");
599 }
600
601 return static_cast<size_t>(sqlite3_changes(db_));
602}
603
604} // namespace kcenon::pacs::storage
605
606#endif // PACS_WITH_DATABASE_SYSTEM
Repository for audit log persistence.
auto audit_count() const -> Result< size_t >
auto query_audit_log(const audit_query &query) const -> Result< std::vector< audit_record > >
static auto to_like_pattern(std::string_view pattern) -> std::string
auto add_audit_log(const audit_record &record) -> Result< int64_t >
auto operator=(const audit_repository &) -> audit_repository &=delete
auto cleanup_old_audit_logs(std::chrono::hours age) -> Result< size_t >
static auto parse_timestamp(const std::string &str) -> std::chrono::system_clock::time_point
auto parse_audit_row(void *stmt) const -> audit_record
auto find_audit_by_pk(int64_t pk) const -> std::optional< audit_record >
Compatibility header providing kcenon::pacs::compat::format as an alias for std::format.
std::tm * gmtime_safe(const std::time_t *time, std::tm *result)
Cross-platform thread-safe UTC time conversion.
Definition time.h:62
@ move
C-MOVE move request/response.
const atna_coded_value query
Query (110112)
Query parameters for audit log search.
Audit log record from the database.
Compatibility header for cross-platform time functions.