fabrica

hanna/fabrica

feat(store): group, key, and token queries; repo rename/delete

9fa34c4 · hanna committed on 2026-07-25

Add the store surface the CLI's key/repo/group/token commands need:

- groups: ensure_group_path creates a path and every missing intermediate
  (linking parents), reusing existing groups — powering both `group add`
  and the `repo add owner group/sub/repo` convenience; plus lookup, list,
  and cascading delete.
- keys: create assigns ordinals unique per user (across kinds, so
  `key del <index>` needs no --type) and never renumbers survivors;
  lookup by ordinal and by fingerprint (for duplicate rejection and SSH
  auth); delete.
- tokens: create returns the row whose id is the JWT jti; list, lookup,
  revoke (revoked_at), delete, and a once-a-minute last_used_at touch.
- repos: by-id, list-all, metadata-only rename, and row delete.

Model gains Group, Key, and ApiToken entities. All queries stay in the
portable SQL subset and are covered against both SQLite flavours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +857 −2UnifiedSplit
crates/model/src/entity.rs +67 −0
@@ -79,6 +79,73 @@ pub struct User {
7979 pub updated_at: Timestamp,
8080 }
8181
82+/// A nested, user-owned group. Groups provide the repository hierarchy; `path` is
83+/// the materialized `group/sub/leaf` under the owner.
84+#[derive(Debug, Clone, PartialEq, Eq)]
85+pub struct Group {
86+ /// ULID primary key.
87+ pub id: String,
88+ /// Owning user.
89+ pub owner_id: String,
90+ /// Parent group, or `None` for a top-level group.
91+ pub parent_id: Option<String>,
92+ /// The group's own name (the final path segment).
93+ pub name: String,
94+ /// Materialized full path within the owner, e.g. `group/sub/leaf`.
95+ pub path: String,
96+ /// Optional description.
97+ pub description: Option<String>,
98+ /// Creation time.
99+ pub created_at: Timestamp,
100+}
101+
102+/// A registered public key, for SSH push authentication and/or signature
103+/// verification.
104+#[derive(Debug, Clone, PartialEq, Eq)]
105+pub struct Key {
106+ /// ULID primary key.
107+ pub id: String,
108+ /// Owning user.
109+ pub user_id: String,
110+ /// SSH or GPG.
111+ pub kind: KeyKind,
112+ /// Optional label.
113+ pub name: Option<String>,
114+ /// `SHA256:…` for SSH, full hex for GPG.
115+ pub fingerprint: String,
116+ /// The `authorized_keys` line (SSH) or ASCII-armored block (GPG).
117+ pub public_key: String,
118+ /// Stable 1-based index within `(user, kind)`, used by `key del`. Deleting one
119+ /// key never renumbers the others.
120+ pub ordinal: i64,
121+ /// When the key was last used to authenticate, if ever.
122+ pub last_used_at: Option<Timestamp>,
123+ /// Creation time.
124+ pub created_at: Timestamp,
125+}
126+
127+/// An API token record. The JWT itself is never stored; this row exists so a token
128+/// can be listed and revoked (`revoked_at`), which the API checks on every request.
129+#[derive(Debug, Clone, PartialEq, Eq)]
130+pub struct ApiToken {
131+ /// ULID primary key, and the JWT `jti`.
132+ pub id: String,
133+ /// Owning user.
134+ pub user_id: String,
135+ /// Human label.
136+ pub name: String,
137+ /// Comma-separated scope list.
138+ pub scopes: String,
139+ /// Expiry, or `None` for a non-expiring token.
140+ pub expires_at: Option<Timestamp>,
141+ /// When the token was revoked, if it has been.
142+ pub revoked_at: Option<Timestamp>,
143+ /// When the token was last used, if ever.
144+ pub last_used_at: Option<Timestamp>,
145+ /// Creation time.
146+ pub created_at: Timestamp,
147+}
148+
82149 /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the
83150 /// database is the sole authority mapping `(owner, path)` to this id.
84151 #[derive(Debug, Clone, PartialEq, Eq)]
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
1111 pub mod entity;
1212 pub mod name;
1313
14-pub use entity::{KeyKind, Repo, Timestamp, User};
14+pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User};
1515 pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/store/src/groups.rs +159 −0
@@ -0,0 +1,159 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Groups: the nested, user-owned repository hierarchy.
6+
7+use model::Group;
8+use sqlx::any::AnyRow;
9+use sqlx::{AssertSqlSafe, Row};
10+
11+use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12+
13+/// The columns of `groups` in a fixed order, shared by every `SELECT`.
14+const GROUP_COLUMNS: &str = "id, owner_id, parent_id, name, path, description, created_at";
15+
16+impl Store {
17+ /// Ensure a group exists at `owner_id`/`path`, creating it and every missing
18+ /// intermediate group, and return the leaf group. Existing groups along the
19+ /// path are reused. Each segment name is validated.
20+ ///
21+ /// This powers both `fabrica group add` and the `repo add owner group/sub/repo`
22+ /// convenience that auto-creates the groups a repo is filed under.
23+ ///
24+ /// # Errors
25+ ///
26+ /// * [`StoreError::Name`] if any segment is an invalid name.
27+ /// * [`StoreError::Query`] on a database failure.
28+ pub async fn ensure_group_path(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {
29+ let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
30+ if segments.is_empty() {
31+ return Err(StoreError::Name(model::NameError::Empty));
32+ }
33+ for segment in &segments {
34+ model::validate_name(segment)?;
35+ }
36+
37+ let mut parent_id: Option<String> = None;
38+ let mut prefix = String::new();
39+ let mut leaf: Option<Group> = None;
40+ for segment in segments {
41+ if prefix.is_empty() {
42+ prefix.push_str(segment);
43+ } else {
44+ prefix.push('/');
45+ prefix.push_str(segment);
46+ }
47+ let group = match self.group_by_owner_path(owner_id, &prefix).await? {
48+ Some(existing) => existing,
49+ None => {
50+ self.insert_group(owner_id, parent_id.as_deref(), segment, &prefix)
51+ .await?
52+ }
53+ };
54+ parent_id = Some(group.id.clone());
55+ leaf = Some(group);
56+ }
57+ // `leaf` is always set: `segments` was non-empty.
58+ leaf.ok_or(StoreError::Name(model::NameError::Empty))
59+ }
60+
61+ /// Insert a single group row.
62+ async fn insert_group(
63+ &self,
64+ owner_id: &str,
65+ parent_id: Option<&str>,
66+ name: &str,
67+ path: &str,
68+ ) -> Result<Group, StoreError> {
69+ let group = Group {
70+ id: new_id(),
71+ owner_id: owner_id.to_string(),
72+ parent_id: parent_id.map(str::to_string),
73+ name: name.to_string(),
74+ path: path.to_string(),
75+ description: None,
76+ created_at: now_ms(),
77+ };
78+ let sql = "INSERT INTO groups (id, owner_id, parent_id, name, path, description, created_at) \
79+ VALUES ($1, $2, $3, $4, $5, $6, $7)";
80+ sqlx::query(sql)
81+ .bind(group.id.clone())
82+ .bind(group.owner_id.clone())
83+ .bind(group.parent_id.clone())
84+ .bind(group.name.clone())
85+ .bind(group.path.clone())
86+ .bind(group.description.clone())
87+ .bind(group.created_at)
88+ .execute(&self.pool)
89+ .await
90+ .map_err(|e| map_conflict(e, "group", &[("path", "path")]))?;
91+ Ok(group)
92+ }
93+
94+ /// Resolve a group by `(owner_id, path)`, or `None` if absent.
95+ ///
96+ /// # Errors
97+ ///
98+ /// Returns [`StoreError::Query`] on a database failure.
99+ pub async fn group_by_owner_path(
100+ &self,
101+ owner_id: &str,
102+ path: &str,
103+ ) -> Result<Option<Group>, StoreError> {
104+ let sql = format!("SELECT {GROUP_COLUMNS} FROM groups WHERE owner_id = $1 AND path = $2");
105+ let row = sqlx::query(AssertSqlSafe(sql))
106+ .bind(owner_id)
107+ .bind(path)
108+ .fetch_optional(&self.pool)
109+ .await?;
110+ Ok(row.as_ref().map(map_group).transpose()?)
111+ }
112+
113+ /// List every group owned by `owner_id`, ordered by path.
114+ ///
115+ /// # Errors
116+ ///
117+ /// Returns [`StoreError::Query`] on a database failure.
118+ pub async fn groups_by_owner(&self, owner_id: &str) -> Result<Vec<Group>, StoreError> {
119+ let sql =
120+ format!("SELECT {GROUP_COLUMNS} FROM groups WHERE owner_id = $1 ORDER BY path ASC");
121+ let rows = sqlx::query(AssertSqlSafe(sql))
122+ .bind(owner_id)
123+ .fetch_all(&self.pool)
124+ .await?;
125+ rows.iter()
126+ .map(map_group)
127+ .collect::<Result<_, _>>()
128+ .map_err(Into::into)
129+ }
130+
131+ /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
132+ /// the group have their `group_id` set null by the schema, so they survive as
133+ /// ungrouped repos. Returns `true` if a row was deleted.
134+ ///
135+ /// # Errors
136+ ///
137+ /// Returns [`StoreError::Query`] on a database failure.
138+ pub async fn delete_group(&self, group_id: &str) -> Result<bool, StoreError> {
139+ let deleted = sqlx::query("DELETE FROM groups WHERE id = $1")
140+ .bind(group_id)
141+ .execute(&self.pool)
142+ .await?
143+ .rows_affected();
144+ Ok(deleted > 0)
145+ }
146+}
147+
148+/// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].
149+fn map_group(row: &AnyRow) -> Result<Group, sqlx::Error> {
150+ Ok(Group {
151+ id: row.try_get("id")?,
152+ owner_id: row.try_get("owner_id")?,
153+ parent_id: row.try_get("parent_id")?,
154+ name: row.try_get("name")?,
155+ path: row.try_get("path")?,
156+ description: row.try_get("description")?,
157+ created_at: row.try_get("created_at")?,
158+ })
159+}
crates/store/src/keys.rs +173 −0
@@ -0,0 +1,173 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Public keys: SSH (push auth + signatures) and GPG (signatures).
6+
7+use model::{Key, KeyKind};
8+use sqlx::any::AnyRow;
9+use sqlx::{AssertSqlSafe, Row};
10+
11+use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12+
13+/// The columns of `keys` in a fixed order, shared by every `SELECT`.
14+const KEY_COLUMNS: &str =
15+ "id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at";
16+
17+/// Fields needed to register a key. The fingerprint is computed by the caller
18+/// (which parses and validates the key material); the store assigns the id and
19+/// ordinal.
20+#[derive(Debug, Clone)]
21+pub struct NewKey {
22+ /// Owning user id.
23+ pub user_id: String,
24+ /// SSH or GPG.
25+ pub kind: KeyKind,
26+ /// Optional label.
27+ pub name: Option<String>,
28+ /// `SHA256:…` (SSH) or hex (GPG) fingerprint.
29+ pub fingerprint: String,
30+ /// The `authorized_keys` line or armored block.
31+ pub public_key: String,
32+}
33+
34+impl Store {
35+ /// Register a key, assigning the next ordinal for its owner.
36+ ///
37+ /// Ordinals are assigned unique **per user** (across both kinds), not merely
38+ /// per `(user, kind)`, so `fabrica key del <user> <index>` needs no `--type` to
39+ /// be unambiguous. Deleting a key never renumbers the survivors.
40+ ///
41+ /// # Errors
42+ ///
43+ /// * [`StoreError::Conflict`] if the fingerprint is already registered.
44+ /// * [`StoreError::Query`] on any other database failure.
45+ pub async fn create_key(&self, new: NewKey) -> Result<Key, StoreError> {
46+ let next: i64 = sqlx::query(
47+ "SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM keys WHERE user_id = $1",
48+ )
49+ .bind(&new.user_id)
50+ .fetch_one(&self.pool)
51+ .await?
52+ .try_get("next")?;
53+
54+ let key = Key {
55+ id: new_id(),
56+ user_id: new.user_id,
57+ kind: new.kind,
58+ name: new.name,
59+ fingerprint: new.fingerprint,
60+ public_key: new.public_key,
61+ ordinal: next,
62+ last_used_at: None,
63+ created_at: now_ms(),
64+ };
65+
66+ let sql = "INSERT INTO keys \
67+ (id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at) \
68+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
69+ sqlx::query(sql)
70+ .bind(key.id.clone())
71+ .bind(key.user_id.clone())
72+ .bind(key.kind.as_str())
73+ .bind(key.name.clone())
74+ .bind(key.fingerprint.clone())
75+ .bind(key.public_key.clone())
76+ .bind(key.ordinal)
77+ .bind(key.last_used_at)
78+ .bind(key.created_at)
79+ .execute(&self.pool)
80+ .await
81+ .map_err(|e| map_conflict(e, "key", &[("fingerprint", "fingerprint")]))?;
82+
83+ Ok(key)
84+ }
85+
86+ /// List a user's keys, ordered by ordinal (i.e. registration order).
87+ ///
88+ /// # Errors
89+ ///
90+ /// Returns [`StoreError::Query`] on a database failure.
91+ pub async fn keys_by_user(&self, user_id: &str) -> Result<Vec<Key>, StoreError> {
92+ let sql = format!("SELECT {KEY_COLUMNS} FROM keys WHERE user_id = $1 ORDER BY ordinal ASC");
93+ let rows = sqlx::query(AssertSqlSafe(sql))
94+ .bind(user_id)
95+ .fetch_all(&self.pool)
96+ .await?;
97+ rows.iter()
98+ .map(map_key)
99+ .collect::<Result<_, _>>()
100+ .map_err(Into::into)
101+ }
102+
103+ /// Fetch a user's key by its 1-based ordinal, or `None`.
104+ ///
105+ /// # Errors
106+ ///
107+ /// Returns [`StoreError::Query`] on a database failure.
108+ pub async fn key_by_ordinal(
109+ &self,
110+ user_id: &str,
111+ ordinal: i64,
112+ ) -> Result<Option<Key>, StoreError> {
113+ let sql = format!("SELECT {KEY_COLUMNS} FROM keys WHERE user_id = $1 AND ordinal = $2");
114+ let row = sqlx::query(AssertSqlSafe(sql))
115+ .bind(user_id)
116+ .bind(ordinal)
117+ .fetch_optional(&self.pool)
118+ .await?;
119+ Ok(row.as_ref().map(map_key).transpose()?)
120+ }
121+
122+ /// Look up any key by `(kind, fingerprint)` — used to reject a duplicate before
123+ /// insert and name the existing owner, and by SSH auth to bind a session.
124+ ///
125+ /// # Errors
126+ ///
127+ /// Returns [`StoreError::Query`] on a database failure.
128+ pub async fn key_by_fingerprint(
129+ &self,
130+ kind: KeyKind,
131+ fingerprint: &str,
132+ ) -> Result<Option<Key>, StoreError> {
133+ let sql = format!("SELECT {KEY_COLUMNS} FROM keys WHERE kind = $1 AND fingerprint = $2");
134+ let row = sqlx::query(AssertSqlSafe(sql))
135+ .bind(kind.as_str())
136+ .bind(fingerprint)
137+ .fetch_optional(&self.pool)
138+ .await?;
139+ Ok(row.as_ref().map(map_key).transpose()?)
140+ }
141+
142+ /// Delete a key by id. Returns `true` if a row was removed.
143+ ///
144+ /// # Errors
145+ ///
146+ /// Returns [`StoreError::Query`] on a database failure.
147+ pub async fn delete_key(&self, key_id: &str) -> Result<bool, StoreError> {
148+ let deleted = sqlx::query("DELETE FROM keys WHERE id = $1")
149+ .bind(key_id)
150+ .execute(&self.pool)
151+ .await?
152+ .rows_affected();
153+ Ok(deleted > 0)
154+ }
155+}
156+
157+/// Map a `keys` row (selected via [`KEY_COLUMNS`]) to a [`Key`].
158+fn map_key(row: &AnyRow) -> Result<Key, sqlx::Error> {
159+ let kind_str: String = row.try_get("kind")?;
160+ let kind = KeyKind::from_token(&kind_str)
161+ .ok_or_else(|| sqlx::Error::Decode(format!("invalid key kind {kind_str:?}").into()))?;
162+ Ok(Key {
163+ id: row.try_get("id")?,
164+ user_id: row.try_get("user_id")?,
165+ kind,
166+ name: row.try_get("name")?,
167+ fingerprint: row.try_get("fingerprint")?,
168+ public_key: row.try_get("public_key")?,
169+ ordinal: row.try_get("ordinal")?,
170+ last_used_at: row.try_get("last_used_at")?,
171+ created_at: row.try_get("created_at")?,
172+ })
173+}
crates/store/src/lib.rs +5 −0
@@ -25,7 +25,10 @@
2525 //! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching
2626 //! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28+mod groups;
29+mod keys;
2830 mod repos;
31+mod tokens;
2932 mod users;
3033
3134 use std::sync::Once;
@@ -36,7 +39,9 @@ use sqlx::Row;
3639 use sqlx::any::{AnyPoolOptions, AnyRow};
3740 use sqlx::migrate::Migrator;
3841
42+pub use crate::keys::NewKey;
3943 pub use crate::repos::NewRepo;
44+pub use crate::tokens::NewToken;
4045 pub use crate::users::NewUser;
4146
4247 /// Migrations for the SQLite dialect, embedded at build time.
crates/store/src/repos.rs +74 −0
@@ -124,6 +124,80 @@ impl Store {
124124 .map_err(Into::into)
125125 }
126126
127+ /// Fetch a repository by id, or `None`.
128+ ///
129+ /// # Errors
130+ ///
131+ /// Returns [`StoreError::Query`] on a database failure.
132+ pub async fn repo_by_id(&self, id: &str) -> Result<Option<Repo>, StoreError> {
133+ let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE id = $1");
134+ let row = sqlx::query(AssertSqlSafe(sql))
135+ .bind(id)
136+ .fetch_optional(&self.pool)
137+ .await?;
138+ Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?)
139+ }
140+
141+ /// List every repository, ordered by owner then path. Used by `repo list`.
142+ ///
143+ /// # Errors
144+ ///
145+ /// Returns [`StoreError::Query`] on a database failure.
146+ pub async fn list_repos(&self) -> Result<Vec<Repo>, StoreError> {
147+ let sql = format!("SELECT {REPO_COLUMNS} FROM repos ORDER BY owner_id ASC, path ASC");
148+ let rows = sqlx::query(AssertSqlSafe(sql))
149+ .fetch_all(&self.pool)
150+ .await?;
151+ rows.iter()
152+ .map(|r| self.map_repo(r))
153+ .collect::<Result<_, _>>()
154+ .map_err(Into::into)
155+ }
156+
157+ /// Rename a repository: update its leaf `name` and materialized `path`
158+ /// (metadata only — the on-disk directory is keyed by id and never moves).
159+ ///
160+ /// # Errors
161+ ///
162+ /// * [`StoreError::Name`] if `new_name` is invalid.
163+ /// * [`StoreError::Conflict`] if the owner already has a repo at `new_path`.
164+ /// * [`StoreError::Query`] on any other database failure.
165+ pub async fn rename_repo(
166+ &self,
167+ repo_id: &str,
168+ new_name: &str,
169+ new_path: &str,
170+ ) -> Result<bool, StoreError> {
171+ model::validate_name(new_name)?;
172+ let updated =
173+ sqlx::query("UPDATE repos SET name = $1, path = $2, updated_at = $3 WHERE id = $4")
174+ .bind(new_name)
175+ .bind(new_path)
176+ .bind(now_ms())
177+ .bind(repo_id)
178+ .execute(&self.pool)
179+ .await
180+ .map_err(|e| map_conflict(e, "repo", &[("path", "path")]))?
181+ .rows_affected();
182+ Ok(updated > 0)
183+ }
184+
185+ /// Delete a repository row (cascading to collaborators). The caller is
186+ /// responsible for the on-disk directory — moving it to trash, or purging it.
187+ /// Returns `true` if a row was removed.
188+ ///
189+ /// # Errors
190+ ///
191+ /// Returns [`StoreError::Query`] on a database failure.
192+ pub async fn delete_repo(&self, repo_id: &str) -> Result<bool, StoreError> {
193+ let deleted = sqlx::query("DELETE FROM repos WHERE id = $1")
194+ .bind(repo_id)
195+ .execute(&self.pool)
196+ .await?
197+ .rows_affected();
198+ Ok(deleted > 0)
199+ }
200+
127201 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
128202 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
129203 Ok(Repo {
crates/store/src/tests.rs +207 −1
@@ -8,9 +8,10 @@
88
99 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1010
11+use model::KeyKind;
1112 use tempfile::TempDir;
1213
13-use super::{Backend, NewRepo, NewUser, Store, StoreError};
14+use super::{Backend, NewKey, NewRepo, NewToken, NewUser, Store, StoreError};
1415
1516 /// A migrated store plus any temp directory that must outlive it.
1617 struct Fixture {
@@ -526,6 +527,211 @@ fn column_line<'a>(sql: &'a str, column: &str) -> &'a str {
526527 .unwrap_or("")
527528 }
528529
530+#[tokio::test]
531+async fn ensure_group_path_creates_intermediates_and_reuses() {
532+ for fx in sqlite_fixtures().await {
533+ let owner = fx
534+ .store
535+ .create_user(sample_user("g", "g@example.com"))
536+ .await
537+ .unwrap();
538+
539+ let leaf = fx
540+ .store
541+ .ensure_group_path(&owner.id, "backend/service/v1")
542+ .await
543+ .unwrap();
544+ assert_eq!(leaf.name, "v1");
545+ assert_eq!(leaf.path, "backend/service/v1");
546+
547+ // Every intermediate was created, parents linked.
548+ let all = fx.store.groups_by_owner(&owner.id).await.unwrap();
549+ let paths: Vec<&str> = all.iter().map(|g| g.path.as_str()).collect();
550+ assert_eq!(paths, ["backend", "backend/service", "backend/service/v1"]);
551+ let api = all.iter().find(|g| g.path == "backend/service").unwrap();
552+ let backend = all.iter().find(|g| g.path == "backend").unwrap();
553+ assert_eq!(api.parent_id.as_deref(), Some(backend.id.as_str()));
554+ assert!(backend.parent_id.is_none());
555+
556+ // Re-ensuring reuses existing groups (no duplicates, same leaf id).
557+ let again = fx
558+ .store
559+ .ensure_group_path(&owner.id, "backend/service/v1")
560+ .await
561+ .unwrap();
562+ assert_eq!(again.id, leaf.id);
563+ assert_eq!(fx.store.groups_by_owner(&owner.id).await.unwrap().len(), 3);
564+
565+ assert!(fx.store.delete_group(&backend.id).await.unwrap());
566+ // Deleting the top group cascades to its descendants.
567+ assert!(
568+ fx.store
569+ .groups_by_owner(&owner.id)
570+ .await
571+ .unwrap()
572+ .is_empty()
573+ );
574+ }
575+}
576+
577+#[tokio::test]
578+async fn keys_get_stable_per_user_ordinals() {
579+ for fx in sqlite_fixtures().await {
580+ let user = fx
581+ .store
582+ .create_user(sample_user("k", "k@example.com"))
583+ .await
584+ .unwrap();
585+
586+ let new_key = |fp: &str, kind| NewKey {
587+ user_id: user.id.clone(),
588+ kind,
589+ name: None,
590+ fingerprint: fp.to_string(),
591+ public_key: format!("material-{fp}"),
592+ };
593+
594+ let k1 = fx
595+ .store
596+ .create_key(new_key("SHA256:a", KeyKind::Ssh))
597+ .await
598+ .unwrap();
599+ let k2 = fx
600+ .store
601+ .create_key(new_key("fp-b", KeyKind::Gpg))
602+ .await
603+ .unwrap();
604+ // Ordinals are unique per user across kinds, so `key del <index>` needs no
605+ // type.
606+ assert_eq!(k1.ordinal, 1);
607+ assert_eq!(k2.ordinal, 2);
608+
609+ // Duplicate fingerprint is rejected.
610+ let err = fx
611+ .store
612+ .create_key(new_key("SHA256:a", KeyKind::Ssh))
613+ .await
614+ .unwrap_err();
615+ assert!(
616+ matches!(err, StoreError::Conflict { entity: "key", .. }),
617+ "got {err:?}"
618+ );
619+
620+ // Delete the first; the second keeps its ordinal (no renumbering).
621+ assert!(fx.store.delete_key(&k1.id).await.unwrap());
622+ let remaining = fx.store.keys_by_user(&user.id).await.unwrap();
623+ assert_eq!(remaining.len(), 1);
624+ assert_eq!(remaining[0].ordinal, 2);
625+
626+ // A new key takes max(ordinal)+1, not a filled gap.
627+ let k3 = fx
628+ .store
629+ .create_key(new_key("SHA256:c", KeyKind::Ssh))
630+ .await
631+ .unwrap();
632+ assert_eq!(k3.ordinal, 3);
633+
634+ let by_ord = fx.store.key_by_ordinal(&user.id, 2).await.unwrap().unwrap();
635+ assert_eq!(by_ord.id, k2.id);
636+ let by_fp = fx
637+ .store
638+ .key_by_fingerprint(KeyKind::Gpg, "fp-b")
639+ .await
640+ .unwrap()
641+ .unwrap();
642+ assert_eq!(by_fp.id, k2.id);
643+ }
644+}
645+
646+#[tokio::test]
647+async fn tokens_create_list_and_revoke() {
648+ for fx in sqlite_fixtures().await {
649+ let user = fx
650+ .store
651+ .create_user(sample_user("t", "t@example.com"))
652+ .await
653+ .unwrap();
654+
655+ let first = fx
656+ .store
657+ .create_token(NewToken {
658+ user_id: user.id.clone(),
659+ name: "ci".to_string(),
660+ scopes: "repo:read".to_string(),
661+ expires_at: None,
662+ })
663+ .await
664+ .unwrap();
665+ fx.store
666+ .create_token(NewToken {
667+ user_id: user.id.clone(),
668+ name: "deploy".to_string(),
669+ scopes: "repo:read,repo:write".to_string(),
670+ expires_at: Some(now_plus_a_day()),
671+ })
672+ .await
673+ .unwrap();
674+
675+ let listed = fx.store.tokens_by_user(&user.id).await.unwrap();
676+ assert_eq!(listed.len(), 2);
677+ assert_eq!(listed[0].name, "ci", "ordered by creation");
678+
679+ // Revocation is what makes a token invalid to the API.
680+ assert!(fx.store.revoke_token(&first.id).await.unwrap());
681+ let revoked = fx.store.token_by_id(&first.id).await.unwrap().unwrap();
682+ assert!(revoked.revoked_at.is_some());
683+ // Revoking again is a no-op (already revoked).
684+ assert!(!fx.store.revoke_token(&first.id).await.unwrap());
685+
686+ assert!(fx.store.delete_token(&first.id).await.unwrap());
687+ assert!(fx.store.token_by_id(&first.id).await.unwrap().is_none());
688+ }
689+}
690+
691+fn now_plus_a_day() -> i64 {
692+ super::now_ms() + 86_400_000
693+}
694+
695+#[tokio::test]
696+async fn rename_and_delete_repo() {
697+ for fx in sqlite_fixtures().await {
698+ let owner = fx
699+ .store
700+ .create_user(sample_user("r", "r@example.com"))
701+ .await
702+ .unwrap();
703+ let repo = fx
704+ .store
705+ .create_repo(NewRepo {
706+ owner_id: owner.id.clone(),
707+ group_id: None,
708+ name: "old".to_string(),
709+ path: "old".to_string(),
710+ description: None,
711+ is_private: true,
712+ default_branch: "main".to_string(),
713+ })
714+ .await
715+ .unwrap();
716+
717+ assert!(fx.store.rename_repo(&repo.id, "new", "new").await.unwrap());
718+ assert!(
719+ fx.store
720+ .repo_by_owner_path(&owner.id, "old")
721+ .await
722+ .unwrap()
723+ .is_none()
724+ );
725+ let renamed = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
726+ assert_eq!(renamed.name, "new");
727+ assert_eq!(renamed.path, "new");
728+
729+ assert!(fx.store.delete_repo(&repo.id).await.unwrap());
730+ assert!(fx.store.repo_by_id(&repo.id).await.unwrap().is_none());
731+ assert!(!fx.store.delete_repo(&repo.id).await.unwrap());
732+ }
733+}
734+
529735 /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the
530736 /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a
531737 /// reachable database; the test cleans up the rows it creates so it can rerun.
crates/store/src/tokens.rs +171 −0
@@ -0,0 +1,171 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! API tokens: the revocation-tracking rows behind the stateless JWTs.
6+//!
7+//! The JWT itself is never stored. This row exists so a token can be listed and
8+//! revoked: every API request validates the JWT signature *and* checks that the
9+//! `jti` row is present and `revoked_at IS NULL`, which is what makes revocation
10+//! real.
11+
12+use model::ApiToken;
13+use sqlx::any::AnyRow;
14+use sqlx::{AssertSqlSafe, Row};
15+
16+use crate::{Store, StoreError, new_id, now_ms};
17+
18+/// The columns of `api_tokens` in a fixed order, shared by every `SELECT`.
19+const TOKEN_COLUMNS: &str =
20+ "id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at";
21+
22+/// Fields needed to mint a token row. The store generates the id, which the caller
23+/// then embeds in the JWT as its `jti`.
24+#[derive(Debug, Clone)]
25+pub struct NewToken {
26+ /// Owning user id.
27+ pub user_id: String,
28+ /// Human label.
29+ pub name: String,
30+ /// Comma-separated scope list.
31+ pub scopes: String,
32+ /// Expiry, or `None` for a non-expiring token.
33+ pub expires_at: Option<i64>,
34+}
35+
36+impl Store {
37+ /// Create a token row and return it; the returned `id` is the JWT `jti` the
38+ /// caller signs into the token.
39+ ///
40+ /// # Errors
41+ ///
42+ /// Returns [`StoreError::Query`] on a database failure.
43+ pub async fn create_token(&self, new: NewToken) -> Result<ApiToken, StoreError> {
44+ let token = ApiToken {
45+ id: new_id(),
46+ user_id: new.user_id,
47+ name: new.name,
48+ scopes: new.scopes,
49+ expires_at: new.expires_at,
50+ revoked_at: None,
51+ last_used_at: None,
52+ created_at: now_ms(),
53+ };
54+ let sql = "INSERT INTO api_tokens \
55+ (id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at) \
56+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)";
57+ sqlx::query(sql)
58+ .bind(token.id.clone())
59+ .bind(token.user_id.clone())
60+ .bind(token.name.clone())
61+ .bind(token.scopes.clone())
62+ .bind(token.expires_at)
63+ .bind(token.revoked_at)
64+ .bind(token.last_used_at)
65+ .bind(token.created_at)
66+ .execute(&self.pool)
67+ .await?;
68+ Ok(token)
69+ }
70+
71+ /// List a user's tokens, newest last (ordered by creation), so the 1-based list
72+ /// position is stable for `token del <user> <index>`.
73+ ///
74+ /// # Errors
75+ ///
76+ /// Returns [`StoreError::Query`] on a database failure.
77+ pub async fn tokens_by_user(&self, user_id: &str) -> Result<Vec<ApiToken>, StoreError> {
78+ let sql = format!(
79+ "SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE user_id = $1 ORDER BY created_at ASC, id ASC"
80+ );
81+ let rows = sqlx::query(AssertSqlSafe(sql))
82+ .bind(user_id)
83+ .fetch_all(&self.pool)
84+ .await?;
85+ rows.iter()
86+ .map(map_token)
87+ .collect::<Result<_, _>>()
88+ .map_err(Into::into)
89+ }
90+
91+ /// Fetch a token row by id (`jti`), or `None`. Used by the API to enforce
92+ /// revocation on every request.
93+ ///
94+ /// # Errors
95+ ///
96+ /// Returns [`StoreError::Query`] on a database failure.
97+ pub async fn token_by_id(&self, id: &str) -> Result<Option<ApiToken>, StoreError> {
98+ let sql = format!("SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE id = $1");
99+ let row = sqlx::query(AssertSqlSafe(sql))
100+ .bind(id)
101+ .fetch_optional(&self.pool)
102+ .await?;
103+ Ok(row.as_ref().map(map_token).transpose()?)
104+ }
105+
106+ /// Mark a token revoked (idempotent). Returns `true` if a row was updated.
107+ ///
108+ /// # Errors
109+ ///
110+ /// Returns [`StoreError::Query`] on a database failure.
111+ pub async fn revoke_token(&self, id: &str) -> Result<bool, StoreError> {
112+ let updated = sqlx::query(
113+ "UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND revoked_at IS NULL",
114+ )
115+ .bind(now_ms())
116+ .bind(id)
117+ .execute(&self.pool)
118+ .await?
119+ .rows_affected();
120+ Ok(updated > 0)
121+ }
122+
123+ /// Delete a token row by id. Returns `true` if a row was removed.
124+ ///
125+ /// # Errors
126+ ///
127+ /// Returns [`StoreError::Query`] on a database failure.
128+ pub async fn delete_token(&self, id: &str) -> Result<bool, StoreError> {
129+ let deleted = sqlx::query("DELETE FROM api_tokens WHERE id = $1")
130+ .bind(id)
131+ .execute(&self.pool)
132+ .await?
133+ .rows_affected();
134+ Ok(deleted > 0)
135+ }
136+
137+ /// Record that a token was just used, at most once a minute per token (the
138+ /// `last_used_at` throttle the spec calls for).
139+ ///
140+ /// # Errors
141+ ///
142+ /// Returns [`StoreError::Query`] on a database failure.
143+ pub async fn touch_token(&self, id: &str) -> Result<(), StoreError> {
144+ let now = now_ms();
145+ let minute_ago = now - 60_000;
146+ sqlx::query(
147+ "UPDATE api_tokens SET last_used_at = $1 \
148+ WHERE id = $2 AND (last_used_at IS NULL OR last_used_at < $3)",
149+ )
150+ .bind(now)
151+ .bind(id)
152+ .bind(minute_ago)
153+ .execute(&self.pool)
154+ .await?;
155+ Ok(())
156+ }
157+}
158+
159+/// Map an `api_tokens` row (selected via [`TOKEN_COLUMNS`]) to an [`ApiToken`].
160+fn map_token(row: &AnyRow) -> Result<ApiToken, sqlx::Error> {
161+ Ok(ApiToken {
162+ id: row.try_get("id")?,
163+ user_id: row.try_get("user_id")?,
164+ name: row.try_get("name")?,
165+ scopes: row.try_get("scopes")?,
166+ expires_at: row.try_get("expires_at")?,
167+ revoked_at: row.try_get("revoked_at")?,
168+ last_used_at: row.try_get("last_used_at")?,
169+ created_at: row.try_get("created_at")?,
170+ })
171+}