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 {
79 pub updated_at: Timestamp,79 pub updated_at: Timestamp,
80}80}
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)]
85pub 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)]
105pub 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)]
130pub 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
82/// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the149/// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the
83/// database is the sole authority mapping `(owner, path)` to this id.150/// database is the sole authority mapping `(owner, path)` to this id.
84#[derive(Debug, Clone, PartialEq, Eq)]151#[derive(Debug, Clone, PartialEq, Eq)]
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
11pub mod entity;11pub mod entity;
12pub mod name;12pub mod name;
1313
14pub use entity::{KeyKind, Repo, Timestamp, User};14pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User};
15pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};15pub 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
7use model::Group;
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12
13/// The columns of `groups` in a fixed order, shared by every `SELECT`.
14const GROUP_COLUMNS: &str = "id, owner_id, parent_id, name, path, description, created_at";
15
16impl 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`].
149fn 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
7use model::{Key, KeyKind};
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12
13/// The columns of `keys` in a fixed order, shared by every `SELECT`.
14const 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)]
21pub 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
34impl 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`].
158fn 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 @@
25//! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching25//! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching
26//! set is embedded at build time and selected at runtime by [`Store::migrate`].26//! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28mod groups;
29mod keys;
28mod repos;30mod repos;
31mod tokens;
29mod users;32mod users;
3033
31use std::sync::Once;34use std::sync::Once;
@@ -36,7 +39,9 @@ use sqlx::Row;
36use sqlx::any::{AnyPoolOptions, AnyRow};39use sqlx::any::{AnyPoolOptions, AnyRow};
37use sqlx::migrate::Migrator;40use sqlx::migrate::Migrator;
3841
42pub use crate::keys::NewKey;
39pub use crate::repos::NewRepo;43pub use crate::repos::NewRepo;
44pub use crate::tokens::NewToken;
40pub use crate::users::NewUser;45pub use crate::users::NewUser;
4146
42/// Migrations for the SQLite dialect, embedded at build time.47/// Migrations for the SQLite dialect, embedded at build time.
crates/store/src/repos.rs +74 −0
@@ -124,6 +124,80 @@ impl Store {
124 .map_err(Into::into)124 .map_err(Into::into)
125 }125 }
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
127 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].201 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
128 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {202 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
129 Ok(Repo {203 Ok(Repo {
crates/store/src/tests.rs +207 −1
@@ -8,9 +8,10 @@
88
9#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]9#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1010
11use model::KeyKind;
11use tempfile::TempDir;12use tempfile::TempDir;
1213
13use super::{Backend, NewRepo, NewUser, Store, StoreError};14use super::{Backend, NewKey, NewRepo, NewToken, NewUser, Store, StoreError};
1415
15/// A migrated store plus any temp directory that must outlive it.16/// A migrated store plus any temp directory that must outlive it.
16struct Fixture {17struct Fixture {
@@ -526,6 +527,211 @@ fn column_line<'a>(sql: &'a str, column: &str) -> &'a str {
526 .unwrap_or("")527 .unwrap_or("")
527}528}
528529
530#[tokio::test]
531async 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]
578async 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]
647async 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
691fn now_plus_a_day() -> i64 {
692 super::now_ms() + 86_400_000
693}
694
695#[tokio::test]
696async 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
529/// Postgres round-trips, mirroring the SQLite ones. Ignored unless the735/// Postgres round-trips, mirroring the SQLite ones. Ignored unless the
530/// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a736/// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a
531/// reachable database; the test cleans up the rows it creates so it can rerun.737/// 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
12use model::ApiToken;
13use sqlx::any::AnyRow;
14use sqlx::{AssertSqlSafe, Row};
15
16use crate::{Store, StoreError, new_id, now_ms};
17
18/// The columns of `api_tokens` in a fixed order, shared by every `SELECT`.
19const 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)]
25pub 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
36impl 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`].
160fn 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}