Skip to main content

kasl_server/
provision.rs

1//! Getting the first agents onto a server that has no admin UI yet.
2//!
3//! Until accounts and token issuing arrive with the people milestone, the
4//! operator declares agents in the environment:
5//!
6//! ```text
7//! KASL_AGENTS=alice@example.com:s3cr3t-token,bob@example.com:another-token
8//! ```
9//!
10//! On startup each entry becomes a user and an agent holding that token's
11//! hash. Deliberately not a public enrollment endpoint: a server whose only
12//! way in is a secret the operator already knows cannot be joined by anyone
13//! who does not.
14//!
15//! This is a bootstrap, and it says so - when the admin UI issues tokens, the
16//! variable stops being the way in and the ingest contract does not change.
17
18use anyhow::{Context, Result};
19use sqlx::PgPool;
20
21use crate::{auth::hash_token, session::hash_password};
22
23/// One `email:token` pair from the environment.
24#[derive(Debug, PartialEq, Eq)]
25pub struct AgentSeed {
26    pub email: String,
27    pub token: String,
28}
29
30/// Parses `KASL_AGENTS`. An empty or absent value yields nothing, which is the
31/// normal state of a server whose agents are already in the database.
32pub 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        // `rsplit_once` so a colon inside the token - plausible in a generated
37        // secret - stays part of it.
38        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
56/// Creates or updates the declared users and agents.
57///
58/// Idempotent: restarting the server with the same variable changes nothing,
59/// and rotating a token in the variable rotates it in the database.
60pub async fn apply_seeds(pool: &PgPool, seeds: &[AgentSeed]) -> Result<()> {
61    for seed in seeds {
62        let mut tx = pool.begin().await?;
63
64        // The display name is the local part until someone sets a real one;
65        // the admin UI will own this field later.
66        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        // One seeded agent per user, identified by its name: re-running with a
80        // new token replaces the hash instead of leaving the old one valid.
81        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        // Count only: the tokens are secrets and the addresses are personal.
103        tracing::info!(agents = seeds.len(), "provisioned agents from KASL_AGENTS");
104    }
105
106    Ok(())
107}
108
109/// Creates the first administrator, or resets an existing one's password.
110///
111/// The account is upserted rather than refused when it exists: an operator who
112/// locked themselves out has no other way back in, and demanding they first
113/// delete a row by hand in psql helps nobody. Promoting to admin is part of it
114/// for the same reason - the alternative is a server with data in it and no way
115/// to administer it.
116pub 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    // Not a policy, a floor. Real password rules belong with the account
122    // management UI, where they can be explained to the person typing.
123    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
142/// Creates the first administrator with a generated password, if there is no
143/// administrator at all.
144///
145/// The alternative - refusing to start until the operator sets `KASL_ADMIN` -
146/// makes the first run a documentation exercise, and the password it teaches
147/// them to write then lives in a `.env` file forever. Here the secret exists
148/// for one line of one log and is never stored in a file the operator has to
149/// remember to clean up.
150///
151/// Does nothing once an administrator exists, so a restart is not a way to
152/// mint credentials, and nothing is printed on the hundredth boot.
153pub 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
167/// A password nobody has to remember: it is used once, to sign in and change
168/// it. Base32-ish alphabet without the characters people misread aloud or in a
169/// terminal font - this gets copied off a screen more often than pasted.
170fn generated_password() -> String {
171    use rand::RngExt;
172
173    const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
174    // 20 characters of a 32-symbol alphabet: 100 bits, which is beyond
175    // guessing for something that also only has to survive until it is changed.
176    let mut rng = rand::rng();
177    (0..20).map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char).collect()
178}
179
180/// Parses `KASL_ADMIN`, which is `email:password`.
181///
182/// Absent or empty yields nothing: a server whose admin already exists has no
183/// reason to carry the password in its environment forever.
184pub 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    // `split_once`, so a colon inside the password stays in the password.
190    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}