michal/tit
Browse tree · Show commit · Download archive
Diff
d8a1e6ba2cba → 4ea6cb21b1cf
README.md
Mode 100644 → 100644; object 7713f1f083a2 → 4cac34e9c07e
@@ -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 100644 → 100644; object 54dd8b0d2eee → ed50e9ec295f
@@ -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 100755 → 100755; object 41966c3469da → b0aaee3a65d6
@@ -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 100644 → 100644; object 696df1d706f8 → 2346cfb07b4d
@@ -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 100644 → 100644; object 97e32100440b → bd8e476da2c1
@@ -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 100644 → 100644; object 953394cc0c60 → b618fda981f2
@@ -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 100644 → 100644; object 56285496ae13 → cea4c4aeb8fd
@@ -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 100644 → 100644; object f925e69097df → ecac89789877
@@ -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 100644 → 100644; object c44187436546 → 1963f58da3b1
@@ -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 100644 → 100644; object d147facd7dd2 → 11bbf8127dd7
@@ -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 100644 → 100644; object 27034b5ccb09 → d2aa6ca8d204
@@ -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 100644 → 100644; object aa66c033d697 → 478be87ac15f
@@ -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 100644 → 100644; object f5bf1e643300 → 104abe95a5f7
@@ -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"]