Skip to main content

systemprompt_users/repository/user/
declared.rs

1//! Rows the identity config plane manages: users whose `declared_source`
2//! names the declaration file they come from.
3//!
4//! The plane reads and writes only these rows on the apply transaction. An
5//! upsert keyed on the normalised email takes over an existing row of the same
6//! account kind and stamps it declared; a row of the other kind is refused,
7//! never converted. Releasing a row (its declaration was removed) clears the
8//! stamp and deactivates it, so access granted from Git is withdrawn from Git
9//! and the row's history stays.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use chrono::Utc;
15use sqlx::PgConnection;
16use systemprompt_identifiers::UserId;
17
18use crate::error::{Result, UserError};
19use crate::models::{UserStatus, normalise_email};
20use crate::repository::UserRepository;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DeclaredUserRow {
24    pub email: String,
25    pub name: String,
26    pub account_kind: String,
27    pub roles: Vec<String>,
28    pub status: String,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub struct DeclaredUserWrite<'a> {
33    pub email: &'a str,
34    pub name: &'a str,
35    pub account_kind: &'a str,
36    pub roles: &'a [String],
37    pub status: &'a str,
38    pub source: &'a str,
39}
40
41impl UserRepository {
42    pub async fn list_declared_on(conn: &mut PgConnection) -> Result<Vec<DeclaredUserRow>> {
43        let rows = sqlx::query_as!(
44            DeclaredUserRow,
45            r#"
46            SELECT email, name, account_kind, roles, status
47            FROM users
48            WHERE declared_source IS NOT NULL
49            ORDER BY email
50            "#,
51        )
52        .fetch_all(conn)
53        .await?;
54        Ok(rows)
55    }
56
57    pub async fn upsert_declared_on(
58        conn: &mut PgConnection,
59        user: &DeclaredUserWrite<'_>,
60    ) -> Result<UserId> {
61        let id = UserId::generate();
62        let email = normalise_email(user.email);
63        let verified = user.account_kind == crate::models::AccountKind::Service.as_str();
64        let written = sqlx::query_scalar!(
65            r#"
66            INSERT INTO users (
67                id, name, email, display_name, status, email_verified, roles, is_bot,
68                account_kind, declared_source, created_at, updated_at
69            )
70            VALUES ($1, $2, $3, $2, $4, $5, $6, false, $7, $8, $9, $9)
71            ON CONFLICT (email) DO UPDATE
72            SET name = EXCLUDED.name,
73                status = EXCLUDED.status,
74                roles = EXCLUDED.roles,
75                declared_source = EXCLUDED.declared_source,
76                updated_at = EXCLUDED.updated_at
77            WHERE users.account_kind = EXCLUDED.account_kind
78            RETURNING id
79            "#,
80            id.as_str(),
81            user.name,
82            email,
83            user.status,
84            verified,
85            user.roles,
86            user.account_kind,
87            user.source,
88            Utc::now()
89        )
90        .fetch_optional(conn)
91        .await?;
92        written
93            .map(UserId::new)
94            .ok_or(UserError::AccountKindMismatch(email))
95    }
96
97    pub async fn release_declared_on(conn: &mut PgConnection, email: &str) -> Result<()> {
98        sqlx::query!(
99            r#"
100            UPDATE users
101            SET declared_source = NULL, status = $2, updated_at = $3
102            WHERE email = $1 AND declared_source IS NOT NULL
103            "#,
104            normalise_email(email),
105            UserStatus::Inactive.as_str(),
106            Utc::now()
107        )
108        .execute(conn)
109        .await?;
110        Ok(())
111    }
112
113    pub async fn find_id_by_email_on(
114        conn: &mut PgConnection,
115        email: &str,
116    ) -> Result<Option<UserId>> {
117        let id = sqlx::query_scalar!(
118            "SELECT id FROM users WHERE email = $1",
119            normalise_email(email)
120        )
121        .fetch_optional(conn)
122        .await?;
123        Ok(id.map(UserId::new))
124    }
125
126    pub async fn emails_by_ids_on(
127        conn: &mut PgConnection,
128        ids: &[UserId],
129    ) -> Result<Vec<(UserId, String)>> {
130        let ids: Vec<String> = ids.iter().map(|id| id.as_str().to_owned()).collect();
131        let rows = sqlx::query!(
132            "SELECT id, email FROM users WHERE id = ANY($1::text[])",
133            &ids
134        )
135        .fetch_all(conn)
136        .await?;
137        Ok(rows
138            .into_iter()
139            .map(|row| (UserId::new(row.id), row.email))
140            .collect())
141    }
142}