1use std::sync::{Arc, Mutex};
14
15use pylon_auth::api_key::{ApiKey, ApiKeyBackend};
16use rusqlite::Connection;
17
18const SQLITE_TABLE: &str = "_pylon_api_keys";
19const PG_TABLE: &str = "_pylon_api_keys";
20
21pub struct SqliteApiKeyBackend {
26 conn: Arc<Mutex<Connection>>,
27}
28
29impl SqliteApiKeyBackend {
30 pub fn open(path: &str) -> Result<Self, String> {
31 let conn = Connection::open(path).map_err(|e| format!("open: {e}"))?;
32 Self::from_connection(conn)
33 }
34 pub fn in_memory() -> Result<Self, String> {
35 let conn = Connection::open_in_memory().map_err(|e| format!("open: {e}"))?;
36 Self::from_connection(conn)
37 }
38 fn from_connection(conn: Connection) -> Result<Self, String> {
39 conn.execute_batch(&format!(
40 "CREATE TABLE IF NOT EXISTS {SQLITE_TABLE} (
41 id TEXT PRIMARY KEY,
42 user_id TEXT NOT NULL,
43 name TEXT NOT NULL DEFAULT '',
44 prefix TEXT NOT NULL DEFAULT '',
45 secret_hash TEXT NOT NULL,
46 scopes TEXT,
47 expires_at INTEGER,
48 last_used_at INTEGER,
49 created_at INTEGER NOT NULL
50 );
51 CREATE INDEX IF NOT EXISTS {SQLITE_TABLE}_user_idx ON {SQLITE_TABLE}(user_id);"
52 ))
53 .map_err(|e| format!("init schema: {e}"))?;
54 Ok(Self {
55 conn: Arc::new(Mutex::new(conn)),
56 })
57 }
58}
59
60impl ApiKeyBackend for SqliteApiKeyBackend {
61 fn put(&self, key: &ApiKey) {
62 if let Ok(guard) = self.conn.lock() {
63 let _ = guard.execute(
64 &format!(
65 "INSERT INTO {SQLITE_TABLE}
66 (id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at)
67 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
68 ON CONFLICT(id) DO UPDATE SET
69 name=excluded.name,
70 prefix=excluded.prefix,
71 secret_hash=excluded.secret_hash,
72 scopes=excluded.scopes,
73 expires_at=excluded.expires_at,
74 last_used_at=excluded.last_used_at"
75 ),
76 rusqlite::params![
77 key.id,
78 key.user_id,
79 key.name,
80 key.prefix,
81 key.secret_hash,
82 key.scopes,
83 key.expires_at.map(|v| v as i64),
84 key.last_used_at.map(|v| v as i64),
85 key.created_at as i64,
86 ],
87 );
88 }
89 }
90
91 fn get(&self, id: &str) -> Option<ApiKey> {
92 let guard = self.conn.lock().ok()?;
93 guard
94 .query_row(
95 &format!(
96 "SELECT id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at
97 FROM {SQLITE_TABLE} WHERE id = ?1"
98 ),
99 rusqlite::params![id],
100 row_to_key,
101 )
102 .ok()
103 }
104
105 fn delete(&self, id: &str) -> bool {
106 let Ok(guard) = self.conn.lock() else {
107 return false;
108 };
109 guard
110 .execute(
111 &format!("DELETE FROM {SQLITE_TABLE} WHERE id = ?1"),
112 rusqlite::params![id],
113 )
114 .map(|n| n > 0)
115 .unwrap_or(false)
116 }
117
118 fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
119 let Ok(guard) = self.conn.lock() else {
120 return vec![];
121 };
122 let mut stmt = match guard.prepare(&format!(
123 "SELECT id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at
124 FROM {SQLITE_TABLE} WHERE user_id = ?1 ORDER BY created_at DESC"
125 )) {
126 Ok(s) => s,
127 Err(_) => return vec![],
128 };
129 let iter = match stmt.query_map(rusqlite::params![user_id], row_to_key) {
130 Ok(it) => it,
131 Err(_) => return vec![],
132 };
133 iter.filter_map(|r| r.ok()).collect()
134 }
135
136 fn touch(&self, id: &str, now: u64) {
137 if let Ok(guard) = self.conn.lock() {
138 let _ = guard.execute(
139 &format!("UPDATE {SQLITE_TABLE} SET last_used_at = ?2 WHERE id = ?1"),
140 rusqlite::params![id, now as i64],
141 );
142 }
143 }
144}
145
146fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApiKey> {
147 Ok(ApiKey {
148 id: row.get(0)?,
149 user_id: row.get(1)?,
150 name: row.get(2)?,
151 prefix: row.get(3)?,
152 secret_hash: row.get(4)?,
153 scopes: row.get(5)?,
154 expires_at: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
155 last_used_at: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
156 created_at: row.get::<_, i64>(8)? as u64,
157 })
158}
159
160pub use pg::PostgresApiKeyBackend;
165
166mod pg {
167 use super::*;
168 use postgres::Client;
169
170 pub struct PostgresApiKeyBackend {
171 client: Mutex<Client>,
172 }
173
174 impl PostgresApiKeyBackend {
175 pub fn connect(url: &str) -> Result<Self, String> {
176 let mut client = pylon_storage::postgres::live::connect_pg(url)?;
177 client
178 .batch_execute(&format!(
179 "CREATE TABLE IF NOT EXISTS {PG_TABLE} (
180 id TEXT PRIMARY KEY,
181 user_id TEXT NOT NULL,
182 name TEXT NOT NULL DEFAULT '',
183 prefix TEXT NOT NULL DEFAULT '',
184 secret_hash TEXT NOT NULL,
185 scopes TEXT,
186 expires_at BIGINT,
187 last_used_at BIGINT,
188 created_at BIGINT NOT NULL
189 );
190 CREATE INDEX IF NOT EXISTS {PG_TABLE}_user_idx ON {PG_TABLE}(user_id);"
191 ))
192 .map_err(|e| format!("PG init schema: {e}"))?;
193 Ok(Self {
194 client: Mutex::new(client),
195 })
196 }
197 }
198
199 impl ApiKeyBackend for PostgresApiKeyBackend {
200 fn put(&self, key: &ApiKey) {
201 if let Ok(mut c) = self.client.lock() {
202 let _ = c.execute(
203 &format!(
204 "INSERT INTO {PG_TABLE}
205 (id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at)
206 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
207 ON CONFLICT (id) DO UPDATE SET
208 name = EXCLUDED.name,
209 prefix = EXCLUDED.prefix,
210 secret_hash = EXCLUDED.secret_hash,
211 scopes = EXCLUDED.scopes,
212 expires_at = EXCLUDED.expires_at,
213 last_used_at = EXCLUDED.last_used_at"
214 ),
215 &[
216 &key.id,
217 &key.user_id,
218 &key.name,
219 &key.prefix,
220 &key.secret_hash,
221 &key.scopes,
222 &key.expires_at.map(|v| v as i64),
223 &key.last_used_at.map(|v| v as i64),
224 &(key.created_at as i64),
225 ],
226 );
227 }
228 }
229
230 fn get(&self, id: &str) -> Option<ApiKey> {
231 let mut c = self.client.lock().ok()?;
232 let row = c
233 .query_opt(
234 &format!(
235 "SELECT id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at
236 FROM {PG_TABLE} WHERE id = $1"
237 ),
238 &[&id],
239 )
240 .ok()??;
241 Some(pg_row_to_key(&row))
242 }
243
244 fn delete(&self, id: &str) -> bool {
245 let mut c = match self.client.lock() {
246 Ok(c) => c,
247 Err(_) => return false,
248 };
249 c.execute(&format!("DELETE FROM {PG_TABLE} WHERE id = $1"), &[&id])
250 .map(|n| n > 0)
251 .unwrap_or(false)
252 }
253
254 fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
255 let Ok(mut c) = self.client.lock() else {
256 return vec![];
257 };
258 let rows = match c.query(
259 &format!(
260 "SELECT id, user_id, name, prefix, secret_hash, scopes, expires_at, last_used_at, created_at
261 FROM {PG_TABLE} WHERE user_id = $1 ORDER BY created_at DESC"
262 ),
263 &[&user_id],
264 ) {
265 Ok(r) => r,
266 Err(_) => return vec![],
267 };
268 rows.iter().map(pg_row_to_key).collect()
269 }
270
271 fn touch(&self, id: &str, now: u64) {
272 if let Ok(mut c) = self.client.lock() {
273 let _ = c.execute(
274 &format!("UPDATE {PG_TABLE} SET last_used_at = $2 WHERE id = $1"),
275 &[&id, &(now as i64)],
276 );
277 }
278 }
279 }
280
281 fn pg_row_to_key(row: &postgres::Row) -> ApiKey {
282 ApiKey {
283 id: row.get(0),
284 user_id: row.get(1),
285 name: row.get(2),
286 prefix: row.get(3),
287 secret_hash: row.get(4),
288 scopes: row.get(5),
289 expires_at: row.get::<_, Option<i64>>(6).map(|v| v as u64),
290 last_used_at: row.get::<_, Option<i64>>(7).map(|v| v as u64),
291 created_at: row.get::<_, i64>(8) as u64,
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn sqlite_roundtrip() {
302 let backend = SqliteApiKeyBackend::in_memory().unwrap();
303 let key = ApiKey {
304 id: "key_test".into(),
305 user_id: "user_1".into(),
306 name: "ci".into(),
307 prefix: "pk.key_test.…".into(),
308 secret_hash: "$argon2id$...".into(),
309 scopes: Some("read".into()),
310 expires_at: Some(123),
311 last_used_at: None,
312 created_at: 100,
313 };
314 backend.put(&key);
315 let got = backend.get("key_test").unwrap();
316 assert_eq!(got.user_id, "user_1");
317 assert_eq!(got.name, "ci");
318 assert_eq!(got.scopes.as_deref(), Some("read"));
319 assert_eq!(got.expires_at, Some(123));
320 backend.touch("key_test", 999);
322 assert_eq!(backend.get("key_test").unwrap().last_used_at, Some(999));
323 assert!(backend.delete("key_test"));
325 assert!(backend.get("key_test").is_none());
326 }
327
328 #[test]
329 fn sqlite_list_for_user_orders_newest_first() {
330 let backend = SqliteApiKeyBackend::in_memory().unwrap();
331 for (i, name) in ["a", "b", "c"].iter().enumerate() {
332 backend.put(&ApiKey {
333 id: format!("key_{i}"),
334 user_id: "u".into(),
335 name: name.to_string(),
336 prefix: "p".into(),
337 secret_hash: "h".into(),
338 scopes: None,
339 expires_at: None,
340 last_used_at: None,
341 created_at: 100 + i as u64,
342 });
343 }
344 let list = backend.list_for_user("u");
345 let names: Vec<_> = list.iter().map(|k| k.name.clone()).collect();
347 assert_eq!(names, vec!["c", "b", "a"]);
348 }
349}