michal/tit

Browse tree · Show commit · Download archive

Diff

d8a1e6ba2cba4ea6cb21b1cf

README.md

Mode 100644100644; object 7713f1f083a24cac34e9c07e

@@ -2,7 +2,7 @@
 
 `tit` is a small, self-hosted collaborative development environment (CDE) for
 Git. The current implementation has a read-only Web UI, HTTP and SSH clone
-services, public feeds, and bounded source search.
+services, authenticated SSH push, public feeds, and bounded source search.
 
 Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read
 [CONTRIBUTING.md](CONTRIBUTING.md) before you change code.
@@ -54,7 +54,10 @@
 The policy permits a reader to read and a writer to write. It permits a
 maintainer to change repository settings and collaborators. Only the owner can
 change ownership. An owner or collaborator can read a private repository in the
-Web UI after login. Authenticated Git enforcement starts in Milestone 3.4.
+Web UI after login. The built-in SSH server binds the supplied key to its
+account. The SSH username does not select the account. An owner, maintainer, or
+writer can push branches and tags. A reader cannot push. HTTP Git access stays
+read-only.
 
 ## Quality gate
 
@@ -200,3 +203,16 @@
 suspended accounts, archived repositories, and anonymous HTTP routes. Read the
 [repository authorization architectural decision record](docs/adr/0012-repository-authorization.md)
 for the complete access matrix.
+
+## Milestone 3.4 gate
+
+Install stock Git and OpenSSH. Then, run the authenticated Git gate:
+
+```text
+./scripts/check-m3-4
+```
+
+This command tests account-bound SSH keys, each repository role, key revocation,
+account suspension, role removal, push permission, and ref policy. Read the
+[authenticated Git architectural decision record](docs/adr/0013-authenticated-git.md)
+for the service and ref-update checks.

docs/adr/0012-repository-authorization.md

Mode 100644100644; object 54dd8b0d2eeeed50e9ec295f

@@ -33,10 +33,9 @@
 UI to read a private repository. Private raw responses and feeds use a private
 no-store cache policy.
 
-The SSH repository catalog also comes from the policy. Thus, an anonymous HTTP
-request and an anonymous SSH Git request use the same read rule. Milestone 3.4
-will supply the authenticated SSH actor and operation to this service before it
-starts a Git service.
+The SSH repository catalog also comes from the policy. Thus, an HTTP request
+and an SSH Git request use the same read rule. Authenticated SSH Git supplies
+the account and operation to this service before it starts a Git service.
 
 Offline administrator commands set visibility, set a collaborator role, and
 remove a collaborator. The instance lock requires the administrator to stop
@@ -67,5 +66,5 @@
 ## Consequences
 
 Role and visibility changes take effect on the next policy query. The policy
-does not cache authorization state. Authenticated Git and ref policy stay in
-Milestone 3.4.
+does not cache authorization state. Architectural decision record 0013 applies
+this policy to authenticated Git and ref updates.

docs/adr/0013-authenticated-git.md

Mode 100644; object 87e3821b98a3

@@ -1,0 +1,76 @@
+# Architectural decision record 0013: Authenticated Git
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+The built-in SSH server must use the same account and repository policy as the
+Web UI. A key removal, account suspension, or role change must stop subsequent
+Git access. A change during a push must not permit a ref update after access is
+removed.
+
+HTTP Git access stays read-only in the first stable release.
+
+## Decision
+
+Load each active, non-revoked SSH key with its account username. The key selects
+the account. The username that an SSH client supplies does not select the
+account. SQLite continues to own the account and key records.
+
+Before `git-upload-pack` starts, verify that the key is still active and ask
+`RepositoryPolicy` for read permission. Before `git-receive-pack` starts, do
+the same key check and ask for write permission. Resolve the repository path
+from its stable repository ID only after authorization.
+
+Before a receive operation changes refs, verify the key-to-account binding
+again and ask `RepositoryPolicy` for write permission again. Do these checks
+while the server holds the one-push permit and immediately before
+`ReceivePack::finish` applies the ref changes. `ReceivePack` then permits only
+branch and tag refs. It rejects a non-fast-forward branch update and a ref that
+was changed after the initial advertisement. It validates the pack and Git
+objects before it changes a ref.
+
+The Web recovery and key operations reload the active SSH key map after a
+successful account change. Repository policy reads the current database state
+for each decision. Offline repository access commands continue to require the
+instance lock.
+
+## Failure and threat cases
+
+A revoked key and a key for a suspended account cannot authenticate after the
+active key map reloads. A connection that authenticated before a key reload
+cannot start a new Git service with the removed key. If removal occurs during a
+receive operation, the check before the ref update rejects the push.
+
+A removed or reduced collaborator role rejects a new Git service. If the role
+changes during a receive operation, the second policy check rejects the ref
+update. A reader can clone a private repository but cannot start
+`git-receive-pack`. An account without a role cannot discover a private
+repository.
+
+An unsupported ref, a non-fast-forward branch update, an invalid pack, and a
+concurrent ref change return a rejected push. These failures do not change a
+ref. HTTP does not start `git-receive-pack` and has no write route.
+
+## Evidence
+
+The production server test uses stock Git and OpenSSH. It tests the owner,
+maintainer, writer, reader, and account-without-role cases against a private
+repository. It also tests a suspended account, a revoked key, a public
+repository, a successful writer push, a rejected reader push, role removal, an
+unsupported ref, and a non-fast-forward update.
+
+The receive-pack tests test SHA-1 and SHA-256 pushes, malformed packs, invalid
+objects, concurrent ref changes, process termination, and restart recovery.
+
+## Consequences
+
+SSH Git requires an account key, including reads of a public repository. This
+keeps SSH identity unambiguous. Anonymous users can continue to clone a public
+repository through HTTP.
+
+Each Git service start uses a SQLite policy query. A push uses one additional
+policy query before ref updates. This cost keeps authorization current and
+prevents a server-side permission cache from extending access.

scripts/check-m3-3

Mode 100755100755; object 41966c3469dab0aaee3a65d6

@@ -4,5 +4,5 @@
 ./scripts/check
 cargo test --locked --release --test repository_policy
 cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
-cargo test --locked --release --test serve hides_a_private_repository_from_http_and_ssh_discovery
+cargo test --locked --release --test serve keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh
 cargo test --locked --release --test cli administers_repository_visibility_and_collaborators

scripts/check-m3-4

Mode 100755; object 73cc88b3797f

@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test serve enforces_account_roles_and_ref_policy_through_the_production_ssh_server
+cargo test --locked --release --test git_push_ssh

src/git/transport.rs

Mode 100644100644; object 696df1d706f82346cfb07b4d

@@ -6,12 +6,15 @@
 use thiserror::Error;
 use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
 
+use crate::policy::{RepositoryOperation, RepositoryPolicy};
+
 const MAX_BLOCKING_GIT_JOBS: usize = 4;
 
 #[derive(Clone)]
 pub(crate) struct GitRepositories {
     root: PathBuf,
     managed_public: Option<Arc<HashMap<(String, String), String>>>,
+    policy: Option<RepositoryPolicy>,
     push_database: Option<PathBuf>,
     push_jobs: std::sync::Arc<Semaphore>,
     blocking_jobs: std::sync::Arc<Semaphore>,
@@ -26,6 +29,7 @@
         Ok(Self {
             root,
             managed_public: None,
+            policy: None,
             push_database: None,
             push_jobs: std::sync::Arc::new(Semaphore::new(1)),
             blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
@@ -60,10 +64,30 @@
         Ok(service)
     }
 
+    pub(crate) fn new_managed_authorized(
+        root: &Path,
+        database: &Path,
+    ) -> Result<Self, RepositoryPathError> {
+        let mut service = Self::new(root)?;
+        service.push_database = Some(database.to_owned());
+        service.policy = Some(RepositoryPolicy::new(database));
+        Ok(service)
+    }
+
     pub(crate) fn resolve(
         &self,
         owner: &str,
         repository: &str,
+    ) -> Result<PathBuf, RepositoryPathError> {
+        self.resolve_for(None, owner, repository, RepositoryOperation::Read)
+    }
+
+    fn resolve_for(
+        &self,
+        actor: Option<&str>,
+        owner: &str,
+        repository: &str,
+        operation: RepositoryOperation,
     ) -> Result<PathBuf, RepositoryPathError> {
         if !valid_name(owner, 40) {
             return Err(RepositoryPathError::InvalidName);
@@ -72,20 +96,27 @@
         if !valid_name(repository, 100) {
             return Err(RepositoryPathError::InvalidName);
         }
-        let candidate = match &self.managed_public {
-            Some(repositories) => {
-                let id = repositories
-                    .get(&(owner.to_owned(), repository.to_owned()))
-                    .ok_or_else(|| RepositoryPathError::Repository {
-                        path: self.root.join(owner).join(repository),
-                        source: std::io::Error::new(
-                            std::io::ErrorKind::NotFound,
-                            "repository is not public and active",
-                        ),
-                    })?;
-                self.root.join(format!("{id}.git"))
+        let candidate = if let Some(policy) = &self.policy {
+            let record = policy
+                .authorize(actor, owner, repository, operation)
+                .map_err(|_| RepositoryPathError::Unauthorized)?;
+            self.root.join(format!("{}.git", record.id))
+        } else {
+            match &self.managed_public {
+                Some(repositories) => {
+                    let id = repositories
+                        .get(&(owner.to_owned(), repository.to_owned()))
+                        .ok_or_else(|| RepositoryPathError::Repository {
+                            path: self.root.join(owner).join(repository),
+                            source: std::io::Error::new(
+                                std::io::ErrorKind::NotFound,
+                                "repository is not public and active",
+                            ),
+                        })?;
+                    self.root.join(format!("{id}.git"))
+                }
+                None => self.root.join(owner).join(format!("{repository}.git")),
             }
-            None => self.root.join(owner).join(format!("{repository}.git")),
         };
         let candidate =
             fs::canonicalize(&candidate).map_err(|source| RepositoryPathError::Repository {
@@ -129,30 +160,48 @@
         &self,
         command: &[u8],
     ) -> Result<GitSshService, RepositoryPathError> {
+        self.resolve_ssh_service_for(None, command)
+    }
+
+    pub(crate) fn resolve_ssh_service_for(
+        &self,
+        actor: Option<&str>,
+        command: &[u8],
+    ) -> Result<GitSshService, RepositoryPathError> {
         if command.starts_with(b"git-upload-pack ") {
-            return self.resolve_ssh_command(command).map(GitSshService::Upload);
+            let (owner, repository) = parse_ssh_repository(command, "git-upload-pack '")?;
+            let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Read)?;
+            return Ok(GitSshService::Upload {
+                path,
+                owner,
+                repository,
+            });
         }
-        let command =
-            std::str::from_utf8(command).map_err(|_| RepositoryPathError::InvalidCommand)?;
-        let path = command
-            .strip_prefix("git-receive-pack '")
-            .and_then(|value| value.strip_suffix('\''))
-            .ok_or(RepositoryPathError::InvalidCommand)?;
-        if path.contains('\'') {
-            return Err(RepositoryPathError::InvalidCommand);
-        }
-        let path = path.strip_prefix('/').unwrap_or(path);
-        let mut components = path.split('/');
-        let owner = components
-            .next()
-            .ok_or(RepositoryPathError::InvalidCommand)?;
-        let repository = components
-            .next()
-            .ok_or(RepositoryPathError::InvalidCommand)?;
-        if components.next().is_some() {
-            return Err(RepositoryPathError::InvalidCommand);
-        }
-        self.resolve(owner, repository).map(GitSshService::Receive)
+        let (owner, repository) = parse_ssh_repository(command, "git-receive-pack '")?;
+        let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Write)?;
+        Ok(GitSshService::Receive {
+            path,
+            owner,
+            repository,
+        })
+    }
+
+    pub(crate) fn authorize(
+        &self,
+        actor: &str,
+        owner: &str,
+        repository: &str,
+        operation: RepositoryOperation,
+    ) -> bool {
+        self.policy.as_ref().is_none_or(|policy| {
+            policy
+                .authorize(Some(actor), owner, repository, operation)
+                .is_ok()
+        })
+    }
+
+    pub(crate) fn uses_policy(&self) -> bool {
+        self.policy.is_some()
     }
 
     pub(crate) fn push_database(&self) -> Option<&Path> {
@@ -176,8 +225,43 @@
 }
 
 pub(crate) enum GitSshService {
-    Upload(PathBuf),
-    Receive(PathBuf),
+    Upload {
+        path: PathBuf,
+        owner: String,
+        repository: String,
+    },
+    Receive {
+        path: PathBuf,
+        owner: String,
+        repository: String,
+    },
+}
+
+fn parse_ssh_repository(
+    command: &[u8],
+    prefix: &str,
+) -> Result<(String, String), RepositoryPathError> {
+    let command = std::str::from_utf8(command).map_err(|_| RepositoryPathError::InvalidCommand)?;
+    let path = command
+        .strip_prefix(prefix)
+        .and_then(|value| value.strip_suffix('\''))
+        .ok_or(RepositoryPathError::InvalidCommand)?;
+    if path.contains('\'') {
+        return Err(RepositoryPathError::InvalidCommand);
+    }
+    let path = path.strip_prefix('/').unwrap_or(path);
+    let mut components = path.split('/');
+    let owner = components
+        .next()
+        .ok_or(RepositoryPathError::InvalidCommand)?;
+    let repository = components
+        .next()
+        .ok_or(RepositoryPathError::InvalidCommand)?;
+    if components.next().is_some() {
+        return Err(RepositoryPathError::InvalidCommand);
+    }
+    let repository = repository.strip_suffix(".git").unwrap_or(repository);
+    Ok((owner.to_owned(), repository.to_owned()))
 }
 
 fn valid_name(value: &str, maximum: usize) -> bool {
@@ -212,6 +296,8 @@
     OutsideRoot,
     #[error("managed repository catalog is not valid")]
     InvalidCatalog,
+    #[error("repository access is not authorized")]
+    Unauthorized,
 }
 
 #[cfg(test)]
@@ -240,7 +326,7 @@
             repositories
                 .resolve_ssh_service(b"git-receive-pack '/alice/example.git'")
                 .expect("resolve receive-pack"),
-            GitSshService::Receive(path) if path == fs::canonicalize(&repository).expect("canonicalize the repository")
+            GitSshService::Receive { path, .. } if path == fs::canonicalize(&repository).expect("canonicalize the repository")
         ));
         assert_eq!(
             repositories

src/policy.rs

Mode 100644100644; object 97e32100440bbd8e476da2c1

@@ -32,6 +32,10 @@
         }
     }
 
+    #[allow(
+        dead_code,
+        reason = "policy tests verify anonymous catalog behavior independently"
+    )]
     pub(crate) fn public_repositories(&self) -> Result<Vec<RepositoryRecord>, PolicyError> {
         Store::open(&self.database)?
             .active_repositories()?

src/serve.rs

Mode 100644100644; object 953394cc0c60b618fda981f2

@@ -14,7 +14,7 @@
 use crate::git::transport::{GitRepositories, RepositoryPathError};
 use crate::http::{PublicWebConfig, RunningWebServer, WebError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
-use crate::policy::{PolicyError, RepositoryPolicy};
+use crate::policy::PolicyError;
 use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
 use crate::store::{Store, StoreError};
 
@@ -23,34 +23,22 @@
     let database = prepare_database(&config.instance_dir)?;
     let repository_root = prepare_repository_root(&config.instance_dir)?;
     let store = Store::open(&database)?;
-    let keys = store
-        .active_ssh_public_keys()?
-        .into_iter()
-        .map(|key| SshPublicKey::parse(&key))
-        .collect::<Result<Vec<_>, _>>()?;
-    let policy = RepositoryPolicy::new(&database);
-    let repositories = policy
-        .public_repositories()?
-        .into_iter()
-        .map(|repository| (repository.owner, repository.slug, repository.id));
-    let git = GitRepositories::new_managed_public(&repository_root, repositories)?;
+    let keys = active_ssh_identities(&store)?;
+    let git = GitRepositories::new_managed_authorized(&repository_root, &database)?;
     drop(store);
 
     let accounts = AccountService::new(database);
     let control = RunningControlServer::start(&config.instance_dir, accounts.clone())?;
-    let authorized_keys = AuthorizedSshKeys::new(&keys);
+    let authorized_keys = AuthorizedSshKeys::for_accounts(keys);
 
     let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
     let host_key = load_or_create_host_key(&config.instance_dir)?;
     let reload_keys = {
         let authorized_keys = authorized_keys.clone();
         std::sync::Arc::new(move |accounts: &AccountService| {
-            let active = Store::open(accounts.database())?
-                .active_ssh_public_keys()?
-                .into_iter()
-                .map(|key| SshPublicKey::parse(&key))
-                .collect::<Result<Vec<_>, _>>()?;
-            authorized_keys.replace(&active);
+            let store = Store::open(accounts.database())?;
+            let active = active_ssh_identities(&store)?;
+            authorized_keys.replace_accounts(active);
             Ok(())
         })
     };
@@ -87,6 +75,24 @@
     web_result?;
     control_result?;
     signal.map_err(ServeError::Signal)
+}
+
+fn active_ssh_identities(store: &Store) -> Result<Vec<(String, SshPublicKey)>, StoreError> {
+    store
+        .active_ssh_identities()?
+        .into_iter()
+        .map(|identity| {
+            let key = SshPublicKey::parse(&identity.canonical_key)
+                .map_err(|error| StoreError::Integrity(error.to_string()))?;
+            if key.fingerprint() != identity.fingerprint {
+                return Err(StoreError::Integrity(format!(
+                    "SSH key fingerprint does not match for account {}",
+                    identity.username
+                )));
+            }
+            Ok((identity.username, key))
+        })
+        .collect()
 }
 
 const HOST_KEY_FILE: &str = "ssh_host_ed25519_key";

src/ssh.rs

Mode 100644100644; object 56285496ae13cea4c4aeb8fd

@@ -20,6 +20,7 @@
 use crate::git::receive_pack::{ReceivePack, ReceivePackError};
 use crate::git::transport::{GitRepositories, GitSshService};
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
+use crate::policy::RepositoryOperation;
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
@@ -34,7 +35,13 @@
 
 #[derive(Clone)]
 pub(crate) struct AuthorizedSshKeys {
-    keys: Arc<RwLock<HashMap<PublicKey, String>>>,
+    keys: Arc<RwLock<HashMap<PublicKey, SshIdentity>>>,
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct SshIdentity {
+    username: String,
+    fingerprint: String,
 }
 
 impl AuthorizedSshKeys {
@@ -44,13 +51,19 @@
         }
     }
 
-    pub(crate) fn replace(&self, keys: &[SshPublicKey]) {
-        if let Ok(mut current) = self.keys.write() {
-            *current = key_map(keys);
+    pub(crate) fn for_accounts(keys: Vec<(String, SshPublicKey)>) -> Self {
+        Self {
+            keys: Arc::new(RwLock::new(account_key_map(keys))),
         }
     }
 
-    fn actor(&self, key: &PublicKey) -> Option<String> {
+    pub(crate) fn replace_accounts(&self, keys: Vec<(String, SshPublicKey)>) {
+        if let Ok(mut current) = self.keys.write() {
+            *current = account_key_map(keys);
+        }
+    }
+
+    fn identity(&self, key: &PublicKey) -> Option<SshIdentity> {
         self.keys.read().ok()?.get(key).cloned()
     }
 
@@ -59,9 +72,32 @@
     }
 }
 
-fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, String> {
+fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, SshIdentity> {
     keys.iter()
-        .map(|key| (key.public_key().clone(), key.fingerprint().to_owned()))
+        .map(|key| {
+            let fingerprint = key.fingerprint().to_owned();
+            (
+                key.public_key().clone(),
+                SshIdentity {
+                    username: fingerprint.clone(),
+                    fingerprint,
+                },
+            )
+        })
+        .collect()
+}
+
+fn account_key_map(keys: Vec<(String, SshPublicKey)>) -> HashMap<PublicKey, SshIdentity> {
+    keys.into_iter()
+        .map(|(username, key)| {
+            (
+                key.public_key().clone(),
+                SshIdentity {
+                    username,
+                    fingerprint: key.fingerprint().to_owned(),
+                },
+            )
+        })
         .collect()
 }
 
@@ -98,6 +134,7 @@
         repositories: GitRepositories,
         host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
+        recover_pushes(&repositories).await?;
         Self::start_inner_with_keys(address, authorized_keys, &[], Some(repositories), host_key)
             .await
     }
@@ -108,16 +145,7 @@
         writable_keys: &[SshPublicKey],
         repositories: GitRepositories,
     ) -> Result<Self, SshServerError> {
-        let database = repositories
-            .push_database()
-            .ok_or_else(|| SshServerError::Recovery("push storage is not configured".to_owned()))?
-            .to_owned();
-        tokio::task::spawn_blocking(move || {
-            crate::git::receive_pack::recover_incomplete_pushes(&database)
-        })
-        .await
-        .map_err(|_| SshServerError::Join)?
-        .map_err(|error| SshServerError::Recovery(error.to_string()))?;
+        recover_pushes(&repositories).await?;
         let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
         Self::start_inner(
             address,
@@ -220,6 +248,19 @@
     }
 }
 
+async fn recover_pushes(repositories: &GitRepositories) -> Result<(), SshServerError> {
+    let database = repositories
+        .push_database()
+        .ok_or_else(|| SshServerError::Recovery("push storage is not configured".to_owned()))?
+        .to_owned();
+    tokio::task::spawn_blocking(move || {
+        crate::git::receive_pack::recover_incomplete_pushes(&database)
+    })
+    .await
+    .map_err(|_| SshServerError::Join)?
+    .map_err(|error| SshServerError::Recovery(error.to_string()))
+}
+
 #[derive(Debug, Error)]
 pub(crate) enum SshServerError {
     #[error("SSH listener error: {0}")]
@@ -253,7 +294,8 @@
             repositories: self.repositories.clone(),
             protocol: ProtocolVersion::V0,
             git_channels: HashMap::new(),
-            authenticated_actor: None,
+            authenticated_identity: None,
+            authenticated_key: None,
             authenticated_writer: false,
         }
     }
@@ -266,7 +308,8 @@
     repositories: Option<Arc<GitRepositories>>,
     protocol: ProtocolVersion,
     git_channels: HashMap<ChannelId, GitChannel>,
-    authenticated_actor: Option<String>,
+    authenticated_identity: Option<SshIdentity>,
+    authenticated_key: Option<PublicKey>,
     authenticated_writer: bool,
 }
 
@@ -282,6 +325,11 @@
 
 struct ReceiveChannel {
     service: ReceivePack,
+    owner: String,
+    repository: String,
+    identity: SshIdentity,
+    public_key: PublicKey,
+    authorized_keys: AuthorizedSshKeys,
     commands: Vec<u8>,
     commands_complete: bool,
     pack: tokio::fs::File,
@@ -304,8 +352,9 @@
         _user: &str,
         public_key: &PublicKey,
     ) -> Result<Auth, Self::Error> {
-        if let Some(actor) = self.authorized_keys.actor(public_key) {
-            self.authenticated_actor = Some(actor);
+        if let Some(identity) = self.authorized_keys.identity(public_key) {
+            self.authenticated_identity = Some(identity);
+            self.authenticated_key = Some(public_key.clone());
             self.authenticated_writer = self.writable_keys.contains(public_key);
             Ok(Auth::Accept)
         } else {
@@ -459,10 +508,15 @@
                             })),
                         );
                     }
-                    InitialGitService::Receive {
-                        service,
-                        advertisement,
-                    } => {
+                    InitialGitService::Receive(receive) => {
+                        let InitialReceiveService {
+                            service,
+                            advertisement,
+                            owner,
+                            repository,
+                            identity,
+                            public_key,
+                        } = *receive;
                         session.data(channel, advertisement)?;
                         let pack = tokio::fs::File::create(service.incoming_pack()).await;
                         match pack {
@@ -470,7 +524,12 @@
                                 self.git_channels.insert(
                                     channel,
                                     GitChannel::Receive(Box::new(ReceiveChannel {
-                                        service,
+                                        service: *service,
+                                        owner,
+                                        repository,
+                                        identity,
+                                        public_key,
+                                        authorized_keys: self.authorized_keys.clone(),
                                         commands: Vec::new(),
                                         commands_complete: false,
                                         pack,
@@ -666,9 +725,12 @@
 
     async fn open_git_service(&mut self, command: &[u8]) -> Option<InitialGitService> {
         let repositories = self.repositories.as_ref()?;
-        let service = repositories.resolve_ssh_service(command).ok()?;
+        let identity = self.active_identity()?;
+        let service = repositories
+            .resolve_ssh_service_for(Some(&identity.username), command)
+            .ok()?;
         match service {
-            GitSshService::Upload(path) => {
+            GitSshService::Upload { path, .. } => {
                 let permit = repositories.blocking_permit().await.ok()?;
                 let protocol = self.protocol;
                 tokio::task::spawn_blocking(move || {
@@ -684,27 +746,45 @@
                 .ok()?
                 .ok()
             }
-            GitSshService::Receive(path) => {
-                if !self.authenticated_writer {
+            GitSshService::Receive {
+                path,
+                owner,
+                repository,
+            } => {
+                if !repositories.uses_policy() && !self.authenticated_writer {
                     return None;
                 }
                 let database = repositories.push_database()?.to_owned();
-                let actor = self.authenticated_actor.clone()?;
+                let actor = identity.username.clone();
+                let public_key = self.authenticated_key.clone()?;
                 let permit = repositories.blocking_permit().await.ok()?;
                 tokio::task::spawn_blocking(move || {
                     let _permit = permit;
                     let service = ReceivePack::open(&path, &database, actor)?;
                     let advertisement = service.advertisement()?;
-                    Ok::<_, ReceivePackError>(InitialGitService::Receive {
-                        service,
-                        advertisement,
-                    })
+                    Ok::<_, ReceivePackError>(InitialGitService::Receive(Box::new(
+                        InitialReceiveService {
+                            service: Box::new(service),
+                            advertisement,
+                            owner,
+                            repository,
+                            identity,
+                            public_key,
+                        },
+                    )))
                 })
                 .await
                 .ok()?
                 .ok()
             }
         }
+    }
+
+    fn active_identity(&self) -> Option<SshIdentity> {
+        let public_key = self.authenticated_key.as_ref()?;
+        let authenticated = self.authenticated_identity.as_ref()?;
+        let current = self.authorized_keys.identity(public_key)?;
+        (current == *authenticated).then_some(current)
     }
 }
 
@@ -713,10 +793,16 @@
         service: Box<UploadPack>,
         advertisement: Vec<u8>,
     },
-    Receive {
-        service: ReceivePack,
-        advertisement: Vec<u8>,
-    },
+    Receive(Box<InitialReceiveService>),
+}
+
+struct InitialReceiveService {
+    service: Box<ReceivePack>,
+    advertisement: Vec<u8>,
+    owner: String,
+    repository: String,
+    identity: SshIdentity,
+    public_key: PublicKey,
 }
 
 async fn receive_data(git: &mut ReceiveChannel, data: &[u8]) -> Result<(), ()> {
@@ -751,6 +837,11 @@
 ) -> ReceiveResult {
     let ReceiveChannel {
         mut service,
+        owner,
+        repository,
+        identity,
+        public_key,
+        authorized_keys,
         commands,
         mut pack,
         ..
@@ -764,16 +855,27 @@
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
         let _push_permit = push_permit;
-        match service.finish(&commands) {
+        if authorized_keys.identity(&public_key).as_ref() != Some(&identity)
+            || !repositories.authorize(
+                &identity.username,
+                &owner,
+                &repository,
+                RepositoryOperation::Write,
+            )
+        {
+            return None;
+        }
+        Some(match service.finish(&commands) {
             Ok(response) => Ok(response),
             Err(error) => {
                 let response = service.rejection_response(&commands, &error);
                 Err((error, response))
             }
-        }
+        })
     })
     .await
     .ok()
+    .flatten()
 }
 
 fn send_receive_result(

src/store/mod.rs

Mode 100644100644; object f925e69097dfecac89789877

@@ -1186,6 +1186,31 @@
 
     #[allow(
         dead_code,
+        reason = "some integration tests compile storage without the production SSH server"
+    )]
+    pub(crate) fn active_ssh_identities(&self) -> Result<Vec<ActiveSshIdentity>, StoreError> {
+        let mut statement = self.connection.prepare(
+            "SELECT account.username, ssh_public_key.canonical_key,
+                    ssh_public_key.fingerprint
+             FROM ssh_public_key
+             JOIN account ON account.id = ssh_public_key.account_id
+             WHERE account.state = 'active' AND ssh_public_key.revoked_at IS NULL
+             ORDER BY ssh_public_key.id",
+        )?;
+        statement
+            .query_map([], |row| {
+                Ok(ActiveSshIdentity {
+                    username: row.get(0)?,
+                    canonical_key: row.get(1)?,
+                    fingerprint: row.get(2)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
         reason = "some integration tests import storage without the server"
     )]
     pub(crate) fn active_public_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
@@ -1398,6 +1423,16 @@
 pub(crate) struct RepositoryAuthorizationRecord {
     pub(crate) repository: RepositoryRecord,
     pub(crate) role: Option<String>,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without the production SSH server"
+)]
+pub(crate) struct ActiveSshIdentity {
+    pub(crate) username: String,
+    pub(crate) canonical_key: String,
+    pub(crate) fingerprint: String,
 }
 
 #[allow(

tests/git_http.rs

Mode 100644100644; object c441874365461963f58da3b1

@@ -6,12 +6,18 @@
 )]
 #[path = "../src/git/packetline.rs"]
 mod packetline;
+#[allow(dead_code, reason = "the HTTP test does not use repository policy")]
+#[path = "../src/policy.rs"]
+mod policy;
 #[allow(
     dead_code,
     reason = "the HTTP test does not inspect repository internals"
 )]
 #[path = "../src/git/repository.rs"]
 mod repository;
+#[allow(dead_code, reason = "the HTTP test does not use the intent store")]
+#[path = "../src/store/mod.rs"]
+mod store;
 #[allow(
     dead_code,
     reason = "the HTTP test uses transport resolution through HTTP"

tests/git_push_ssh.rs

Mode 100644100644; object d147facd7dd211bbf8127dd7

@@ -10,6 +10,9 @@
 )]
 #[path = "../src/git/mod.rs"]
 mod git;
+#[allow(dead_code, reason = "the SSH push test does not use repository policy")]
+#[path = "../src/policy.rs"]
+mod policy;
 #[allow(
     dead_code,
     reason = "the SSH push test does not inspect the request audit"

tests/git_ssh.rs

Mode 100644100644; object 27034b5ccb09d2aa6ca8d204

@@ -10,6 +10,9 @@
 )]
 #[path = "../src/git/mod.rs"]
 mod git;
+#[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
+#[path = "../src/policy.rs"]
+mod policy;
 #[allow(
     dead_code,
     reason = "the SSH Git test does not inspect the request audit"

tests/serve.rs

Mode 100644100644; object aa66c033d697478be87ac15f

@@ -9,7 +9,7 @@
 use std::net::{SocketAddr, TcpStream};
 use std::os::unix::fs::PermissionsExt;
 use std::path::Path;
-use std::process::{Child, Command, Stdio};
+use std::process::{Child, Command, ExitStatus, Stdio};
 use std::thread;
 use std::time::{Duration, Instant};
 
@@ -347,7 +347,7 @@
 }
 
 #[test]
-fn hides_a_private_repository_from_http_and_ssh_discovery() {
+fn keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh() {
     let instance = TempDir::new().expect("create an instance directory");
     let http = free_address();
     let ssh = free_address();
@@ -422,7 +422,192 @@
         .env("GIT_SSH_COMMAND", ssh_command)
         .output()
         .expect("query the private repository through SSH");
-    assert!(!ssh_discovery.status.success());
+    assert!(ssh_discovery.status.success());
+
+    let unknown_key = instance.path().join("unknown");
+    create_ssh_key_fixture(&unknown_key);
+    assert!(!ssh_clone_repository_succeeds(
+        ssh,
+        &unknown_key,
+        "alice",
+        "private",
+        &instance.path().join("unknown-clone")
+    ));
+    server.terminate();
+}
+
+#[test]
+fn enforces_account_roles_and_ref_policy_through_the_production_ssh_server() {
+    let instance = TempDir::new().expect("create an instance directory");
+    let http = free_address();
+    let ssh = free_address();
+    let config = instance.path().join("config.toml");
+    fs::write(
+        &config,
+        format!(
+            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+            ssh.port()
+        ),
+    )
+    .expect("write the server configuration");
+    let config_text = config.to_str().expect("a UTF-8 configuration path");
+    let owner_key = instance.path().join("owner");
+    create_ssh_key_fixture(&owner_key);
+    let owner_public =
+        fs::read_to_string(owner_key.with_extension("pub")).expect("read the owner public key");
+    command(
+        instance.path(),
+        [
+            "--config",
+            config_text,
+            "setup",
+            "admin",
+            "alice",
+            owner_public.trim(),
+        ],
+    );
+
+    let source = create_source_repository(instance.path());
+    for repository in ["private", "public"] {
+        command(
+            instance.path(),
+            [
+                "--config",
+                config_text,
+                "admin",
+                "repository",
+                "import",
+                "alice",
+                repository,
+                source.to_str().expect("a UTF-8 source path"),
+            ],
+        );
+    }
+    command(
+        instance.path(),
+        [
+            "--config",
+            config_text,
+            "admin",
+            "repository",
+            "visibility",
+            "alice",
+            "private",
+            "private",
+        ],
+    );
+
+    let maintainer_key = provision_account(instance.path(), "maintainer", "active", false);
+    let writer_key = provision_account(instance.path(), "writer", "active", false);
+    let reader_key = provision_account(instance.path(), "reader", "active", false);
+    let outsider_key = provision_account(instance.path(), "outsider", "active", false);
+    let suspended_key = provision_account(instance.path(), "suspended", "active", false);
+    let revoked_key = provision_account(instance.path(), "revoked", "active", true);
+    for (username, role) in [
+        ("maintainer", "maintainer"),
+        ("writer", "writer"),
+        ("reader", "reader"),
+        ("suspended", "writer"),
+        ("revoked", "writer"),
+    ] {
+        command(
+            instance.path(),
+            [
+                "--config",
+                config_text,
+                "admin",
+                "repository",
+                "collaborator-set",
+                "alice",
+                "private",
+                username,
+                role,
+            ],
+        );
+    }
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the repository database");
+    database
+        .execute(
+            "UPDATE account SET state = 'suspended' WHERE username = 'suspended'",
+            [],
+        )
+        .expect("suspend an account fixture");
+    drop(database);
+
+    let mut server = spawn_server(&config);
+    wait_for_listener(http, &mut server);
+    wait_for_listener(ssh, &mut server);
+    for (name, key) in [
+        ("owner", &owner_key),
+        ("maintainer", &maintainer_key),
+        ("writer", &writer_key),
+        ("reader", &reader_key),
+    ] {
+        assert!(ssh_clone_repository_succeeds(
+            ssh,
+            key,
+            "alice",
+            "private",
+            &instance.path().join(format!("{name}-private"))
+        ));
+    }
+    for (name, key) in [
+        ("outsider", &outsider_key),
+        ("suspended", &suspended_key),
+        ("revoked", &revoked_key),
+    ] {
+        assert!(!ssh_clone_repository_succeeds(
+            ssh,
+            key,
+            "alice",
+            "private",
+            &instance.path().join(format!("{name}-private"))
+        ));
+    }
+    assert!(ssh_clone_repository_succeeds(
+        ssh,
+        &outsider_key,
+        "alice",
+        "public",
+        &instance.path().join("outsider-public")
+    ));
+
+    let writer_clone = instance.path().join("writer-private");
+    fs::write(writer_clone.join("writer.txt"), b"writer update\n").expect("write a writer change");
+    git_commit(&writer_clone, "writer update");
+    assert!(git_push(&writer_key, &writer_clone, &["main"]).success());
+    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:refs/notes/test"]).success());
+    command(&writer_clone, ["reset", "--hard", "HEAD~1"]);
+    assert!(!git_push(&writer_key, &writer_clone, &["--force", "main"]).success());
+
+    let reader_clone = instance.path().join("reader-write-private");
+    assert!(ssh_clone_repository_succeeds(
+        ssh,
+        &reader_key,
+        "alice",
+        "private",
+        &reader_clone
+    ));
+    fs::write(reader_clone.join("reader.txt"), b"reader update\n").expect("write a reader change");
+    git_commit(&reader_clone, "reader update");
+    assert!(!git_push(&reader_key, &reader_clone, &["main"]).success());
+
+    command(&writer_clone, ["reset", "--hard", "origin/main"]);
+    fs::write(writer_clone.join("removed-role.txt"), b"removed role\n")
+        .expect("write a change before role removal");
+    git_commit(&writer_clone, "change before role removal");
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the repository database");
+    database
+        .execute(
+            "DELETE FROM repository_collaborator
+             WHERE account_id = (SELECT id FROM account WHERE username = 'writer')",
+            [],
+        )
+        .expect("remove the writer role");
+    drop(database);
+    assert!(!git_push(&writer_key, &writer_clone, &["main"]).success());
     server.terminate();
 }
 
@@ -696,6 +881,16 @@
 }
 
 fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
+    ssh_clone_repository_succeeds(address, private_key, "alice", "example", target)
+}
+
+fn ssh_clone_repository_succeeds(
+    address: SocketAddr,
+    private_key: &Path,
+    owner: &str,
+    repository: &str,
+    target: &Path,
+) -> bool {
     let ssh_command = format!(
         "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
         private_key.display()
@@ -705,8 +900,8 @@
             "clone",
             "-q",
             &format!(
-                "ssh://ignored@127.0.0.1:{}/alice/example.git",
-                address.port()
+                "ssh://ignored@127.0.0.1:{}/{owner}/{repository}.git",
+                address.port(),
             ),
         ])
         .arg(target)
@@ -715,6 +910,91 @@
         .expect("clone through the tit SSH server")
         .status
         .success()
+}
+
+fn provision_account(
+    instance: &Path,
+    username: &str,
+    state: &str,
+    revoked: bool,
+) -> std::path::PathBuf {
+    let private_key = instance.join(username);
+    create_ssh_key_fixture(&private_key);
+    let public_key =
+        fs::read_to_string(private_key.with_extension("pub")).expect("read an account public key");
+    let mut fields = public_key.split_whitespace();
+    let canonical = format!(
+        "{} {}",
+        fields.next().expect("read the key algorithm"),
+        fields.next().expect("read the key data")
+    );
+    let fingerprint_output = Command::new("ssh-keygen")
+        .args(["-E", "sha256", "-lf"])
+        .arg(private_key.with_extension("pub"))
+        .output()
+        .expect("read an SSH key fingerprint");
+    assert!(fingerprint_output.status.success());
+    let fingerprint_text =
+        String::from_utf8(fingerprint_output.stdout).expect("read a UTF-8 SSH key fingerprint");
+    let fingerprint = fingerprint_text
+        .split_whitespace()
+        .nth(1)
+        .expect("read the SSH key fingerprint");
+    let database = rusqlite::Connection::open(instance.join("tit.sqlite3"))
+        .expect("open the repository database");
+    database
+        .execute(
+            "INSERT INTO account (username, is_administrator, state, created_at)
+             VALUES (?1, 0, ?2, 1)",
+            rusqlite::params![username, state],
+        )
+        .expect("create an account fixture");
+    let account_id = database.last_insert_rowid();
+    database
+        .execute(
+            "INSERT INTO ssh_public_key
+             (account_id, canonical_key, fingerprint, created_at, label, revoked_at)
+             VALUES (?1, ?2, ?3, 1, 'initial', ?4)",
+            rusqlite::params![account_id, canonical, fingerprint, revoked.then_some(2)],
+        )
+        .expect("create an SSH key fixture");
+    private_key
+}
+
+fn git_commit(worktree: &Path, message: &str) {
+    command(worktree, ["add", "."]);
+    let output = Command::new("git")
+        .args(["commit", "-q", "-m", message])
+        .env("GIT_AUTHOR_NAME", "Tit Test")
+        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
+        .env("GIT_COMMITTER_NAME", "Tit Test")
+        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
+        .env("GIT_CONFIG_COUNT", "1")
+        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
+        .env("GIT_CONFIG_VALUE_0", "false")
+        .current_dir(worktree)
+        .output()
+        .expect("commit a Git change");
+    assert!(
+        output.status.success(),
+        "Git commit failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn git_push(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> ExitStatus {
+    let ssh_command = format!(
+        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+        private_key.display()
+    );
+    Command::new("git")
+        .arg("push")
+        .arg("origin")
+        .args(refspecs)
+        .env("GIT_SSH_COMMAND", ssh_command)
+        .current_dir(worktree)
+        .status()
+        .expect("push through the tit SSH server")
 }
 
 struct ChildGuard(Option<Child>);

tests/ssh.rs

Mode 100644100644; object f5bf1e643300104abe95a5f7

@@ -9,6 +9,12 @@
 mod git;
 #[allow(
     dead_code,
+    reason = "the SSH identity test does not use repository policy"
+)]
+#[path = "../src/policy.rs"]
+mod policy;
+#[allow(
+    dead_code,
     reason = "the SSH identity test does not start a Git service"
 )]
 #[path = "../src/ssh.rs"]