1use chrono::{DateTime, Utc};
4use sqlx::{PgPool, Postgres, Row, Transaction};
5
6use crate::db::pioneer_codes::consume_pioneer_code_for_human_tx;
7use agentics_domain::models::auth::{GithubUserId, HumanRole, HumanStatus};
8use agentics_domain::models::ids::{CreatorApiTokenId, HumanId, HumanSessionId};
9use agentics_error::{ErrorDetail, Result, ServiceError};
10
11use super::ids::{creator_api_token_id_from_row, human_id_from_row};
12
13mod account_deletion;
14mod admin_service_tokens;
15
16pub use account_deletion::{DeleteHumanAccountOutcome, delete_human_account};
17pub use admin_service_tokens::{
18 AdminServiceTokenRecord, AuthenticatedAdminServiceToken, CreateAdminServiceTokenInput,
19 authenticate_admin_service_token, create_admin_service_token, list_admin_service_tokens,
20 revoke_admin_service_token,
21};
22
23const CREATOR_API_TOKEN_ACTIVE_LABEL_INDEX: &str = "idx_creator_api_tokens_owner_active_label";
24
25#[derive(Debug, Clone)]
27pub struct HumanRecord {
28 pub human_id: HumanId,
29 pub status: String,
30 pub github_user_id: GithubUserId,
31 pub github_login: String,
32 pub roles: Vec<HumanRole>,
33 pub created_at: DateTime<Utc>,
34 pub disabled_at: Option<DateTime<Utc>>,
35 pub deleted_at: Option<DateTime<Utc>>,
36}
37
38#[derive(Debug, Clone)]
40pub struct AuthenticatedHumanSession {
41 pub session_id: HumanSessionId,
42 pub human_id: HumanId,
43 pub status: HumanStatus,
44 pub github_user_id: GithubUserId,
45 pub github_login: String,
46 pub roles: Vec<HumanRole>,
47 pub csrf_token_hash: String,
48 pub expires_at: DateTime<Utc>,
49}
50
51#[derive(Debug, Clone)]
53pub struct AuthenticatedCreatorApiToken {
54 pub token_id: CreatorApiTokenId,
55 pub human_id: HumanId,
56 pub status: HumanStatus,
57 pub github_user_id: GithubUserId,
58 pub github_login: String,
59 pub roles: Vec<HumanRole>,
60 pub label: String,
61 pub expires_at: Option<DateTime<Utc>>,
62}
63
64#[derive(Debug, Clone)]
66pub struct CreatorApiTokenRecord {
67 pub id: CreatorApiTokenId,
68 pub label: String,
69 pub status: String,
70 pub created_by_human_id: HumanId,
71 pub created_at: DateTime<Utc>,
72 pub last_used_at: Option<DateTime<Utc>>,
73 pub expires_at: Option<DateTime<Utc>>,
74 pub revoked_at: Option<DateTime<Utc>>,
75}
76
77#[derive(Debug, Clone)]
79pub struct ResolveGithubHumanInput {
80 pub fallback_human_id: HumanId,
81 pub github_user_id: GithubUserId,
82 pub github_login: String,
83 pub bootstrap_admin_candidate: bool,
84}
85
86#[derive(Debug, Clone)]
88pub struct CreateHumanSessionInput {
89 pub session_id: HumanSessionId,
90 pub session_token_hash: String,
91 pub csrf_token_hash: String,
92 pub human_id: HumanId,
93 pub expires_at: DateTime<Utc>,
94}
95
96#[derive(Debug, Clone)]
98pub struct CreateGithubSignInStateInput {
99 pub state_hash: String,
100 pub browser_nonce_hash: String,
101 pub return_to: Option<String>,
102 pub expires_at: DateTime<Utc>,
103}
104
105#[derive(Debug, Clone)]
107pub struct ConsumedGithubSignInState {
108 pub return_to: Option<String>,
109}
110
111#[derive(Debug, Clone)]
113pub struct CreateCreatorApiTokenInput {
114 pub id: CreatorApiTokenId,
115 pub token_hash: String,
116 pub label: String,
117 pub created_by_human_id: HumanId,
118 pub expires_at: Option<DateTime<Utc>>,
119}
120
121pub async fn resolve_github_human(
123 pool: &PgPool,
124 input: &ResolveGithubHumanInput,
125) -> Result<HumanRecord> {
126 let mut tx = pool.begin().await?;
127
128 if let Some(existing) = find_github_human_for_update_tx(&mut tx, input.github_user_id).await? {
129 if existing.status == HumanStatus::Disabled.as_str() {
130 return Err(ServiceError::Forbidden(
131 "linked human account is disabled".to_string(),
132 ));
133 }
134 if existing.status == HumanStatus::Deleted.as_str() {
135 return Err(ServiceError::Forbidden(
136 "linked human account has been deleted".to_string(),
137 ));
138 }
139 if input.bootstrap_admin_candidate {
140 lock_bootstrap_admin_scope_tx(&mut tx).await?;
141 if !active_admin_exists_tx(&mut tx).await? {
142 activate_human_tx(&mut tx, &existing.human_id).await?;
143 grant_role_tx(&mut tx, &existing.human_id, HumanRole::Creator, None).await?;
144 grant_role_tx(&mut tx, &existing.human_id, HumanRole::Admin, None).await?;
145 }
146 }
147 sqlx::query(
148 r#"
149 UPDATE human_external_identities
150 SET provider_login = $1,
151 updated_at = NOW()
152 WHERE provider = 'github'
153 AND provider_user_id = $2
154 "#,
155 )
156 .bind(input.github_login.trim())
157 .bind(input.github_user_id.as_i64())
158 .execute(&mut *tx)
159 .await?;
160 tx.commit().await?;
161 return get_human_by_id(pool, &existing.human_id).await;
162 }
163
164 let bootstrap_admin = if input.bootstrap_admin_candidate {
165 lock_bootstrap_admin_scope_tx(&mut tx).await?;
166 !active_admin_exists_tx(&mut tx).await?
167 } else {
168 false
169 };
170
171 let initial_status = if bootstrap_admin {
172 HumanStatus::Active
173 } else {
174 HumanStatus::SetupRequired
175 };
176 insert_human_tx(&mut tx, &input.fallback_human_id, initial_status).await?;
177 insert_github_identity_tx(
178 &mut tx,
179 &input.fallback_human_id,
180 input.github_user_id,
181 &input.github_login,
182 )
183 .await?;
184
185 if bootstrap_admin {
186 grant_role_tx(&mut tx, &input.fallback_human_id, HumanRole::Creator, None).await?;
187 grant_role_tx(&mut tx, &input.fallback_human_id, HumanRole::Admin, None).await?;
188 }
189
190 tx.commit().await?;
191
192 get_human_by_id(pool, &input.fallback_human_id).await
193}
194
195pub async fn create_github_sign_in_state(
197 pool: &PgPool,
198 input: &CreateGithubSignInStateInput,
199) -> Result<()> {
200 sqlx::query(
201 r#"
202 INSERT INTO github_sign_in_states (
203 state_hash,
204 browser_nonce_hash,
205 return_to,
206 expires_at
207 )
208 VALUES ($1, $2, $3, $4)
209 "#,
210 )
211 .bind(&input.state_hash)
212 .bind(&input.browser_nonce_hash)
213 .bind(&input.return_to)
214 .bind(input.expires_at)
215 .execute(pool)
216 .await?;
217
218 Ok(())
219}
220
221pub async fn consume_github_sign_in_state(
223 pool: &PgPool,
224 state_hash: &str,
225 browser_nonce_hash: &str,
226) -> Result<Option<ConsumedGithubSignInState>> {
227 let row = sqlx::query(
228 r#"
229 DELETE FROM github_sign_in_states
230 WHERE state_hash = $1
231 AND browser_nonce_hash = $2
232 AND expires_at > NOW()
233 RETURNING return_to
234 "#,
235 )
236 .bind(state_hash)
237 .bind(browser_nonce_hash)
238 .fetch_optional(pool)
239 .await?;
240
241 let Some(row) = row else {
242 return Ok(None);
243 };
244
245 Ok(Some(ConsumedGithubSignInState {
246 return_to: row.try_get("return_to")?,
247 }))
248}
249
250pub async fn complete_human_setup(
252 pool: &PgPool,
253 human_id: &HumanId,
254 code_hash: &str,
255) -> Result<HumanRecord> {
256 let mut tx = pool.begin().await?;
257 let row = sqlx::query(
258 r#"
259 SELECT status
260 FROM humans
261 WHERE id = $1::uuid
262 FOR UPDATE
263 "#,
264 )
265 .bind(human_id.as_str())
266 .fetch_optional(&mut *tx)
267 .await?
268 .ok_or(ServiceError::NotFound)?;
269 let status: String = row.try_get("status")?;
270 if status == HumanStatus::Disabled.as_str() {
271 return Err(ServiceError::Forbidden(
272 "human account is disabled".to_string(),
273 ));
274 }
275 if status == HumanStatus::Deleted.as_str() {
276 return Err(ServiceError::Forbidden(
277 "human account has been deleted".to_string(),
278 ));
279 }
280 if status == HumanStatus::Active.as_str()
281 && active_role_exists_tx(&mut tx, human_id, HumanRole::Creator).await?
282 {
283 tx.commit().await?;
284 return get_human_by_id(pool, human_id).await;
285 }
286
287 consume_pioneer_code_for_human_tx(&mut tx, code_hash, human_id.as_str()).await?;
288 activate_human_tx(&mut tx, human_id).await?;
289 grant_role_tx(&mut tx, human_id, HumanRole::Creator, None).await?;
290 tx.commit().await?;
291
292 get_human_by_id(pool, human_id).await
293}
294
295pub async fn create_human_session(pool: &PgPool, input: &CreateHumanSessionInput) -> Result<()> {
297 sqlx::query(
298 r#"
299 INSERT INTO human_sessions (
300 id,
301 session_token_hash,
302 csrf_token_hash,
303 human_id,
304 expires_at
305 )
306 VALUES ($1::uuid, $2, $3, $4::uuid, $5)
307 "#,
308 )
309 .bind(input.session_id.as_str())
310 .bind(&input.session_token_hash)
311 .bind(&input.csrf_token_hash)
312 .bind(input.human_id.as_str())
313 .bind(input.expires_at)
314 .execute(pool)
315 .await?;
316
317 Ok(())
318}
319
320pub async fn authenticate_human_session(
322 pool: &PgPool,
323 session_token: &str,
324) -> Result<Option<AuthenticatedHumanSession>> {
325 let session_token_hash = crate::auth::hash_opaque_token(session_token);
326 let row = sqlx::query(
327 r#"
328 SELECT
329 s.id::text AS session_id,
330 h.id::text AS human_id,
331 h.status,
332 e.provider_user_id AS github_user_id,
333 e.provider_login AS github_login,
334 s.csrf_token_hash,
335 s.expires_at,
336 COALESCE(
337 array_agg(r.role ORDER BY r.role)
338 FILTER (WHERE r.role IS NOT NULL AND r.revoked_at IS NULL),
339 ARRAY[]::TEXT[]
340 ) AS roles
341 FROM human_sessions s
342 JOIN humans h ON h.id = s.human_id
343 JOIN human_external_identities e ON e.human_id = h.id AND e.provider = 'github'
344 LEFT JOIN human_roles r ON r.human_id = h.id
345 WHERE s.session_token_hash = $1
346 AND s.expires_at > NOW()
347 AND h.status IN ('active', 'setup_required')
348 GROUP BY s.id, h.id, h.status, e.provider_user_id, e.provider_login, s.csrf_token_hash, s.expires_at
349 LIMIT 1
350 "#,
351 )
352 .bind(&session_token_hash)
353 .fetch_optional(pool)
354 .await?;
355
356 let Some(row) = row else {
357 return Ok(None);
358 };
359
360 let session_id = HumanSessionId::try_new(row.try_get::<String, _>("session_id")?)
361 .map_err(|e| ServiceError::Internal(format!("stored invalid human session id: {e}")))?;
362 sqlx::query("UPDATE human_sessions SET last_used_at = NOW() WHERE id = $1::uuid")
363 .bind(session_id.as_str())
364 .execute(pool)
365 .await?;
366
367 Ok(Some(AuthenticatedHumanSession {
368 session_id,
369 human_id: human_id_from_row(&row, "human_id")?,
370 status: human_status_from_row(&row, "status")?,
371 github_user_id: github_user_id_from_row(&row, "github_user_id")?,
372 github_login: row.try_get("github_login")?,
373 roles: roles_from_row(&row)?,
374 csrf_token_hash: row.try_get("csrf_token_hash")?,
375 expires_at: row.try_get("expires_at")?,
376 }))
377}
378
379pub async fn delete_human_session_by_token(pool: &PgPool, session_token: &str) -> Result<()> {
381 let session_token_hash = crate::auth::hash_opaque_token(session_token);
382 sqlx::query("DELETE FROM human_sessions WHERE session_token_hash = $1")
383 .bind(session_token_hash)
384 .execute(pool)
385 .await?;
386 Ok(())
387}
388
389pub async fn list_humans(pool: &PgPool) -> Result<Vec<HumanRecord>> {
391 let rows = sqlx::query(human_list_sql()).fetch_all(pool).await?;
392 rows.iter().map(human_record_from_row).collect()
393}
394
395pub async fn get_human_by_id(pool: &PgPool, human_id: &HumanId) -> Result<HumanRecord> {
397 let row = sqlx::query(
398 r#"
399 SELECT
400 h.id::text AS human_id,
401 h.status,
402 h.created_at,
403 h.disabled_at,
404 h.deleted_at,
405 e.provider_user_id AS github_user_id,
406 e.provider_login AS github_login,
407 COALESCE(
408 array_agg(r.role ORDER BY r.role)
409 FILTER (WHERE r.role IS NOT NULL AND r.revoked_at IS NULL),
410 ARRAY[]::TEXT[]
411 ) AS roles
412 FROM humans h
413 JOIN human_external_identities e ON e.human_id = h.id AND e.provider = 'github'
414 LEFT JOIN human_roles r ON r.human_id = h.id
415 WHERE h.id = $1::uuid
416 GROUP BY h.id, h.status, h.created_at, h.disabled_at, h.deleted_at, e.provider_user_id, e.provider_login
417 "#,
418 )
419 .bind(human_id.as_str())
420 .fetch_optional(pool)
421 .await?
422 .ok_or(ServiceError::NotFound)?;
423 human_record_from_row(&row)
424}
425
426pub async fn grant_admin_role(
428 pool: &PgPool,
429 human_id: &HumanId,
430 granted_by_human_id: &HumanId,
431) -> Result<HumanRecord> {
432 let mut tx = pool.begin().await?;
433 lock_bootstrap_admin_scope_tx(&mut tx).await?;
434 ensure_active_human_tx(&mut tx, human_id).await?;
435 grant_role_tx(
436 &mut tx,
437 human_id,
438 HumanRole::Admin,
439 Some(granted_by_human_id),
440 )
441 .await?;
442 tx.commit().await?;
443 get_human_by_id(pool, human_id).await
444}
445
446pub async fn revoke_admin_role(
448 pool: &PgPool,
449 human_id: &HumanId,
450 revoked_by_human_id: &HumanId,
451) -> Result<HumanRecord> {
452 let mut tx = pool.begin().await?;
453 lock_bootstrap_admin_scope_tx(&mut tx).await?;
454 let role_exists = sqlx::query(
455 r#"
456 SELECT id
457 FROM human_roles
458 WHERE human_id = $1::uuid
459 AND role = 'admin'
460 AND revoked_at IS NULL
461 FOR UPDATE
462 "#,
463 )
464 .bind(human_id.as_str())
465 .fetch_optional(&mut *tx)
466 .await?
467 .is_some();
468 if !role_exists {
469 return Err(ServiceError::NotFound);
470 }
471 let active_admin_count = active_admin_count_tx(&mut tx).await?;
472 if active_admin_count <= 1 {
473 return Err(ServiceError::BadRequest(
474 "cannot revoke the final active human admin".to_string(),
475 ));
476 }
477 sqlx::query(
478 r#"
479 UPDATE human_roles
480 SET revoked_at = COALESCE(revoked_at, NOW()),
481 revoked_by_human_id = COALESCE(revoked_by_human_id, $2::uuid)
482 WHERE human_id = $1::uuid
483 AND role = 'admin'
484 AND revoked_at IS NULL
485 "#,
486 )
487 .bind(human_id.as_str())
488 .bind(revoked_by_human_id.as_str())
489 .execute(&mut *tx)
490 .await?;
491 revoke_admin_service_tokens_created_by_human_for_admin_revocation_tx(
492 &mut tx,
493 human_id,
494 revoked_by_human_id,
495 )
496 .await?;
497 tx.commit().await?;
498 get_human_by_id(pool, human_id).await
499}
500
501async fn revoke_admin_service_tokens_created_by_human_for_admin_revocation_tx(
502 tx: &mut Transaction<'_, Postgres>,
503 human_id: &HumanId,
504 revoked_by_human_id: &HumanId,
505) -> Result<()> {
506 sqlx::query(
507 r#"
508 UPDATE admin_service_tokens
509 SET status = 'revoked',
510 revoked_at = COALESCE(revoked_at, NOW()),
511 revoked_by_human_id = COALESCE(revoked_by_human_id, $2::uuid)
512 WHERE created_by_human_id = $1::uuid
513 AND status = 'active'
514 "#,
515 )
516 .bind(human_id.as_str())
517 .bind(revoked_by_human_id.as_str())
518 .execute(&mut **tx)
519 .await?;
520 Ok(())
521}
522
523pub async fn create_creator_api_token(
525 pool: &PgPool,
526 input: &CreateCreatorApiTokenInput,
527) -> Result<CreatorApiTokenRecord> {
528 let row = sqlx::query(
529 r#"
530 INSERT INTO creator_api_tokens (
531 id,
532 token_hash,
533 label,
534 created_by_human_id,
535 expires_at
536 )
537 VALUES ($1::uuid, $2, $3, $4::uuid, $5)
538 RETURNING
539 id::text AS id,
540 label,
541 status,
542 created_by_human_id::text AS created_by_human_id,
543 created_at,
544 last_used_at,
545 expires_at,
546 revoked_at
547 "#,
548 )
549 .bind(input.id.as_str())
550 .bind(&input.token_hash)
551 .bind(input.label.trim())
552 .bind(input.created_by_human_id.as_str())
553 .bind(input.expires_at)
554 .fetch_one(pool)
555 .await
556 .map_err(map_creator_api_token_create_error)?;
557
558 creator_api_token_record_from_row(&row)
559}
560
561pub async fn list_creator_api_tokens(
563 pool: &PgPool,
564 human_id: &HumanId,
565) -> Result<Vec<CreatorApiTokenRecord>> {
566 let rows = sqlx::query(
567 r#"
568 SELECT
569 id::text AS id,
570 label,
571 status,
572 created_by_human_id::text AS created_by_human_id,
573 created_at,
574 last_used_at,
575 expires_at,
576 revoked_at
577 FROM creator_api_tokens
578 WHERE created_by_human_id = $1::uuid
579 ORDER BY created_at DESC
580 "#,
581 )
582 .bind(human_id.as_str())
583 .fetch_all(pool)
584 .await?;
585
586 rows.iter().map(creator_api_token_record_from_row).collect()
587}
588
589pub async fn revoke_creator_api_token(
591 pool: &PgPool,
592 human_id: &HumanId,
593 id: &CreatorApiTokenId,
594) -> Result<CreatorApiTokenRecord> {
595 let row = sqlx::query(
596 r#"
597 UPDATE creator_api_tokens
598 SET status = 'revoked',
599 revoked_at = COALESCE(revoked_at, NOW())
600 WHERE id = $1::uuid
601 AND created_by_human_id = $2::uuid
602 RETURNING
603 id::text AS id,
604 label,
605 status,
606 created_by_human_id::text AS created_by_human_id,
607 created_at,
608 last_used_at,
609 expires_at,
610 revoked_at
611 "#,
612 )
613 .bind(id.as_str())
614 .bind(human_id.as_str())
615 .fetch_optional(pool)
616 .await?
617 .ok_or(ServiceError::NotFound)?;
618
619 creator_api_token_record_from_row(&row)
620}
621
622pub async fn authenticate_creator_api_token(
624 pool: &PgPool,
625 token_hash: &str,
626) -> Result<Option<AuthenticatedCreatorApiToken>> {
627 let row = sqlx::query(
628 r#"
629 SELECT
630 t.id::text AS id,
631 t.label,
632 t.created_by_human_id::text AS human_id,
633 t.expires_at,
634 h.status,
635 e.provider_user_id AS github_user_id,
636 e.provider_login AS github_login,
637 COALESCE(
638 array_agg(r.role ORDER BY r.role)
639 FILTER (WHERE r.role IS NOT NULL AND r.revoked_at IS NULL),
640 ARRAY[]::TEXT[]
641 ) AS roles
642 FROM creator_api_tokens t
643 JOIN humans h ON h.id = t.created_by_human_id
644 JOIN human_external_identities e ON e.human_id = h.id AND e.provider = 'github'
645 LEFT JOIN human_roles r ON r.human_id = h.id
646 WHERE t.token_hash = $1
647 AND t.status = 'active'
648 AND (t.expires_at IS NULL OR t.expires_at > NOW())
649 GROUP BY t.id, t.label, t.created_by_human_id, t.expires_at, h.status, e.provider_user_id, e.provider_login
650 LIMIT 1
651 "#,
652 )
653 .bind(token_hash)
654 .fetch_optional(pool)
655 .await?;
656
657 let Some(row) = row else {
658 return Ok(None);
659 };
660
661 let token_id = creator_api_token_id_from_row(&row, "id")?;
662 sqlx::query("UPDATE creator_api_tokens SET last_used_at = NOW() WHERE id = $1::uuid")
663 .bind(token_id.as_str())
664 .execute(pool)
665 .await?;
666
667 Ok(Some(AuthenticatedCreatorApiToken {
668 token_id,
669 label: row.try_get("label")?,
670 human_id: human_id_from_row(&row, "human_id")?,
671 status: human_status_from_row(&row, "status")?,
672 github_user_id: github_user_id_from_row(&row, "github_user_id")?,
673 github_login: row.try_get("github_login")?,
674 roles: roles_from_row(&row)?,
675 expires_at: row.try_get("expires_at")?,
676 }))
677}
678
679pub async fn delete_expired_web_auth_rows(pool: &PgPool) -> Result<()> {
681 sqlx::query("DELETE FROM github_sign_in_states WHERE expires_at <= NOW()")
682 .execute(pool)
683 .await?;
684 sqlx::query("DELETE FROM human_sessions WHERE expires_at <= NOW()")
685 .execute(pool)
686 .await?;
687 Ok(())
688}
689
690async fn find_github_human_for_update_tx(
691 tx: &mut Transaction<'_, Postgres>,
692 github_user_id: GithubUserId,
693) -> Result<Option<HumanRecord>> {
694 let row = sqlx::query(
695 r#"
696 WITH locked_human AS (
697 SELECT
698 h.id,
699 h.status,
700 h.created_at,
701 h.disabled_at,
702 h.deleted_at,
703 e.provider_user_id,
704 e.provider_login
705 FROM human_external_identities e
706 JOIN humans h ON h.id = e.human_id
707 WHERE e.provider = 'github'
708 AND e.provider_user_id = $1
709 FOR UPDATE OF h, e
710 )
711 SELECT
712 locked_human.id::text AS human_id,
713 locked_human.status,
714 locked_human.created_at,
715 locked_human.disabled_at,
716 locked_human.deleted_at,
717 locked_human.provider_user_id AS github_user_id,
718 locked_human.provider_login AS github_login,
719 COALESCE(
720 array_agg(r.role ORDER BY r.role)
721 FILTER (WHERE r.role IS NOT NULL AND r.revoked_at IS NULL),
722 ARRAY[]::TEXT[]
723 ) AS roles
724 FROM locked_human
725 LEFT JOIN human_roles r ON r.human_id = locked_human.id
726 GROUP BY
727 locked_human.id,
728 locked_human.status,
729 locked_human.created_at,
730 locked_human.disabled_at,
731 locked_human.deleted_at,
732 locked_human.provider_user_id,
733 locked_human.provider_login
734 "#,
735 )
736 .bind(github_user_id.as_i64())
737 .fetch_optional(&mut **tx)
738 .await?;
739
740 row.as_ref().map(human_record_from_row).transpose()
741}
742
743async fn insert_human_tx(
744 tx: &mut Transaction<'_, Postgres>,
745 human_id: &HumanId,
746 status: HumanStatus,
747) -> Result<()> {
748 sqlx::query(
749 r#"
750 INSERT INTO humans (id, status)
751 VALUES ($1::uuid, $2)
752 "#,
753 )
754 .bind(human_id.as_str())
755 .bind(status.as_str())
756 .execute(&mut **tx)
757 .await?;
758 Ok(())
759}
760
761async fn activate_human_tx(tx: &mut Transaction<'_, Postgres>, human_id: &HumanId) -> Result<()> {
762 sqlx::query(
763 r#"
764 UPDATE humans
765 SET status = 'active',
766 disabled_at = NULL,
767 deleted_at = NULL
768 WHERE id = $1::uuid
769 AND status NOT IN ('disabled', 'deleted')
770 "#,
771 )
772 .bind(human_id.as_str())
773 .execute(&mut **tx)
774 .await?;
775 Ok(())
776}
777
778async fn insert_github_identity_tx(
779 tx: &mut Transaction<'_, Postgres>,
780 human_id: &HumanId,
781 github_user_id: GithubUserId,
782 github_login: &str,
783) -> Result<()> {
784 sqlx::query(
785 r#"
786 INSERT INTO human_external_identities (
787 human_id,
788 provider,
789 provider_user_id,
790 provider_login
791 )
792 VALUES ($1::uuid, 'github', $2, $3)
793 "#,
794 )
795 .bind(human_id.as_str())
796 .bind(github_user_id.as_i64())
797 .bind(github_login.trim())
798 .execute(&mut **tx)
799 .await?;
800 Ok(())
801}
802
803async fn grant_role_tx(
804 tx: &mut Transaction<'_, Postgres>,
805 human_id: &HumanId,
806 role: HumanRole,
807 granted_by_human_id: Option<&HumanId>,
808) -> Result<()> {
809 sqlx::query(
810 r#"
811 INSERT INTO human_roles (id, human_id, role, granted_by_human_id)
812 VALUES ($1::uuid, $2::uuid, $3, $4::uuid)
813 ON CONFLICT (human_id, role) WHERE revoked_at IS NULL DO NOTHING
814 "#,
815 )
816 .bind(uuid::Uuid::new_v4().to_string())
817 .bind(human_id.as_str())
818 .bind(role.as_str())
819 .bind(granted_by_human_id.map(HumanId::as_str))
820 .execute(&mut **tx)
821 .await?;
822 Ok(())
823}
824
825async fn active_role_exists_tx(
826 tx: &mut Transaction<'_, Postgres>,
827 human_id: &HumanId,
828 role: HumanRole,
829) -> Result<bool> {
830 let row = sqlx::query(
831 r#"
832 SELECT id
833 FROM human_roles
834 WHERE human_id = $1::uuid
835 AND role = $2
836 AND revoked_at IS NULL
837 LIMIT 1
838 "#,
839 )
840 .bind(human_id.as_str())
841 .bind(role.as_str())
842 .fetch_optional(&mut **tx)
843 .await?;
844 Ok(row.is_some())
845}
846
847async fn ensure_active_human_tx(
848 tx: &mut Transaction<'_, Postgres>,
849 human_id: &HumanId,
850) -> Result<()> {
851 let row = sqlx::query(
852 r#"
853 SELECT status
854 FROM humans
855 WHERE id = $1::uuid
856 FOR UPDATE
857 "#,
858 )
859 .bind(human_id.as_str())
860 .fetch_optional(&mut **tx)
861 .await?
862 .ok_or(ServiceError::NotFound)?;
863 let status: String = row.try_get("status")?;
864 if status != "active" {
865 return Err(ServiceError::Forbidden(
866 "human account is not active".to_string(),
867 ));
868 }
869 Ok(())
870}
871
872async fn lock_bootstrap_admin_scope_tx(tx: &mut Transaction<'_, Postgres>) -> Result<()> {
873 sqlx::query(
874 r#"
875 INSERT INTO quota_admission_locks (scope)
876 VALUES ('global:bootstrap-admin')
877 ON CONFLICT (scope) DO NOTHING
878 "#,
879 )
880 .execute(&mut **tx)
881 .await?;
882
883 sqlx::query(
884 r#"
885 SELECT scope
886 FROM quota_admission_locks
887 WHERE scope = 'global:bootstrap-admin'
888 FOR UPDATE
889 "#,
890 )
891 .fetch_one(&mut **tx)
892 .await?;
893
894 Ok(())
895}
896
897async fn active_admin_exists_tx(tx: &mut Transaction<'_, Postgres>) -> Result<bool> {
898 Ok(active_admin_count_tx(tx).await? > 0)
899}
900
901async fn active_admin_count_tx(tx: &mut Transaction<'_, Postgres>) -> Result<i64> {
902 let row = sqlx::query(
903 r#"
904 SELECT COUNT(*) AS count
905 FROM human_roles r
906 JOIN humans h ON h.id = r.human_id
907 WHERE r.role = 'admin'
908 AND r.revoked_at IS NULL
909 AND h.status = 'active'
910 "#,
911 )
912 .fetch_one(&mut **tx)
913 .await?;
914 row.try_get("count").map_err(ServiceError::from)
915}
916
917fn human_list_sql() -> &'static str {
918 r#"
919 SELECT
920 h.id::text AS human_id,
921 h.status,
922 h.created_at,
923 h.disabled_at,
924 h.deleted_at,
925 e.provider_user_id AS github_user_id,
926 e.provider_login AS github_login,
927 COALESCE(
928 array_agg(r.role ORDER BY r.role)
929 FILTER (WHERE r.role IS NOT NULL AND r.revoked_at IS NULL),
930 ARRAY[]::TEXT[]
931 ) AS roles
932 FROM humans h
933 JOIN human_external_identities e ON e.human_id = h.id AND e.provider = 'github'
934 LEFT JOIN human_roles r ON r.human_id = h.id
935 GROUP BY h.id, h.status, h.created_at, h.disabled_at, h.deleted_at, e.provider_user_id, e.provider_login
936 ORDER BY h.created_at DESC
937 "#
938}
939
940fn human_record_from_row(row: &sqlx::postgres::PgRow) -> Result<HumanRecord> {
941 Ok(HumanRecord {
942 human_id: human_id_from_row(row, "human_id")?,
943 status: row.try_get("status")?,
944 github_user_id: github_user_id_from_row(row, "github_user_id")?,
945 github_login: row.try_get("github_login")?,
946 roles: roles_from_row(row)?,
947 created_at: row.try_get("created_at")?,
948 disabled_at: row.try_get("disabled_at")?,
949 deleted_at: row.try_get("deleted_at")?,
950 })
951}
952
953fn github_user_id_from_row(row: &sqlx::postgres::PgRow, field: &str) -> Result<GithubUserId> {
954 let value = row.try_get::<i64, _>(field)?;
955 GithubUserId::try_new(value)
956 .map_err(|e| ServiceError::Internal(format!("stored invalid GitHub user id: {e}")))
957}
958
959fn human_status_from_row(row: &sqlx::postgres::PgRow, field: &str) -> Result<HumanStatus> {
960 let value = row.try_get::<String, _>(field)?;
961 HumanStatus::from_storage_value(&value)
962 .ok_or_else(|| ServiceError::Internal(format!("stored invalid human status `{value}`")))
963}
964
965fn roles_from_row(row: &sqlx::postgres::PgRow) -> Result<Vec<HumanRole>> {
966 let roles = row.try_get::<Vec<String>, _>("roles")?;
967 roles
968 .into_iter()
969 .map(|role| {
970 HumanRole::from_storage_value(&role).ok_or_else(|| {
971 ServiceError::Internal(format!("stored invalid human role `{role}`"))
972 })
973 })
974 .collect()
975}
976
977fn creator_api_token_record_from_row(row: &sqlx::postgres::PgRow) -> Result<CreatorApiTokenRecord> {
978 Ok(CreatorApiTokenRecord {
979 id: creator_api_token_id_from_row(row, "id")?,
980 label: row.try_get("label")?,
981 status: row.try_get("status")?,
982 created_by_human_id: human_id_from_row(row, "created_by_human_id")?,
983 created_at: row.try_get("created_at")?,
984 last_used_at: row.try_get("last_used_at")?,
985 expires_at: row.try_get("expires_at")?,
986 revoked_at: row.try_get("revoked_at")?,
987 })
988}
989
990fn map_creator_api_token_create_error(error: sqlx::Error) -> ServiceError {
991 match error {
992 sqlx::Error::Database(db_err)
993 if db_err.is_unique_violation()
994 && db_err
995 .constraint()
996 .is_some_and(|name| name == CREATOR_API_TOKEN_ACTIVE_LABEL_INDEX) =>
997 {
998 duplicate_token_label_conflict(
999 "active creator API token label already exists for this human",
1000 "An active creator API token already uses this label.",
1001 )
1002 }
1003 error => ServiceError::Database(error),
1004 }
1005}
1006
1007fn duplicate_token_label_conflict(message: &str, detail_message: &str) -> ServiceError {
1008 ServiceError::conflict_with_details(
1009 message,
1010 [ErrorDetail {
1011 field: Some("label".to_string()),
1012 message: detail_message.to_string(),
1013 }],
1014 )
1015}