summaryrefslogtreecommitdiff
path: root/src/cmd_audit.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd_audit.c')
-rw-r--r--src/cmd_audit.c288
1 files changed, 288 insertions, 0 deletions
diff --git a/src/cmd_audit.c b/src/cmd_audit.c
new file mode 100644
index 0000000..183e7e6
--- /dev/null
+++ b/src/cmd_audit.c
@@ -0,0 +1,288 @@
+#include "commands.h"
+#include "cmd_util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#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 fail(r, "INTERNAL", "database error");
+ 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]
+};