Skip to main content

pacenotes_server/
admin.rs

1//! The `token` subcommands. A raw token exists only in `mint`'s return value;
2//! every other command works on its hash.
3
4/// One row of `token list`.
5#[derive(Debug)]
6pub struct Credential {
7    pub token_hash: String,
8    pub label: Option<String>,
9    pub created_at: i64,
10    pub expires_at: Option<i64>,
11    pub revoked: bool,
12    pub grants: Vec<String>,
13}
14
15impl Credential {
16    pub fn short_hash(&self) -> &str {
17        short_hash(&self.token_hash)
18    }
19}
20
21/// Identifies a credential without looking like a usable one.
22pub fn short_hash(token_hash: &str) -> &str {
23    &token_hash[..token_hash.len().min(SHORT_HASH_LENGTH)]
24}
25
26const SHORT_HASH_LENGTH: usize = 12;
27const SECONDS_PER_DAY: i64 = 86_400;
28
29/// A raw `pcn_…` token, a full hash, or a prefix of one that matches exactly one credential.
30pub async fn resolve_hash(
31    connection: &libsql::Connection,
32    token_or_hash: &str,
33) -> anyhow::Result<String> {
34    if token_or_hash.starts_with(crate::auth::TOKEN_PREFIX) {
35        return Ok(crate::auth::hash_token(token_or_hash));
36    }
37
38    let mut rows = connection
39        .query(
40            "SELECT token_hash FROM tokens WHERE token_hash LIKE ?1 || '%'",
41            libsql::params![token_or_hash],
42        )
43        .await
44        .map_err(|e| anyhow::anyhow!("looking up credential: {e}"))?;
45
46    let mut matches: Vec<String> = Vec::new();
47    while let Some(row) = rows
48        .next()
49        .await
50        .map_err(|e| anyhow::anyhow!("reading credential row: {e}"))?
51    {
52        matches.push(row.get::<String>(0).unwrap_or_default());
53    }
54    match matches.len() {
55        0 => anyhow::bail!("no credential matches {token_or_hash:?}"),
56        1 => Ok(matches.remove(0)),
57        count => anyhow::bail!("{token_or_hash:?} matches {count} credentials — use the full hash"),
58    }
59}
60
61/// Stores the hash and grants and returns the raw token, which nothing keeps.
62pub async fn mint(
63    connection: &libsql::Connection,
64    label: Option<&str>,
65    grants: &[String],
66    expires_days: Option<i64>,
67) -> anyhow::Result<String> {
68    for directory in grants {
69        check_grantable(directory)?;
70    }
71
72    let token = crate::auth::mint_token();
73    let hash = crate::auth::hash_token(&token);
74    let created_at = crate::auth::now();
75    let expires_at = expires_days.map(|days| created_at + days * SECONDS_PER_DAY);
76
77    connection
78        .execute(
79            "INSERT INTO tokens (token_hash, label, created_at, expires_at, revoked)
80             VALUES (?1, ?2, ?3, ?4, 0)",
81            libsql::params![hash.clone(), label, created_at, expires_at],
82        )
83        .await
84        .map_err(|e| anyhow::anyhow!("storing credential: {e}"))?;
85
86    for directory in grants {
87        insert_grant(connection, &hash, directory).await?;
88    }
89    Ok(token)
90}
91
92/// Granting a directory twice is not an error.
93pub async fn grant(
94    connection: &libsql::Connection,
95    token_or_hash: &str,
96    directory: &str,
97) -> anyhow::Result<String> {
98    check_grantable(directory)?;
99    let hash = resolve_hash(connection, token_or_hash).await?;
100    insert_grant(connection, &hash, directory).await?;
101    Ok(hash)
102}
103
104/// Effective on the next request, since grants are read per request.
105pub async fn ungrant(
106    connection: &libsql::Connection,
107    token_or_hash: &str,
108    directory: &str,
109) -> anyhow::Result<String> {
110    let hash = resolve_hash(connection, token_or_hash).await?;
111    connection
112        .execute(
113            "DELETE FROM grants WHERE token_hash = ?1 AND directory = ?2",
114            libsql::params![hash.clone(), directory],
115        )
116        .await
117        .map_err(|e| anyhow::anyhow!("removing grant: {e}"))?;
118    Ok(hash)
119}
120
121/// Keeps the row, so `token list` still shows it.
122pub async fn revoke(
123    connection: &libsql::Connection,
124    token_or_hash: &str,
125) -> anyhow::Result<String> {
126    let hash = resolve_hash(connection, token_or_hash).await?;
127    connection
128        .execute(
129            "UPDATE tokens SET revoked = 1 WHERE token_hash = ?1",
130            libsql::params![hash.clone()],
131        )
132        .await
133        .map_err(|e| anyhow::anyhow!("revoking credential: {e}"))?;
134    Ok(hash)
135}
136
137/// Newest first.
138pub async fn list(connection: &libsql::Connection) -> anyhow::Result<Vec<Credential>> {
139    let mut rows = connection
140        .query(
141            "SELECT t.token_hash, t.label, t.created_at, t.expires_at, t.revoked, g.directory
142             FROM tokens t
143             LEFT JOIN grants g ON g.token_hash = t.token_hash
144             ORDER BY t.created_at DESC, t.token_hash, g.directory",
145            (),
146        )
147        .await
148        .map_err(|e| anyhow::anyhow!("listing credentials: {e}"))?;
149
150    let mut credentials: Vec<Credential> = Vec::new();
151    while let Some(row) = rows
152        .next()
153        .await
154        .map_err(|e| anyhow::anyhow!("reading credential row: {e}"))?
155    {
156        let token_hash = row.get::<String>(0).unwrap_or_default();
157        let is_new_credential = credentials
158            .last()
159            .is_none_or(|credential| credential.token_hash != token_hash);
160        if is_new_credential {
161            credentials.push(Credential {
162                token_hash,
163                label: row.get::<Option<String>>(1).unwrap_or_default(),
164                created_at: row.get::<i64>(2).unwrap_or_default(),
165                expires_at: row.get::<Option<i64>>(3).unwrap_or_default(),
166                revoked: row.get::<i64>(4).unwrap_or_default() != 0,
167                grants: Vec::new(),
168            });
169        }
170        if let (Some(credential), Ok(Some(directory))) =
171            (credentials.last_mut(), row.get::<Option<String>>(5))
172        {
173            credential.grants.push(directory);
174        }
175    }
176    Ok(credentials)
177}
178
179async fn insert_grant(
180    connection: &libsql::Connection,
181    hash: &str,
182    directory: &str,
183) -> anyhow::Result<()> {
184    connection
185        .execute(
186            "INSERT OR IGNORE INTO grants (token_hash, directory) VALUES (?1, ?2)",
187            libsql::params![hash, directory],
188        )
189        .await
190        .map_err(|e| anyhow::anyhow!("storing grant: {e}"))?;
191    Ok(())
192}
193
194/// `public` is everyone's already, and an invalid name could never match a directory.
195fn check_grantable(directory: &str) -> anyhow::Result<()> {
196    if directory == pacenotes_shared::PUBLIC_DIR {
197        anyhow::bail!(
198            "{:?} is visible to everyone and cannot be granted",
199            pacenotes_shared::PUBLIC_DIR
200        );
201    }
202    if !pacenotes_shared::valid_id(directory) {
203        anyhow::bail!("{directory:?} is not a valid directory name [a-z0-9_-]+");
204    }
205    Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210    async fn open_database() -> libsql::Connection {
211        crate::db::open(":memory:").await.unwrap()
212    }
213
214    #[tokio::test]
215    async fn mint_stores_only_the_hash() {
216        let connection = open_database().await;
217        let token = super::mint(&connection, Some("Jan"), &["beta".into()], None)
218            .await
219            .unwrap();
220        assert!(token.starts_with(crate::auth::TOKEN_PREFIX));
221
222        let credentials = super::list(&connection).await.unwrap();
223        assert_eq!(credentials.len(), 1);
224        assert_eq!(credentials[0].token_hash, crate::auth::hash_token(&token));
225        assert_eq!(credentials[0].label.as_deref(), Some("Jan"));
226        assert_eq!(credentials[0].grants, vec!["beta".to_string()]);
227        assert!(!credentials[0].revoked);
228        assert!(credentials[0].expires_at.is_none());
229
230        let looked_up_by_raw_token = crate::auth::load_token(&connection, &token).await.unwrap();
231        assert!(looked_up_by_raw_token.is_none());
232    }
233
234    #[tokio::test]
235    async fn expires_days_becomes_an_absolute_deadline() {
236        let connection = open_database().await;
237        let before = crate::auth::now();
238        super::mint(&connection, None, &[], Some(7)).await.unwrap();
239        let expires_at = super::list(&connection).await.unwrap()[0]
240            .expires_at
241            .unwrap();
242        let clock_slack = 2;
243        assert!((expires_at - before - 7 * super::SECONDS_PER_DAY).abs() <= clock_slack);
244    }
245
246    #[tokio::test]
247    async fn grant_and_ungrant_round_trip_by_token_or_hash() {
248        let connection = open_database().await;
249        let token = super::mint(&connection, None, &[], None).await.unwrap();
250        let hash = crate::auth::hash_token(&token);
251
252        super::grant(&connection, &token, "beta").await.unwrap();
253        super::grant(&connection, &hash, "premium-voices")
254            .await
255            .unwrap();
256        super::grant(
257            &connection,
258            &hash[..super::SHORT_HASH_LENGTH],
259            "early-access",
260        )
261        .await
262        .unwrap();
263        super::grant(&connection, &token, "beta").await.unwrap();
264
265        let mut grants = super::list(&connection).await.unwrap()[0].grants.clone();
266        grants.sort();
267        assert_eq!(grants, vec!["beta", "early-access", "premium-voices"]);
268
269        super::ungrant(&connection, &token, "beta").await.unwrap();
270        let grants = super::list(&connection).await.unwrap()[0].grants.clone();
271        assert!(!grants.contains(&"beta".to_string()), "{grants:?}");
272
273        super::ungrant(&connection, &token, "beta").await.unwrap();
274    }
275
276    #[tokio::test]
277    async fn public_and_invalid_directories_cannot_be_granted() {
278        let connection = open_database().await;
279        let token = super::mint(&connection, None, &[], None).await.unwrap();
280
281        let error = super::grant(&connection, &token, pacenotes_shared::PUBLIC_DIR)
282            .await
283            .unwrap_err()
284            .to_string();
285        assert!(error.contains("everyone"), "{error}");
286
287        let error = super::grant(&connection, &token, "Premium Voices")
288            .await
289            .unwrap_err()
290            .to_string();
291        assert!(error.contains("not a valid directory name"), "{error}");
292
293        let error = super::mint(&connection, None, &["public".into()], None)
294            .await
295            .unwrap_err()
296            .to_string();
297        assert!(error.contains("everyone"), "{error}");
298        assert_eq!(super::list(&connection).await.unwrap().len(), 1);
299    }
300
301    #[tokio::test]
302    async fn revoke_marks_the_row_and_decide_refuses_it() {
303        let connection = open_database().await;
304        let token = super::mint(&connection, None, &["beta".into()], None)
305            .await
306            .unwrap();
307        super::revoke(&connection, &token).await.unwrap();
308
309        assert!(super::list(&connection).await.unwrap()[0].revoked);
310        let loaded = crate::auth::load_token(&connection, &crate::auth::hash_token(&token))
311            .await
312            .unwrap()
313            .unwrap();
314        assert!(matches!(
315            crate::auth::decide(Some(&loaded), "beta", crate::auth::now()),
316            crate::auth::Decision::Unauthorized
317        ));
318    }
319
320    #[tokio::test]
321    async fn unknown_and_ambiguous_references_are_refused() {
322        let connection = open_database().await;
323        let error = super::resolve_hash(&connection, "deadbeef")
324            .await
325            .unwrap_err()
326            .to_string();
327        assert!(error.contains("no credential matches"), "{error}");
328
329        super::mint(&connection, None, &[], None).await.unwrap();
330        super::mint(&connection, None, &[], None).await.unwrap();
331        let prefix_of_every_hash = "";
332        let error = super::resolve_hash(&connection, prefix_of_every_hash)
333            .await
334            .unwrap_err()
335            .to_string();
336        assert!(error.contains("use the full hash"), "{error}");
337    }
338}