1use anyhow::{Context, Result};
19use sqlx::PgPool;
20
21use crate::{auth::hash_token, session::hash_password};
22
23#[derive(Debug, PartialEq, Eq)]
25pub struct AgentSeed {
26 pub email: String,
27 pub token: String,
28}
29
30pub fn parse_seeds(raw: &str) -> Result<Vec<AgentSeed>> {
33 let mut seeds = Vec::new();
34
35 for entry in raw.split(',').map(str::trim).filter(|entry| !entry.is_empty()) {
36 let (email, token) = entry
39 .split_once(':')
40 .with_context(|| format!("KASL_AGENTS entry `{entry}` is not `email:token`"))?;
41 let (email, token) = (email.trim(), token.trim());
42
43 if email.is_empty() || token.is_empty() {
44 anyhow::bail!("KASL_AGENTS entry `{entry}` has an empty email or token");
45 }
46
47 seeds.push(AgentSeed {
48 email: email.to_string(),
49 token: token.to_string(),
50 });
51 }
52
53 Ok(seeds)
54}
55
56pub async fn apply_seeds(pool: &PgPool, seeds: &[AgentSeed]) -> Result<()> {
61 for seed in seeds {
62 let mut tx = pool.begin().await?;
63
64 let display_name = seed.email.split('@').next().unwrap_or(&seed.email);
67
68 let user_id: uuid::Uuid = sqlx::query_scalar(
69 "INSERT INTO users (email, display_name) VALUES ($1, $2)
70 ON CONFLICT (lower(email)) DO UPDATE SET active = true
71 RETURNING id",
72 )
73 .bind(&seed.email)
74 .bind(display_name)
75 .fetch_one(&mut *tx)
76 .await
77 .with_context(|| format!("failed to provision the user for {}", seed.email))?;
78
79 sqlx::query(
82 "INSERT INTO agents (user_id, name, token_hash, revoked_at) VALUES ($1, 'seeded', $2, NULL)
83 ON CONFLICT (token_hash) DO NOTHING",
84 )
85 .bind(user_id)
86 .bind(hash_token(&seed.token))
87 .execute(&mut *tx)
88 .await
89 .with_context(|| format!("failed to provision the agent for {}", seed.email))?;
90
91 sqlx::query("UPDATE agents SET revoked_at = now() WHERE user_id = $1 AND name = 'seeded' AND token_hash <> $2 AND revoked_at IS NULL")
92 .bind(user_id)
93 .bind(hash_token(&seed.token))
94 .execute(&mut *tx)
95 .await
96 .with_context(|| format!("failed to revoke the previous token for {}", seed.email))?;
97
98 tx.commit().await?;
99 }
100
101 if !seeds.is_empty() {
102 tracing::info!(agents = seeds.len(), "provisioned agents from KASL_AGENTS");
104 }
105
106 Ok(())
107}
108
109pub async fn ensure_admin(pool: &sqlx::PgPool, email: &str, password: &str) -> Result<()> {
117 let email = email.trim();
118 if email.is_empty() {
119 anyhow::bail!("an administrator needs an email address");
120 }
121 if password.chars().count() < 8 {
124 anyhow::bail!("the password must be at least 8 characters");
125 }
126
127 let hash = hash_password(password)?;
128 sqlx::query(
129 "INSERT INTO users (email, display_name, role, password_hash, active)
130 VALUES ($1, $1, 'admin', $2, true)
131 ON CONFLICT (lower(email)) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = 'admin', active = true",
132 )
133 .bind(email)
134 .bind(&hash)
135 .execute(pool)
136 .await
137 .context("failed to create the administrator")?;
138
139 Ok(())
140}
141
142pub async fn ensure_some_admin(pool: &sqlx::PgPool, email: &str) -> Result<Option<String>> {
154 let admins: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE role = 'admin' AND active")
155 .fetch_one(pool)
156 .await
157 .context("failed to look for an administrator")?;
158 if admins > 0 {
159 return Ok(None);
160 }
161
162 let password = generated_password();
163 ensure_admin(pool, email, &password).await?;
164 Ok(Some(password))
165}
166
167fn generated_password() -> String {
171 use rand::RngExt;
172
173 const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
174 let mut rng = rand::rng();
177 (0..20).map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char).collect()
178}
179
180pub fn parse_admin(raw: &str) -> Result<Option<(String, String)>> {
185 let raw = raw.trim();
186 if raw.is_empty() {
187 return Ok(None);
188 }
189 let (email, password) = raw.split_once(':').context("KASL_ADMIN is not `email:password`")?;
191 if password.is_empty() {
192 anyhow::bail!("KASL_ADMIN carries no password");
193 }
194 Ok(Some((email.trim().to_string(), password.to_string())))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn parses_several_entries() {
203 let seeds = parse_seeds("alice@example.test:token-a, bob@example.test:token-b").unwrap();
204 assert_eq!(
205 seeds,
206 vec![
207 AgentSeed {
208 email: "alice@example.test".into(),
209 token: "token-a".into()
210 },
211 AgentSeed {
212 email: "bob@example.test".into(),
213 token: "token-b".into()
214 },
215 ]
216 );
217 }
218
219 #[test]
220 fn an_absent_variable_provisions_nothing() {
221 assert!(parse_seeds("").unwrap().is_empty());
222 assert!(parse_seeds(" , ").unwrap().is_empty());
223 }
224
225 #[test]
226 fn a_colon_inside_the_token_survives() {
227 let seeds = parse_seeds("alice@example.test:ab:cd").unwrap();
228 assert_eq!(seeds[0].token, "ab:cd", "only the first colon separates");
229 }
230
231 #[test]
232 fn malformed_entries_name_themselves() {
233 let error = parse_seeds("alice@example.test").unwrap_err().to_string();
234 assert!(error.contains("alice@example.test"), "the message should quote the entry: {error}");
235
236 assert!(parse_seeds("alice@example.test:").is_err(), "an empty token is not usable");
237 assert!(parse_seeds(":token").is_err(), "an agent with no user has nobody to report for");
238 }
239}