comparison auth/auth_store.c @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents
children
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
1 #include "auth/auth_store.h"
2
3 #include "deita/deita.h"
4
5 #include <openssl/crypto.h>
6
7 #include <fcntl.h>
8 #include <limits.h>
9 #include <pthread.h>
10 #include <stdio.h>
11 #include <stdlib.h>
12 #include <string.h>
13 #include <time.h>
14 #include <unistd.h>
15
16 struct Auth_Store {
17 Deita_Connection *p_connection;
18 pthread_mutex_t mutex;
19 };
20
21 /* ------------------------------------------------------------------ */
22 /* Migration SQL */
23 /* ------------------------------------------------------------------ */
24
25 static const char *k_migration_v1 =
26 "CREATE TABLE IF NOT EXISTS users ("
27 " id TEXT PRIMARY KEY,"
28 " username TEXT NOT NULL,"
29 " normalized_username TEXT NOT NULL UNIQUE,"
30 " password_hash TEXT NOT NULL,"
31 " role TEXT NOT NULL CHECK(role IN ('admin','member')),"
32 " status TEXT NOT NULL DEFAULT 'active'"
33 " CHECK(status IN ('active','disabled')),"
34 " must_change_password INTEGER NOT NULL DEFAULT 0,"
35 " password_changed_at INTEGER NOT NULL DEFAULT 0,"
36 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
37 " updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
38 ");"
39 "CREATE TABLE IF NOT EXISTS auth_sessions ("
40 " token_digest TEXT PRIMARY KEY,"
41 " csrf_digest TEXT NOT NULL,"
42 " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,"
43 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
44 " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
45 " idle_expires_at INTEGER NOT NULL,"
46 " absolute_expires_at INTEGER NOT NULL,"
47 " password_changed_at_snapshot INTEGER NOT NULL DEFAULT 0,"
48 " revoked_at INTEGER"
49 ");"
50 "CREATE TABLE IF NOT EXISTS guest_identities ("
51 " id TEXT PRIMARY KEY,"
52 " ip_binding_digest TEXT NOT NULL,"
53 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
54 " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
55 " expires_at INTEGER NOT NULL"
56 ");"
57 "CREATE TABLE IF NOT EXISTS guest_usage ("
58 " guest_id TEXT NOT NULL"
59 " REFERENCES guest_identities(id) ON DELETE CASCADE,"
60 " window_start INTEGER NOT NULL,"
61 " count INTEGER NOT NULL DEFAULT 0,"
62 " PRIMARY KEY (guest_id, window_start)"
63 ");"
64 "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
65 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
66 " guest_id TEXT NOT NULL"
67 " REFERENCES guest_identities(id) ON DELETE CASCADE,"
68 " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
69 " expires_at INTEGER NOT NULL"
70 ");"
71 "CREATE TABLE IF NOT EXISTS admin_audit_log ("
72 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
73 " actor_user_id TEXT,"
74 " action TEXT NOT NULL,"
75 " target_user_id TEXT,"
76 " detail TEXT,"
77 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
78 ");"
79 "CREATE INDEX IF NOT EXISTS idx_users_normalized"
80 " ON users(normalized_username);"
81 "CREATE INDEX IF NOT EXISTS idx_sessions_user"
82 " ON auth_sessions(user_id);"
83 "CREATE INDEX IF NOT EXISTS idx_sessions_expiry"
84 " ON auth_sessions(absolute_expires_at) WHERE revoked_at IS NULL;"
85 "CREATE INDEX IF NOT EXISTS idx_guest_expiry"
86 " ON guest_identities(expires_at);"
87 "CREATE INDEX IF NOT EXISTS idx_audit_created"
88 " ON admin_audit_log(created_at DESC);";
89
90 /* ------------------------------------------------------------------ */
91 /* Internal helpers */
92 /* ------------------------------------------------------------------ */
93
94 static void auth__copy_text_fixed(char *dest, size_t size, const char *text)
95 {
96 const char *src = text ? text : "";
97 size_t n = strlen(src);
98 if (n >= size)
99 n = size - 1;
100 memcpy(dest, src, n);
101 dest[n] = '\0';
102 }
103
104 static boolean auth__generate_uuid(char output[37])
105 {
106 uint8 bytes[16];
107 int fd = open("/dev/urandom", O_RDONLY);
108 size_t offset = 0;
109 ssize_t amount;
110
111 if (fd < 0)
112 return FALSE;
113 while (offset < sizeof(bytes))
114 {
115 amount = read(fd, bytes + offset, sizeof(bytes) - offset);
116 if (amount <= 0)
117 {
118 close(fd);
119 return FALSE;
120 }
121 offset += (size_t)amount;
122 }
123 close(fd);
124
125 bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40);
126 bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80);
127 snprintf(
128 output, 37,
129 "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
130 bytes[0], bytes[1], bytes[2], bytes[3],
131 bytes[4], bytes[5], bytes[6], bytes[7],
132 bytes[8], bytes[9], bytes[10], bytes[11],
133 bytes[12], bytes[13], bytes[14], bytes[15]);
134 return TRUE;
135 }
136
137 static Auth_Store_Result auth__rollback(Auth_Store *p_store,
138 Auth_Store_Result result)
139 {
140 Deita_Query_Execute_Update(p_store->p_connection, "ROLLBACK");
141 return result;
142 }
143
144 /* Forward declaration — implementation is in the guest quota section. */
145 static void auth__i64_str(char *buf, size_t size, int64 value);
146
147 static boolean auth__insert_audit_log_locked(
148 Auth_Store *p_store,
149 const char *actor_user_id,
150 const char *action,
151 const char *target_user_id,
152 const char *detail)
153 {
154 /* actor_user_id and detail may be NULL — represented as empty string */
155 const char *actor = actor_user_id ? actor_user_id : "";
156 const char *tgt = target_user_id ? target_user_id : "";
157 const char *det = detail ? detail : "";
158 const char *params[] = {actor, action, tgt, det};
159 if (Deita_Query_Execute_Update_Prepared(
160 p_store->p_connection,
161 "INSERT INTO admin_audit_log"
162 " (actor_user_id, action, target_user_id, detail)"
163 " VALUES (?, ?, ?, ?)",
164 4, params) < 0)
165 return FALSE;
166 /* Trim to the most recent 10 000 entries. */
167 Deita_Query_Execute_Update(
168 p_store->p_connection,
169 "DELETE FROM admin_audit_log"
170 " WHERE id <= (SELECT MAX(id) FROM admin_audit_log) - 10000");
171 return TRUE;
172 }
173
174 static boolean auth__digest_is_valid(const char *digest)
175 {
176 if (!digest)
177 return FALSE;
178 for (size_t i = 0; i < AUTH_CRYPTO_TOKEN_DIGEST_SIZE - 1; i++)
179 {
180 uint8 c = (uint8)digest[i];
181 if (c == '\0' ||
182 !((c >= '0' && c <= '9') ||
183 (c >= 'a' && c <= 'f') ||
184 (c >= 'A' && c <= 'F')))
185 return FALSE;
186 }
187 return digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE - 1] == '\0';
188 }
189
190 static boolean auth__session_arguments_are_valid(
191 const char *token_digest,
192 const char *csrf_digest,
193 int64 idle_ttl_secs,
194 int64 absolute_ttl_secs,
195 int64 current_unix)
196 {
197 if (!auth__digest_is_valid(token_digest) ||
198 !auth__digest_is_valid(csrf_digest) ||
199 current_unix < 0 || idle_ttl_secs <= 0 || absolute_ttl_secs <= 0 ||
200 idle_ttl_secs > absolute_ttl_secs)
201 return FALSE;
202 if (current_unix > (int64)LLONG_MAX - idle_ttl_secs ||
203 current_unix > (int64)LLONG_MAX - absolute_ttl_secs)
204 return FALSE;
205 return TRUE;
206 }
207
208 static void auth__fill_session_record(
209 Auth_Session_Record *p_record,
210 const char *user_id,
211 int64 idle_ttl_secs,
212 int64 absolute_ttl_secs,
213 int64 current_unix,
214 int64 password_changed_at)
215 {
216 memset(p_record, 0, sizeof(*p_record));
217 auth__copy_text_fixed(p_record->user_id, sizeof(p_record->user_id), user_id);
218 p_record->created_at = current_unix;
219 p_record->last_seen_at = current_unix;
220 p_record->idle_expires_at = current_unix + idle_ttl_secs;
221 p_record->absolute_expires_at = current_unix + absolute_ttl_secs;
222 p_record->password_changed_at_snapshot = password_changed_at;
223 }
224
225 static Auth_Store_Result auth__insert_session_locked(
226 Auth_Store *p_store,
227 const char *user_id,
228 const char *token_digest,
229 const char *csrf_digest,
230 int64 idle_ttl_secs,
231 int64 absolute_ttl_secs,
232 int64 current_unix,
233 int64 password_changed_at)
234 {
235 char created_str[32], idle_exp_str[32], abs_exp_str[32], snap_str[32];
236 snprintf(created_str, sizeof(created_str), "%lld", (long long)current_unix);
237 snprintf(idle_exp_str, sizeof(idle_exp_str), "%lld",
238 (long long)(current_unix + idle_ttl_secs));
239 snprintf(abs_exp_str, sizeof(abs_exp_str), "%lld",
240 (long long)(current_unix + absolute_ttl_secs));
241 snprintf(snap_str, sizeof(snap_str), "%lld",
242 (long long)password_changed_at);
243
244 const char *params[] = {
245 token_digest, csrf_digest, user_id,
246 created_str, created_str, idle_exp_str, abs_exp_str, snap_str
247 };
248 int32 result = Deita_Query_Execute_Update_Prepared(
249 p_store->p_connection,
250 "INSERT INTO auth_sessions"
251 " (token_digest, csrf_digest, user_id,"
252 " created_at, last_seen_at, idle_expires_at,"
253 " absolute_expires_at, password_changed_at_snapshot)"
254 " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
255 8, params);
256 return result < 0 ? AUTH_STORE_CONFLICT : AUTH_STORE_OK;
257 }
258
259 /* ------------------------------------------------------------------ */
260 /* Migration system */
261 /* ------------------------------------------------------------------ */
262
263 static boolean auth__apply_migration(Auth_Store *p_store,
264 int32 version,
265 const char *sql)
266 {
267 char version_str[32];
268 snprintf(version_str, sizeof(version_str), "%d", (int)version);
269
270 if (Deita_Query_Execute_Update(
271 p_store->p_connection, "BEGIN EXCLUSIVE") < 0)
272 return FALSE;
273
274 /* Check if already applied. */
275 Dowa_Arena *p_arena = Dowa_Arena_Create(1024);
276 if (!p_arena)
277 {
278 auth__rollback(p_store, AUTH_STORE_ERROR);
279 return FALSE;
280 }
281 const char *check_params[] = {version_str};
282 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
283 p_store->p_connection,
284 "SELECT version FROM auth_schema_migrations WHERE version = ?",
285 1, check_params, p_arena);
286 boolean already = p_result && Deita_Result_Set_Next(p_result);
287 if (p_result)
288 Deita_Result_Set_Free(p_result);
289 Dowa_Arena_Free(p_arena);
290
291 if (already)
292 {
293 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
294 {
295 auth__rollback(p_store, AUTH_STORE_ERROR);
296 return FALSE;
297 }
298 return TRUE;
299 }
300
301 /* Apply the migration. */
302 if (Deita_Query_Execute_Update(p_store->p_connection, sql) < 0)
303 {
304 auth__rollback(p_store, AUTH_STORE_ERROR);
305 return FALSE;
306 }
307
308 const char *ins_params[] = {version_str};
309 if (Deita_Query_Execute_Update_Prepared(
310 p_store->p_connection,
311 "INSERT INTO auth_schema_migrations (version) VALUES (?)",
312 1, ins_params) < 0)
313 {
314 auth__rollback(p_store, AUTH_STORE_ERROR);
315 return FALSE;
316 }
317
318 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
319 {
320 auth__rollback(p_store, AUTH_STORE_ERROR);
321 return FALSE;
322 }
323 return TRUE;
324 }
325
326 /*
327 * Migration v2: extend guest quota tables.
328 * - Backfills turns_used from the legacy count column before any new schema
329 * is applied, so existing usage is preserved (req 1).
330 * - Adds turns_used, output_tokens_used, output_tokens_reserved to guest_usage.
331 * - Replaces guest_usage_reservations with a richer schema keyed by request_id.
332 * The old reservations table is ephemeral (in-flight requests only), so
333 * dropping and recreating it is safe; legacy reservations had no token-amount
334 * column so output_tokens_reserved stays 0 after the replace.
335 */
336 static const char *k_migration_v2 =
337 "ALTER TABLE guest_usage"
338 " ADD COLUMN turns_used INTEGER NOT NULL DEFAULT 0;"
339 "ALTER TABLE guest_usage"
340 " ADD COLUMN output_tokens_used INTEGER NOT NULL DEFAULT 0;"
341 "ALTER TABLE guest_usage"
342 " ADD COLUMN output_tokens_reserved INTEGER NOT NULL DEFAULT 0;"
343 /* Backfill turns_used from the legacy request-count column. */
344 "UPDATE guest_usage SET turns_used = count WHERE count > 0;"
345 "DROP TABLE IF EXISTS guest_usage_reservations;"
346 "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
347 " request_id TEXT PRIMARY KEY,"
348 " guest_id TEXT NOT NULL"
349 " REFERENCES guest_identities(id) ON DELETE CASCADE,"
350 " window_start INTEGER NOT NULL,"
351 " output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
352 " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
353 " expires_at INTEGER NOT NULL"
354 ");"
355 "CREATE INDEX IF NOT EXISTS idx_reservations_guest"
356 " ON guest_usage_reservations(guest_id);"
357 "CREATE INDEX IF NOT EXISTS idx_reservations_expiry"
358 " ON guest_usage_reservations(expires_at);"
359 "CREATE INDEX IF NOT EXISTS idx_guest_usage_guest"
360 " ON guest_usage(guest_id);";
361
362 static boolean auth__run_migrations(Auth_Store *p_store)
363 {
364 if (Deita_Query_Execute_Update(
365 p_store->p_connection,
366 "CREATE TABLE IF NOT EXISTS auth_schema_migrations ("
367 " version INTEGER PRIMARY KEY,"
368 " applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
369 ")") < 0)
370 return FALSE;
371
372 if (!auth__apply_migration(p_store, 1, k_migration_v1))
373 return FALSE;
374 return auth__apply_migration(p_store, 2, k_migration_v2);
375 }
376
377 /* ------------------------------------------------------------------ */
378 /* Store lifecycle */
379 /* ------------------------------------------------------------------ */
380
381 /*
382 * Reap expired reservations with the mutex already held.
383 * Run atomically in a nested SAVEPOINT to avoid interfering with any outer
384 * transaction (callers sometimes hold BEGIN IMMEDIATE).
385 */
386 static void auth__reap_expired_reservations_locked(
387 Auth_Store *p_store,
388 int64 current_unix)
389 {
390 char now_str[32];
391 auth__i64_str(now_str, sizeof(now_str), current_unix);
392
393 /* Use a savepoint so we can run inside or outside a transaction. */
394 if (Deita_Query_Execute_Update(
395 p_store->p_connection,
396 "SAVEPOINT reap_expired") < 0)
397 return;
398
399 const char *upd_params[] = {now_str, now_str};
400 if (Deita_Query_Execute_Update_Prepared(
401 p_store->p_connection,
402 "UPDATE guest_usage"
403 " SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
404 " SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
405 " FROM guest_usage_reservations r"
406 " WHERE r.guest_id = guest_usage.guest_id"
407 " AND r.window_start = guest_usage.window_start"
408 " AND r.expires_at < ?"
409 " ))"
410 " WHERE output_tokens_reserved > 0"
411 " AND EXISTS ("
412 " SELECT 1 FROM guest_usage_reservations r2"
413 " WHERE r2.guest_id = guest_usage.guest_id"
414 " AND r2.expires_at < ?)",
415 2, upd_params) < 0)
416 {
417 Deita_Query_Execute_Update(p_store->p_connection,
418 "ROLLBACK TO reap_expired");
419 Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
420 return;
421 }
422
423 const char *del_params[] = {now_str};
424 if (Deita_Query_Execute_Update_Prepared(
425 p_store->p_connection,
426 "DELETE FROM guest_usage_reservations WHERE expires_at < ?",
427 1, del_params) < 0)
428 {
429 Deita_Query_Execute_Update(p_store->p_connection,
430 "ROLLBACK TO reap_expired");
431 Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
432 return;
433 }
434
435 Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
436 }
437
438 Auth_Store *Auth_Store_Create(const char *database_path)
439 {
440 if (!database_path)
441 return NULL;
442
443 Auth_Store *p_store = calloc(1, sizeof(*p_store));
444 if (!p_store)
445 return NULL;
446
447 p_store->p_connection = Deita_Connection_Create(
448 DEITA_DATABASE_TYPE_SQLITE3, database_path);
449 if (!p_store->p_connection ||
450 !Deita_Connection_Is_Open(p_store->p_connection))
451 {
452 if (p_store->p_connection)
453 Deita_Connection_Close(p_store->p_connection);
454 free(p_store);
455 return NULL;
456 }
457
458 if (pthread_mutex_init(&p_store->mutex, NULL) != 0)
459 {
460 Deita_Connection_Close(p_store->p_connection);
461 free(p_store);
462 return NULL;
463 }
464
465 /* Connection-level settings — must be re-applied on every open. */
466 if (Deita_Query_Execute_Update(p_store->p_connection,
467 "PRAGMA foreign_keys = ON;"
468 "PRAGMA journal_mode = WAL;") < 0)
469 {
470 Auth_Store_Destroy(p_store);
471 return NULL;
472 }
473
474 if (!auth__run_migrations(p_store))
475 {
476 Auth_Store_Destroy(p_store);
477 return NULL;
478 }
479
480 /* Reap any expired reservations left over from a previous run. */
481 pthread_mutex_lock(&p_store->mutex);
482 auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
483 pthread_mutex_unlock(&p_store->mutex);
484
485 return p_store;
486 }
487
488 void Auth_Store_Destroy(Auth_Store *p_store)
489 {
490 if (!p_store)
491 return;
492 if (p_store->p_connection)
493 Deita_Connection_Close(p_store->p_connection);
494 pthread_mutex_destroy(&p_store->mutex);
495 free(p_store);
496 }
497
498 /* ------------------------------------------------------------------ */
499 /* Username utilities */
500 /* ------------------------------------------------------------------ */
501
502 boolean Auth_Store_Normalize_Username(
503 const char *username,
504 char *normalized,
505 size_t capacity)
506 {
507 if (!username || !normalized || capacity == 0)
508 return FALSE;
509
510 size_t len = strlen(username);
511 size_t start = 0;
512 size_t end = len;
513
514 while (start < len && username[start] == ' ')
515 start++;
516 while (end > start && username[end - 1] == ' ')
517 end--;
518
519 size_t norm_len = end - start;
520 if (norm_len < AUTH_STORE_USERNAME_MIN ||
521 norm_len > AUTH_STORE_USERNAME_MAX)
522 return FALSE;
523 if (capacity <= norm_len)
524 return FALSE;
525
526 for (size_t i = 0; i < norm_len; i++)
527 {
528 uint8 c = (uint8)username[start + i];
529 if (c >= 'A' && c <= 'Z')
530 c = (uint8)(c - 'A' + 'a');
531 else if ((c >= 'a' && c <= 'z') ||
532 (c >= '0' && c <= '9') ||
533 c == '_' || c == '-' || c == '.')
534 ; /* valid as-is */
535 else
536 return FALSE;
537 normalized[i] = (char)c;
538 }
539 normalized[norm_len] = '\0';
540 return TRUE;
541 }
542
543 boolean Auth_Store_Validate_Username(const char *normalized_username)
544 {
545 if (!normalized_username)
546 return FALSE;
547 size_t i = 0;
548 while (normalized_username[i] != '\0')
549 {
550 if (i >= AUTH_STORE_USERNAME_MAX)
551 return FALSE;
552 uint8 c = (uint8)normalized_username[i];
553 if (!((c >= 'a' && c <= 'z') ||
554 (c >= '0' && c <= '9') ||
555 c == '_' || c == '-' || c == '.'))
556 return FALSE;
557 i++;
558 }
559 return i >= AUTH_STORE_USERNAME_MIN;
560 }
561
562 /* ------------------------------------------------------------------ */
563 /* Internal: populate Auth_User_Record from an open result set */
564 /* ------------------------------------------------------------------ */
565
566 /*
567 * Columns expected (0-based):
568 * 0 id, 1 username, 2 normalized_username, 3 role, 4 status,
569 * 5 must_change_password, 6 password_changed_at, 7 created_at, 8 updated_at
570 */
571 static void auth__read_user_record(Auth_User_Record *r,
572 Deita_Result_Set *p)
573 {
574 auth__copy_text_fixed(r->id, sizeof(r->id),
575 Deita_Result_Set_Get_Text(p, 0));
576 auth__copy_text_fixed(r->username, sizeof(r->username),
577 Deita_Result_Set_Get_Text(p, 1));
578 auth__copy_text_fixed(r->normalized_username,sizeof(r->normalized_username),
579 Deita_Result_Set_Get_Text(p, 2));
580 auth__copy_text_fixed(r->role, sizeof(r->role),
581 Deita_Result_Set_Get_Text(p, 3));
582 auth__copy_text_fixed(r->status, sizeof(r->status),
583 Deita_Result_Set_Get_Text(p, 4));
584 r->must_change_password = Deita_Result_Set_Get_Integer(p, 5) ? TRUE : FALSE;
585 r->password_changed_at = Deita_Result_Set_Get_Integer(p, 6);
586 r->created_at = Deita_Result_Set_Get_Integer(p, 7);
587 r->updated_at = Deita_Result_Set_Get_Integer(p, 8);
588 }
589
590 /* ------------------------------------------------------------------ */
591 /* User management */
592 /* ------------------------------------------------------------------ */
593
594 Auth_Store_Result Auth_Store_Create_User(
595 Auth_Store *p_store,
596 const char *username,
597 const char *encoded_hash,
598 const char *role,
599 boolean must_change_password,
600 char output_id[37])
601 {
602 if (!p_store || !username || !encoded_hash || !role || !output_id)
603 return AUTH_STORE_INVALID_ARG;
604 if (encoded_hash[0] == '\0')
605 return AUTH_STORE_INVALID_ARG;
606 if (strcmp(role, "admin") != 0 && strcmp(role, "member") != 0)
607 return AUTH_STORE_INVALID_ARG;
608
609 char normalized[AUTH_STORE_USERNAME_MAX + 1];
610 if (!Auth_Store_Normalize_Username(
611 username, normalized, sizeof(normalized)))
612 return AUTH_STORE_INVALID_ARG;
613
614 if (!auth__generate_uuid(output_id))
615 return AUTH_STORE_ERROR;
616
617 const char *mcp_str = must_change_password ? "1" : "0";
618 const char *params[] = {
619 output_id, username, normalized, encoded_hash, role, mcp_str
620 };
621
622 pthread_mutex_lock(&p_store->mutex);
623 int32 result = Deita_Query_Execute_Update_Prepared(
624 p_store->p_connection,
625 "INSERT INTO users"
626 " (id, username, normalized_username, password_hash, role,"
627 " must_change_password)"
628 " VALUES (?, ?, ?, ?, ?, ?)",
629 6, params);
630 pthread_mutex_unlock(&p_store->mutex);
631
632 if (result < 0)
633 return AUTH_STORE_CONFLICT; /* most likely UNIQUE constraint on norm_name */
634 return AUTH_STORE_OK;
635 }
636
637 Auth_Store_Result Auth_Store_Create_User_Audited(
638 Auth_Store *p_store,
639 const char *username,
640 const char *encoded_hash,
641 const char *role,
642 boolean must_change_password,
643 const char *actor_user_id,
644 char output_id[37])
645 {
646 if (!p_store || !username || !encoded_hash || !role || !output_id)
647 return AUTH_STORE_INVALID_ARG;
648 if (encoded_hash[0] == '\0')
649 return AUTH_STORE_INVALID_ARG;
650 if (strcmp(role, "admin") != 0 && strcmp(role, "member") != 0)
651 return AUTH_STORE_INVALID_ARG;
652
653 char normalized[AUTH_STORE_USERNAME_MAX + 1];
654 if (!Auth_Store_Normalize_Username(
655 username, normalized, sizeof(normalized)))
656 return AUTH_STORE_INVALID_ARG;
657 if (!auth__generate_uuid(output_id))
658 return AUTH_STORE_ERROR;
659
660 const char *mcp_str = must_change_password ? "1" : "0";
661 const char *params[] = {
662 output_id, username, normalized, encoded_hash, role, mcp_str
663 };
664
665 pthread_mutex_lock(&p_store->mutex);
666 if (Deita_Query_Execute_Update(
667 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
668 {
669 pthread_mutex_unlock(&p_store->mutex);
670 return AUTH_STORE_ERROR;
671 }
672
673 int32 result = Deita_Query_Execute_Update_Prepared(
674 p_store->p_connection,
675 "INSERT INTO users"
676 " (id, username, normalized_username, password_hash, role,"
677 " must_change_password)"
678 " VALUES (?, ?, ?, ?, ?, ?)",
679 6, params);
680 if (result < 0)
681 {
682 auth__rollback(p_store, AUTH_STORE_ERROR);
683 pthread_mutex_unlock(&p_store->mutex);
684 return AUTH_STORE_CONFLICT;
685 }
686
687 if (!auth__insert_audit_log_locked(
688 p_store, actor_user_id, "admin_user_created", output_id, role))
689 {
690 auth__rollback(p_store, AUTH_STORE_ERROR);
691 pthread_mutex_unlock(&p_store->mutex);
692 return AUTH_STORE_ERROR;
693 }
694
695 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
696 {
697 auth__rollback(p_store, AUTH_STORE_ERROR);
698 pthread_mutex_unlock(&p_store->mutex);
699 return AUTH_STORE_ERROR;
700 }
701
702 pthread_mutex_unlock(&p_store->mutex);
703 return AUTH_STORE_OK;
704 }
705
706 Auth_Store_Result Auth_Store_Bootstrap_Admin(
707 Auth_Store *p_store,
708 const char *username,
709 const char *encoded_hash,
710 Auth_Store_Bootstrap_Result *p_bootstrap_result,
711 char output_id[37])
712 {
713 if (!p_store || !username || !encoded_hash || !p_bootstrap_result)
714 return AUTH_STORE_INVALID_ARG;
715 if (encoded_hash[0] == '\0')
716 return AUTH_STORE_INVALID_ARG;
717
718 char normalized[AUTH_STORE_USERNAME_MAX + 1];
719 if (!Auth_Store_Normalize_Username(
720 username, normalized, sizeof(normalized)))
721 return AUTH_STORE_INVALID_ARG;
722
723 char id_buf[37];
724 if (!auth__generate_uuid(id_buf))
725 return AUTH_STORE_ERROR;
726
727 pthread_mutex_lock(&p_store->mutex);
728
729 if (Deita_Query_Execute_Update(
730 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
731 {
732 pthread_mutex_unlock(&p_store->mutex);
733 return AUTH_STORE_ERROR;
734 }
735
736 /* Check for any existing admin (active or disabled). */
737 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
738 if (!p_arena)
739 {
740 auth__rollback(p_store, AUTH_STORE_ERROR);
741 pthread_mutex_unlock(&p_store->mutex);
742 return AUTH_STORE_ERROR;
743 }
744
745 Deita_Result_Set *p_result = Deita_Query_Execute(
746 p_store->p_connection,
747 "SELECT id FROM users WHERE role = 'admin' LIMIT 1",
748 p_arena);
749 boolean admin_exists = p_result && Deita_Result_Set_Next(p_result);
750 char existing_id[37] = {0};
751 if (admin_exists && p_result)
752 auth__copy_text_fixed(existing_id, sizeof(existing_id),
753 Deita_Result_Set_Get_Text(p_result, 0));
754 if (p_result)
755 Deita_Result_Set_Free(p_result);
756 Dowa_Arena_Free(p_arena);
757
758 if (admin_exists)
759 {
760 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
761 {
762 auth__rollback(p_store, AUTH_STORE_ERROR);
763 pthread_mutex_unlock(&p_store->mutex);
764 return AUTH_STORE_ERROR;
765 }
766 pthread_mutex_unlock(&p_store->mutex);
767 *p_bootstrap_result = AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT;
768 if (output_id)
769 auth__copy_text_fixed(output_id, 37, existing_id);
770 return AUTH_STORE_OK;
771 }
772
773 const char *params[] = {
774 id_buf, username, normalized, encoded_hash, "admin"
775 };
776 int32 ins = Deita_Query_Execute_Update_Prepared(
777 p_store->p_connection,
778 "INSERT INTO users"
779 " (id, username, normalized_username, password_hash, role)"
780 " VALUES (?, ?, ?, ?, ?)",
781 5, params);
782 if (ins < 0)
783 {
784 auth__rollback(p_store, AUTH_STORE_ERROR);
785 pthread_mutex_unlock(&p_store->mutex);
786 return AUTH_STORE_ERROR;
787 }
788
789 if (!auth__insert_audit_log_locked(
790 p_store, NULL, "bootstrap_admin_created", id_buf, NULL))
791 {
792 auth__rollback(p_store, AUTH_STORE_ERROR);
793 pthread_mutex_unlock(&p_store->mutex);
794 return AUTH_STORE_ERROR;
795 }
796
797 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
798 {
799 auth__rollback(p_store, AUTH_STORE_ERROR);
800 pthread_mutex_unlock(&p_store->mutex);
801 return AUTH_STORE_ERROR;
802 }
803
804 pthread_mutex_unlock(&p_store->mutex);
805 *p_bootstrap_result = AUTH_STORE_BOOTSTRAP_CREATED;
806 if (output_id)
807 auth__copy_text_fixed(output_id, 37, id_buf);
808 return AUTH_STORE_OK;
809 }
810
811 Auth_Store_Result Auth_Store_Find_User_By_Username(
812 Auth_Store *p_store,
813 const char *username,
814 Auth_User_Auth_Record *p_record)
815 {
816 if (!p_store || !username || !p_record)
817 return AUTH_STORE_INVALID_ARG;
818
819 char normalized[AUTH_STORE_USERNAME_MAX + 1];
820 if (!Auth_Store_Normalize_Username(
821 username, normalized, sizeof(normalized)))
822 return AUTH_STORE_NOT_FOUND;
823
824 memset(p_record, 0, sizeof(*p_record));
825
826 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
827 if (!p_arena)
828 return AUTH_STORE_ERROR;
829
830 const char *params[] = {normalized};
831 pthread_mutex_lock(&p_store->mutex);
832 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
833 p_store->p_connection,
834 "SELECT id, username, normalized_username, role, status,"
835 " must_change_password, password_changed_at,"
836 " created_at, updated_at, password_hash"
837 " FROM users WHERE normalized_username = ?",
838 1, params, p_arena);
839 if (!p_result || !Deita_Result_Set_Next(p_result))
840 {
841 if (p_result)
842 Deita_Result_Set_Free(p_result);
843 pthread_mutex_unlock(&p_store->mutex);
844 Dowa_Arena_Free(p_arena);
845 return AUTH_STORE_NOT_FOUND;
846 }
847 auth__read_user_record(&p_record->user, p_result);
848 auth__copy_text_fixed(p_record->password_hash,
849 sizeof(p_record->password_hash),
850 Deita_Result_Set_Get_Text(p_result, 9));
851 Deita_Result_Set_Free(p_result);
852 pthread_mutex_unlock(&p_store->mutex);
853 Dowa_Arena_Free(p_arena);
854 return AUTH_STORE_OK;
855 }
856
857 Auth_Store_Result Auth_Store_Get_User(
858 Auth_Store *p_store,
859 const char *user_id,
860 Auth_User_Record *p_record)
861 {
862 if (!p_store || !user_id || !p_record)
863 return AUTH_STORE_INVALID_ARG;
864
865 memset(p_record, 0, sizeof(*p_record));
866
867 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
868 if (!p_arena)
869 return AUTH_STORE_ERROR;
870
871 const char *params[] = {user_id};
872 pthread_mutex_lock(&p_store->mutex);
873 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
874 p_store->p_connection,
875 "SELECT id, username, normalized_username, role, status,"
876 " must_change_password, password_changed_at,"
877 " created_at, updated_at"
878 " FROM users WHERE id = ?",
879 1, params, p_arena);
880 if (!p_result || !Deita_Result_Set_Next(p_result))
881 {
882 if (p_result)
883 Deita_Result_Set_Free(p_result);
884 pthread_mutex_unlock(&p_store->mutex);
885 Dowa_Arena_Free(p_arena);
886 return AUTH_STORE_NOT_FOUND;
887 }
888 auth__read_user_record(p_record, p_result);
889 Deita_Result_Set_Free(p_result);
890 pthread_mutex_unlock(&p_store->mutex);
891 Dowa_Arena_Free(p_arena);
892 return AUTH_STORE_OK;
893 }
894
895 Auth_Store_Result Auth_Store_List_Users(
896 Auth_Store *p_store,
897 Auth_User_Record **pp_records,
898 Dowa_Arena *p_arena)
899 {
900 if (!p_store || !pp_records || !p_arena)
901 return AUTH_STORE_INVALID_ARG;
902
903 *pp_records = NULL;
904
905 Dowa_Arena *p_local = Dowa_Arena_Create(2048);
906 if (!p_local)
907 return AUTH_STORE_ERROR;
908
909 pthread_mutex_lock(&p_store->mutex);
910 Deita_Result_Set *p_result = Deita_Query_Execute(
911 p_store->p_connection,
912 "SELECT id, username, normalized_username, role, status,"
913 " must_change_password, password_changed_at,"
914 " created_at, updated_at"
915 " FROM users ORDER BY created_at",
916 p_local);
917 if (!p_result)
918 {
919 pthread_mutex_unlock(&p_store->mutex);
920 Dowa_Arena_Free(p_local);
921 return AUTH_STORE_ERROR;
922 }
923
924 Auth_User_Record *records = NULL;
925 while (Deita_Result_Set_Next(p_result))
926 {
927 Auth_User_Record record;
928 memset(&record, 0, sizeof(record));
929 auth__read_user_record(&record, p_result);
930 Dowa_Array_Push_Arena(records, record, p_arena);
931 }
932 boolean err = Deita_Result_Set_Has_Error(p_result);
933 Deita_Result_Set_Free(p_result);
934 pthread_mutex_unlock(&p_store->mutex);
935 Dowa_Arena_Free(p_local);
936
937 if (err)
938 return AUTH_STORE_ERROR;
939
940 *pp_records = records;
941 return AUTH_STORE_OK;
942 }
943
944 static Auth_Store_Result auth__update_user_status(
945 Auth_Store *p_store,
946 const char *user_id,
947 const char *new_status,
948 const char *actor_user_id,
949 boolean revoke_sessions)
950 {
951 if (!p_store || !user_id || !new_status)
952 return AUTH_STORE_INVALID_ARG;
953 if (strcmp(new_status, "active") != 0 &&
954 strcmp(new_status, "disabled") != 0)
955 return AUTH_STORE_INVALID_ARG;
956
957 pthread_mutex_lock(&p_store->mutex);
958
959 if (Deita_Query_Execute_Update(
960 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
961 {
962 pthread_mutex_unlock(&p_store->mutex);
963 return AUTH_STORE_ERROR;
964 }
965
966 /* Fetch current role and status. */
967 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
968 if (!p_arena)
969 {
970 auth__rollback(p_store, AUTH_STORE_ERROR);
971 pthread_mutex_unlock(&p_store->mutex);
972 return AUTH_STORE_ERROR;
973 }
974 const char *sel_params[] = {user_id};
975 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
976 p_store->p_connection,
977 "SELECT role, status FROM users WHERE id = ?",
978 1, sel_params, p_arena);
979 if (!p_result || !Deita_Result_Set_Next(p_result))
980 {
981 if (p_result)
982 Deita_Result_Set_Free(p_result);
983 Dowa_Arena_Free(p_arena);
984 auth__rollback(p_store, AUTH_STORE_ERROR);
985 pthread_mutex_unlock(&p_store->mutex);
986 return AUTH_STORE_NOT_FOUND;
987 }
988 char cur_role[8], cur_status[9];
989 auth__copy_text_fixed(cur_role, sizeof(cur_role),
990 Deita_Result_Set_Get_Text(p_result, 0));
991 auth__copy_text_fixed(cur_status, sizeof(cur_status),
992 Deita_Result_Set_Get_Text(p_result, 1));
993 Deita_Result_Set_Free(p_result);
994 Dowa_Arena_Free(p_arena);
995
996 /* Last-admin protection: prevent disabling the final active admin. */
997 if (strcmp(new_status, "disabled") == 0 &&
998 strcmp(cur_role, "admin") == 0 &&
999 strcmp(cur_status, "active") == 0)
1000 {
1001 Dowa_Arena *p_count_arena = Dowa_Arena_Create(1024);
1002 if (!p_count_arena)
1003 {
1004 auth__rollback(p_store, AUTH_STORE_ERROR);
1005 pthread_mutex_unlock(&p_store->mutex);
1006 return AUTH_STORE_ERROR;
1007 }
1008 p_result = Deita_Query_Execute(
1009 p_store->p_connection,
1010 "SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'",
1011 p_count_arena);
1012 int64 count = 0;
1013 if (p_result && Deita_Result_Set_Next(p_result))
1014 count = Deita_Result_Set_Get_Integer(p_result, 0);
1015 if (p_result)
1016 Deita_Result_Set_Free(p_result);
1017 Dowa_Arena_Free(p_count_arena);
1018
1019 if (count <= 1)
1020 {
1021 auth__rollback(p_store, AUTH_STORE_ERROR);
1022 pthread_mutex_unlock(&p_store->mutex);
1023 return AUTH_STORE_LAST_ADMIN;
1024 }
1025 }
1026
1027 const char *upd_params[] = {new_status, user_id};
1028 int32 upd = Deita_Query_Execute_Update_Prepared(
1029 p_store->p_connection,
1030 "UPDATE users SET status = ?, updated_at = strftime('%s','now')"
1031 " WHERE id = ?",
1032 2, upd_params);
1033 if (upd <= 0)
1034 {
1035 auth__rollback(p_store, AUTH_STORE_ERROR);
1036 pthread_mutex_unlock(&p_store->mutex);
1037 return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
1038 }
1039
1040 if (revoke_sessions)
1041 {
1042 const char *rev_params[] = {user_id};
1043 if (Deita_Query_Execute_Update_Prepared(
1044 p_store->p_connection,
1045 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1046 " WHERE user_id = ? AND revoked_at IS NULL",
1047 1, rev_params) < 0)
1048 {
1049 auth__rollback(p_store, AUTH_STORE_ERROR);
1050 pthread_mutex_unlock(&p_store->mutex);
1051 return AUTH_STORE_ERROR;
1052 }
1053 }
1054
1055 char detail[64];
1056 snprintf(detail, sizeof(detail), "status->%s", new_status);
1057 if (!auth__insert_audit_log_locked(
1058 p_store, actor_user_id, "user_status_updated", user_id, detail))
1059 {
1060 auth__rollback(p_store, AUTH_STORE_ERROR);
1061 pthread_mutex_unlock(&p_store->mutex);
1062 return AUTH_STORE_ERROR;
1063 }
1064
1065 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1066 {
1067 auth__rollback(p_store, AUTH_STORE_ERROR);
1068 pthread_mutex_unlock(&p_store->mutex);
1069 return AUTH_STORE_ERROR;
1070 }
1071
1072 pthread_mutex_unlock(&p_store->mutex);
1073 return AUTH_STORE_OK;
1074 }
1075
1076 Auth_Store_Result Auth_Store_Update_User_Status(
1077 Auth_Store *p_store,
1078 const char *user_id,
1079 const char *new_status,
1080 const char *actor_user_id)
1081 {
1082 return auth__update_user_status(
1083 p_store, user_id, new_status, actor_user_id, FALSE);
1084 }
1085
1086 Auth_Store_Result Auth_Store_Enable_User(
1087 Auth_Store *p_store,
1088 const char *user_id,
1089 const char *actor_user_id)
1090 {
1091 return auth__update_user_status(
1092 p_store, user_id, "active", actor_user_id, FALSE);
1093 }
1094
1095 Auth_Store_Result Auth_Store_Disable_User_And_Revoke_Sessions(
1096 Auth_Store *p_store,
1097 const char *user_id,
1098 const char *actor_user_id)
1099 {
1100 return auth__update_user_status(
1101 p_store, user_id, "disabled", actor_user_id, TRUE);
1102 }
1103
1104 static Auth_Store_Result auth__update_user_role(
1105 Auth_Store *p_store,
1106 const char *user_id,
1107 const char *new_role,
1108 const char *actor_user_id,
1109 boolean revoke_sessions)
1110 {
1111 if (!p_store || !user_id || !new_role)
1112 return AUTH_STORE_INVALID_ARG;
1113 if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0)
1114 return AUTH_STORE_INVALID_ARG;
1115
1116 pthread_mutex_lock(&p_store->mutex);
1117
1118 if (Deita_Query_Execute_Update(
1119 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1120 {
1121 pthread_mutex_unlock(&p_store->mutex);
1122 return AUTH_STORE_ERROR;
1123 }
1124
1125 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
1126 if (!p_arena)
1127 {
1128 auth__rollback(p_store, AUTH_STORE_ERROR);
1129 pthread_mutex_unlock(&p_store->mutex);
1130 return AUTH_STORE_ERROR;
1131 }
1132 const char *sel_params[] = {user_id};
1133 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1134 p_store->p_connection,
1135 "SELECT role, status FROM users WHERE id = ?",
1136 1, sel_params, p_arena);
1137 if (!p_result || !Deita_Result_Set_Next(p_result))
1138 {
1139 if (p_result)
1140 Deita_Result_Set_Free(p_result);
1141 Dowa_Arena_Free(p_arena);
1142 auth__rollback(p_store, AUTH_STORE_ERROR);
1143 pthread_mutex_unlock(&p_store->mutex);
1144 return AUTH_STORE_NOT_FOUND;
1145 }
1146 char cur_role[8], cur_status[9];
1147 auth__copy_text_fixed(cur_role, sizeof(cur_role),
1148 Deita_Result_Set_Get_Text(p_result, 0));
1149 auth__copy_text_fixed(cur_status, sizeof(cur_status),
1150 Deita_Result_Set_Get_Text(p_result, 1));
1151 Deita_Result_Set_Free(p_result);
1152 Dowa_Arena_Free(p_arena);
1153
1154 /* Last-admin protection: prevent demoting the final active admin. */
1155 if (strcmp(new_role, "member") == 0 &&
1156 strcmp(cur_role, "admin") == 0 &&
1157 strcmp(cur_status, "active") == 0)
1158 {
1159 Dowa_Arena *p_count_arena = Dowa_Arena_Create(1024);
1160 if (!p_count_arena)
1161 {
1162 auth__rollback(p_store, AUTH_STORE_ERROR);
1163 pthread_mutex_unlock(&p_store->mutex);
1164 return AUTH_STORE_ERROR;
1165 }
1166 p_result = Deita_Query_Execute(
1167 p_store->p_connection,
1168 "SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'",
1169 p_count_arena);
1170 int64 count = 0;
1171 if (p_result && Deita_Result_Set_Next(p_result))
1172 count = Deita_Result_Set_Get_Integer(p_result, 0);
1173 if (p_result)
1174 Deita_Result_Set_Free(p_result);
1175 Dowa_Arena_Free(p_count_arena);
1176
1177 if (count <= 1)
1178 {
1179 auth__rollback(p_store, AUTH_STORE_ERROR);
1180 pthread_mutex_unlock(&p_store->mutex);
1181 return AUTH_STORE_LAST_ADMIN;
1182 }
1183 }
1184
1185 const char *upd_params[] = {new_role, user_id};
1186 int32 upd = Deita_Query_Execute_Update_Prepared(
1187 p_store->p_connection,
1188 "UPDATE users SET role = ?, updated_at = strftime('%s','now')"
1189 " WHERE id = ?",
1190 2, upd_params);
1191 if (upd <= 0)
1192 {
1193 auth__rollback(p_store, AUTH_STORE_ERROR);
1194 pthread_mutex_unlock(&p_store->mutex);
1195 return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
1196 }
1197
1198 if (revoke_sessions)
1199 {
1200 const char *rev_params[] = {user_id};
1201 if (Deita_Query_Execute_Update_Prepared(
1202 p_store->p_connection,
1203 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1204 " WHERE user_id = ? AND revoked_at IS NULL",
1205 1, rev_params) < 0)
1206 {
1207 auth__rollback(p_store, AUTH_STORE_ERROR);
1208 pthread_mutex_unlock(&p_store->mutex);
1209 return AUTH_STORE_ERROR;
1210 }
1211 }
1212
1213 char detail[64];
1214 snprintf(detail, sizeof(detail), "role->%s", new_role);
1215 if (!auth__insert_audit_log_locked(
1216 p_store, actor_user_id, "user_role_updated", user_id, detail))
1217 {
1218 auth__rollback(p_store, AUTH_STORE_ERROR);
1219 pthread_mutex_unlock(&p_store->mutex);
1220 return AUTH_STORE_ERROR;
1221 }
1222
1223 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1224 {
1225 auth__rollback(p_store, AUTH_STORE_ERROR);
1226 pthread_mutex_unlock(&p_store->mutex);
1227 return AUTH_STORE_ERROR;
1228 }
1229
1230 pthread_mutex_unlock(&p_store->mutex);
1231 return AUTH_STORE_OK;
1232 }
1233
1234 Auth_Store_Result Auth_Store_Update_User_Role(
1235 Auth_Store *p_store,
1236 const char *user_id,
1237 const char *new_role,
1238 const char *actor_user_id)
1239 {
1240 return auth__update_user_role(
1241 p_store, user_id, new_role, actor_user_id, FALSE);
1242 }
1243
1244 Auth_Store_Result Auth_Store_Update_Role_And_Revoke_Sessions(
1245 Auth_Store *p_store,
1246 const char *user_id,
1247 const char *new_role,
1248 const char *actor_user_id)
1249 {
1250 return auth__update_user_role(
1251 p_store, user_id, new_role, actor_user_id, TRUE);
1252 }
1253
1254 Auth_Store_Result Auth_Store_Set_Must_Change_Password(
1255 Auth_Store *p_store,
1256 const char *user_id,
1257 boolean value,
1258 const char *actor_user_id)
1259 {
1260 if (!p_store || !user_id)
1261 return AUTH_STORE_INVALID_ARG;
1262
1263 const char *val_str = value ? "1" : "0";
1264 const char *params[] = {val_str, user_id};
1265
1266 pthread_mutex_lock(&p_store->mutex);
1267
1268 if (Deita_Query_Execute_Update(
1269 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1270 {
1271 pthread_mutex_unlock(&p_store->mutex);
1272 return AUTH_STORE_ERROR;
1273 }
1274
1275 int32 upd = Deita_Query_Execute_Update_Prepared(
1276 p_store->p_connection,
1277 "UPDATE users"
1278 " SET must_change_password = ?, updated_at = strftime('%s','now')"
1279 " WHERE id = ?",
1280 2, params);
1281 if (upd <= 0)
1282 {
1283 auth__rollback(p_store, AUTH_STORE_ERROR);
1284 pthread_mutex_unlock(&p_store->mutex);
1285 return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
1286 }
1287
1288 if (!auth__insert_audit_log_locked(
1289 p_store, actor_user_id,
1290 value ? "must_change_password_set" : "must_change_password_cleared",
1291 user_id, NULL))
1292 {
1293 auth__rollback(p_store, AUTH_STORE_ERROR);
1294 pthread_mutex_unlock(&p_store->mutex);
1295 return AUTH_STORE_ERROR;
1296 }
1297
1298 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1299 {
1300 auth__rollback(p_store, AUTH_STORE_ERROR);
1301 pthread_mutex_unlock(&p_store->mutex);
1302 return AUTH_STORE_ERROR;
1303 }
1304
1305 pthread_mutex_unlock(&p_store->mutex);
1306 return AUTH_STORE_OK;
1307 }
1308
1309 Auth_Store_Result Auth_Store_Update_Password(
1310 Auth_Store *p_store,
1311 const char *user_id,
1312 const char *new_encoded_hash,
1313 boolean revoke_other_sessions,
1314 const char *keep_token_digest)
1315 {
1316 if (!p_store || !user_id || !new_encoded_hash)
1317 return AUTH_STORE_INVALID_ARG;
1318 if (new_encoded_hash[0] == '\0')
1319 return AUTH_STORE_INVALID_ARG;
1320
1321 pthread_mutex_lock(&p_store->mutex);
1322
1323 if (Deita_Query_Execute_Update(
1324 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1325 {
1326 pthread_mutex_unlock(&p_store->mutex);
1327 return AUTH_STORE_ERROR;
1328 }
1329
1330 const char *upd_params[] = {new_encoded_hash, user_id};
1331 int32 upd = Deita_Query_Execute_Update_Prepared(
1332 p_store->p_connection,
1333 "UPDATE users"
1334 " SET password_hash = ?,"
1335 " password_changed_at = strftime('%s','now'),"
1336 " must_change_password = 0,"
1337 " updated_at = strftime('%s','now')"
1338 " WHERE id = ?",
1339 2, upd_params);
1340 if (upd <= 0)
1341 {
1342 auth__rollback(p_store, AUTH_STORE_ERROR);
1343 pthread_mutex_unlock(&p_store->mutex);
1344 return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
1345 }
1346
1347 if (revoke_other_sessions)
1348 {
1349 int32 rev;
1350 if (keep_token_digest && keep_token_digest[0] != '\0')
1351 {
1352 const char *rev_params[] = {user_id, keep_token_digest};
1353 rev = Deita_Query_Execute_Update_Prepared(
1354 p_store->p_connection,
1355 "UPDATE auth_sessions"
1356 " SET revoked_at = strftime('%s','now')"
1357 " WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
1358 2, rev_params);
1359 }
1360 else
1361 {
1362 const char *rev_params[] = {user_id};
1363 rev = Deita_Query_Execute_Update_Prepared(
1364 p_store->p_connection,
1365 "UPDATE auth_sessions"
1366 " SET revoked_at = strftime('%s','now')"
1367 " WHERE user_id = ? AND revoked_at IS NULL",
1368 1, rev_params);
1369 }
1370 if (rev < 0)
1371 {
1372 auth__rollback(p_store, AUTH_STORE_ERROR);
1373 pthread_mutex_unlock(&p_store->mutex);
1374 return AUTH_STORE_ERROR;
1375 }
1376 }
1377
1378 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1379 {
1380 auth__rollback(p_store, AUTH_STORE_ERROR);
1381 pthread_mutex_unlock(&p_store->mutex);
1382 return AUTH_STORE_ERROR;
1383 }
1384
1385 pthread_mutex_unlock(&p_store->mutex);
1386 return AUTH_STORE_OK;
1387 }
1388
1389 /* ------------------------------------------------------------------ */
1390 /* Session management */
1391 /* ------------------------------------------------------------------ */
1392
1393 Auth_Store_Result Auth_Store_Create_Session(
1394 Auth_Store *p_store,
1395 const char *user_id,
1396 const char *token_digest,
1397 const char *csrf_digest,
1398 int64 idle_ttl_secs,
1399 int64 absolute_ttl_secs,
1400 int64 current_unix,
1401 Auth_Session_Record *p_record)
1402 {
1403 if (!p_store || !user_id || !token_digest || !csrf_digest || !p_record)
1404 return AUTH_STORE_INVALID_ARG;
1405 if (!auth__session_arguments_are_valid(
1406 token_digest, csrf_digest, idle_ttl_secs,
1407 absolute_ttl_secs, current_unix))
1408 return AUTH_STORE_INVALID_ARG;
1409
1410 pthread_mutex_lock(&p_store->mutex);
1411
1412 if (Deita_Query_Execute_Update(
1413 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1414 {
1415 pthread_mutex_unlock(&p_store->mutex);
1416 return AUTH_STORE_ERROR;
1417 }
1418
1419 /* Verify user is active and read password_changed_at snapshot. */
1420 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
1421 if (!p_arena)
1422 {
1423 auth__rollback(p_store, AUTH_STORE_ERROR);
1424 pthread_mutex_unlock(&p_store->mutex);
1425 return AUTH_STORE_ERROR;
1426 }
1427 const char *sel_params[] = {user_id};
1428 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1429 p_store->p_connection,
1430 "SELECT status, password_changed_at FROM users WHERE id = ?",
1431 1, sel_params, p_arena);
1432 if (!p_result || !Deita_Result_Set_Next(p_result))
1433 {
1434 if (p_result)
1435 Deita_Result_Set_Free(p_result);
1436 Dowa_Arena_Free(p_arena);
1437 auth__rollback(p_store, AUTH_STORE_ERROR);
1438 pthread_mutex_unlock(&p_store->mutex);
1439 return AUTH_STORE_NOT_FOUND;
1440 }
1441 char cur_status[9];
1442 int64 pca;
1443 auth__copy_text_fixed(cur_status, sizeof(cur_status),
1444 Deita_Result_Set_Get_Text(p_result, 0));
1445 pca = Deita_Result_Set_Get_Integer(p_result, 1);
1446 Deita_Result_Set_Free(p_result);
1447 Dowa_Arena_Free(p_arena);
1448
1449 if (strcmp(cur_status, "disabled") == 0)
1450 {
1451 auth__rollback(p_store, AUTH_STORE_ERROR);
1452 pthread_mutex_unlock(&p_store->mutex);
1453 return AUTH_STORE_USER_DISABLED;
1454 }
1455
1456 Auth_Store_Result insert_result = auth__insert_session_locked(
1457 p_store, user_id, token_digest, csrf_digest,
1458 idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
1459 if (insert_result != AUTH_STORE_OK)
1460 {
1461 auth__rollback(p_store, AUTH_STORE_ERROR);
1462 pthread_mutex_unlock(&p_store->mutex);
1463 return insert_result;
1464 }
1465
1466 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1467 {
1468 auth__rollback(p_store, AUTH_STORE_ERROR);
1469 pthread_mutex_unlock(&p_store->mutex);
1470 return AUTH_STORE_ERROR;
1471 }
1472
1473 pthread_mutex_unlock(&p_store->mutex);
1474
1475 auth__fill_session_record(
1476 p_record, user_id, idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
1477 return AUTH_STORE_OK;
1478 }
1479
1480 Auth_Store_Result Auth_Store_Create_Session_CAS(
1481 Auth_Store *p_store,
1482 const char *user_id,
1483 const char *expected_password_hash,
1484 const char *token_digest,
1485 const char *csrf_digest,
1486 int64 idle_ttl_secs,
1487 int64 absolute_ttl_secs,
1488 int64 current_unix,
1489 Auth_Session_Record *p_record)
1490 {
1491 if (!p_store || !user_id || !expected_password_hash ||
1492 expected_password_hash[0] == '\0' || !p_record)
1493 return AUTH_STORE_INVALID_ARG;
1494 if (!auth__session_arguments_are_valid(
1495 token_digest, csrf_digest, idle_ttl_secs,
1496 absolute_ttl_secs, current_unix))
1497 return AUTH_STORE_INVALID_ARG;
1498
1499 char current_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
1500 char current_status[9] = {0};
1501 int64 password_changed_at = 0;
1502 Auth_Store_Result result = AUTH_STORE_ERROR;
1503
1504 pthread_mutex_lock(&p_store->mutex);
1505 if (Deita_Query_Execute_Update(
1506 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1507 {
1508 pthread_mutex_unlock(&p_store->mutex);
1509 OPENSSL_cleanse(current_hash, sizeof(current_hash));
1510 return AUTH_STORE_ERROR;
1511 }
1512
1513 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
1514 if (!p_arena)
1515 {
1516 result = auth__rollback(p_store, AUTH_STORE_ERROR);
1517 goto create_session_cas_done;
1518 }
1519
1520 const char *select_params[] = {user_id};
1521 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1522 p_store->p_connection,
1523 "SELECT status, password_changed_at, password_hash"
1524 " FROM users WHERE id = ?",
1525 1, select_params, p_arena);
1526 if (!p_result)
1527 {
1528 Dowa_Arena_Free(p_arena);
1529 result = auth__rollback(p_store, AUTH_STORE_ERROR);
1530 goto create_session_cas_done;
1531 }
1532 if (!Deita_Result_Set_Next(p_result))
1533 {
1534 boolean query_error = Deita_Result_Set_Has_Error(p_result);
1535 Deita_Result_Set_Free(p_result);
1536 Dowa_Arena_Free(p_arena);
1537 result = auth__rollback(
1538 p_store, query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND);
1539 goto create_session_cas_done;
1540 }
1541
1542 auth__copy_text_fixed(
1543 current_status, sizeof(current_status),
1544 Deita_Result_Set_Get_Text(p_result, 0));
1545 password_changed_at = Deita_Result_Set_Get_Integer(p_result, 1);
1546 auth__copy_text_fixed(
1547 current_hash, sizeof(current_hash),
1548 Deita_Result_Set_Get_Text(p_result, 2));
1549 Deita_Result_Set_Free(p_result);
1550 Dowa_Arena_Free(p_arena);
1551
1552 if (strcmp(current_status, "disabled") == 0)
1553 {
1554 result = auth__rollback(p_store, AUTH_STORE_USER_DISABLED);
1555 goto create_session_cas_done;
1556 }
1557 if (strcmp(current_hash, expected_password_hash) != 0)
1558 {
1559 result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
1560 goto create_session_cas_done;
1561 }
1562
1563 result = auth__insert_session_locked(
1564 p_store, user_id, token_digest, csrf_digest,
1565 idle_ttl_secs, absolute_ttl_secs, current_unix, password_changed_at);
1566 if (result != AUTH_STORE_OK)
1567 {
1568 auth__rollback(p_store, AUTH_STORE_ERROR);
1569 goto create_session_cas_done;
1570 }
1571
1572 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1573 {
1574 result = auth__rollback(p_store, AUTH_STORE_ERROR);
1575 goto create_session_cas_done;
1576 }
1577
1578 auth__fill_session_record(
1579 p_record, user_id, idle_ttl_secs, absolute_ttl_secs,
1580 current_unix, password_changed_at);
1581 result = AUTH_STORE_OK;
1582
1583 create_session_cas_done:
1584 OPENSSL_cleanse(current_hash, sizeof(current_hash));
1585 pthread_mutex_unlock(&p_store->mutex);
1586 return result;
1587 }
1588
1589 Auth_Store_Result Auth_Store_Find_Session(
1590 Auth_Store *p_store,
1591 const char *token_digest,
1592 int64 current_unix,
1593 Auth_Session_Record *p_session,
1594 Auth_User_Record *p_user)
1595 {
1596 if (!p_store || !token_digest || !p_session || !p_user)
1597 return AUTH_STORE_INVALID_ARG;
1598
1599 memset(p_session, 0, sizeof(*p_session));
1600 memset(p_user, 0, sizeof(*p_user));
1601
1602 Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
1603 if (!p_arena)
1604 return AUTH_STORE_ERROR;
1605
1606 const char *params[] = {token_digest};
1607 pthread_mutex_lock(&p_store->mutex);
1608 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1609 p_store->p_connection,
1610 "SELECT"
1611 " s.user_id, s.created_at, s.last_seen_at,"
1612 " s.idle_expires_at, s.absolute_expires_at,"
1613 " s.password_changed_at_snapshot, s.revoked_at,"
1614 " u.id, u.username, u.normalized_username, u.role, u.status,"
1615 " u.must_change_password, u.password_changed_at,"
1616 " u.created_at, u.updated_at"
1617 " FROM auth_sessions s"
1618 " JOIN users u ON s.user_id = u.id"
1619 " WHERE s.token_digest = ?",
1620 1, params, p_arena);
1621
1622 if (!p_result || !Deita_Result_Set_Next(p_result))
1623 {
1624 if (p_result)
1625 Deita_Result_Set_Free(p_result);
1626 pthread_mutex_unlock(&p_store->mutex);
1627 Dowa_Arena_Free(p_arena);
1628 return AUTH_STORE_NOT_FOUND;
1629 }
1630
1631 /* Read session fields. */
1632 auth__copy_text_fixed(p_session->user_id, sizeof(p_session->user_id),
1633 Deita_Result_Set_Get_Text(p_result, 0));
1634 p_session->created_at = Deita_Result_Set_Get_Integer(p_result, 1);
1635 p_session->last_seen_at = Deita_Result_Set_Get_Integer(p_result, 2);
1636 p_session->idle_expires_at = Deita_Result_Set_Get_Integer(p_result, 3);
1637 p_session->absolute_expires_at = Deita_Result_Set_Get_Integer(p_result, 4);
1638 p_session->password_changed_at_snapshot =
1639 Deita_Result_Set_Get_Integer(p_result, 5);
1640 boolean is_revoked =
1641 (Deita_Result_Set_Get_Column_Type(p_result, 6) != DEITA_COLUMN_TYPE_NULL);
1642
1643 /* Read user fields (columns 7-15). */
1644 /* Reuse auth__read_user_record after shifting: pass a pointer with offset */
1645 Auth_User_Record tmp_user;
1646 memset(&tmp_user, 0, sizeof(tmp_user));
1647 auth__copy_text_fixed(tmp_user.id, sizeof(tmp_user.id),
1648 Deita_Result_Set_Get_Text(p_result, 7));
1649 auth__copy_text_fixed(tmp_user.username, sizeof(tmp_user.username),
1650 Deita_Result_Set_Get_Text(p_result, 8));
1651 auth__copy_text_fixed(tmp_user.normalized_username,
1652 sizeof(tmp_user.normalized_username),
1653 Deita_Result_Set_Get_Text(p_result, 9));
1654 auth__copy_text_fixed(tmp_user.role, sizeof(tmp_user.role),
1655 Deita_Result_Set_Get_Text(p_result, 10));
1656 auth__copy_text_fixed(tmp_user.status, sizeof(tmp_user.status),
1657 Deita_Result_Set_Get_Text(p_result, 11));
1658 tmp_user.must_change_password = Deita_Result_Set_Get_Integer(p_result, 12) ?
1659 TRUE : FALSE;
1660 tmp_user.password_changed_at = Deita_Result_Set_Get_Integer(p_result, 13);
1661 tmp_user.created_at = Deita_Result_Set_Get_Integer(p_result, 14);
1662 tmp_user.updated_at = Deita_Result_Set_Get_Integer(p_result, 15);
1663
1664 Deita_Result_Set_Free(p_result);
1665 pthread_mutex_unlock(&p_store->mutex);
1666 Dowa_Arena_Free(p_arena);
1667
1668 /* Apply validity checks in order of specificity. */
1669 if (is_revoked)
1670 return AUTH_STORE_REVOKED;
1671
1672 if (current_unix >= p_session->idle_expires_at ||
1673 current_unix >= p_session->absolute_expires_at)
1674 return AUTH_STORE_EXPIRED;
1675
1676 if (strcmp(tmp_user.status, "disabled") == 0)
1677 return AUTH_STORE_USER_DISABLED;
1678
1679 if (tmp_user.password_changed_at != p_session->password_changed_at_snapshot)
1680 return AUTH_STORE_STALE_PASSWORD;
1681
1682 *p_user = tmp_user;
1683 return AUTH_STORE_OK;
1684 }
1685
1686 Auth_Store_Result Auth_Store_Touch_Session(
1687 Auth_Store *p_store,
1688 const char *token_digest,
1689 int64 current_unix,
1690 int64 idle_ttl_secs)
1691 {
1692 if (!p_store || !token_digest)
1693 return AUTH_STORE_INVALID_ARG;
1694
1695 char last_seen_str[32], idle_exp_str[32];
1696 snprintf(last_seen_str, sizeof(last_seen_str), "%lld", (long long)current_unix);
1697 snprintf(idle_exp_str, sizeof(idle_exp_str), "%lld",
1698 (long long)(current_unix + idle_ttl_secs));
1699
1700 const char *params[] = {last_seen_str, idle_exp_str, token_digest};
1701
1702 pthread_mutex_lock(&p_store->mutex);
1703 int32 result = Deita_Query_Execute_Update_Prepared(
1704 p_store->p_connection,
1705 "UPDATE auth_sessions"
1706 " SET last_seen_at = ?, idle_expires_at = ?"
1707 " WHERE token_digest = ? AND revoked_at IS NULL",
1708 3, params);
1709 pthread_mutex_unlock(&p_store->mutex);
1710
1711 if (result < 0)
1712 return AUTH_STORE_ERROR;
1713 return result == 0 ? AUTH_STORE_NOT_FOUND : AUTH_STORE_OK;
1714 }
1715
1716 Auth_Store_Result Auth_Store_Revoke_Session(
1717 Auth_Store *p_store,
1718 const char *token_digest)
1719 {
1720 if (!p_store || !token_digest)
1721 return AUTH_STORE_INVALID_ARG;
1722
1723 const char *params[] = {token_digest};
1724
1725 pthread_mutex_lock(&p_store->mutex);
1726 int32 result = Deita_Query_Execute_Update_Prepared(
1727 p_store->p_connection,
1728 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1729 " WHERE token_digest = ?",
1730 1, params);
1731 pthread_mutex_unlock(&p_store->mutex);
1732
1733 if (result < 0)
1734 return AUTH_STORE_ERROR;
1735 return result == 0 ? AUTH_STORE_NOT_FOUND : AUTH_STORE_OK;
1736 }
1737
1738 Auth_Store_Result Auth_Store_Revoke_All_Sessions(
1739 Auth_Store *p_store,
1740 const char *user_id,
1741 const char *except_token_digest)
1742 {
1743 if (!p_store || !user_id)
1744 return AUTH_STORE_INVALID_ARG;
1745
1746 pthread_mutex_lock(&p_store->mutex);
1747 int32 result;
1748 if (except_token_digest && except_token_digest[0] != '\0')
1749 {
1750 const char *params[] = {user_id, except_token_digest};
1751 result = Deita_Query_Execute_Update_Prepared(
1752 p_store->p_connection,
1753 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1754 " WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
1755 2, params);
1756 }
1757 else
1758 {
1759 const char *params[] = {user_id};
1760 result = Deita_Query_Execute_Update_Prepared(
1761 p_store->p_connection,
1762 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1763 " WHERE user_id = ? AND revoked_at IS NULL",
1764 1, params);
1765 }
1766 pthread_mutex_unlock(&p_store->mutex);
1767
1768 return result < 0 ? AUTH_STORE_ERROR : AUTH_STORE_OK;
1769 }
1770
1771 Auth_Store_Result Auth_Store_Revoke_All_Sessions_Audited(
1772 Auth_Store *p_store,
1773 const char *user_id,
1774 const char *except_token_digest,
1775 const char *actor_user_id)
1776 {
1777 if (!p_store || !user_id)
1778 return AUTH_STORE_INVALID_ARG;
1779 if (except_token_digest && except_token_digest[0] != '\0' &&
1780 !auth__digest_is_valid(except_token_digest))
1781 return AUTH_STORE_INVALID_ARG;
1782
1783 pthread_mutex_lock(&p_store->mutex);
1784 if (Deita_Query_Execute_Update(
1785 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1786 {
1787 pthread_mutex_unlock(&p_store->mutex);
1788 return AUTH_STORE_ERROR;
1789 }
1790
1791 Dowa_Arena *p_arena = Dowa_Arena_Create(1024);
1792 if (!p_arena)
1793 {
1794 auth__rollback(p_store, AUTH_STORE_ERROR);
1795 pthread_mutex_unlock(&p_store->mutex);
1796 return AUTH_STORE_ERROR;
1797 }
1798
1799 const char *find_params[] = {user_id};
1800 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1801 p_store->p_connection,
1802 "SELECT 1 FROM users WHERE id = ?",
1803 1, find_params, p_arena);
1804 if (!p_result)
1805 {
1806 Dowa_Arena_Free(p_arena);
1807 auth__rollback(p_store, AUTH_STORE_ERROR);
1808 pthread_mutex_unlock(&p_store->mutex);
1809 return AUTH_STORE_ERROR;
1810 }
1811 if (!Deita_Result_Set_Next(p_result))
1812 {
1813 boolean query_error = Deita_Result_Set_Has_Error(p_result);
1814 Deita_Result_Set_Free(p_result);
1815 Dowa_Arena_Free(p_arena);
1816 auth__rollback(p_store, AUTH_STORE_ERROR);
1817 pthread_mutex_unlock(&p_store->mutex);
1818 return query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
1819 }
1820 Deita_Result_Set_Free(p_result);
1821 Dowa_Arena_Free(p_arena);
1822
1823 int32 revoke_result;
1824 if (except_token_digest && except_token_digest[0] != '\0')
1825 {
1826 const char *params[] = {user_id, except_token_digest};
1827 revoke_result = Deita_Query_Execute_Update_Prepared(
1828 p_store->p_connection,
1829 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1830 " WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
1831 2, params);
1832 }
1833 else
1834 {
1835 const char *params[] = {user_id};
1836 revoke_result = Deita_Query_Execute_Update_Prepared(
1837 p_store->p_connection,
1838 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1839 " WHERE user_id = ? AND revoked_at IS NULL",
1840 1, params);
1841 }
1842 if (revoke_result < 0)
1843 {
1844 auth__rollback(p_store, AUTH_STORE_ERROR);
1845 pthread_mutex_unlock(&p_store->mutex);
1846 return AUTH_STORE_ERROR;
1847 }
1848
1849 if (!auth__insert_audit_log_locked(
1850 p_store, actor_user_id, "admin_sessions_revoked", user_id, NULL))
1851 {
1852 auth__rollback(p_store, AUTH_STORE_ERROR);
1853 pthread_mutex_unlock(&p_store->mutex);
1854 return AUTH_STORE_ERROR;
1855 }
1856
1857 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1858 {
1859 auth__rollback(p_store, AUTH_STORE_ERROR);
1860 pthread_mutex_unlock(&p_store->mutex);
1861 return AUTH_STORE_ERROR;
1862 }
1863
1864 pthread_mutex_unlock(&p_store->mutex);
1865 return AUTH_STORE_OK;
1866 }
1867
1868 Auth_Store_Result Auth_Store_Rotate_Session(
1869 Auth_Store *p_store,
1870 const char *user_id,
1871 const char *old_token_digest,
1872 const char *new_token_digest,
1873 const char *new_csrf_digest,
1874 int64 idle_ttl_secs,
1875 int64 absolute_ttl_secs,
1876 int64 current_unix,
1877 Auth_Session_Record *p_record)
1878 {
1879 if (!p_store || !user_id || !old_token_digest || !new_token_digest ||
1880 !new_csrf_digest || !p_record || !auth__digest_is_valid(old_token_digest))
1881 return AUTH_STORE_INVALID_ARG;
1882 if (!auth__session_arguments_are_valid(
1883 new_token_digest, new_csrf_digest, idle_ttl_secs,
1884 absolute_ttl_secs, current_unix))
1885 return AUTH_STORE_INVALID_ARG;
1886
1887 pthread_mutex_lock(&p_store->mutex);
1888
1889 if (Deita_Query_Execute_Update(
1890 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
1891 {
1892 pthread_mutex_unlock(&p_store->mutex);
1893 return AUTH_STORE_ERROR;
1894 }
1895
1896 /* Verify user is active and read password_changed_at snapshot. */
1897 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
1898 if (!p_arena)
1899 {
1900 auth__rollback(p_store, AUTH_STORE_ERROR);
1901 pthread_mutex_unlock(&p_store->mutex);
1902 return AUTH_STORE_ERROR;
1903 }
1904 const char *sel_params[] = {user_id};
1905 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
1906 p_store->p_connection,
1907 "SELECT status, password_changed_at FROM users WHERE id = ?",
1908 1, sel_params, p_arena);
1909 if (!p_result || !Deita_Result_Set_Next(p_result))
1910 {
1911 if (p_result)
1912 Deita_Result_Set_Free(p_result);
1913 Dowa_Arena_Free(p_arena);
1914 auth__rollback(p_store, AUTH_STORE_ERROR);
1915 pthread_mutex_unlock(&p_store->mutex);
1916 return AUTH_STORE_NOT_FOUND;
1917 }
1918 char cur_status[9];
1919 int64 pca;
1920 auth__copy_text_fixed(cur_status, sizeof(cur_status),
1921 Deita_Result_Set_Get_Text(p_result, 0));
1922 pca = Deita_Result_Set_Get_Integer(p_result, 1);
1923 Deita_Result_Set_Free(p_result);
1924 Dowa_Arena_Free(p_arena);
1925
1926 if (strcmp(cur_status, "disabled") == 0)
1927 {
1928 auth__rollback(p_store, AUTH_STORE_ERROR);
1929 pthread_mutex_unlock(&p_store->mutex);
1930 return AUTH_STORE_USER_DISABLED;
1931 }
1932
1933 /* Insert new session. */
1934 Auth_Store_Result insert_result = auth__insert_session_locked(
1935 p_store, user_id, new_token_digest, new_csrf_digest,
1936 idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
1937 if (insert_result != AUTH_STORE_OK)
1938 {
1939 auth__rollback(p_store, AUTH_STORE_ERROR);
1940 pthread_mutex_unlock(&p_store->mutex);
1941 return insert_result;
1942 }
1943
1944 /* Revoke old session (idempotent: ignore if already revoked). */
1945 const char *rev_params[] = {old_token_digest};
1946 int32 revoke_result = Deita_Query_Execute_Update_Prepared(
1947 p_store->p_connection,
1948 "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
1949 " WHERE token_digest = ? AND revoked_at IS NULL",
1950 1, rev_params);
1951 if (revoke_result < 0)
1952 {
1953 auth__rollback(p_store, AUTH_STORE_ERROR);
1954 pthread_mutex_unlock(&p_store->mutex);
1955 return AUTH_STORE_ERROR;
1956 }
1957
1958 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
1959 {
1960 auth__rollback(p_store, AUTH_STORE_ERROR);
1961 pthread_mutex_unlock(&p_store->mutex);
1962 return AUTH_STORE_ERROR;
1963 }
1964
1965 pthread_mutex_unlock(&p_store->mutex);
1966
1967 auth__fill_session_record(
1968 p_record, user_id, idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
1969 return AUTH_STORE_OK;
1970 }
1971
1972 Auth_Store_Result Auth_Store_Self_Change_Password(
1973 Auth_Store *p_store,
1974 const char *user_id,
1975 const char *old_encoded_hash,
1976 const char *new_encoded_hash,
1977 const char *new_token_digest,
1978 const char *new_csrf_digest,
1979 int64 idle_ttl_secs,
1980 int64 absolute_ttl_secs,
1981 int64 current_unix,
1982 Auth_Session_Record *p_record)
1983 {
1984 if (!p_store || !user_id || !old_encoded_hash || !new_encoded_hash ||
1985 old_encoded_hash[0] == '\0' || new_encoded_hash[0] == '\0' || !p_record)
1986 return AUTH_STORE_INVALID_ARG;
1987 if (!auth__session_arguments_are_valid(
1988 new_token_digest, new_csrf_digest, idle_ttl_secs,
1989 absolute_ttl_secs, current_unix))
1990 return AUTH_STORE_INVALID_ARG;
1991
1992 char current_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
1993 char reread_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
1994 char status[9] = {0};
1995 char timestamp[32];
1996 snprintf(timestamp, sizeof(timestamp), "%lld", (long long)current_unix);
1997 Auth_Store_Result result = AUTH_STORE_ERROR;
1998
1999 pthread_mutex_lock(&p_store->mutex);
2000 if (Deita_Query_Execute_Update(
2001 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2002 {
2003 pthread_mutex_unlock(&p_store->mutex);
2004 OPENSSL_cleanse(current_hash, sizeof(current_hash));
2005 OPENSSL_cleanse(reread_hash, sizeof(reread_hash));
2006 return AUTH_STORE_ERROR;
2007 }
2008
2009 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
2010 if (!p_arena)
2011 {
2012 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2013 goto self_change_done;
2014 }
2015
2016 const char *select_params[] = {user_id};
2017 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2018 p_store->p_connection,
2019 "SELECT status, password_hash FROM users WHERE id = ?",
2020 1, select_params, p_arena);
2021 if (!p_result)
2022 {
2023 Dowa_Arena_Free(p_arena);
2024 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2025 goto self_change_done;
2026 }
2027 if (!Deita_Result_Set_Next(p_result))
2028 {
2029 boolean query_error = Deita_Result_Set_Has_Error(p_result);
2030 Deita_Result_Set_Free(p_result);
2031 Dowa_Arena_Free(p_arena);
2032 result = auth__rollback(
2033 p_store, query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND);
2034 goto self_change_done;
2035 }
2036 auth__copy_text_fixed(
2037 status, sizeof(status), Deita_Result_Set_Get_Text(p_result, 0));
2038 auth__copy_text_fixed(
2039 current_hash, sizeof(current_hash),
2040 Deita_Result_Set_Get_Text(p_result, 1));
2041 Deita_Result_Set_Free(p_result);
2042 Dowa_Arena_Free(p_arena);
2043
2044 if (strcmp(status, "disabled") == 0)
2045 {
2046 result = auth__rollback(p_store, AUTH_STORE_USER_DISABLED);
2047 goto self_change_done;
2048 }
2049 if (strcmp(current_hash, old_encoded_hash) != 0)
2050 {
2051 result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
2052 goto self_change_done;
2053 }
2054
2055 const char *update_params[] = {
2056 new_encoded_hash, timestamp, timestamp, user_id, old_encoded_hash
2057 };
2058 int32 update_result = Deita_Query_Execute_Update_Prepared(
2059 p_store->p_connection,
2060 "UPDATE users"
2061 " SET password_hash = ?, password_changed_at = ?,"
2062 " must_change_password = 0, updated_at = ?"
2063 " WHERE id = ? AND password_hash = ?",
2064 5, update_params);
2065 if (update_result < 0)
2066 {
2067 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2068 goto self_change_done;
2069 }
2070 if (update_result == 0)
2071 {
2072 result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
2073 goto self_change_done;
2074 }
2075
2076 p_arena = Dowa_Arena_Create(2048);
2077 if (!p_arena)
2078 {
2079 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2080 goto self_change_done;
2081 }
2082 p_result = Deita_Query_Execute_Prepared(
2083 p_store->p_connection,
2084 "SELECT status, password_changed_at, password_hash"
2085 " FROM users WHERE id = ?",
2086 1, select_params, p_arena);
2087 if (!p_result)
2088 {
2089 Dowa_Arena_Free(p_arena);
2090 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2091 goto self_change_done;
2092 }
2093 if (!Deita_Result_Set_Next(p_result))
2094 {
2095 Deita_Result_Set_Free(p_result);
2096 Dowa_Arena_Free(p_arena);
2097 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2098 goto self_change_done;
2099 }
2100 auth__copy_text_fixed(
2101 status, sizeof(status), Deita_Result_Set_Get_Text(p_result, 0));
2102 int64 password_changed_at = Deita_Result_Set_Get_Integer(p_result, 1);
2103 auth__copy_text_fixed(
2104 reread_hash, sizeof(reread_hash),
2105 Deita_Result_Set_Get_Text(p_result, 2));
2106 Deita_Result_Set_Free(p_result);
2107 Dowa_Arena_Free(p_arena);
2108 if (strcmp(status, "active") != 0 ||
2109 password_changed_at != current_unix ||
2110 strcmp(reread_hash, new_encoded_hash) != 0)
2111 {
2112 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2113 goto self_change_done;
2114 }
2115
2116 const char *revoke_params[] = {timestamp, user_id};
2117 if (Deita_Query_Execute_Update_Prepared(
2118 p_store->p_connection,
2119 "UPDATE auth_sessions SET revoked_at = ?"
2120 " WHERE user_id = ? AND revoked_at IS NULL",
2121 2, revoke_params) < 0)
2122 {
2123 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2124 goto self_change_done;
2125 }
2126
2127 result = auth__insert_session_locked(
2128 p_store, user_id, new_token_digest, new_csrf_digest,
2129 idle_ttl_secs, absolute_ttl_secs, current_unix, password_changed_at);
2130 if (result != AUTH_STORE_OK)
2131 {
2132 auth__rollback(p_store, AUTH_STORE_ERROR);
2133 goto self_change_done;
2134 }
2135
2136 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2137 {
2138 result = auth__rollback(p_store, AUTH_STORE_ERROR);
2139 goto self_change_done;
2140 }
2141
2142 auth__fill_session_record(
2143 p_record, user_id, idle_ttl_secs, absolute_ttl_secs,
2144 current_unix, password_changed_at);
2145 result = AUTH_STORE_OK;
2146
2147 self_change_done:
2148 OPENSSL_cleanse(current_hash, sizeof(current_hash));
2149 OPENSSL_cleanse(reread_hash, sizeof(reread_hash));
2150 pthread_mutex_unlock(&p_store->mutex);
2151 return result;
2152 }
2153
2154 /* ------------------------------------------------------------------ */
2155 /* Guest identity */
2156 /* ------------------------------------------------------------------ */
2157
2158 Auth_Store_Result Auth_Store_Upsert_Guest_Identity(
2159 Auth_Store *p_store,
2160 const char *guest_id,
2161 const char *ip_binding_digest,
2162 int64 expires_at,
2163 Auth_Guest_Identity_Record *p_record)
2164 {
2165 if (!p_store || !guest_id || !ip_binding_digest || !p_record)
2166 return AUTH_STORE_INVALID_ARG;
2167
2168 char exp_str[32];
2169 snprintf(exp_str, sizeof(exp_str), "%lld", (long long)expires_at);
2170
2171 pthread_mutex_lock(&p_store->mutex);
2172
2173 if (Deita_Query_Execute_Update(
2174 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2175 {
2176 pthread_mutex_unlock(&p_store->mutex);
2177 return AUTH_STORE_ERROR;
2178 }
2179
2180 /* INSERT OR IGNORE so usage data is not cascade-deleted on upsert. */
2181 const char *ins_params[] = {guest_id, ip_binding_digest, exp_str};
2182 Deita_Query_Execute_Update_Prepared(
2183 p_store->p_connection,
2184 "INSERT OR IGNORE INTO guest_identities (id, ip_binding_digest, expires_at)"
2185 " VALUES (?, ?, ?)",
2186 3, ins_params);
2187
2188 /* Always refresh last_seen_at and expires_at. */
2189 const char *upd_params[] = {exp_str, guest_id};
2190 if (Deita_Query_Execute_Update_Prepared(
2191 p_store->p_connection,
2192 "UPDATE guest_identities"
2193 " SET last_seen_at = strftime('%s','now'), expires_at = ?"
2194 " WHERE id = ?",
2195 2, upd_params) < 0)
2196 {
2197 auth__rollback(p_store, AUTH_STORE_ERROR);
2198 pthread_mutex_unlock(&p_store->mutex);
2199 return AUTH_STORE_ERROR;
2200 }
2201
2202 /* Read back the current row. */
2203 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
2204 if (!p_arena)
2205 {
2206 auth__rollback(p_store, AUTH_STORE_ERROR);
2207 pthread_mutex_unlock(&p_store->mutex);
2208 return AUTH_STORE_ERROR;
2209 }
2210 const char *sel_params[] = {guest_id};
2211 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2212 p_store->p_connection,
2213 "SELECT id, created_at, last_seen_at, expires_at"
2214 " FROM guest_identities WHERE id = ?",
2215 1, sel_params, p_arena);
2216 if (!p_result || !Deita_Result_Set_Next(p_result))
2217 {
2218 if (p_result)
2219 Deita_Result_Set_Free(p_result);
2220 Dowa_Arena_Free(p_arena);
2221 auth__rollback(p_store, AUTH_STORE_ERROR);
2222 pthread_mutex_unlock(&p_store->mutex);
2223 return AUTH_STORE_ERROR;
2224 }
2225 auth__copy_text_fixed(p_record->id, sizeof(p_record->id),
2226 Deita_Result_Set_Get_Text(p_result, 0));
2227 p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 1);
2228 p_record->last_seen_at = Deita_Result_Set_Get_Integer(p_result, 2);
2229 p_record->expires_at = Deita_Result_Set_Get_Integer(p_result, 3);
2230 Deita_Result_Set_Free(p_result);
2231 Dowa_Arena_Free(p_arena);
2232
2233 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2234 {
2235 auth__rollback(p_store, AUTH_STORE_ERROR);
2236 pthread_mutex_unlock(&p_store->mutex);
2237 return AUTH_STORE_ERROR;
2238 }
2239
2240 pthread_mutex_unlock(&p_store->mutex);
2241 return AUTH_STORE_OK;
2242 }
2243
2244 Auth_Store_Result Auth_Store_Find_Guest_Identity(
2245 Auth_Store *p_store,
2246 const char *guest_id,
2247 int64 current_unix,
2248 Auth_Guest_Identity_Record *p_record)
2249 {
2250 if (!p_store || !guest_id || !p_record)
2251 return AUTH_STORE_INVALID_ARG;
2252
2253 memset(p_record, 0, sizeof(*p_record));
2254
2255 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
2256 if (!p_arena)
2257 return AUTH_STORE_ERROR;
2258
2259 const char *params[] = {guest_id};
2260 pthread_mutex_lock(&p_store->mutex);
2261 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2262 p_store->p_connection,
2263 "SELECT id, created_at, last_seen_at, expires_at"
2264 " FROM guest_identities WHERE id = ?",
2265 1, params, p_arena);
2266 if (!p_result || !Deita_Result_Set_Next(p_result))
2267 {
2268 if (p_result)
2269 Deita_Result_Set_Free(p_result);
2270 pthread_mutex_unlock(&p_store->mutex);
2271 Dowa_Arena_Free(p_arena);
2272 return AUTH_STORE_NOT_FOUND;
2273 }
2274 auth__copy_text_fixed(p_record->id, sizeof(p_record->id),
2275 Deita_Result_Set_Get_Text(p_result, 0));
2276 p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 1);
2277 p_record->last_seen_at = Deita_Result_Set_Get_Integer(p_result, 2);
2278 p_record->expires_at = Deita_Result_Set_Get_Integer(p_result, 3);
2279 Deita_Result_Set_Free(p_result);
2280 pthread_mutex_unlock(&p_store->mutex);
2281 Dowa_Arena_Free(p_arena);
2282
2283 if (current_unix >= p_record->expires_at)
2284 return AUTH_STORE_EXPIRED;
2285
2286 return AUTH_STORE_OK;
2287 }
2288
2289 /* ------------------------------------------------------------------ */
2290 /* Guest quota */
2291 /* ------------------------------------------------------------------ */
2292
2293 static void auth__i64_str(char *buf, size_t size, int64 value)
2294 {
2295 snprintf(buf, size, "%lld", (long long)value);
2296 }
2297
2298 Auth_Store_Result Auth_Store_Guest_Get_Usage(
2299 Auth_Store *p_store,
2300 const char *guest_id,
2301 int64 window_start,
2302 Auth_Store_Guest_Usage *p_usage)
2303 {
2304 if (!p_store || !guest_id || !p_usage)
2305 return AUTH_STORE_INVALID_ARG;
2306
2307 memset(p_usage, 0, sizeof(*p_usage));
2308
2309 char ws_str[32];
2310 auth__i64_str(ws_str, sizeof(ws_str), window_start);
2311
2312 Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
2313 if (!p_arena)
2314 return AUTH_STORE_ERROR;
2315
2316 const char *params[] = {guest_id, ws_str};
2317 pthread_mutex_lock(&p_store->mutex);
2318 auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
2319 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2320 p_store->p_connection,
2321 "SELECT turns_used, output_tokens_used, output_tokens_reserved"
2322 " FROM guest_usage WHERE guest_id = ? AND window_start = ?",
2323 2, params, p_arena);
2324 if (p_result && Deita_Result_Set_Next(p_result))
2325 {
2326 p_usage->turns_used = Deita_Result_Set_Get_Integer(p_result, 0);
2327 p_usage->output_tokens_used = Deita_Result_Set_Get_Integer(p_result, 1);
2328 p_usage->output_tokens_reserved = Deita_Result_Set_Get_Integer(p_result, 2);
2329 }
2330 if (p_result)
2331 Deita_Result_Set_Free(p_result);
2332 pthread_mutex_unlock(&p_store->mutex);
2333 Dowa_Arena_Free(p_arena);
2334 return AUTH_STORE_OK;
2335 }
2336
2337 Auth_Store_Guest_Quota_Result Auth_Store_Guest_Reserve(
2338 Auth_Store *p_store,
2339 const char *guest_id,
2340 const char *request_id,
2341 int64 window_start,
2342 int64 max_output_tokens,
2343 int64 turns_limit,
2344 int64 tokens_limit,
2345 int64 reservation_expires)
2346 {
2347 if (!p_store || !guest_id || !request_id ||
2348 max_output_tokens <= 0 || turns_limit <= 0 || tokens_limit <= 0)
2349 return AUTH_STORE_GUEST_QUOTA_ERROR;
2350
2351 char ws_str[32], exp_str[32], tok_str[32];
2352 auth__i64_str(ws_str, sizeof(ws_str), window_start);
2353 auth__i64_str(exp_str, sizeof(exp_str), reservation_expires);
2354 auth__i64_str(tok_str, sizeof(tok_str), max_output_tokens);
2355
2356 Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
2357 if (!p_arena)
2358 return AUTH_STORE_GUEST_QUOTA_ERROR;
2359
2360 pthread_mutex_lock(&p_store->mutex);
2361
2362 if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2363 {
2364 pthread_mutex_unlock(&p_store->mutex);
2365 Dowa_Arena_Free(p_arena);
2366 return AUTH_STORE_GUEST_QUOTA_ERROR;
2367 }
2368 auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
2369
2370 /* Ensure usage row exists for this window. */
2371 const char *ins_params[] = {guest_id, ws_str};
2372 if (Deita_Query_Execute_Update_Prepared(
2373 p_store->p_connection,
2374 "INSERT OR IGNORE INTO guest_usage"
2375 " (guest_id, window_start, count)"
2376 " VALUES (?, ?, 0)",
2377 2, ins_params) < 0)
2378 {
2379 auth__rollback(p_store, AUTH_STORE_ERROR);
2380 pthread_mutex_unlock(&p_store->mutex);
2381 Dowa_Arena_Free(p_arena);
2382 return AUTH_STORE_GUEST_QUOTA_ERROR;
2383 }
2384
2385 /* Read current usage. */
2386 const char *sel_params[] = {guest_id, ws_str};
2387 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2388 p_store->p_connection,
2389 "SELECT turns_used, output_tokens_used, output_tokens_reserved"
2390 " FROM guest_usage WHERE guest_id = ? AND window_start = ?",
2391 2, sel_params, p_arena);
2392 if (!p_result || !Deita_Result_Set_Next(p_result))
2393 {
2394 if (p_result) Deita_Result_Set_Free(p_result);
2395 auth__rollback(p_store, AUTH_STORE_ERROR);
2396 pthread_mutex_unlock(&p_store->mutex);
2397 Dowa_Arena_Free(p_arena);
2398 return AUTH_STORE_GUEST_QUOTA_ERROR;
2399 }
2400 int64 turns_used = Deita_Result_Set_Get_Integer(p_result, 0);
2401 int64 tokens_used = Deita_Result_Set_Get_Integer(p_result, 1);
2402 int64 tokens_resvd = Deita_Result_Set_Get_Integer(p_result, 2);
2403 Deita_Result_Set_Free(p_result);
2404
2405 /* Check turn limit. */
2406 if (turns_used >= turns_limit)
2407 {
2408 auth__rollback(p_store, AUTH_STORE_ERROR);
2409 pthread_mutex_unlock(&p_store->mutex);
2410 Dowa_Arena_Free(p_arena);
2411 return AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED;
2412 }
2413
2414 /* Check token limit: used + reserved + new_reservation <= limit. */
2415 if (tokens_used + tokens_resvd + max_output_tokens > tokens_limit)
2416 {
2417 auth__rollback(p_store, AUTH_STORE_ERROR);
2418 pthread_mutex_unlock(&p_store->mutex);
2419 Dowa_Arena_Free(p_arena);
2420 return AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED;
2421 }
2422
2423 /* Update usage: charge turn, add token reservation. */
2424 const char *upd_params[] = {tok_str, guest_id, ws_str};
2425 if (Deita_Query_Execute_Update_Prepared(
2426 p_store->p_connection,
2427 "UPDATE guest_usage"
2428 " SET turns_used = turns_used + 1,"
2429 " output_tokens_reserved = output_tokens_reserved + ?,"
2430 " count = count + 1"
2431 " WHERE guest_id = ? AND window_start = ?",
2432 3, upd_params) <= 0)
2433 {
2434 auth__rollback(p_store, AUTH_STORE_ERROR);
2435 pthread_mutex_unlock(&p_store->mutex);
2436 Dowa_Arena_Free(p_arena);
2437 return AUTH_STORE_GUEST_QUOTA_ERROR;
2438 }
2439
2440 /* Insert reservation row. */
2441 const char *res_params[] = {request_id, guest_id, ws_str, tok_str, exp_str};
2442 if (Deita_Query_Execute_Update_Prepared(
2443 p_store->p_connection,
2444 "INSERT INTO guest_usage_reservations"
2445 " (request_id, guest_id, window_start, output_tokens_reserved, expires_at)"
2446 " VALUES (?, ?, ?, ?, ?)",
2447 5, res_params) < 0)
2448 {
2449 auth__rollback(p_store, AUTH_STORE_ERROR);
2450 pthread_mutex_unlock(&p_store->mutex);
2451 Dowa_Arena_Free(p_arena);
2452 return AUTH_STORE_GUEST_QUOTA_ERROR;
2453 }
2454
2455 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2456 {
2457 auth__rollback(p_store, AUTH_STORE_ERROR);
2458 pthread_mutex_unlock(&p_store->mutex);
2459 Dowa_Arena_Free(p_arena);
2460 return AUTH_STORE_GUEST_QUOTA_ERROR;
2461 }
2462
2463 pthread_mutex_unlock(&p_store->mutex);
2464 Dowa_Arena_Free(p_arena);
2465 return AUTH_STORE_GUEST_QUOTA_OK;
2466 }
2467
2468 Auth_Store_Result Auth_Store_Guest_Reconcile(
2469 Auth_Store *p_store,
2470 const char *request_id,
2471 int64 actual_output_tokens)
2472 {
2473 if (!p_store || !request_id)
2474 return AUTH_STORE_INVALID_ARG;
2475 if (actual_output_tokens < 0)
2476 actual_output_tokens = 0;
2477
2478 Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
2479 if (!p_arena)
2480 return AUTH_STORE_ERROR;
2481
2482 pthread_mutex_lock(&p_store->mutex);
2483
2484 if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2485 {
2486 pthread_mutex_unlock(&p_store->mutex);
2487 Dowa_Arena_Free(p_arena);
2488 return AUTH_STORE_ERROR;
2489 }
2490
2491 /* Fetch reservation. */
2492 const char *sel_params[] = {request_id};
2493 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2494 p_store->p_connection,
2495 "SELECT guest_id, window_start, output_tokens_reserved"
2496 " FROM guest_usage_reservations WHERE request_id = ?",
2497 1, sel_params, p_arena);
2498 if (!p_result || !Deita_Result_Set_Next(p_result))
2499 {
2500 /* Idempotent: reservation already gone. */
2501 if (p_result) Deita_Result_Set_Free(p_result);
2502 Deita_Query_Execute_Update(p_store->p_connection, "COMMIT");
2503 pthread_mutex_unlock(&p_store->mutex);
2504 Dowa_Arena_Free(p_arena);
2505 return AUTH_STORE_OK;
2506 }
2507
2508 char guest_id[37];
2509 auth__copy_text_fixed(guest_id, sizeof(guest_id),
2510 Deita_Result_Set_Get_Text(p_result, 0));
2511 int64 window_start = Deita_Result_Set_Get_Integer(p_result, 1);
2512 int64 tokens_reserved = Deita_Result_Set_Get_Integer(p_result, 2);
2513 Deita_Result_Set_Free(p_result);
2514
2515 /* Charge provider-reported usage even when it exceeds the reservation. */
2516 int64 to_charge = actual_output_tokens;
2517
2518 char ws_str[32], charge_str[32], res_str[32];
2519 auth__i64_str(ws_str, sizeof(ws_str), window_start);
2520 auth__i64_str(charge_str, sizeof(charge_str), to_charge);
2521 auth__i64_str(res_str, sizeof(res_str), tokens_reserved);
2522
2523 /* Update usage: add actual tokens, subtract reservation. */
2524 const char *upd_params[] = {charge_str, res_str, guest_id, ws_str};
2525 if (Deita_Query_Execute_Update_Prepared(
2526 p_store->p_connection,
2527 "UPDATE guest_usage"
2528 " SET output_tokens_used = output_tokens_used + ?,"
2529 " output_tokens_reserved = MAX(0, output_tokens_reserved - ?)"
2530 " WHERE guest_id = ? AND window_start = ?",
2531 4, upd_params) < 0)
2532 {
2533 auth__rollback(p_store, AUTH_STORE_ERROR);
2534 pthread_mutex_unlock(&p_store->mutex);
2535 Dowa_Arena_Free(p_arena);
2536 return AUTH_STORE_ERROR;
2537 }
2538
2539 /* Delete reservation. */
2540 const char *del_params[] = {request_id};
2541 if (Deita_Query_Execute_Update_Prepared(
2542 p_store->p_connection,
2543 "DELETE FROM guest_usage_reservations WHERE request_id = ?",
2544 1, del_params) < 0)
2545 {
2546 auth__rollback(p_store, AUTH_STORE_ERROR);
2547 pthread_mutex_unlock(&p_store->mutex);
2548 Dowa_Arena_Free(p_arena);
2549 return AUTH_STORE_ERROR;
2550 }
2551
2552 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2553 {
2554 auth__rollback(p_store, AUTH_STORE_ERROR);
2555 pthread_mutex_unlock(&p_store->mutex);
2556 Dowa_Arena_Free(p_arena);
2557 return AUTH_STORE_ERROR;
2558 }
2559
2560 pthread_mutex_unlock(&p_store->mutex);
2561 Dowa_Arena_Free(p_arena);
2562 return AUTH_STORE_OK;
2563 }
2564
2565 Auth_Store_Result Auth_Store_Guest_Release(
2566 Auth_Store *p_store,
2567 const char *request_id)
2568 {
2569 if (!p_store || !request_id)
2570 return AUTH_STORE_INVALID_ARG;
2571
2572 Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
2573 if (!p_arena)
2574 return AUTH_STORE_ERROR;
2575
2576 pthread_mutex_lock(&p_store->mutex);
2577
2578 if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2579 {
2580 pthread_mutex_unlock(&p_store->mutex);
2581 Dowa_Arena_Free(p_arena);
2582 return AUTH_STORE_ERROR;
2583 }
2584
2585 /* Fetch reservation. */
2586 const char *sel_params[] = {request_id};
2587 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
2588 p_store->p_connection,
2589 "SELECT guest_id, window_start, output_tokens_reserved"
2590 " FROM guest_usage_reservations WHERE request_id = ?",
2591 1, sel_params, p_arena);
2592 if (!p_result || !Deita_Result_Set_Next(p_result))
2593 {
2594 /* Idempotent: reservation already gone. */
2595 if (p_result) Deita_Result_Set_Free(p_result);
2596 Deita_Query_Execute_Update(p_store->p_connection, "COMMIT");
2597 pthread_mutex_unlock(&p_store->mutex);
2598 Dowa_Arena_Free(p_arena);
2599 return AUTH_STORE_OK;
2600 }
2601
2602 char guest_id[37];
2603 auth__copy_text_fixed(guest_id, sizeof(guest_id),
2604 Deita_Result_Set_Get_Text(p_result, 0));
2605 int64 window_start = Deita_Result_Set_Get_Integer(p_result, 1);
2606 int64 tokens_reserved = Deita_Result_Set_Get_Integer(p_result, 2);
2607 Deita_Result_Set_Free(p_result);
2608
2609 char ws_str[32], res_str[32];
2610 auth__i64_str(ws_str, sizeof(ws_str), window_start);
2611 auth__i64_str(res_str, sizeof(res_str), tokens_reserved);
2612
2613 /* Release token reservation; turns_used is unchanged (turn already charged). */
2614 const char *upd_params[] = {res_str, guest_id, ws_str};
2615 if (Deita_Query_Execute_Update_Prepared(
2616 p_store->p_connection,
2617 "UPDATE guest_usage"
2618 " SET output_tokens_reserved = MAX(0, output_tokens_reserved - ?)"
2619 " WHERE guest_id = ? AND window_start = ?",
2620 3, upd_params) < 0)
2621 {
2622 auth__rollback(p_store, AUTH_STORE_ERROR);
2623 pthread_mutex_unlock(&p_store->mutex);
2624 Dowa_Arena_Free(p_arena);
2625 return AUTH_STORE_ERROR;
2626 }
2627
2628 /* Delete reservation. */
2629 const char *del_params[] = {request_id};
2630 if (Deita_Query_Execute_Update_Prepared(
2631 p_store->p_connection,
2632 "DELETE FROM guest_usage_reservations WHERE request_id = ?",
2633 1, del_params) < 0)
2634 {
2635 auth__rollback(p_store, AUTH_STORE_ERROR);
2636 pthread_mutex_unlock(&p_store->mutex);
2637 Dowa_Arena_Free(p_arena);
2638 return AUTH_STORE_ERROR;
2639 }
2640
2641 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2642 {
2643 auth__rollback(p_store, AUTH_STORE_ERROR);
2644 pthread_mutex_unlock(&p_store->mutex);
2645 Dowa_Arena_Free(p_arena);
2646 return AUTH_STORE_ERROR;
2647 }
2648
2649 pthread_mutex_unlock(&p_store->mutex);
2650 Dowa_Arena_Free(p_arena);
2651 return AUTH_STORE_OK;
2652 }
2653
2654 Auth_Store_Result Auth_Store_Guest_Clear_Reservations(
2655 Auth_Store *p_store,
2656 const char *guest_id)
2657 {
2658 if (!p_store || !guest_id)
2659 return AUTH_STORE_INVALID_ARG;
2660
2661 pthread_mutex_lock(&p_store->mutex);
2662
2663 if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2664 {
2665 pthread_mutex_unlock(&p_store->mutex);
2666 return AUTH_STORE_ERROR;
2667 }
2668
2669 /*
2670 * Subtract each window's reserved tokens from the usage row.
2671 * The correlated subquery aggregates reservations per window.
2672 */
2673 const char *upd_params[] = {guest_id, guest_id};
2674 if (Deita_Query_Execute_Update_Prepared(
2675 p_store->p_connection,
2676 "UPDATE guest_usage"
2677 " SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
2678 " SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
2679 " FROM guest_usage_reservations r"
2680 " WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start"
2681 " ))"
2682 " WHERE guest_id = ?",
2683 2, upd_params) < 0)
2684 {
2685 auth__rollback(p_store, AUTH_STORE_ERROR);
2686 pthread_mutex_unlock(&p_store->mutex);
2687 return AUTH_STORE_ERROR;
2688 }
2689
2690 /* Delete all reservations for this guest. */
2691 const char *del_params[] = {guest_id};
2692 if (Deita_Query_Execute_Update_Prepared(
2693 p_store->p_connection,
2694 "DELETE FROM guest_usage_reservations WHERE guest_id = ?",
2695 1, del_params) < 0)
2696 {
2697 auth__rollback(p_store, AUTH_STORE_ERROR);
2698 pthread_mutex_unlock(&p_store->mutex);
2699 return AUTH_STORE_ERROR;
2700 }
2701
2702 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2703 {
2704 auth__rollback(p_store, AUTH_STORE_ERROR);
2705 pthread_mutex_unlock(&p_store->mutex);
2706 return AUTH_STORE_ERROR;
2707 }
2708
2709 pthread_mutex_unlock(&p_store->mutex);
2710 return AUTH_STORE_OK;
2711 }
2712
2713 Auth_Store_Result Auth_Store_Guest_Reap_Expired(
2714 Auth_Store *p_store,
2715 int64 current_unix)
2716 {
2717 if (!p_store)
2718 return AUTH_STORE_INVALID_ARG;
2719 pthread_mutex_lock(&p_store->mutex);
2720 auth__reap_expired_reservations_locked(p_store, current_unix);
2721 pthread_mutex_unlock(&p_store->mutex);
2722 return AUTH_STORE_OK;
2723 }
2724
2725 /* ------------------------------------------------------------------ */
2726 /* Audit log (public) */
2727 /* ------------------------------------------------------------------ */
2728
2729 Auth_Store_Result Auth_Store_Insert_Audit_Log(
2730 Auth_Store *p_store,
2731 const char *actor_user_id,
2732 const char *action,
2733 const char *target_user_id,
2734 const char *detail)
2735 {
2736 if (!p_store || !action || action[0] == '\0')
2737 return AUTH_STORE_INVALID_ARG;
2738
2739 pthread_mutex_lock(&p_store->mutex);
2740 boolean inserted = auth__insert_audit_log_locked(
2741 p_store, actor_user_id, action, target_user_id, detail);
2742 pthread_mutex_unlock(&p_store->mutex);
2743 return inserted ? AUTH_STORE_OK : AUTH_STORE_ERROR;
2744 }
2745
2746 /* ------------------------------------------------------------------ */
2747 /* Admin password reset */
2748 /* ------------------------------------------------------------------ */
2749
2750 Auth_Store_Result Auth_Store_Admin_Reset_Password(
2751 Auth_Store *p_store,
2752 const char *user_id,
2753 const char *new_encoded_hash,
2754 const char *actor_user_id)
2755 {
2756 if (!p_store || !user_id || !new_encoded_hash)
2757 return AUTH_STORE_INVALID_ARG;
2758 if (new_encoded_hash[0] == '\0')
2759 return AUTH_STORE_INVALID_ARG;
2760
2761 pthread_mutex_lock(&p_store->mutex);
2762
2763 if (Deita_Query_Execute_Update(
2764 p_store->p_connection, "BEGIN IMMEDIATE") < 0)
2765 {
2766 pthread_mutex_unlock(&p_store->mutex);
2767 return AUTH_STORE_ERROR;
2768 }
2769
2770 const char *upd_params[] = {new_encoded_hash, user_id};
2771 int32 upd = Deita_Query_Execute_Update_Prepared(
2772 p_store->p_connection,
2773 "UPDATE users"
2774 " SET password_hash = ?,"
2775 " password_changed_at = strftime('%s','now'),"
2776 " must_change_password = 1,"
2777 " updated_at = strftime('%s','now')"
2778 " WHERE id = ?",
2779 2, upd_params);
2780 if (upd <= 0)
2781 {
2782 auth__rollback(p_store, AUTH_STORE_ERROR);
2783 pthread_mutex_unlock(&p_store->mutex);
2784 return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
2785 }
2786
2787 const char *rev_params[] = {user_id};
2788 int32 rev = Deita_Query_Execute_Update_Prepared(
2789 p_store->p_connection,
2790 "UPDATE auth_sessions"
2791 " SET revoked_at = strftime('%s','now')"
2792 " WHERE user_id = ? AND revoked_at IS NULL",
2793 1, rev_params);
2794 if (rev < 0)
2795 {
2796 auth__rollback(p_store, AUTH_STORE_ERROR);
2797 pthread_mutex_unlock(&p_store->mutex);
2798 return AUTH_STORE_ERROR;
2799 }
2800
2801 if (!auth__insert_audit_log_locked(
2802 p_store, actor_user_id, "admin_temp_password_reset", user_id, NULL))
2803 {
2804 auth__rollback(p_store, AUTH_STORE_ERROR);
2805 pthread_mutex_unlock(&p_store->mutex);
2806 return AUTH_STORE_ERROR;
2807 }
2808
2809 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
2810 {
2811 auth__rollback(p_store, AUTH_STORE_ERROR);
2812 pthread_mutex_unlock(&p_store->mutex);
2813 return AUTH_STORE_ERROR;
2814 }
2815
2816 pthread_mutex_unlock(&p_store->mutex);
2817 return AUTH_STORE_OK;
2818 }