diff options
Diffstat (limited to 'src/cmd_users.c')
| -rw-r--r-- | src/cmd_users.c | 287 |
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] +}; |
