#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) { return req_list(r, "SELECT id,username,display_name,is_admin,created_at,disabled_at" " FROM users ORDER BY id", "id:i,username:s,display_name:s,is_admin:b,created_at:s," "disabled_at:n", ""); } 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 db_error(r); } 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); bind_text_or_null(st, 7, expires_at); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { free(token); return db_sqlite_error(r); } 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) { return req_list(r, "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", "id:i,label:s,scopes:s,created_at:s,expires_at:n," "last_used_at:n,revoked_at:n", "ii", r->org_id, r->sess->user_id); } 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; int rc = is_owner ? req_exec(r, "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3", "sii", ts, id, r->org_id) : req_exec(r, "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" " AND user_id=?4", "siii", ts, id, r->org_id, r->sess->user_id); if (rc != 0) return NULL; 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] };