aboutsummaryrefslogtreecommitdiff
path: root/src/cmd_users.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
commit8351fa7752bb8341ef405a37ff7c73142715e981 (patch)
treedde603d313f4f26934f2ce20a22dfd9b69324cf0 /src/cmd_users.c
parentbeafdbe0d64340387d619c38554976410129f05b (diff)
downloadbokf-8351fa7752bb8341ef405a37ff7c73142715e981.tar.gz
bokf-8351fa7752bb8341ef405a37ff7c73142715e981.zip
commands: split the command table by domain
Diffstat (limited to 'src/cmd_users.c')
-rw-r--r--src/cmd_users.c287
1 files changed, 287 insertions, 0 deletions
diff --git a/src/cmd_users.c b/src/cmd_users.c
new file mode 100644
index 0000000..f327c13
--- /dev/null
+++ b/src/cmd_users.c
@@ -0,0 +1,287 @@
+#include "commands.h"
+#include "cmd_util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#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]
+};