#include "commands.h" #include "cmd_util.h" #include #include #include #include #include "audit.h" #include "auth.h" #include "db.h" #include "util.h" /* ------------------------------------------------------------------ */ /* users and tokens */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *h_user_create(struct req *r) { const char *username = arg_str(r->args, "username"); const char *password = arg_str(r->args, "password"); const char *display = arg_str(r->args, "display_name"); if (!username || !password) return fail(r, "INVALID_ARGS", "username and password are required"); int is_admin = 0; arg_bool(r->args, "is_admin", &is_admin); char *err = NULL; int64_t uid = 0; if (db_create_user(r->db, username, display, password, is_admin, &uid, &err) != 0) { const char *code = err && strstr(err, "already exists") ? "CONFLICT" : "INTERNAL"; yyjson_mut_val *res = fail(r, code, err ? err : "could not create user"); free(err); return res; } free(err); char *reqjson = audit_args_json(r->args); audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", uid); yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username); yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", display ? display : username); yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0); return o; } static yyjson_mut_val *h_user_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,username,display_name,is_admin,created_at,disabled_at" " FROM users ORDER BY id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", sqlite3_column_int(st, 3) != 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at", sq(sqlite3_column_text(st, 5))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static int scope_allowed(const char *role, const char *scope) { int is_owner = strcmp(role, "owner") == 0; int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0; if (strcmp(scope, "read") == 0) return 1; if (strcmp(scope, "write") == 0) return is_bookkeeper; if (strcmp(scope, "admin") == 0) return is_owner; return 0; } static yyjson_mut_val *h_token_create(struct req *r) { const char *label = arg_str(r->args, "label"); if (!label || !*label) return fail(r, "INVALID_ARGS", "label is required"); char scopes[64] = ""; yyjson_val *sv = r->args && yyjson_is_obj(r->args) ? yyjson_obj_get(r->args, "scopes") : NULL; if (sv) { if (!yyjson_is_arr(sv)) return fail(r, "INVALID_ARGS", "scopes must be an array"); yyjson_val *item; yyjson_arr_iter it = yyjson_arr_iter_with(sv); while ((item = yyjson_arr_iter_next(&it))) { const char *s = yyjson_get_str(item); if (!s || !scope_allowed(r->role, s)) return fail(r, "FORBIDDEN", "scope not allowed for your role"); if (scopes[0]) strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1); strncat(scopes, s, sizeof scopes - strlen(scopes) - 1); } } if (!scopes[0]) snprintf(scopes, sizeof scopes, "%s", strcmp(r->role, "owner") == 0 ? "read,write,admin" : (strcmp(r->role, "bookkeeper") == 0 ? "read,write" : "read")); const char *expires_at = arg_str(r->args, "expires_at"); if (expires_at && !util_parse_iso_date(expires_at)) return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD"); char *token = auth_generate_token(); unsigned char th[32]; auth_hash_token(token, th); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes," "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", -1, &st, NULL) != SQLITE_OK) { free(token); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, r->sess->user_id); sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT); sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); if (expires_at) sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 7); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { free(token); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t token_id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes); yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token); if (expires_at) yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at); else yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); free(token); return o; } static yyjson_mut_val *h_token_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,label,scopes,created_at,expires_at,last_used_at," "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2" " ORDER BY id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, r->sess->user_id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 3))); if (sqlite3_column_type(st, 4) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at", sq(sqlite3_column_text(st, 5))); if (sqlite3_column_type(st, 6) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at", sq(sqlite3_column_text(st, 6))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_token_revoke(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); int is_owner = strcmp(r->role, "owner") == 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, is_owner ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" " AND user_id=?4", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int64(st, 3, r->org_id); if (!is_owner) sqlite3_bind_int64(st, 4, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "token not found"); audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}", "OK", NULL); return yyjson_mut_obj(r->rdoc); } static const struct cmd_arg args_user_create[] = { { "username", ARG_STR, 1, NULL, NULL, "Username" }, { "password", ARG_STR, 1, NULL, NULL, "Password" }, { "display_name", ARG_STR, 0, NULL, NULL, "Display name" }, { "is_admin", ARG_BOOL, 0, "false", NULL, "System admin" }, }; static const struct cmd_arg args_token_create[] = { { "label", ARG_STR, 1, NULL, NULL, "Label shown in token.list" }, { "scopes", ARG_JSON, 0, NULL, NULL, "Array of read, write, admin" }, { "expires_at", ARG_DATE, 0, NULL, NULL, "Expiry date" }, }; static const struct cmd_arg args_token_revoke[] = { { "id", ARG_INT, 1, NULL, NULL, "Token id" }, }; const struct command g_cmd_users[] = { { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, h_user_create, CMD_ARGS(args_user_create) }, { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, h_user_list, NULL, 0 }, { "token.create", "Create an API token for the active org", PERM_READ, 1, 1, 1, h_token_create, CMD_ARGS(args_token_create) }, { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0, 0, h_token_list, NULL, 0 }, { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1, h_token_revoke, CMD_ARGS(args_token_revoke) }, }; const struct cmd_table g_cmd_table_users = { g_cmd_users, sizeof g_cmd_users / sizeof g_cmd_users[0] };