#include "commands.h" #include "cmd_util.h" #include #include #include #include #include #include #include "audit.h" #include "config.h" #include "db.h" #include "ledger.h" #include "sha256.h" #include "util.h" /* ------------------------------------------------------------------ */ /* audit and backup */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *h_audit_list(struct req *r) { int64_t cursor = 0, limit = 100; arg_int(r->args, "cursor", &cursor); arg_int(r->args, "limit", &limit); if (limit < 1) limit = 100; if (limit > 1000) limit = 1000; const char *action = arg_str(r->args, "action"); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; const char *sql = action ? "SELECT seq,at,actor_user_id,actor_token_id,action," "request_json,result_code FROM audit_log" " WHERE org_id=?1 AND seq>?2 AND action=?3" " ORDER BY seq LIMIT ?4" : "SELECT seq,at,actor_user_id,actor_token_id,action," "request_json,result_code FROM audit_log" " WHERE org_id=?1 AND seq>?2 ORDER BY seq LIMIT ?4"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, cursor); if (action) sqlite3_bind_text(st, 3, action, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, limit); int64_t last_seq = cursor; int64_t n = 0; while (sqlite3_step(st) == SQLITE_ROW) { n++; last_seq = sqlite3_column_int64(st, 0); yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "seq", last_seq); yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", sq(sqlite3_column_text(st, 1))); if (sqlite3_column_type(st, 2) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "actor_user_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "actor_user_id", sqlite3_column_int64(st, 2)); if (sqlite3_column_type(st, 3) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "actor_token_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "actor_token_id", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "action", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "result_code", sq(sqlite3_column_text(st, 6))); const char *req = (const char *)sqlite3_column_text(st, 5); yyjson_doc *parsed = req ? yyjson_read(req, strlen(req), 0) : NULL; if (parsed && yyjson_is_obj(yyjson_doc_get_root(parsed))) yyjson_mut_obj_add_val(r->rdoc, o, "request", yyjson_val_mut_copy(r->rdoc, yyjson_doc_get_root(parsed))); if (parsed) yyjson_doc_free(parsed); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); if (n == limit) yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last_seq); else yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); return out; } static int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id, char **err) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, "SELECT id,sha256,size_bytes,content FROM attachments" " ORDER BY id", -1, &st, NULL) != SQLITE_OK) { if (err && !*err) *err = xstrdup(sqlite3_errmsg(db)); return -1; } int ret = 0; for (;;) { int step = sqlite3_step(st); if (step == SQLITE_DONE) break; if (step != SQLITE_ROW) { if (err && !*err) *err = xstrdup(sqlite3_errmsg(db)); ret = -1; break; } int64_t id = sqlite3_column_int64(st, 0); const void *sha = sqlite3_column_blob(st, 1); int sha_n = sqlite3_column_bytes(st, 1); int64_t size = sqlite3_column_int64(st, 2); const void *content = sqlite3_column_blob(st, 3); int content_n = sqlite3_column_bytes(st, 3); unsigned char digest[32]; util_sha256(content, (size_t)content_n, digest); (*checked)++; if (sha_n != 32 || memcmp(sha, digest, 32) != 0 || size != content_n) { if (!*bad_id) *bad_id = id; ret = 1; } } sqlite3_finalize(st); return ret; } static yyjson_mut_val *h_audit_verify(struct req *r) { int full = 0; arg_bool(r->args, "full", &full); int64_t audit_checked = 0, audit_bad = 0; char *err = NULL; int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err); if (audit_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); err = NULL; struct ledger_verify_result lv; int ledger_rc = ledger_verify(r->db, &lv, &err); if (ledger_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); err = NULL; int64_t att_checked = 0, att_bad = 0; int att_rc = 0; if (full) { att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err); if (att_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); } int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok); yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked); yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked", lv.vouchers_checked); yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers", lv.unbalanced_vouchers); if (audit_bad) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); if (lv.first_bad_voucher_id) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id", lv.first_bad_voucher_id); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id"); if (lv.first_unbalanced_voucher_id) yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id", lv.first_unbalanced_voucher_id); else yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id"); if (full) { yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked", att_checked); if (att_bad) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id", att_bad); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id"); } return o; } static yyjson_mut_val *h_backup_snapshot(struct req *r) { const char *dest = arg_str(r->args, "dest"); if (mkdir_p(g_cfg.backup_dir, 0700) != 0) return fail(r, "INTERNAL", "cannot create backup directory"); char path[4096]; if (dest && *dest) { snprintf(path, sizeof path, "%s", dest); } else { char stamp[32]; time_t t = (time_t)util_now(); struct tm tm; gmtime_r(&t, &tm); strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp); } if (access(path, F_OK) == 0) return fail(r, "CONFLICT", "destination already exists"); char *sql = sqlite3_mprintf("VACUUM INTO %Q", path); int rc = db_exec(r->db, sql, NULL); sqlite3_free(sql); if (rc != 0) return fail(r, "INTERNAL", "VACUUM INTO failed"); struct stat sb; if (stat(path, &sb) != 0) return fail(r, "INTERNAL", "backup file not found after snapshot"); FILE *f = fopen(path, "rb"); if (!f) return fail(r, "INTERNAL", "cannot read backup file"); SHA256_CTX ctx; sha256_init(&ctx); unsigned char chunk[65536]; size_t rn; while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) sha256_update(&ctx, (const BYTE *)chunk, rn); fclose(f); unsigned char digest[32]; sha256_final(&ctx, digest); char hex[65]; util_hex(digest, 32, hex); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot", "{}", "OK", NULL); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size); yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); return o; } static const struct cmd_arg args_audit_list[] = { { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, { "action", ARG_STR, 0, NULL, NULL, "Filter by action" }, }; static const struct cmd_arg args_audit_verify[] = { { "full", ARG_BOOL, 0, "false", NULL, "Also re-hash attachments" }, }; static const struct cmd_arg args_backup_snapshot[] = { { "dest", ARG_STR, 0, NULL, NULL, "Destination path; defaults to backup_dir with a timestamp" }, }; const struct command g_cmd_audit[] = { { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, 0, h_audit_list, CMD_ARGS(args_audit_list) }, { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0, 0, 0, h_audit_verify, CMD_ARGS(args_audit_verify) }, { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, 0, 1, 0, h_backup_snapshot, CMD_ARGS(args_backup_snapshot) }, }; const struct cmd_table g_cmd_table_audit = { g_cmd_audit, sizeof g_cmd_audit / sizeof g_cmd_audit[0] };