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 {
106106 Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?)
107107 }
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+
109127 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
110128 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
111129 Ok(Repo {
crates/store/src/tests.rs +168 −0
@@ -180,6 +180,174 @@ async fn booleans_round_trip() {
180180 }
181181 }
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`].
186+async 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+
198+async 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]
207+async 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]
243+async 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]
266+async 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]
287+async 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]
318+async 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+
183351 #[tokio::test]
184352 async fn create_and_fetch_repo() {
185353 for fx in sqlite_fixtures().await {
crates/store/src/users.rs +106 −0
@@ -114,6 +114,112 @@ impl Store {
114114 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
115115 }
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+
117223 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
118224 fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
119225 Ok(User {