michal/tit

Browse tree · Show commit · Download archive

Diff

ccaae751b642ba7a9a1a87ff

.github/workflows/ci.yml

Mode 100644100644; object 3816cc016a90a4827919afa5

@@ -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 100644100644; object 39797a0677f57713f1f083a2

@@ -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 100644100644; object 4268bac3cf24fd27875c1dbb

@@ -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 100644100644; object dd4807fee74bd8508db5202c

@@ -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 100644100644; object 3112a015310704dd1dfc20c4

@@ -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 100644100644; object 86437b648d06c888b44334b8

@@ -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 100644100644; object f51455176375b2aea38cd3c5

@@ -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 100644100644; object 5103d1c4a61e953394cc0c60

@@ -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 100644100644; object b6b32acbd397f925e69097df

@@ -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 100644100644; object b4f6b5d4fab8b7b85a5486c8

@@ -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 100644100644; object 6c0ff2e83fe8e070864333e2

@@ -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 100644100644; object 6ef6e9ee3c7aaa66c033d697

@@ -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 100644100644; object 92695311f3d396fa80994ea6

@@ -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 100644100644; object a4e4e6a8fc5535e4a9a350f2

@@ -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;