michal/tit
Browse tree · Show commit · Download archive
Diff
ccaae751b642 → ba7a9a1a87ff
.github/workflows/ci.yml
Mode 100644 → 100644; object 3816cc016a90 → a4827919afa5
@@ -30,7 +30,7 @@
persist-credentials: false
- run: cargo --version
- run: cargo test --locked --all-targets --all-features
- - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session
+ - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session --test repository_policy
- run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
- run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
- run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
README.md
Mode 100644 → 100644; object 39797a0677f5 → 7713f1f083a2
@@ -42,6 +42,20 @@ create the account. Store the recovery credential offline when the Web UI shows it. Open `/recover` to replace all account keys with a new key. +Stop the server before you change repository access with an offline +administrator command: + +```text +tit --config /srv/tit/config.toml admin repository visibility alice example private +tit --config /srv/tit/config.toml admin repository collaborator-set alice example bob writer +tit --config /srv/tit/config.toml admin repository collaborator-remove alice example bob +``` + +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. + ## Quality gate Install `cargo-deny` version 0.20.2. Then, run this command from the repository @@ -173,3 +187,16 @@ creates an opaque session. Read the [Web login architectural decision record](docs/adr/0011-web-login-sessions.md) for the session and CSRF behavior. + +## Milestone 3.3 gate + +Run the repository authorization gate: + +```text +./scripts/check-m3-3 +``` + +This command tests public and private visibility, each collaborator role, +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.
docs/adr/0012-repository-authorization.md
Mode → 100644; object → 54dd8b0d2eee
@@ -1,0 +1,71 @@ +# Architectural decision record 0012: Repository authorization + +Status: Accepted + +Date: 2026-07-23 + +## Context + +A repository can be public or private. A private repository must not enter an +anonymous HTTP or SSH result. HTTP and SSH must not use different role rules. + +## Decision + +Keep the owner account ID in the repository row. Derive the `owner` role from +this row. Store only the `maintainer`, `writer`, and `reader` roles in the +`repository_collaborator` table. This design prevents a second account from +getting the `owner` role through a collaborator update. + +Use one `RepositoryPolicy` service for repository decisions. Give this service +an optional active account, a repository, and an operation. Use these rules: + +- A public, active repository permits read access to all users. +- A private, active repository permits read access to its owner and + collaborators. +- The `owner`, `maintainer`, and `writer` roles permit write access. +- The `owner` and `maintainer` roles permit maintenance access. +- Only the `owner` role permits ownership access. +- A suspended account and an archived repository permit no transport access. + +The repository-route middleware validates an optional Web session and supplies +its account to the policy. The public Web routes ask the policy before they open +repository metadata or Git objects. An owner or collaborator can use the Web +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. + +Offline administrator commands set visibility, set a collaborator role, and +remove a collaborator. The instance lock requires the administrator to stop +the server before these commands change repository access. + +## Failure and threat cases + +Each policy query gets the account state and collaborator role in the same +SQLite query as the repository record. A suspended account cannot keep access +through an old collaborator row. A missing account has the same permissions as +an anonymous user. A private or archived repository returns the same HTTP 404 +result as a missing repository to an anonymous user. + +The schema permits only the three non-owner collaborator roles and one role per +account and repository. The application rejects an owner as a collaborator. +Repository ownership does not change when a collaborator role changes. + +## Evidence + +The policy access-matrix test covers anonymous users, each role, a stranger, a +suspended collaborator, a missing account, both visibility values, and an +archived repository. It also tests role change and removal. The server test +confirms that a Web session can read its private repository and that anonymous +HTTP and SSH Git discovery cannot find it. The public-route test confirms that +private repositories do not appear in summary, raw, feed, search, archive, or +Git discovery routes. + +## 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.
scripts/check-m3-3
Mode → 100755; object → 41966c3469da
@@ -1,0 +1,8 @@ +#!/bin/sh +set -eu + +./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 cli administers_repository_visibility_and_collaborators
src/admin.rs
Mode 100644 → 100644; object 4268bac3cf24 → fd27875c1dbb
@@ -86,6 +86,51 @@
inspect_with_store(instance_dir, &store, owner, slug)
}
+pub(crate) fn set_repository_visibility(
+ instance_dir: &Path,
+ owner: &str,
+ slug: &str,
+ visibility: &str,
+) -> Result<RepositoryRecord, AdminError> {
+ validate_names(owner, slug)?;
+ let _lock = InstanceLock::acquire(instance_dir)?;
+ let database = prepare_database(instance_dir)?;
+ let mut store = Store::open(&database)?;
+ store.set_repository_visibility(owner, slug, visibility)?;
+ inspect_with_store(instance_dir, &store, owner, slug)
+}
+
+pub(crate) fn set_repository_collaborator(
+ instance_dir: &Path,
+ owner: &str,
+ slug: &str,
+ username: &str,
+ role: &str,
+) -> Result<RepositoryRecord, AdminError> {
+ validate_names(owner, slug)?;
+ validate_username(username)?;
+ let _lock = InstanceLock::acquire(instance_dir)?;
+ let database = prepare_database(instance_dir)?;
+ let mut store = Store::open(&database)?;
+ store.set_repository_collaborator(owner, slug, username, role, timestamp()?)?;
+ inspect_with_store(instance_dir, &store, owner, slug)
+}
+
+pub(crate) fn remove_repository_collaborator(
+ instance_dir: &Path,
+ owner: &str,
+ slug: &str,
+ username: &str,
+) -> Result<RepositoryRecord, AdminError> {
+ validate_names(owner, slug)?;
+ validate_username(username)?;
+ let _lock = InstanceLock::acquire(instance_dir)?;
+ let database = prepare_database(instance_dir)?;
+ let mut store = Store::open(&database)?;
+ store.remove_repository_collaborator(owner, slug, username)?;
+ inspect_with_store(instance_dir, &store, owner, slug)
+}
+
pub(crate) fn inspect_repository(
instance_dir: &Path,
owner: &str,
src/cli.rs
Mode 100644 → 100644; object dd4807fee74b → d8508db5202c
@@ -120,8 +120,40 @@
},
/// Archive a repository
Archive { owner: String, slug: String },
+ /// Set repository visibility
+ Visibility {
+ owner: String,
+ slug: String,
+ visibility: RepositoryVisibility,
+ },
+ /// Set a collaborator role
+ CollaboratorSet {
+ owner: String,
+ slug: String,
+ username: String,
+ role: CollaboratorRole,
+ },
+ /// Remove a collaborator
+ CollaboratorRemove {
+ owner: String,
+ slug: String,
+ username: String,
+ },
/// Inspect a repository
Inspect { owner: String, slug: String },
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+pub(crate) enum RepositoryVisibility {
+ Public,
+ Private,
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+pub(crate) enum CollaboratorRole {
+ Maintainer,
+ Writer,
+ Reader,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
src/http/mod.rs
Mode 100644 → 100644; object 3112a0153107 → 04dd1dfc20c4
@@ -43,6 +43,9 @@
secure_cookies: bool,
}
+#[derive(Clone)]
+pub(super) struct RequestActor(pub(super) Option<String>);
+
type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
#[derive(Clone, Debug)]
@@ -157,6 +160,10 @@
}
fn router_with_state(state: WebState) -> Router {
+ let repository_routes = public::routes().layer(middleware::from_fn_with_state(
+ state.clone(),
+ repository_actor,
+ ));
Router::new()
.route("/", get(home))
.route("/go", get(go_to_repository))
@@ -192,11 +199,27 @@
axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
)
.route("/assets/style.css", get(style))
- .merge(public::routes())
+ .merge(repository_routes)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.with_state(state)
.layer(middleware::from_fn(response_policy))
+}
+
+async fn repository_actor(
+ State(state): State<WebState>,
+ mut request: Request,
+ next: Next,
+) -> Response {
+ let actor = match cookie(request.headers(), SESSION_COOKIE) {
+ Some(session) => login_job(state, move |login| login.authenticate(&session, None))
+ .await
+ .ok()
+ .map(|record| record.username),
+ None => None,
+ };
+ request.extensions_mut().insert(RequestActor(actor));
+ next.run(request).await
}
async fn home(Extension(request_id): Extension<RequestId>) -> Response {
src/http/public.rs
Mode 100644 → 100644; object 86437b648d06 → c888b44334b8
@@ -30,9 +30,10 @@
};
use crate::git::upload_pack::{ProtocolVersion, UploadPack};
use crate::markdown::{self, RenderedMarkdown};
+use crate::policy::{PolicyError, RepositoryOperation, RepositoryPolicy};
use crate::store::{DATABASE_FILE, RepositoryRecord, Store, StoreError};
-use super::{PublicWebConfig, RequestId, WebState, render_error};
+use super::{PublicWebConfig, RequestActor, RequestId, WebState, render_error};
const MAX_HISTORY_COMMITS: usize = 10_000;
const MAX_SUMMARY_COMMITS: usize = 50;
@@ -45,6 +46,7 @@
http_clone_base: String,
ssh_clone_base: String,
jobs: Arc<Semaphore>,
+ policy: RepositoryPolicy,
}
impl PublicWeb {
@@ -59,6 +61,7 @@
}
let database = config.instance_dir.join(DATABASE_FILE);
Store::open(&database)?;
+ let policy = RepositoryPolicy::new(&database);
let http_clone_base = clone_base(&config.http_clone_base)?;
let ssh_clone_base = clone_base(&config.ssh_clone_base)?;
Ok(Self {
@@ -67,11 +70,13 @@
http_clone_base,
ssh_clone_base,
jobs,
+ policy,
})
}
async fn read<T, F>(
&self,
+ actor: Option<String>,
owner: String,
repository: String,
operation: F,
@@ -91,7 +96,8 @@
let web = self.clone();
tokio::task::spawn_blocking(move || {
let _permit = permit;
- let (repository, path) = web.resolve_repository(&owner, &repository)?;
+ let (repository, path) =
+ web.resolve_repository(actor.as_deref(), &owner, &repository)?;
let limits = ReadLimits {
max_history_commits: MAX_HISTORY_COMMITS,
..ReadLimits::default()
@@ -105,6 +111,7 @@
async fn event_page(
&self,
+ actor: Option<String>,
owner: String,
repository: String,
before: Option<i64>,
@@ -118,10 +125,17 @@
.await
.map_err(|_| RouteError::Unavailable)?;
let database = self.database.clone();
+ let policy = self.policy.clone();
tokio::task::spawn_blocking(move || {
let _permit = permit;
+ policy.authorize(
+ actor.as_deref(),
+ &owner,
+ &repository,
+ RepositoryOperation::Read,
+ )?;
Store::open(&database)?
- .public_repository_events(&owner, &repository, before, PAGE_SIZE + 1)
+ .repository_events(&owner, &repository, before, PAGE_SIZE + 1)
.map_err(Into::into)
})
.await
@@ -130,6 +144,7 @@
async fn path_job<T, F>(
&self,
+ actor: Option<String>,
owner: String,
repository: String,
operation: F,
@@ -147,7 +162,7 @@
let web = self.clone();
tokio::task::spawn_blocking(move || {
let _permit = permit;
- let (record, path) = web.resolve_repository(&owner, &repository)?;
+ let (record, path) = web.resolve_repository(actor.as_deref(), &owner, &repository)?;
operation(record, path)
})
.await
@@ -156,14 +171,16 @@
fn resolve_repository(
&self,
+ actor: Option<&str>,
owner: &str,
repository: &str,
) -> Result<(RepositoryRecord, PathBuf), RouteError> {
if validate_username(owner).is_err() || validate_slug(repository).is_err() {
return Err(RouteError::NotFound);
}
- let store = Store::open(&self.database)?;
- let record = store.public_repository(owner, repository)?;
+ let record = self
+ .policy
+ .authorize(actor, owner, repository, RepositoryOperation::Read)?;
let candidate = self.repositories.join(format!("{}.git", record.id));
let path = fs::canonicalize(&candidate).map_err(|_| RouteError::Internal)?;
if path.parent() != Some(self.repositories.as_path()) || !path.is_dir() {
@@ -181,12 +198,13 @@
async fn archive(
&self,
+ actor: Option<String>,
owner: String,
repository: String,
id: ObjectId,
) -> Result<Body, RouteError> {
let path = self
- .path_job(owner, repository, move |record, path| {
+ .path_job(actor, owner, repository, move |record, path| {
require_id_format(id, &record)?;
let service = RepositoryReadService::open(&path, ReadLimits::default())?;
let cancellation = ReadCancellation::default();
@@ -243,26 +261,47 @@
async fn atom_feed(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<RepositoryPath>,
Query(query): Query<FeedQuery>,
headers: HeaderMap,
) -> Response {
- feed_response(state, request_id, path, query, headers, FeedFormat::Atom).await
+ feed_response(
+ state,
+ request_id,
+ actor,
+ path,
+ query,
+ headers,
+ FeedFormat::Atom,
+ )
+ .await
}
async fn rss_feed(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<RepositoryPath>,
Query(query): Query<FeedQuery>,
headers: HeaderMap,
) -> Response {
- feed_response(state, request_id, path, query, headers, FeedFormat::Rss).await
+ feed_response(
+ state,
+ request_id,
+ actor,
+ path,
+ query,
+ headers,
+ FeedFormat::Rss,
+ )
+ .await
}
async fn feed_response(
state: WebState,
request_id: RequestId,
+ actor: RequestActor,
path: RepositoryPath,
query: FeedQuery,
headers: HeaderMap,
@@ -277,7 +316,7 @@
let owner = path.owner;
let repository = path.repository;
let (record, mut events) = match web
- .event_page(owner.clone(), repository.clone(), query.before)
+ .event_page(actor.0, owner.clone(), repository.clone(), query.before)
.await
{
Ok(page) => page,
@@ -315,12 +354,13 @@
Ok(body) => body,
Err(_) => return route_error(RouteError::Internal, &request_id.0),
};
- conditional_feed(&headers, name, body, newest)
+ conditional_feed(&headers, name, body, newest, record.visibility == "public")
}
async fn summary(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<RepositoryPath>,
) -> Response {
if let Some(repository) = path.repository.strip_suffix(".git")
@@ -334,28 +374,33 @@
};
let clone_urls = web.clone_urls(&path.owner, &path.repository);
let result = web
- .read(path.owner, path.repository, move |record, service| {
- let cancellation = ReadCancellation::default();
- let references = service.references(&cancellation)?;
- let head = references
- .iter()
- .find(|reference| reference.name == b"HEAD")
- .map(|reference| reference.target);
- let (history, readme) = match head {
- Some(head) => (
- service.history(head, &cancellation)?,
- service.readme(head, &cancellation)?,
- ),
- None => (Vec::new(), None),
- };
- Ok(RepositoryPage::summary(
- record,
- clone_urls,
- head,
- history,
- readme.map(|readme| (readme.path, readme.blob.data)),
- ))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ let cancellation = ReadCancellation::default();
+ let references = service.references(&cancellation)?;
+ let head = references
+ .iter()
+ .find(|reference| reference.name == b"HEAD")
+ .map(|reference| reference.target);
+ let (history, readme) = match head {
+ Some(head) => (
+ service.history(head, &cancellation)?,
+ service.readme(head, &cancellation)?,
+ ),
+ None => (Vec::new(), None),
+ };
+ Ok(RepositoryPage::summary(
+ record,
+ clone_urls,
+ head,
+ history,
+ readme.map(|readme| (readme.path, readme.blob.data)),
+ ))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -363,13 +408,14 @@
async fn refs(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<RepositoryPath>,
) -> Response {
let Some(web) = state.public else {
return route_error(RouteError::NotFound, &request_id.0);
};
let result = web
- .read(path.owner, path.repository, |record, service| {
+ .read(actor.0, path.owner, path.repository, |record, service| {
let cancellation = ReadCancellation::default();
let references = service.references(&cancellation)?;
Ok(RepositoryPage::refs(record, references))
@@ -381,6 +427,7 @@
async fn search(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<RepositoryPath>,
Query(query): Query<SearchQuery>,
) -> Response {
@@ -393,25 +440,30 @@
return route_error(RouteError::InvalidRequest, &request_id.0);
}
let result = web
- .read(path.owner, path.repository, move |record, service| {
- let cancellation = ReadCancellation::default();
- let references = service.references(&cancellation)?;
- let selected = select_search_ref(&references, query.reference.as_deref())?;
- let outcome = match (&query.query, selected.as_ref()) {
- (Some(query), Some((_, commit))) => {
- Some(service.search(*commit, query.as_bytes(), &cancellation)?)
- }
- (Some(_), None) => return Err(RouteError::NotFound),
- (None, _) => None,
- };
- Ok(RepositoryPage::search(
- record,
- references,
- selected,
- query.query.unwrap_or_default(),
- outcome,
- ))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ let cancellation = ReadCancellation::default();
+ let references = service.references(&cancellation)?;
+ let selected = select_search_ref(&references, query.reference.as_deref())?;
+ let outcome = match (&query.query, selected.as_ref()) {
+ (Some(query), Some((_, commit))) => {
+ Some(service.search(*commit, query.as_bytes(), &cancellation)?)
+ }
+ (Some(_), None) => return Err(RouteError::NotFound),
+ (None, _) => None,
+ };
+ Ok(RepositoryPage::search(
+ record,
+ references,
+ selected,
+ query.query.unwrap_or_default(),
+ outcome,
+ ))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -419,6 +471,7 @@
async fn commit(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<CommitPath>,
) -> Response {
let Some(web) = state.public else {
@@ -429,12 +482,17 @@
Err(error) => return route_error(error, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(id, &record)?;
- let cancellation = ReadCancellation::default();
- let commit = service.commit(id, &cancellation)?;
- Ok(RepositoryPage::commit(record, commit))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(id, &record)?;
+ let cancellation = ReadCancellation::default();
+ let commit = service.commit(id, &cancellation)?;
+ Ok(RepositoryPage::commit(record, commit))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -442,6 +500,7 @@
async fn diff(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<DiffPath>,
) -> Response {
let Some(web) = state.public else {
@@ -452,13 +511,18 @@
_ => return route_error(RouteError::NotFound, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(old, &record)?;
- require_id_format(new, &record)?;
- let cancellation = ReadCancellation::default();
- let files = service.diff(old, new, &cancellation)?;
- Ok(RepositoryPage::diff(record, old, new, files))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(old, &record)?;
+ require_id_format(new, &record)?;
+ let cancellation = ReadCancellation::default();
+ let files = service.diff(old, new, &cancellation)?;
+ Ok(RepositoryPage::diff(record, old, new, files))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -466,26 +530,29 @@
async fn tree_root(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<CommitPath>,
) -> Response {
- tree_response(state, request_id, path, Vec::new()).await
+ tree_response(state, request_id, actor, path, Vec::new()).await
}
async fn tree(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
OriginalUri(uri): OriginalUri,
) -> Response {
let (path, git_path) = match content_route(uri.path(), "tree") {
Ok(route) => route,
Err(error) => return route_error(error, &request_id.0),
};
- tree_response(state, request_id, path, git_path).await
+ tree_response(state, request_id, actor, path, git_path).await
}
async fn tree_response(
state: WebState,
request_id: RequestId,
+ actor: RequestActor,
path: CommitPath,
git_path: Vec<u8>,
) -> Response {
@@ -497,12 +564,17 @@
Err(error) => return route_error(error, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(id, &record)?;
- let cancellation = ReadCancellation::default();
- let entries = service.tree(id, &git_path, &cancellation)?;
- Ok(RepositoryPage::tree(record, id, git_path, entries))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(id, &record)?;
+ let cancellation = ReadCancellation::default();
+ let entries = service.tree(id, &git_path, &cancellation)?;
+ Ok(RepositoryPage::tree(record, id, git_path, entries))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -510,6 +582,7 @@
async fn blob(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
OriginalUri(uri): OriginalUri,
) -> Response {
let (path, git_path) = match content_route(uri.path(), "blob") {
@@ -524,12 +597,17 @@
Err(error) => return route_error(error, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(id, &record)?;
- let cancellation = ReadCancellation::default();
- let blob = service.blob(id, &git_path, &cancellation)?;
- Ok(RepositoryPage::blob(record, id, git_path, blob.data))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(id, &record)?;
+ let cancellation = ReadCancellation::default();
+ let blob = service.blob(id, &git_path, &cancellation)?;
+ Ok(RepositoryPage::blob(record, id, git_path, blob.data))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -537,6 +615,7 @@
async fn raw(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
OriginalUri(uri): OriginalUri,
) -> Response {
let (path, git_path) = match content_route(uri.path(), "raw") {
@@ -551,19 +630,31 @@
Err(error) => return route_error(error, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(id, &record)?;
- let cancellation = ReadCancellation::default();
- let mut content = Vec::new();
- service.raw(id, &git_path, &cancellation, &mut content)?;
- Ok(content)
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(id, &record)?;
+ let cancellation = ReadCancellation::default();
+ let mut content = Vec::new();
+ service.raw(id, &git_path, &cancellation, &mut content)?;
+ Ok((record.visibility == "public", content))
+ },
+ )
.await;
match result {
- Ok(content) => Response::builder()
+ Ok((is_public, content)) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
- .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
+ .header(
+ header::CACHE_CONTROL,
+ if is_public {
+ "public, max-age=31536000, immutable"
+ } else {
+ "private, no-store"
+ },
+ )
.body(Body::from(content))
.expect("the raw response is valid"),
Err(error) => route_error(error, &request_id.0),
@@ -573,6 +664,7 @@
async fn blame(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
OriginalUri(uri): OriginalUri,
) -> Response {
let (path, git_path) = match content_route(uri.path(), "blame") {
@@ -587,15 +679,20 @@
Err(error) => return route_error(error, &request_id.0),
};
let result = web
- .read(path.owner, path.repository, move |record, service| {
- require_id_format(id, &record)?;
- let cancellation = ReadCancellation::default();
- let blob = service.blob(id, &git_path, &cancellation)?;
- let hunks = service.blame(id, &git_path, &cancellation)?;
- Ok(RepositoryPage::blame(
- record, id, git_path, blob.data, hunks,
- ))
- })
+ .read(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |record, service| {
+ require_id_format(id, &record)?;
+ let cancellation = ReadCancellation::default();
+ let blob = service.blob(id, &git_path, &cancellation)?;
+ let hunks = service.blame(id, &git_path, &cancellation)?;
+ Ok(RepositoryPage::blame(
+ record, id, git_path, blob.data, hunks,
+ ))
+ },
+ )
.await;
render_page(result, &request_id.0)
}
@@ -603,6 +700,7 @@
async fn archive(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(path): AxumPath<ArchivePath>,
) -> Response {
let Some(commit) = path.archive.strip_suffix(".tar") else {
@@ -615,7 +713,7 @@
let Some(web) = state.public else {
return route_error(RouteError::NotFound, &request_id.0);
};
- let result = web.archive(path.owner, path.repository, id).await;
+ let result = web.archive(actor.0, path.owner, path.repository, id).await;
match result {
Ok(body) => Response::builder()
.status(StatusCode::OK)
@@ -634,6 +732,7 @@
async fn info_refs(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(mut path): AxumPath<RepositoryPath>,
OriginalUri(uri): OriginalUri,
headers: HeaderMap,
@@ -654,11 +753,16 @@
Err(()) => return plain_error(StatusCode::BAD_REQUEST, "Invalid Git protocol version.\n"),
};
let result = web
- .path_job(path.owner, path.repository, move |_record, path| {
- UploadPack::open(&path)
- .and_then(|service| service.advertisement(version, true))
- .map_err(|_| RouteError::Internal)
- })
+ .path_job(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |_record, path| {
+ UploadPack::open(&path)
+ .and_then(|service| service.advertisement(version, true))
+ .map_err(|_| RouteError::Internal)
+ },
+ )
.await;
match result {
Ok(body) => git_response("application/x-git-upload-pack-advertisement", body),
@@ -670,6 +774,7 @@
async fn git_upload_pack(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
AxumPath(mut path): AxumPath<RepositoryPath>,
headers: HeaderMap,
body: Bytes,
@@ -697,11 +802,16 @@
Err(()) => return plain_error(StatusCode::BAD_REQUEST, "Invalid Git protocol version.\n"),
};
let result = web
- .path_job(path.owner, path.repository, move |_record, path| {
- UploadPack::open(&path)
- .and_then(|service| service.respond(version, &body))
- .map_err(|_| RouteError::InvalidRequest)
- })
+ .path_job(
+ actor.0,
+ path.owner,
+ path.repository,
+ move |_record, path| {
+ UploadPack::open(&path)
+ .and_then(|service| service.respond(version, &body))
+ .map_err(|_| RouteError::InvalidRequest)
+ },
+ )
.await;
match result {
Ok(body) => git_response("application/x-git-upload-pack-result", body),
@@ -731,7 +841,13 @@
}
}
-fn conditional_feed(headers: &HeaderMap, name: &str, body: String, timestamp: i64) -> Response {
+fn conditional_feed(
+ headers: &HeaderMap,
+ name: &str,
+ body: String,
+ timestamp: i64,
+ is_public: bool,
+) -> Response {
let digest = Sha256::digest(body.as_bytes());
let etag = format!("\"{}\"", encode_hex(&digest));
let modified = u64::try_from(timestamp)
@@ -761,7 +877,14 @@
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
- .header(header::CACHE_CONTROL, "public, max-age=60")
+ .header(
+ header::CACHE_CONTROL,
+ if is_public {
+ "public, max-age=60"
+ } else {
+ "private, no-store"
+ },
+ )
.header(header::ETAG, etag)
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
.body(if not_modified {
@@ -1501,6 +1624,17 @@
fn from(error: StoreError) -> Self {
match error {
StoreError::RepositoryNotFound(_, _) => Self::NotFound,
+ _ => Self::Internal,
+ }
+ }
+}
+
+impl From<PolicyError> for RouteError {
+ fn from(error: PolicyError) -> Self {
+ match error {
+ PolicyError::Denied | PolicyError::Store(StoreError::RepositoryNotFound(_, _)) => {
+ Self::NotFound
+ }
_ => Self::Internal,
}
}
src/main.rs
Mode 100644 → 100644; object f51455176375 → b2aea38cd3c5
@@ -17,6 +17,7 @@
mod http;
mod instance;
mod markdown;
+mod policy;
mod serve;
mod session;
#[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
@@ -29,7 +30,8 @@
use clap::Parser;
use crate::cli::{
- AccountCommand, AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand,
+ AccountCommand, AdminCommand, Cli, CollaboratorRole, Command, ObjectFormat, RepositoryCommand,
+ RepositoryVisibility, SetupCommand,
};
#[tokio::main]
@@ -190,6 +192,40 @@
RepositoryCommand::Archive { owner, slug } => {
admin::archive_repository(instance_dir, &owner, &slug)
}
+ RepositoryCommand::Visibility {
+ owner,
+ slug,
+ visibility,
+ } => admin::set_repository_visibility(
+ instance_dir,
+ &owner,
+ &slug,
+ match visibility {
+ RepositoryVisibility::Public => "public",
+ RepositoryVisibility::Private => "private",
+ },
+ ),
+ RepositoryCommand::CollaboratorSet {
+ owner,
+ slug,
+ username,
+ role,
+ } => admin::set_repository_collaborator(
+ instance_dir,
+ &owner,
+ &slug,
+ &username,
+ match role {
+ CollaboratorRole::Maintainer => "maintainer",
+ CollaboratorRole::Writer => "writer",
+ CollaboratorRole::Reader => "reader",
+ },
+ ),
+ RepositoryCommand::CollaboratorRemove {
+ owner,
+ slug,
+ username,
+ } => admin::remove_repository_collaborator(instance_dir, &owner, &slug, &username),
RepositoryCommand::Inspect { owner, slug } => {
admin::inspect_repository(instance_dir, &owner, &slug)
}
src/policy.rs
Mode → 100644; object → 97e32100440b
@@ -1,0 +1,114 @@
+use std::path::{Path, PathBuf};
+
+use thiserror::Error;
+
+use crate::store::{RepositoryAuthorizationRecord, RepositoryRecord, Store, StoreError};
+
+#[derive(Clone)]
+pub(crate) struct RepositoryPolicy {
+ database: PathBuf,
+}
+
+impl RepositoryPolicy {
+ pub(crate) fn new(database: &Path) -> Self {
+ Self {
+ database: database.to_owned(),
+ }
+ }
+
+ pub(crate) fn authorize(
+ &self,
+ actor: Option<&str>,
+ owner: &str,
+ repository: &str,
+ operation: RepositoryOperation,
+ ) -> Result<RepositoryRecord, PolicyError> {
+ let record =
+ Store::open(&self.database)?.repository_authorization(owner, repository, actor)?;
+ if allows(&record, operation)? {
+ Ok(record.repository)
+ } else {
+ Err(PolicyError::Denied)
+ }
+ }
+
+ pub(crate) fn public_repositories(&self) -> Result<Vec<RepositoryRecord>, PolicyError> {
+ Store::open(&self.database)?
+ .active_repositories()?
+ .into_iter()
+ .filter_map(|repository| {
+ let record = RepositoryAuthorizationRecord {
+ repository,
+ role: None,
+ };
+ match allows(&record, RepositoryOperation::Read) {
+ Ok(true) => Some(Ok(record.repository)),
+ Ok(false) => None,
+ Err(error) => Some(Err(error)),
+ }
+ })
+ .collect()
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[allow(
+ dead_code,
+ reason = "subsequent transports use the complete repository operation matrix"
+)]
+pub(crate) enum RepositoryOperation {
+ Read,
+ Write,
+ Maintain,
+ Own,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum RepositoryRole {
+ Owner,
+ Maintainer,
+ Writer,
+ Reader,
+}
+
+fn allows(
+ record: &RepositoryAuthorizationRecord,
+ operation: RepositoryOperation,
+) -> Result<bool, PolicyError> {
+ if record.repository.state != "active" {
+ return Ok(false);
+ }
+ let role = record.role.as_deref().map(parse_role).transpose()?;
+ match operation {
+ RepositoryOperation::Read => Ok(record.repository.visibility == "public" || role.is_some()),
+ RepositoryOperation::Write => Ok(matches!(
+ role,
+ Some(RepositoryRole::Owner | RepositoryRole::Maintainer | RepositoryRole::Writer)
+ )),
+ RepositoryOperation::Maintain => Ok(matches!(
+ role,
+ Some(RepositoryRole::Owner | RepositoryRole::Maintainer)
+ )),
+ RepositoryOperation::Own => Ok(role == Some(RepositoryRole::Owner)),
+ }
+}
+
+fn parse_role(role: &str) -> Result<RepositoryRole, PolicyError> {
+ match role {
+ "owner" => Ok(RepositoryRole::Owner),
+ "maintainer" => Ok(RepositoryRole::Maintainer),
+ "writer" => Ok(RepositoryRole::Writer),
+ "reader" => Ok(RepositoryRole::Reader),
+ _ => Err(PolicyError::InvalidRole),
+ }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum PolicyError {
+ #[error(transparent)]
+ Store(#[from] StoreError),
+ #[error("repository access is not authorized")]
+ Denied,
+ #[error("stored repository role is not valid")]
+ InvalidRole,
+}
src/serve.rs
Mode 100644 → 100644; object 5103d1c4a61e → 953394cc0c60
@@ -14,6 +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::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
use crate::store::{Store, StoreError};
@@ -27,8 +28,9 @@
.into_iter()
.map(|key| SshPublicKey::parse(&key))
.collect::<Result<Vec<_>, _>>()?;
- let repositories = store
- .active_public_repositories()?
+ 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)?;
@@ -203,6 +205,8 @@
Instance(#[from] InstanceError),
#[error(transparent)]
Store(#[from] StoreError),
+ #[error(transparent)]
+ Policy(#[from] PolicyError),
#[error(transparent)]
Authentication(#[from] AuthError),
#[error(transparent)]
src/store/migrations/009_repository_authorization.sql
Mode → 100644; object → 09a7130896d8
@@ -1,0 +1,13 @@
+CREATE TABLE repository_collaborator (
+ repository_id TEXT NOT NULL
+ REFERENCES repository (id) ON DELETE RESTRICT,
+ account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ role TEXT NOT NULL
+ CHECK (role IN ('maintainer', 'writer', 'reader')),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ PRIMARY KEY (repository_id, account_id)
+) STRICT;
+
+CREATE INDEX repository_collaborator_account
+ON repository_collaborator (account_id, repository_id);
src/store/mod.rs
Mode 100644 → 100644; object b6b32acbd397 → f925e69097df
@@ -9,7 +9,7 @@
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 8;
+const SCHEMA_VERSION: i64 = 9;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -19,7 +19,7 @@
dead_code,
reason = "M1A proves migrations before the M2 server calls them"
)]
-const MIGRATIONS: [&str; 8] = [
+const MIGRATIONS: [&str; 9] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
@@ -28,6 +28,7 @@
include_str!("migrations/006_repository_events.sql"),
include_str!("migrations/007_account_lifecycle.sql"),
include_str!("migrations/008_web_sessions.sql"),
+ include_str!("migrations/009_repository_authorization.sql"),
];
#[allow(
@@ -92,6 +93,14 @@
RepositoryIdentifierCollision,
#[error("repository is already archived: {0}/{1}")]
RepositoryArchived(String, String),
+ #[error("repository visibility is not valid")]
+ InvalidRepositoryVisibility,
+ #[error("collaborator role is not valid")]
+ InvalidCollaboratorRole,
+ #[error("repository owner cannot be a collaborator")]
+ OwnerCollaborator,
+ #[error("collaborator account does not exist or is not active: {0}")]
+ CollaboratorNotFound(String),
#[allow(
dead_code,
reason = "some integration tests import storage without public event pages"
@@ -907,6 +916,137 @@
Ok(())
}
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without admin commands"
+ )]
+ pub(crate) fn set_repository_visibility(
+ &mut self,
+ owner: &str,
+ slug: &str,
+ visibility: &str,
+ ) -> Result<(), StoreError> {
+ if !matches!(visibility, "public" | "private") {
+ return Err(StoreError::InvalidRepositoryVisibility);
+ }
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let owner_id = active_account_id(&transaction, owner)?;
+ let changed = transaction.execute(
+ "UPDATE repository SET visibility = ?3
+ WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+ rusqlite::params![owner_id, slug, visibility],
+ )?;
+ if changed == 0 {
+ return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
+ }
+ transaction.commit()?;
+ Ok(())
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without admin commands"
+ )]
+ pub(crate) fn set_repository_collaborator(
+ &mut self,
+ owner: &str,
+ slug: &str,
+ username: &str,
+ role: &str,
+ created_at: i64,
+ ) -> Result<(), StoreError> {
+ if !matches!(role, "maintainer" | "writer" | "reader") {
+ return Err(StoreError::InvalidCollaboratorRole);
+ }
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let owner_id = active_account_id(&transaction, owner)?;
+ let repository_id: Option<String> = transaction
+ .query_row(
+ "SELECT id FROM repository
+ WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+ rusqlite::params![owner_id, slug],
+ |row| row.get(0),
+ )
+ .optional()?;
+ let Some(repository_id) = repository_id else {
+ return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
+ };
+ let collaborator_id = match active_account_id(&transaction, username) {
+ Ok(account_id) => account_id,
+ Err(StoreError::AccountNotFound(_)) => {
+ return Err(StoreError::CollaboratorNotFound(username.to_owned()));
+ }
+ Err(error) => return Err(error),
+ };
+ if collaborator_id == owner_id {
+ return Err(StoreError::OwnerCollaborator);
+ }
+ transaction.execute(
+ "INSERT INTO repository_collaborator
+ (repository_id, account_id, role, created_at)
+ VALUES (?1, ?2, ?3, ?4)
+ ON CONFLICT (repository_id, account_id)
+ DO UPDATE SET role = excluded.role",
+ rusqlite::params![repository_id, collaborator_id, role, created_at],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without admin commands"
+ )]
+ pub(crate) fn remove_repository_collaborator(
+ &mut self,
+ owner: &str,
+ slug: &str,
+ username: &str,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let owner_id = active_account_id(&transaction, owner)?;
+ let repository_id: Option<String> = transaction
+ .query_row(
+ "SELECT id FROM repository
+ WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+ rusqlite::params![owner_id, slug],
+ |row| row.get(0),
+ )
+ .optional()?;
+ let Some(repository_id) = repository_id else {
+ return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
+ };
+ let collaborator_id: Option<i64> = transaction
+ .query_row(
+ "SELECT id FROM account WHERE username = ?1",
+ [username],
+ |row| row.get(0),
+ )
+ .optional()?;
+ let Some(collaborator_id) = collaborator_id else {
+ return Err(StoreError::CollaboratorNotFound(username.to_owned()));
+ };
+ if collaborator_id == owner_id {
+ return Err(StoreError::OwnerCollaborator);
+ }
+ let changed = transaction.execute(
+ "DELETE FROM repository_collaborator
+ WHERE repository_id = ?1 AND account_id = ?2",
+ rusqlite::params![repository_id, collaborator_id],
+ )?;
+ if changed == 0 {
+ return Err(StoreError::CollaboratorNotFound(username.to_owned()));
+ }
+ transaction.commit()?;
+ Ok(())
+ }
+
pub(crate) fn repository(
&self,
owner: &str,
@@ -930,6 +1070,70 @@
)),
Err(error) => Err(error.into()),
}
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without authorization"
+ )]
+ pub(crate) fn repository_authorization(
+ &self,
+ owner: &str,
+ slug: &str,
+ username: Option<&str>,
+ ) -> Result<RepositoryAuthorizationRecord, StoreError> {
+ let result = self.connection.query_row(
+ "SELECT repository.id, owner.username, repository.slug,
+ repository.visibility, repository.state, repository.object_format,
+ repository.created_at, repository.archived_at,
+ CASE
+ WHEN actor.state != 'active' THEN NULL
+ WHEN actor.id = repository.owner_account_id THEN 'owner'
+ ELSE repository_collaborator.role
+ END
+ FROM repository
+ JOIN account AS owner ON owner.id = repository.owner_account_id
+ LEFT JOIN account AS actor ON actor.username = ?3
+ LEFT JOIN repository_collaborator
+ ON repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = actor.id
+ WHERE owner.username = ?1 AND repository.slug = ?2",
+ rusqlite::params![owner, slug, username],
+ |row| {
+ Ok(RepositoryAuthorizationRecord {
+ repository: repository_from_row(row)?,
+ role: row.get(8)?,
+ })
+ },
+ );
+ match result {
+ Ok(record) => Ok(record),
+ Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::RepositoryNotFound(
+ owner.to_owned(),
+ slug.to_owned(),
+ )),
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without authorization"
+ )]
+ pub(crate) fn active_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
+ let mut statement = self.connection.prepare(
+ "SELECT repository.id, account.username, repository.slug,
+ repository.visibility, repository.state, repository.object_format,
+ repository.created_at, repository.archived_at
+ FROM repository
+ JOIN account ON account.id = repository.owner_account_id
+ WHERE repository.state = 'active'
+ ORDER BY account.username, repository.slug",
+ )?;
+ statement
+ .query_map([], repository_from_row)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
}
#[allow(
@@ -1012,6 +1216,30 @@
limit: usize,
) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
let repository = self.public_repository(owner, slug)?;
+ self.repository_events_for(repository, before, limit)
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests use only public event queries"
+ )]
+ pub(crate) fn repository_events(
+ &self,
+ owner: &str,
+ slug: &str,
+ before: Option<i64>,
+ limit: usize,
+ ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
+ let repository = self.repository(owner, slug)?;
+ self.repository_events_for(repository, before, limit)
+ }
+
+ fn repository_events_for(
+ &self,
+ repository: RepositoryRecord,
+ before: Option<i64>,
+ limit: usize,
+ ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
let mut statement = self.connection.prepare(
"SELECT id, kind, actor, ref_name, old_target, new_target, created_at
@@ -1161,6 +1389,15 @@
pub(crate) object_format: String,
pub(crate) created_at: i64,
pub(crate) archived_at: Option<i64>,
+}
+
+#[allow(
+ dead_code,
+ reason = "some integration tests compile storage without authorization"
+)]
+pub(crate) struct RepositoryAuthorizationRecord {
+ pub(crate) repository: RepositoryRecord,
+ pub(crate) role: Option<String>,
}
#[allow(
tests/cli.rs
Mode 100644 → 100644; object b4f6b5d4fab8 → b7b85a5486c8
@@ -12,12 +12,13 @@
};
use tempfile::TempDir;
-const V8_DATABASE: &str = concat!(
+const V9_DATABASE: &str = concat!(
include_str!("fixtures/sqlite/v5.sql"),
include_str!("../src/store/migrations/006_repository_events.sql"),
include_str!("../src/store/migrations/007_account_lifecycle.sql"),
include_str!("../src/store/migrations/008_web_sessions.sql"),
- "PRAGMA user_version = 8;\n",
+ include_str!("../src/store/migrations/009_repository_authorization.sql"),
+ "PRAGMA user_version = 9;\n",
);
#[test]
@@ -73,7 +74,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V8_DATABASE)
+ .execute_batch(V9_DATABASE)
.expect("create the current database");
drop(database);
@@ -125,7 +126,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V8_DATABASE)
+ .execute_batch(V9_DATABASE)
.expect("create the current database");
database
.pragma_update(None, "foreign_keys", false)
@@ -489,6 +490,97 @@
"project",
]);
assert_eq!(missing_owner.status.code(), Some(1));
+}
+
+#[test]
+fn administers_repository_visibility_and_collaborators() {
+ let instance = TestInstance::new();
+ create_administrator(&instance, "alice");
+ let config = instance.config().to_str().expect("a UTF-8 path");
+ let created = instance.run(&[
+ "--config",
+ config,
+ "admin",
+ "repository",
+ "create",
+ "alice",
+ "project",
+ ]);
+ assert!(created.status.success());
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the repository database");
+ database
+ .execute(
+ "INSERT INTO account (username, is_administrator, state, created_at)
+ VALUES ('bob', 0, 'active', 1)",
+ [],
+ )
+ .expect("create a collaborator account");
+ drop(database);
+
+ let visibility = instance.run(&[
+ "--config",
+ config,
+ "admin",
+ "repository",
+ "visibility",
+ "alice",
+ "project",
+ "private",
+ ]);
+ assert!(visibility.status.success());
+ assert_eq!(
+ repository_output(&visibility.stdout)
+ .get("visibility")
+ .map(String::as_str),
+ Some("private")
+ );
+
+ let collaborator = instance.run(&[
+ "--config",
+ config,
+ "admin",
+ "repository",
+ "collaborator-set",
+ "alice",
+ "project",
+ "bob",
+ "reader",
+ ]);
+ assert!(collaborator.status.success());
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the repository database");
+ let role: String = database
+ .query_row(
+ "SELECT role FROM repository_collaborator
+ JOIN account ON account.id = repository_collaborator.account_id
+ WHERE account.username = 'bob'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read the collaborator role");
+ assert_eq!(role, "reader");
+ drop(database);
+
+ let removed = instance.run(&[
+ "--config",
+ config,
+ "admin",
+ "repository",
+ "collaborator-remove",
+ "alice",
+ "project",
+ "bob",
+ ]);
+ assert!(removed.status.success());
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the repository database");
+ let collaborators: i64 = database
+ .query_row("SELECT count(*) FROM repository_collaborator", [], |row| {
+ row.get(0)
+ })
+ .expect("count repository collaborators");
+ assert_eq!(collaborators, 0);
}
#[test]
tests/public_routes.rs
Mode 100644 → 100644; object 6c0ff2e83fe8 → e070864333e2
@@ -34,6 +34,9 @@ mod instance; #[path = "../src/markdown.rs"] mod markdown; +#[allow(dead_code, reason = "the public-route test uses anonymous policy only")] +#[path = "../src/policy.rs"] +mod policy; #[allow(dead_code, reason = "the public-route test does not complete a login")] #[path = "../src/session.rs"] mod session;
tests/repository_policy.rs
Mode → 100644; object → 7785dbed7230
@@ -1,0 +1,180 @@
+#[path = "../src/policy.rs"]
+mod policy;
+#[allow(dead_code, reason = "the policy test uses only repository storage")]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use policy::{PolicyError, RepositoryOperation, RepositoryPolicy};
+use store::{NewRepository, RepositoryOrigin, Store, StoreError};
+use tempfile::TempDir;
+
+#[test]
+fn enforces_the_repository_role_matrix() {
+ let directory = TempDir::new().expect("create a policy fixture directory");
+ let database = directory.path().join("tit.sqlite3");
+ let mut store = Store::open(&database).expect("create the policy database");
+ for (id, username, state) in [
+ (1, "owner", "active"),
+ (2, "maintainer", "active"),
+ (3, "writer", "active"),
+ (4, "reader", "active"),
+ (5, "stranger", "active"),
+ (6, "suspended", "active"),
+ ] {
+ store
+ .connection()
+ .execute(
+ "INSERT INTO account (id, username, is_administrator, state, created_at)
+ VALUES (?1, ?2, 0, ?3, 1)",
+ rusqlite::params![id, username, state],
+ )
+ .expect("create a policy account");
+ }
+ store
+ .create_repository(&NewRepository {
+ id: "0123456789abcdef0123456789abcdef",
+ owner: "owner",
+ slug: "project",
+ object_format: "sha1",
+ created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ })
+ .expect("create a policy repository");
+ for (username, role) in [
+ ("maintainer", "maintainer"),
+ ("writer", "writer"),
+ ("reader", "reader"),
+ ("suspended", "writer"),
+ ] {
+ store
+ .set_repository_collaborator("owner", "project", username, role, 3)
+ .expect("set a collaborator role");
+ }
+ store
+ .suspend_account("suspended", true, 4)
+ .expect("suspend a collaborator");
+
+ let policy = RepositoryPolicy::new(&database);
+ assert_allowed(&policy, None, RepositoryOperation::Read);
+ assert_denied(&policy, None, RepositoryOperation::Write);
+ store
+ .set_repository_visibility("owner", "project", "private")
+ .expect("make the repository private");
+
+ for operation in operations() {
+ assert_allowed(&policy, Some("owner"), operation);
+ }
+ assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Read);
+ assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Write);
+ assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Maintain);
+ assert_denied(&policy, Some("maintainer"), RepositoryOperation::Own);
+ assert_allowed(&policy, Some("writer"), RepositoryOperation::Read);
+ assert_allowed(&policy, Some("writer"), RepositoryOperation::Write);
+ assert_denied(&policy, Some("writer"), RepositoryOperation::Maintain);
+ assert_denied(&policy, Some("writer"), RepositoryOperation::Own);
+ assert_allowed(&policy, Some("reader"), RepositoryOperation::Read);
+ for operation in [
+ RepositoryOperation::Write,
+ RepositoryOperation::Maintain,
+ RepositoryOperation::Own,
+ ] {
+ assert_denied(&policy, Some("reader"), operation);
+ }
+ for actor in [None, Some("stranger"), Some("suspended"), Some("missing")] {
+ for operation in operations() {
+ assert_denied(&policy, actor, operation);
+ }
+ }
+}
+
+#[test]
+fn applies_role_visibility_and_archive_changes_immediately() {
+ let directory = TempDir::new().expect("create a policy fixture directory");
+ let database = directory.path().join("tit.sqlite3");
+ let mut store = Store::open(&database).expect("create the policy database");
+ for (id, username) in [(1, "owner"), (2, "member")] {
+ store
+ .connection()
+ .execute(
+ "INSERT INTO account (id, username, is_administrator, state, created_at)
+ VALUES (?1, ?2, 0, 'active', 1)",
+ rusqlite::params![id, username],
+ )
+ .expect("create a policy account");
+ }
+ store
+ .create_repository(&NewRepository {
+ id: "fedcba9876543210fedcba9876543210",
+ owner: "owner",
+ slug: "project",
+ object_format: "sha1",
+ created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ })
+ .expect("create a policy repository");
+ let policy = RepositoryPolicy::new(&database);
+ assert_eq!(
+ policy
+ .public_repositories()
+ .expect("list repositories")
+ .len(),
+ 1
+ );
+
+ store
+ .set_repository_visibility("owner", "project", "private")
+ .expect("make the repository private");
+ assert!(
+ policy
+ .public_repositories()
+ .expect("list repositories")
+ .is_empty()
+ );
+ store
+ .set_repository_collaborator("owner", "project", "member", "writer", 3)
+ .expect("add a writer");
+ assert_allowed(&policy, Some("member"), RepositoryOperation::Write);
+ store
+ .set_repository_collaborator("owner", "project", "member", "reader", 4)
+ .expect("change the role");
+ assert_denied(&policy, Some("member"), RepositoryOperation::Write);
+ assert_allowed(&policy, Some("member"), RepositoryOperation::Read);
+ store
+ .remove_repository_collaborator("owner", "project", "member")
+ .expect("remove the collaborator");
+ assert_denied(&policy, Some("member"), RepositoryOperation::Read);
+ assert!(matches!(
+ store.set_repository_collaborator("owner", "project", "owner", "reader", 5),
+ Err(StoreError::OwnerCollaborator)
+ ));
+ store
+ .archive_repository("owner", "project", 6)
+ .expect("archive the repository");
+ for operation in operations() {
+ assert_denied(&policy, Some("owner"), operation);
+ }
+}
+
+fn operations() -> [RepositoryOperation; 4] {
+ [
+ RepositoryOperation::Read,
+ RepositoryOperation::Write,
+ RepositoryOperation::Maintain,
+ RepositoryOperation::Own,
+ ]
+}
+
+fn assert_allowed(policy: &RepositoryPolicy, actor: Option<&str>, operation: RepositoryOperation) {
+ policy
+ .authorize(actor, "owner", "project", operation)
+ .expect("authorize the repository operation");
+}
+
+fn assert_denied(policy: &RepositoryPolicy, actor: Option<&str>, operation: RepositoryOperation) {
+ assert!(matches!(
+ policy.authorize(actor, "owner", "project", operation),
+ Err(PolicyError::Denied)
+ ));
+}
tests/serve.rs
Mode 100644 → 100644; object 6ef6e9ee3c7a → aa66c033d697
@@ -173,6 +173,33 @@
&[("Cookie", &upload_csrf_cookies)],
);
assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
+ let private_cookies = response_cookies(&uploaded);
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the repository database");
+ database
+ .execute(
+ "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
+ [],
+ )
+ .expect("make the repository private");
+ assert!(http_get(http, "/alice/example").starts_with("HTTP/1.1 404"));
+ let private_summary =
+ http_get_with_headers(http, "/alice/example", &[("Cookie", &private_cookies)]);
+ assert!(private_summary.starts_with("HTTP/1.1 200"));
+ let private_feed = http_get_with_headers(
+ http,
+ "/alice/example/atom.xml",
+ &[("Cookie", &private_cookies)],
+ );
+ assert!(private_feed.starts_with("HTTP/1.1 200"));
+ assert!(private_feed.contains("cache-control: private, no-store"));
+ database
+ .execute(
+ "UPDATE repository SET visibility = 'public' WHERE slug = 'example'",
+ [],
+ )
+ .expect("make the repository public");
+ drop(database);
let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
.args([
@@ -317,6 +344,86 @@
host_key
);
restarted.terminate();
+}
+
+#[test]
+fn hides_a_private_repository_from_http_and_ssh_discovery() {
+ 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 private_key = instance.path().join("administrator");
+ create_ssh_key_fixture(&private_key);
+ let public_key = fs::read_to_string(private_key.with_extension("pub"))
+ .expect("read the administrator public key");
+ let config_text = config.to_str().expect("a UTF-8 configuration path");
+ command(
+ instance.path(),
+ [
+ "--config",
+ config_text,
+ "setup",
+ "admin",
+ "alice",
+ public_key.trim(),
+ ],
+ );
+ let source = create_source_repository(instance.path());
+ command(
+ instance.path(),
+ [
+ "--config",
+ config_text,
+ "admin",
+ "repository",
+ "import",
+ "alice",
+ "private",
+ source.to_str().expect("a UTF-8 source path"),
+ ],
+ );
+ command(
+ instance.path(),
+ [
+ "--config",
+ config_text,
+ "admin",
+ "repository",
+ "visibility",
+ "alice",
+ "private",
+ "private",
+ ],
+ );
+
+ let mut server = spawn_server(&config);
+ wait_for_listener(http, &mut server);
+ wait_for_listener(ssh, &mut server);
+ let discovery = http_get(http, "/alice/private.git/info/refs?service=git-upload-pack");
+ assert!(discovery.starts_with("HTTP/1.1 404"), "{discovery}");
+
+ let ssh_command = format!(
+ "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+ private_key.display()
+ );
+ let ssh_discovery = Command::new("git")
+ .args([
+ "ls-remote",
+ &format!("ssh://ignored@127.0.0.1:{}/alice/private.git", ssh.port()),
+ ])
+ .env("GIT_SSH_COMMAND", ssh_command)
+ .output()
+ .expect("query the private repository through SSH");
+ assert!(!ssh_discovery.status.success());
+ server.terminate();
}
fn spawn_server(config: &Path) -> ChildGuard {
tests/sqlite.rs
Mode 100644 → 100644; object 92695311f3d3 → 96fa80994ea6
@@ -22,6 +22,11 @@
const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
const V6_FIXTURE: &str = include_str!("fixtures/sqlite/v6.sql");
const V7_FIXTURE: &str = include_str!("fixtures/sqlite/v7.sql");
+const V8_FIXTURE: &str = concat!(
+ include_str!("fixtures/sqlite/v7.sql"),
+ include_str!("../src/store/migrations/008_web_sessions.sql"),
+ "PRAGMA user_version = 8;\n",
+);
fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
directory.path().join(name)
@@ -139,7 +144,7 @@
let directory = TempDir::new().expect("create a temporary directory");
let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
- assert_eq!(store.schema_version().expect("read the schema version"), 8);
+ assert_eq!(store.schema_version().expect("read the schema version"), 9);
assert_eq!(
store
.connection()
@@ -776,13 +781,14 @@
(V5_FIXTURE, 5),
(V6_FIXTURE, 6),
(V7_FIXTURE, 7),
+ (V8_FIXTURE, 8),
] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "tit.sqlite3");
create_fixture(&path, fixture);
let store = Store::open(&path).expect("migrate the fixture");
- assert_eq!(store.schema_version().expect("read the schema version"), 8);
+ assert_eq!(store.schema_version().expect("read the schema version"), 9);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -848,7 +854,7 @@
#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 8)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 9)] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "fixture.sqlite");
create_fixture(&path, V1_FIXTURE);
tests/web_shell.rs
Mode 100644 → 100644; object a4e4e6a8fc55 → 35e4a9a350f2
@@ -32,6 +32,9 @@ mod instance; #[path = "../src/markdown.rs"] mod markdown; +#[allow(dead_code, reason = "the Web shell test has no repository catalog")] +#[path = "../src/policy.rs"] +mod policy; #[allow(dead_code, reason = "the Web shell test does not complete a login")] #[path = "../src/session.rs"] mod session;