fabrica

hanna/fabrica

feat(store): user-management queries for the CLI

6df0c07 · hanna committed on 2026-07-25

Add the mutations `fabrica user` needs: list_users (case-insensitive order),
set_password (clears must_change_password and deletes the user's sessions,
honouring the password-change session-invalidation rule), set_disabled (stamps
or clears disabled_at, dropping sessions on disable), delete_user (cascades to
repos/keys/tokens/sessions), and repos_by_owner (so `user del` can refuse and
enumerate owned repos). Tested over both SQLite fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +292 −0UnifiedSplit
crates/store/src/repos.rs +18 −0
@@ -106,6 +106,24 @@ impl Store {
106 Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?)106 Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?)
107 }107 }
108108
109 /// List every repository owned by `owner_id`, ordered by path. Used by
110 /// `fabrica user del` to refuse (and enumerate) when a user still owns repos.
111 ///
112 /// # Errors
113 ///
114 /// Returns [`StoreError::Query`] on a database failure.
115 pub async fn repos_by_owner(&self, owner_id: &str) -> Result<Vec<Repo>, StoreError> {
116 let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE owner_id = $1 ORDER BY path ASC");
117 let rows = sqlx::query(AssertSqlSafe(sql))
118 .bind(owner_id)
119 .fetch_all(&self.pool)
120 .await?;
121 rows.iter()
122 .map(|r| self.map_repo(r))
123 .collect::<Result<_, _>>()
124 .map_err(Into::into)
125 }
126
109 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].127 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
110 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {128 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
111 Ok(Repo {129 Ok(Repo {
crates/store/src/tests.rs +168 −0
@@ -180,6 +180,174 @@ async fn booleans_round_trip() {
180 }180 }
181}181}
182182
183/// Insert a bare session row for `user_id` so the session-deletion side effects
184/// of `set_password`/`set_disabled` can be observed. Returns nothing; the count
185/// is read back with [`session_count`].
186async fn insert_session(store: &Store, user_id: &str) {
187 sqlx::query(
188 "INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at) \
189 VALUES ($1, $2, 0, 0, 0)",
190 )
191 .bind(super::new_id())
192 .bind(user_id)
193 .execute(store.pool())
194 .await
195 .unwrap();
196}
197
198async fn session_count(store: &Store, user_id: &str) -> i64 {
199 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sessions WHERE user_id = $1")
200 .bind(user_id)
201 .fetch_one(store.pool())
202 .await
203 .unwrap()
204}
205
206#[tokio::test]
207async fn set_password_sets_clears_and_kills_sessions() {
208 for fx in sqlite_fixtures().await {
209 let user = fx
210 .store
211 .create_user(sample_user("pw", "pw@example.com"))
212 .await
213 .unwrap();
214 assert!(user.password_hash.is_none());
215 insert_session(&fx.store, &user.id).await;
216 assert_eq!(session_count(&fx.store, &user.id).await, 1);
217
218 // Setting a hash records it, clears must_change_password, and wipes
219 // sessions.
220 assert!(
221 fx.store
222 .set_password(&user.id, Some("$argon2id$fake"))
223 .await
224 .unwrap()
225 );
226 let after = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
227 assert_eq!(after.password_hash.as_deref(), Some("$argon2id$fake"));
228 assert!(!after.must_change_password);
229 assert!(after.updated_at >= user.updated_at);
230 assert_eq!(session_count(&fx.store, &user.id).await, 0);
231
232 // Clearing it returns to the invite-pending state.
233 assert!(fx.store.set_password(&user.id, None).await.unwrap());
234 let cleared = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
235 assert!(cleared.password_hash.is_none());
236
237 // A missing user reports no update.
238 assert!(!fx.store.set_password("ghost", Some("x")).await.unwrap());
239 }
240}
241
242#[tokio::test]
243async fn set_disabled_toggles_and_kills_sessions() {
244 for fx in sqlite_fixtures().await {
245 let user = fx
246 .store
247 .create_user(sample_user("dis", "dis@example.com"))
248 .await
249 .unwrap();
250 insert_session(&fx.store, &user.id).await;
251
252 assert!(fx.store.set_disabled(&user.id, true).await.unwrap());
253 let disabled = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
254 assert!(disabled.disabled_at.is_some());
255 assert_eq!(session_count(&fx.store, &user.id).await, 0);
256
257 assert!(fx.store.set_disabled(&user.id, false).await.unwrap());
258 let enabled = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
259 assert!(enabled.disabled_at.is_none());
260
261 assert!(!fx.store.set_disabled("ghost", true).await.unwrap());
262 }
263}
264
265#[tokio::test]
266async fn list_users_is_sorted_case_insensitively() {
267 for fx in sqlite_fixtures().await {
268 for name in ["Charlie", "alice", "Bob"] {
269 fx.store
270 .create_user(sample_user(name, &format!("{name}@example.com")))
271 .await
272 .unwrap();
273 }
274 let names: Vec<String> = fx
275 .store
276 .list_users()
277 .await
278 .unwrap()
279 .into_iter()
280 .map(|u| u.username)
281 .collect();
282 assert_eq!(names, ["alice", "Bob", "Charlie"]);
283 }
284}
285
286#[tokio::test]
287async fn delete_user_cascades_to_repos() {
288 for fx in sqlite_fixtures().await {
289 let user = fx
290 .store
291 .create_user(sample_user("gone", "gone@example.com"))
292 .await
293 .unwrap();
294 fx.store
295 .create_repo(NewRepo {
296 owner_id: user.id.clone(),
297 group_id: None,
298 name: "r".to_string(),
299 path: "r".to_string(),
300 description: None,
301 is_private: true,
302 default_branch: "main".to_string(),
303 })
304 .await
305 .unwrap();
306
307 assert!(fx.store.delete_user(&user.id).await.unwrap());
308 assert!(fx.store.user_by_id(&user.id).await.unwrap().is_none());
309 assert!(
310 fx.store.repos_by_owner(&user.id).await.unwrap().is_empty(),
311 "the repo should cascade away with its owner"
312 );
313 assert!(!fx.store.delete_user(&user.id).await.unwrap());
314 }
315}
316
317#[tokio::test]
318async fn repos_by_owner_lists_in_path_order() {
319 for fx in sqlite_fixtures().await {
320 let owner = fx
321 .store
322 .create_user(sample_user("multi", "multi@example.com"))
323 .await
324 .unwrap();
325 for name in ["zeta", "alpha", "mu"] {
326 fx.store
327 .create_repo(NewRepo {
328 owner_id: owner.id.clone(),
329 group_id: None,
330 name: name.to_string(),
331 path: name.to_string(),
332 description: None,
333 is_private: true,
334 default_branch: "main".to_string(),
335 })
336 .await
337 .unwrap();
338 }
339 let paths: Vec<String> = fx
340 .store
341 .repos_by_owner(&owner.id)
342 .await
343 .unwrap()
344 .into_iter()
345 .map(|r| r.path)
346 .collect();
347 assert_eq!(paths, ["alpha", "mu", "zeta"]);
348 }
349}
350
183#[tokio::test]351#[tokio::test]
184async fn create_and_fetch_repo() {352async fn create_and_fetch_repo() {
185 for fx in sqlite_fixtures().await {353 for fx in sqlite_fixtures().await {
crates/store/src/users.rs +106 −0
@@ -114,6 +114,112 @@ impl Store {
114 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)114 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
115 }115 }
116116
117 /// List all users, ordered case-insensitively by username.
118 ///
119 /// # Errors
120 ///
121 /// Returns [`StoreError::Query`] on a database failure.
122 pub async fn list_users(&self) -> Result<Vec<User>, StoreError> {
123 let sql = format!("SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC");
124 let rows = sqlx::query(AssertSqlSafe(sql))
125 .fetch_all(&self.pool)
126 .await?;
127 rows.iter()
128 .map(|r| self.map_user(r))
129 .collect::<Result<_, _>>()
130 .map_err(Into::into)
131 }
132
133 /// Set (or clear) a user's password hash.
134 ///
135 /// Passing `Some(hash)` sets the credential and clears the
136 /// `must_change_password` flag — the operator has just chosen a known
137 /// password. Passing `None` returns the account to the invite-pending state.
138 /// Either way, **all of the user's sessions are deleted**, as required on any
139 /// password change, so a rotated credential immediately invalidates existing
140 /// logins.
141 ///
142 /// Returns `true` if a user with `user_id` existed and was updated.
143 ///
144 /// # Errors
145 ///
146 /// Returns [`StoreError::Query`] on a database failure.
147 pub async fn set_password(
148 &self,
149 user_id: &str,
150 password_hash: Option<&str>,
151 ) -> Result<bool, StoreError> {
152 let updated = sqlx::query(
153 "UPDATE users SET password_hash = $1, must_change_password = $2, updated_at = $3 \
154 WHERE id = $4",
155 )
156 .bind(password_hash)
157 .bind(false)
158 .bind(now_ms())
159 .bind(user_id)
160 .execute(&self.pool)
161 .await?
162 .rows_affected();
163
164 // Invalidate every existing session for the user (best-effort — the row
165 // may simply not have existed).
166 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
167 .bind(user_id)
168 .execute(&self.pool)
169 .await?;
170
171 Ok(updated > 0)
172 }
173
174 /// Enable or disable a user. Disabling stamps `disabled_at` with the current
175 /// time and deletes the user's sessions; enabling clears `disabled_at`.
176 ///
177 /// Returns `true` if a user with `user_id` existed and was updated.
178 ///
179 /// # Errors
180 ///
181 /// Returns [`StoreError::Query`] on a database failure.
182 pub async fn set_disabled(&self, user_id: &str, disabled: bool) -> Result<bool, StoreError> {
183 let now = now_ms();
184 let disabled_at = disabled.then_some(now);
185 let updated =
186 sqlx::query("UPDATE users SET disabled_at = $1, updated_at = $2 WHERE id = $3")
187 .bind(disabled_at)
188 .bind(now)
189 .bind(user_id)
190 .execute(&self.pool)
191 .await?
192 .rows_affected();
193
194 if disabled {
195 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
196 .bind(user_id)
197 .execute(&self.pool)
198 .await?;
199 }
200
201 Ok(updated > 0)
202 }
203
204 /// Delete a user by id, cascading to their repositories, keys, tokens, and
205 /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any
206 /// policy that should *prevent* the delete (e.g. refusing when repos exist
207 /// and `--purge` was not given).
208 ///
209 /// Returns `true` if a user was deleted.
210 ///
211 /// # Errors
212 ///
213 /// Returns [`StoreError::Query`] on a database failure.
214 pub async fn delete_user(&self, user_id: &str) -> Result<bool, StoreError> {
215 let deleted = sqlx::query("DELETE FROM users WHERE id = $1")
216 .bind(user_id)
217 .execute(&self.pool)
218 .await?
219 .rows_affected();
220 Ok(deleted > 0)
221 }
222
117 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].223 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
118 fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {224 fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
119 Ok(User {225 Ok(User {