kitgit

tirbofish/kitgit · commit

9cf01e58f7a7433b80cee7820a95e2aa8cf0993c

feat: add repo deploy keys for scoped SSH access

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 15:12

view full diff

diff --git a/migrations/013_deploy_keys.sql b/migrations/013_deploy_keys.sql
new file mode 100644
index 0000000..dd351b3
--- /dev/null
+++ b/migrations/013_deploy_keys.sql
@@ -0,0 +1,14 @@
+-- Repo-scoped SSH deploy keys (read-only or read-write).
+CREATE TABLE IF NOT EXISTS deploy_keys (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    name TEXT NOT NULL DEFAULT 'deploy',
+    public_key TEXT NOT NULL,
+    fingerprint TEXT NOT NULL UNIQUE,
+    read_only BOOLEAN NOT NULL DEFAULT TRUE,
+    created_by UUID REFERENCES users(id) ON DELETE SET NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS deploy_keys_repo_id_idx ON deploy_keys(repo_id);
+CREATE INDEX IF NOT EXISTS deploy_keys_fingerprint_idx ON deploy_keys(fingerprint);
\ No newline at end of file
diff --git a/src/db/deploy_keys.rs b/src/db/deploy_keys.rs
new file mode 100644
index 0000000..d538af3
--- /dev/null
+++ b/src/db/deploy_keys.rs
@@ -0,0 +1,98 @@
+//! Repo-scoped SSH deploy keys (read-only or read-write).
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use serde::Serialize;
+use sqlx::{FromRow, PgPool};
+use uuid::Uuid;
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct DeployKey {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub name: String,
+    pub public_key: String,
+    pub fingerprint: String,
+    pub read_only: bool,
+    pub created_by: Option<Uuid>,
+    pub created_at: DateTime<Utc>,
+}
+
+impl DeployKey {
+    pub fn permission_label(&self) -> &'static str {
+        if self.read_only {
+            "Read-only"
+        } else {
+            "Read-write"
+        }
+    }
+}
+
+pub async fn add_deploy_key(
+    pool: &PgPool,
+    repo_id: Uuid,
+    name: &str,
+    public_key: &str,
+    fingerprint: &str,
+    read_only: bool,
+    created_by: Option<Uuid>,
+) -> Result<DeployKey> {
+    let clash: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT id FROM ssh_keys WHERE rtrim(fingerprint, '=') = rtrim($1, '=') LIMIT 1",
+    )
+    .bind(fingerprint)
+    .fetch_optional(pool)
+    .await?;
+    if clash.is_some() {
+        anyhow::bail!("fingerprint already registered as a user SSH key");
+    }
+    Ok(sqlx::query_as::<_, DeployKey>(
+        r#"
+        INSERT INTO deploy_keys (repo_id, name, public_key, fingerprint, read_only, created_by)
+        VALUES ($1, $2, $3, $4, $5, $6)
+        RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(name)
+    .bind(public_key)
+    .bind(fingerprint)
+    .bind(read_only)
+    .bind(created_by)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_deploy_keys(pool: &PgPool, repo_id: Uuid) -> Result<Vec<DeployKey>> {
+    Ok(sqlx::query_as::<_, DeployKey>(
+        "SELECT * FROM deploy_keys WHERE repo_id = $1 ORDER BY created_at",
+    )
+    .bind(repo_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn delete_deploy_key(pool: &PgPool, repo_id: Uuid, id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM deploy_keys WHERE id = $1 AND repo_id = $2")
+        .bind(id)
+        .bind(repo_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn deploy_key_by_fingerprint(
+    pool: &PgPool,
+    fingerprint: &str,
+) -> Result<Option<DeployKey>> {
+    let normalized = fingerprint.trim_end_matches('=');
+    Ok(sqlx::query_as::<_, DeployKey>(
+        r#"
+        SELECT * FROM deploy_keys
+        WHERE rtrim(fingerprint, '=') = $1
+        "#,
+    )
+    .bind(normalized)
+    .fetch_optional(pool)
+    .await?)
+}
\ No newline at end of file
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 8d84a74..2b7d249 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -1,6 +1,9 @@
+pub mod deploy_keys;
 pub mod models;
 pub mod queries;
 
+pub use deploy_keys::DeployKey;
+
 use anyhow::{Context, Result};
 use sqlx::postgres::PgPoolOptions;
 use sqlx::PgPool;
diff --git a/src/git/ssh.rs b/src/git/ssh.rs
index 95d2ba2..4761546 100644
--- a/src/git/ssh.rs
+++ b/src/git/ssh.rs
@@ -101,28 +101,46 @@ impl Server for SshServer {
             state: self.state.clone(),
             user_id: None,
             username: None,
+            deploy_key: None,
             stdin: None,
             receive_meta: None,
         }
     }
 }
 
+/// Authenticated via a repo-scoped deploy key (not a user key).
+struct DeployKeyAuth {
+    repo_id: Uuid,
+    read_only: bool,
+    created_by: Option<Uuid>,
+    name: String,
+}
+
 struct SshHandler {
     state: AppState,
     user_id: Option<Uuid>,
     username: Option<String>,
+    deploy_key: Option<DeployKeyAuth>,
     stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>,
     receive_meta: Option<(String, String)>,
 }
 
 impl SshHandler {
+    fn is_authenticated(&self) -> bool {
+        self.user_id.is_some() || self.deploy_key.is_some()
+    }
+
     /// Print `static/text.txt` (with `{user}` substituted) and close — no shell access.
     async fn greet_and_quit(
         &self,
         channel: ChannelId,
         session: &mut Session,
     ) -> Result<(), anyhow::Error> {
-        let username = self.username.as_deref().unwrap_or("git");
+        let username = self
+            .username
+            .as_deref()
+            .or_else(|| self.deploy_key.as_ref().map(|d| d.name.as_str()))
+            .unwrap_or("git");
         let path = self.state.config.static_dir.join("text.txt");
         let mut msg = tokio::fs::read_to_string(&path).await.unwrap_or_else(|_| {
             format!(
@@ -159,6 +177,20 @@ impl Handler for SshHandler {
             }
             self.user_id = Some(user.id);
             self.username = Some(user.username);
+            self.deploy_key = None;
+            return Ok(Auth::Accept);
+        }
+        if let Some(key) =
+            crate::db::deploy_keys::deploy_key_by_fingerprint(&self.state.pool, &fp).await?
+        {
+            self.user_id = None;
+            self.username = Some(format!("deploy:{}", key.name));
+            self.deploy_key = Some(DeployKeyAuth {
+                repo_id: key.repo_id,
+                read_only: key.read_only,
+                created_by: key.created_by,
+                name: key.name,
+            });
             return Ok(Auth::Accept);
         }
         tracing::debug!("ssh publickey rejected fp={fp}");
@@ -205,7 +237,7 @@ impl Handler for SshHandler {
         channel: ChannelId,
         session: &mut Session,
     ) -> Result<(), Self::Error> {
-        if self.user_id.is_none() {
+        if !self.is_authenticated() {
             session.channel_failure(channel)?;
             return Ok(());
         }
@@ -219,10 +251,10 @@ impl Handler for SshHandler {
         session: &mut Session,
     ) -> Result<(), Self::Error> {
         let cmd = String::from_utf8_lossy(data).to_string();
-        let Some(user_id) = self.user_id else {
+        if !self.is_authenticated() {
             session.channel_failure(channel)?;
             return Ok(());
-        };
+        }
 
         let (service, repo_path) = match parse_git_command(&cmd) {
             Ok(v) => v,
@@ -239,11 +271,26 @@ impl Handler for SshHandler {
             session.close(channel)?;
             return Ok(());
         };
-        let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?;
-        let allowed = match service {
-            "git-upload-pack" => access.can_read(),
-            "git-receive-pack" => access.can_write() && !repo.archived,
-            _ => false,
+
+        let allowed = if let Some(dk) = &self.deploy_key {
+            // Deploy keys only unlock the single repo they were added to.
+            if dk.repo_id != repo.id {
+                false
+            } else {
+                match service {
+                    "git-upload-pack" => true,
+                    "git-receive-pack" => !dk.read_only && !repo.archived,
+                    _ => false,
+                }
+            }
+        } else {
+            let user_id = self.user_id.expect("authenticated without deploy key");
+            let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?;
+            match service {
+                "git-upload-pack" => access.can_read(),
+                "git-receive-pack" => access.can_write() && !repo.archived,
+                _ => false,
+            }
         };
         if !allowed {
             session.data(channel, b"access denied\n".as_slice())?;
@@ -311,33 +358,39 @@ impl Handler for SshHandler {
             let mut g = stdin.lock().await;
             let _ = g.shutdown().await;
         }
-        if let (Some(uid), Some((owner, name))) = (self.user_id, self.receive_meta.take()) {
-            if let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await {
-                if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await {
-                    let _ = queries::record_activity(
-                        &self.state.pool,
-                        Some(user.id),
-                        Some(repo.id),
-                        "push",
-                        "pushed",
-                        serde_json::json!({}),
-                    )
-                    .await;
-                    let _ = queries::bump_commit_activity(
-                        &self.state.pool,
-                        user.id,
-                        chrono::Utc::now().date_naive(),
-                        1,
-                    )
-                    .await;
-                    if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name)
-                    {
-                        if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) {
-                            let stats = crate::git::languages::detect_languages(&files);
-                            let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await;
-                        }
-                    }
-                }
+        let Some((owner, name)) = self.receive_meta.take() else {
+            return Ok(());
+        };
+        let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await else {
+            return Ok(());
+        };
+        let actor_id = self
+            .user_id
+            .or_else(|| self.deploy_key.as_ref().and_then(|d| d.created_by));
+        if let Some(uid) = actor_id {
+            if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await {
+                let _ = queries::record_activity(
+                    &self.state.pool,
+                    Some(user.id),
+                    Some(repo.id),
+                    "push",
+                    "pushed",
+                    serde_json::json!({}),
+                )
+                .await;
+                let _ = queries::bump_commit_activity(
+                    &self.state.pool,
+                    user.id,
+                    chrono::Utc::now().date_naive(),
+                    1,
+                )
+                .await;
+            }
+        }
+        if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) {
+            if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) {
+                let stats = crate::git::languages::detect_languages(&files);
+                let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await;
             }
         }
         Ok(())
@@ -377,4 +430,4 @@ fn split_repo_path(path: &str) -> Result<(String, String)> {
     let owner = parts.next().context("owner")?.to_string();
     let name = parts.next().context("name")?.to_string();
     Ok((owner, name))
-}
+}
\ No newline at end of file
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..108f4df 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -262,6 +262,14 @@ pub fn app_router(state: AppState) -> Router {
             post(routes::collab_remove),
         )
         .route(
+            "/{owner}/{repo}/settings/deploy-keys",
+            post(routes::deploy_key_add),
+        )
+        .route(
+            "/{owner}/{repo}/settings/deploy-keys/{id}/delete",
+            post(routes::deploy_key_delete),
+        )
+        .route(
             "/{owner}/{repo}/settings/branch-rules",
             post(routes_extra::branch_rule_add),
         )
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..7d9d002 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -3743,6 +3743,7 @@ pub async fn repo_settings(
     }
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?;
+    let deploy_keys = crate::db::deploy_keys::list_deploy_keys(&state.pool, repository.id).await?;
     let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
     Ok(RepoSettingsTemplate {
         viewer,
@@ -3754,6 +3755,7 @@ pub async fn repo_settings(
         clone_http,
         clone_ssh,
         branch_rules,
+        deploy_keys,
         is_site_admin,
         error: None,
     })
@@ -3884,6 +3886,61 @@ pub async fn collab_remove(
 }
 
 #[derive(Deserialize)]
+pub struct DeployKeyAddForm {
+    pub name: String,
+    pub public_key: String,
+    /// When set (checkbox), key may push; otherwise read-only.
+    pub write_access: Option<String>,
+}
+
+pub async fn deploy_key_add(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<DeployKeyAddForm>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    let created_by = viewer.map(|u| u.id);
+    let name = form.name.trim();
+    let public_key = form.public_key.trim();
+    if name.is_empty() || public_key.is_empty() {
+        return Err(AppError::bad("name and public key required"));
+    }
+    let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?;
+    let read_only = !checkbox(&form.write_access);
+    crate::db::deploy_keys::add_deploy_key(
+        &state.pool,
+        repository.id,
+        name,
+        public_key,
+        &fp,
+        read_only,
+        created_by,
+    )
+    .await
+    .map_err(|e| AppError::bad(format!("could not add deploy key: {e}")))?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+pub async fn deploy_key_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, id)): Path<(String, String, Uuid)>,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    crate::db::deploy_keys::delete_deploy_key(&state.pool, repository.id, id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+#[derive(Deserialize)]
 pub struct ArchiveForm {
     pub archived: Option<String>,
 }
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..19c088c 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -3,6 +3,7 @@ use crate::db::models::{
     Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
     UserEmail,
 };
+use crate::db::DeployKey;
 use askama::Template;
 use askama_web::WebTemplate;
 use chrono::{DateTime, Utc};
@@ -521,6 +522,7 @@ pub struct RepoSettingsTemplate {
     pub collaborators: Vec<CollaboratorView>,
     pub branches: Vec<String>,
     pub branch_rules: Vec<BranchRule>,
+    pub deploy_keys: Vec<DeployKey>,
     pub clone_http: String,
     pub clone_ssh: String,
     pub is_site_admin: bool,
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index effe71d..eb79776 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -185,6 +185,47 @@
     </form>
   </div>
 
+  <div class="kg-settings-section">
+    <span class="kg-kicker">deploy keys</span>
+    <p class="kg-muted">Repo-scoped SSH keys for CI and automation. Read-only by default; write access allows push to this repository only.</p>
+    {% if deploy_keys.is_empty() %}
+      <p class="kg-muted">No deploy keys.</p>
+    {% else %}
+      <ul class="kg-list">
+        {% for key in deploy_keys %}
+        <li>
+          <span>
+            <strong>{{ key.name }}</strong>
+            <span class="kg-meta">{{ key.fingerprint }}</span>
+            <span class="kg-badge">{{ key.permission_label() }}</span>
+          </span>
+          <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys/{{ key.id }}/delete">
+            <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
+          </form>
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys" style="margin-top:1rem">
+      <label>
+        name
+        <input type="text" name="name" required placeholder="ci" />
+      </label>
+      <label>
+        public key
+        <textarea name="public_key" rows="3" required placeholder="ssh-ed25519 AAAA…"></textarea>
+      </label>
+      <label class="row">
+        <input type="checkbox" name="write_access" />
+        allow write access (push)
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">add deploy key</button>
+      </div>
+    </form>
+  </div>
+
   {% if access.can_owner() || is_site_admin %}
     <div class="kg-danger-zone">
       <span class="kg-kicker">danger zone</span>