michal/tit

Browse tree · Show commit · Download archive

Diff

7324ba4a356621129e04cd2c

README.md

Mode 100644100644; object 4cac34e9c07e438e26dcbc79

@@ -59,6 +59,16 @@
 writer can push branches and tags. A reader cannot push. HTTP Git access stays
 read-only.
 
+Stop the server and show the newest audit events with this command:
+
+```text
+tit --config /srv/tit/config.toml admin audit --limit 100
+```
+
+Each event shows its action, actor, target, outcome, time, and correlation ID.
+The history does not store recovery credentials, login challenges, signatures,
+session tokens, or SSH private keys.
+
 ## Quality gate
 
 Install `cargo-deny` version 0.20.2. Then, run this command from the repository
@@ -216,3 +226,17 @@
 account suspension, role removal, push permission, and ref policy. Read the
 [authenticated Git architectural decision record](docs/adr/0013-authenticated-git.md)
 for the service and ref-update checks.
+
+## Milestone 3.5 gate
+
+Run the audit history gate:
+
+```text
+./scripts/check-m3-5
+```
+
+This command tests successful and failed account, login, repository,
+collaborator, and ref audit events. It also tests correlation IDs and secret
+exclusion. Read the
+[audit history architectural decision record](docs/adr/0014-audit-history.md)
+for the transaction and recovery rules.

docs/adr/0014-audit-history.md

Mode 100644; object 26ae16622711

@@ -1,0 +1,79 @@
+# Architectural decision record 0014: Audit history
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+Account and repository changes need a durable security history. An operator
+must be able to relate a Web result to its audit event. A successful mutation
+must not exist without its success event. A failed mutation must not change its
+target state.
+
+The audit history must not become a second store for credentials or login
+data.
+
+## Decision
+
+Store audit events in the SQLite `audit_event` table. Each row has an action,
+actor, target, outcome, creation time, and correlation ID. The outcome is
+`success` or `failure`. Keep the target as a bounded identifier. Do not put a
+request body or error text in the row.
+
+Insert a success audit event in the same transaction as its account, session,
+repository, collaborator, or ref mutation. If the mutation transaction fails,
+roll it back and insert a failure audit event in a new transaction. If the
+failure event cannot be stored, return the audit storage error. Do not report
+the mutation as complete.
+
+Use the HTTP request ID as the correlation ID for Web login, signup, and
+recovery. Generate a random correlation ID for each offline administrator
+mutation. Use the durable Git operation ID for a ref update. This rule lets an
+operator relate a Web response, an administrator operation, or a Git push to
+one audit history item.
+
+For a Git push, insert the success audit event when the durable Git operation
+becomes complete. Record a failed receive operation after protocol, pack,
+object, or ref validation rejects it. If restart recovery finds that refs were
+changed, it completes the operation and keeps only the success event. If refs
+were not changed, the server can record the failure event.
+
+The offline `tit admin audit` command shows as many as 1,000 recent events in
+reverse creation order. The default limit is 100. The command uses the instance
+lock and does not change the audit history.
+
+## Failure and threat cases
+
+The schema limits the length of each text field and permits only the two outcome
+values. An index supports the newest-event query. A second index supports a
+correlation-ID query. The first command does not expose a filter because the
+bounded newest-event query is sufficient for this milestone.
+
+Do not store invite codes, recovery credentials, login challenges, signatures,
+session tokens, CSRF tokens, SSH private keys, or HTTP request bodies. A failed
+login stores only a valid username or the value `invalid-account`. A key audit
+target can contain a public-key fingerprint because the fingerprint is an
+identifier and is not a credential.
+
+The audit history is append-only through the application interface. This
+milestone does not add an audit deletion or update command.
+
+## Evidence
+
+The schema migration tests migrate each historical fixture and test a killed
+migration. The storage tests verify bounded history order and successful Git
+and repository events. Account tests verify successful and failed key and
+recovery events.
+
+The production server tests verify Web request correlation, successful and
+failed login events, recovery events, successful and rejected ref updates, and
+secret exclusion. The administrator CLI test verifies successful and failed
+repository and collaborator events through `tit admin audit`.
+
+## Consequences
+
+Security mutations add one SQLite row. Failed attempts add a separate short
+transaction. The audit table grows until a subsequent operations milestone
+defines backup retention and storage policy. No automatic deletion occurs in
+this milestone.

scripts/check-m3-5

Mode 100755; object 6e83fa305881

@@ -1,0 +1,9 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test account_lifecycle
+cargo test --locked --release --test web_session
+cargo test --locked --release --test cli administers_repository_visibility_and_collaborators
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
+cargo test --locked --release --test serve enforces_account_roles_and_ref_policy_through_the_production_ssh_server

src/account.rs

Mode 100644100644; object 10bf2111644e6a886e646682

@@ -6,7 +6,7 @@
 use thiserror::Error;
 
 use crate::auth::{AuthError, SshPublicKey, validate_username};
-use crate::store::{AccountRecovery, InvitedAccount, NewSshKey, Store, StoreError};
+use crate::store::{AccountRecovery, InvitedAccount, NewAuditEvent, NewSshKey, Store, StoreError};
 
 const INVITATION_PREFIX: &str = "tit-invite-v1:";
 const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
@@ -38,19 +38,32 @@
         invitation: &str,
         username: &str,
         public_key: &str,
+        correlation_id: &str,
     ) -> Result<String, AccountError> {
         validate_username(username)?;
         require_secret(invitation, INVITATION_PREFIX)?;
         let key = SshPublicKey::parse(public_key)?;
         let recovery = random_secret(RECOVERY_PREFIX)?;
+        let created_at = now()?;
         let mut store = Store::open(&self.database)?;
-        store.create_account_with_invitation(&InvitedAccount {
+        let result = store.create_account_with_invitation(&InvitedAccount {
             invitation_hash: &hash(invitation),
             username,
             key: new_key(&key, "initial"),
             recovery_hash: &hash(&recovery),
-            created_at: now()?,
-        })?;
+            created_at,
+            correlation_id,
+        });
+        if let Err(error) = result {
+            self.audit_failure(
+                "account.signup",
+                username,
+                username,
+                correlation_id,
+                created_at,
+            )?;
+            return Err(error.into());
+        }
         Ok(recovery)
     }
 
@@ -59,19 +72,32 @@
         username: &str,
         recovery: &str,
         public_key: &str,
+        correlation_id: &str,
     ) -> Result<String, AccountError> {
         validate_username(username)?;
         require_secret(recovery, RECOVERY_PREFIX)?;
         let key = SshPublicKey::parse(public_key)?;
         let replacement = random_secret(RECOVERY_PREFIX)?;
+        let created_at = now()?;
         let mut store = Store::open(&self.database)?;
-        store.recover_account(&AccountRecovery {
+        let result = store.recover_account(&AccountRecovery {
             username,
             old_recovery_hash: &hash(recovery),
             key: new_key(&key, "recovery"),
             new_recovery_hash: &hash(&replacement),
-            created_at: now()?,
-        })?;
+            created_at,
+            correlation_id,
+        });
+        if let Err(error) = result {
+            self.audit_failure(
+                "account.recover",
+                username,
+                username,
+                correlation_id,
+                created_at,
+            )?;
+            return Err(error.into());
+        }
         Ok(replacement)
     }
 
@@ -80,23 +106,94 @@
         username: &str,
         label: &str,
         public_key: &str,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<String, AccountError> {
         validate_username(username)?;
         validate_label(label)?;
         let key = SshPublicKey::parse(public_key)?;
-        Store::open(&self.database)?.add_account_key(username, &new_key(&key, label), now()?)?;
+        let created_at = now()?;
+        let mut store = Store::open(&self.database)?;
+        if let Err(error) = store.add_account_key(
+            username,
+            &new_key(&key, label),
+            created_at,
+            actor,
+            correlation_id,
+        ) {
+            let target = format!("{username}:{}", key.fingerprint());
+            self.audit_failure("key.add", actor, &target, correlation_id, created_at)?;
+            return Err(error.into());
+        }
         Ok(key.fingerprint().to_owned())
     }
 
-    pub(crate) fn revoke_key(&self, username: &str, fingerprint: &str) -> Result<(), AccountError> {
+    pub(crate) fn revoke_key(
+        &self,
+        username: &str,
+        fingerprint: &str,
+        actor: &str,
+        correlation_id: &str,
+    ) -> Result<(), AccountError> {
         validate_username(username)?;
-        Store::open(&self.database)?.revoke_account_key(username, fingerprint, now()?)?;
+        let changed_at = now()?;
+        let mut store = Store::open(&self.database)?;
+        if let Err(error) =
+            store.revoke_account_key(username, fingerprint, changed_at, actor, correlation_id)
+        {
+            let target = format!("{username}:{fingerprint}");
+            self.audit_failure(
+                "key.revoke",
+                actor,
+                bounded_audit_target(&target),
+                correlation_id,
+                changed_at,
+            )?;
+            return Err(error.into());
+        }
         Ok(())
     }
 
-    pub(crate) fn suspend(&self, username: &str, suspended: bool) -> Result<(), AccountError> {
+    pub(crate) fn suspend(
+        &self,
+        username: &str,
+        suspended: bool,
+        actor: &str,
+        correlation_id: &str,
+    ) -> Result<(), AccountError> {
         validate_username(username)?;
-        Store::open(&self.database)?.suspend_account(username, suspended, now()?)?;
+        let changed_at = now()?;
+        let mut store = Store::open(&self.database)?;
+        let action = if suspended {
+            "account.suspend"
+        } else {
+            "account.resume"
+        };
+        if let Err(error) =
+            store.suspend_account(username, suspended, changed_at, actor, correlation_id)
+        {
+            self.audit_failure(action, actor, username, correlation_id, changed_at)?;
+            return Err(error.into());
+        }
+        Ok(())
+    }
+
+    fn audit_failure(
+        &self,
+        action: &str,
+        actor: &str,
+        target: &str,
+        correlation_id: &str,
+        created_at: i64,
+    ) -> Result<(), AccountError> {
+        Store::open(&self.database)?.record_audit_event(&NewAuditEvent {
+            action,
+            actor,
+            target,
+            outcome: "failure",
+            correlation_id,
+            created_at,
+        })?;
         Ok(())
     }
 
@@ -114,6 +211,14 @@
         canonical_key: key.canonical(),
         fingerprint: key.fingerprint(),
         label,
+    }
+}
+
+fn bounded_audit_target(target: &str) -> &str {
+    if target.len() <= 512 && !target.chars().any(char::is_control) {
+        target
+    } else {
+        "invalid-target"
     }
 }
 

src/admin.rs

Mode 100644100644; object fd27875c1dbb92019bbddc84

@@ -11,8 +11,11 @@
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
 use crate::store::{
-    NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+    AuditContext, NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store,
+    StoreError,
 };
+
+const ADMIN_ACTOR: &str = "admin-cli";
 
 pub(crate) fn create_repository(
     instance_dir: &Path,
@@ -69,7 +72,25 @@
     let _lock = InstanceLock::acquire(instance_dir)?;
     let database = prepare_database(instance_dir)?;
     let mut store = Store::open(&database)?;
-    store.rename_repository(owner, old_slug, new_slug)?;
+    let changed_at = timestamp()?;
+    let correlation_id = random_id()?;
+    if let Err(error) = store.rename_repository(
+        owner,
+        old_slug,
+        new_slug,
+        changed_at,
+        ADMIN_ACTOR,
+        &correlation_id,
+    ) {
+        record_failure(
+            &store,
+            "repository.rename",
+            &format!("{owner}/{old_slug}->{new_slug}"),
+            &correlation_id,
+            changed_at,
+        )?;
+        return Err(error.into());
+    }
     inspect_with_store(instance_dir, &store, owner, new_slug)
 }
 
@@ -82,7 +103,20 @@
     let _lock = InstanceLock::acquire(instance_dir)?;
     let database = prepare_database(instance_dir)?;
     let mut store = Store::open(&database)?;
-    store.archive_repository(owner, slug, timestamp()?)?;
+    let changed_at = timestamp()?;
+    let correlation_id = random_id()?;
+    if let Err(error) =
+        store.archive_repository(owner, slug, changed_at, ADMIN_ACTOR, &correlation_id)
+    {
+        record_failure(
+            &store,
+            "repository.archive",
+            &format!("{owner}/{slug}"),
+            &correlation_id,
+            changed_at,
+        )?;
+        return Err(error.into());
+    }
     inspect_with_store(instance_dir, &store, owner, slug)
 }
 
@@ -96,7 +130,25 @@
     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)?;
+    let changed_at = timestamp()?;
+    let correlation_id = random_id()?;
+    if let Err(error) = store.set_repository_visibility(
+        owner,
+        slug,
+        visibility,
+        changed_at,
+        ADMIN_ACTOR,
+        &correlation_id,
+    ) {
+        record_failure(
+            &store,
+            "repository.visibility",
+            &format!("{owner}/{slug}:{visibility}"),
+            &correlation_id,
+            changed_at,
+        )?;
+        return Err(error.into());
+    }
     inspect_with_store(instance_dir, &store, owner, slug)
 }
 
@@ -112,7 +164,28 @@
     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()?)?;
+    let changed_at = timestamp()?;
+    let correlation_id = random_id()?;
+    if let Err(error) = store.set_repository_collaborator(
+        owner,
+        slug,
+        username,
+        role,
+        &AuditContext {
+            actor: ADMIN_ACTOR,
+            correlation_id: &correlation_id,
+            created_at: changed_at,
+        },
+    ) {
+        record_failure(
+            &store,
+            "collaborator.set",
+            &format!("{owner}/{slug}:{username}:{role}"),
+            &correlation_id,
+            changed_at,
+        )?;
+        return Err(error.into());
+    }
     inspect_with_store(instance_dir, &store, owner, slug)
 }
 
@@ -127,7 +200,25 @@
     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)?;
+    let changed_at = timestamp()?;
+    let correlation_id = random_id()?;
+    if let Err(error) = store.remove_repository_collaborator(
+        owner,
+        slug,
+        username,
+        changed_at,
+        ADMIN_ACTOR,
+        &correlation_id,
+    ) {
+        record_failure(
+            &store,
+            "collaborator.remove",
+            &format!("{owner}/{slug}:{username}"),
+            &correlation_id,
+            changed_at,
+        )?;
+        return Err(error.into());
+    }
     inspect_with_store(instance_dir, &store, owner, slug)
 }
 
@@ -163,10 +254,18 @@
     let database = prepare_database(instance_dir)?;
     let mut store = Store::open(&database)?;
     let root = prepare_repository_root(instance_dir)?;
+    let created_at = timestamp()?;
+    let correlation_id = random_id()?;
+    let action = match origin {
+        RepositoryOrigin::Created => "repository.create",
+        RepositoryOrigin::Imported => "repository.import",
+    };
+    let audit_target = format!("{owner}/{slug}");
     let id = random_id()?;
     let pending_path = root.join(format!(".pending-{id}.git"));
     let final_path = root.join(format!("{id}.git"));
     if pending_path.exists() || final_path.exists() {
+        record_failure(&store, action, &audit_target, &correlation_id, created_at)?;
         return Err(AdminError::IdentifierCollision);
     }
 
@@ -174,6 +273,7 @@
         Ok(object_format) => object_format,
         Err(error) => {
             remove_created_repository(&pending_path)?;
+            record_failure(&store, action, &audit_target, &correlation_id, created_at)?;
             return Err(error);
         }
     };
@@ -191,7 +291,6 @@
         return Err(AdminError::PathEscape(canonical_path));
     }
 
-    let created_at = timestamp()?;
     let object_format = object_format_name(object_format)?;
     let git = GitRepository::open(&canonical_path)?;
     let initial_references = git
@@ -213,8 +312,11 @@
         created_at,
         origin,
         initial_references: &initial_references,
+        actor: ADMIN_ACTOR,
+        correlation_id: &correlation_id,
     }) {
         remove_created_repository(&canonical_path)?;
+        record_failure(&store, action, &audit_target, &correlation_id, created_at)?;
         return Err(error.into());
     }
     store.repository(owner, slug).map_err(Into::into)
@@ -238,6 +340,24 @@
 fn validate_names(owner: &str, slug: &str) -> Result<(), AdminError> {
     validate_username(owner)?;
     validate_slug(slug)?;
+    Ok(())
+}
+
+fn record_failure(
+    store: &Store,
+    action: &str,
+    target: &str,
+    correlation_id: &str,
+    created_at: i64,
+) -> Result<(), AdminError> {
+    store.record_audit_event(&crate::store::NewAuditEvent {
+        action,
+        actor: ADMIN_ACTOR,
+        target,
+        outcome: "failure",
+        correlation_id,
+        created_at,
+    })?;
     Ok(())
 }
 

src/cli.rs

Mode 100644100644; object d8508db5202cd07e501a78ef

@@ -66,6 +66,11 @@
 
 #[derive(Clone, Debug, Subcommand)]
 pub(crate) enum AdminCommand {
+    /// Show recent audit events
+    Audit {
+        #[arg(long, default_value_t = 100)]
+        limit: usize,
+    },
     /// Administer repositories
     Repository {
         #[command(subcommand)]

src/git/receive_pack.rs

Mode 100644100644; object 184112d6775edf36e3ecf8ff

@@ -16,7 +16,7 @@
 
 use super::packetline::{Packet, PacketLineError, decode, encode_data, encode_flush};
 use super::upload_pack::hash_name;
-use crate::store::{GitIntentRecord, GitOperationIntent, Store};
+use crate::store::{GitIntentRecord, GitOperationIntent, NewAuditEvent, Store};
 
 const MAX_COMMANDS: usize = 256;
 const MAX_OBJECTS: usize = 100_000;
@@ -192,6 +192,34 @@
             self.cleanup_on_drop = true;
         }
         result
+    }
+
+    pub(crate) fn record_rejection(&self) -> Result<(), ReceivePackError> {
+        let store = Store::open(&self.database_path)?;
+        if store.git_intent_completed(&self.intent_id)? {
+            return Ok(());
+        }
+        let target = self
+            .repository_path
+            .file_name()
+            .and_then(|name| name.to_str())
+            .and_then(|name| name.strip_suffix(".git"))
+            .unwrap_or("repository");
+        let created_at = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map_err(|_| ReceivePackError::Clock)?
+            .as_secs()
+            .try_into()
+            .map_err(|_| ReceivePackError::Clock)?;
+        store.record_audit_event(&NewAuditEvent {
+            action: "ref.update",
+            actor: &self.actor,
+            target,
+            outcome: "failure",
+            correlation_id: &self.intent_id,
+            created_at,
+        })?;
+        Ok(())
     }
 
     pub(crate) fn rejection_response(

src/http/mod.rs

Mode 100644100644; object 04dd1dfc20c4e5546004dd2d

@@ -277,8 +277,14 @@
         }
     };
     let username = fields.username.clone();
+    let correlation_id = request_id.0.clone();
     let result = account_job(state, move |accounts| {
-        accounts.signup(&fields.credential, &fields.username, &fields.public_key)
+        accounts.signup(
+            &fields.credential,
+            &fields.username,
+            &fields.public_key,
+            &correlation_id,
+        )
     })
     .await;
     account_result(result, &request_id.0, AccountFormKind::Signup, &username)
@@ -303,8 +309,14 @@
         }
     };
     let username = fields.username.clone();
+    let correlation_id = request_id.0.clone();
     let result = account_job(state, move |accounts| {
-        accounts.recover(&fields.username, &fields.credential, &fields.public_key)
+        accounts.recover(
+            &fields.username,
+            &fields.credential,
+            &fields.public_key,
+            &correlation_id,
+        )
     })
     .await;
     account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
@@ -385,7 +397,10 @@
         ],
     ) {
         Ok(fields) => fields,
-        Err(()) => return login_error(&request_id.0, "", "The login response is not valid."),
+        Err(()) => {
+            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
+                .await;
+        }
     };
     let username = fields[0].clone();
     let public_key = fields[1].clone();
@@ -393,7 +408,13 @@
     let signature = fields[3].clone();
     let login_csrf = fields[4].clone();
     if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
-        return login_error(&request_id.0, &username, "The login response is not valid.");
+        return rejected_login(
+            state,
+            &request_id.0,
+            &username,
+            "The login response is not valid.",
+        )
+        .await;
     }
     complete_login(
         state,
@@ -417,10 +438,10 @@
         .get(header::CONTENT_TYPE)
         .and_then(|value| value.to_str().ok())
     else {
-        return login_error(&request_id.0, "", "The login response is not valid.");
+        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
     };
     let Ok(boundary) = multra::parse_boundary(content_type) else {
-        return login_error(&request_id.0, "", "The login response is not valid.");
+        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
     };
     let mut multipart = multra::Multipart::new(body.into_data_stream(), boundary);
     let mut username = None;
@@ -432,7 +453,15 @@
         let field = match multipart.next_field().await {
             Ok(Some(field)) => field,
             Ok(None) => break,
-            Err(_) => return login_error(&request_id.0, "", "The login response is not valid."),
+            Err(_) => {
+                return rejected_login(
+                    state,
+                    &request_id.0,
+                    "",
+                    "The login response is not valid.",
+                )
+                .await;
+            }
         };
         match field.name() {
             Some("username") if username.is_none() => username = field.text().await.ok(),
@@ -440,16 +469,30 @@
             Some("challenge") if challenge.is_none() => challenge = field.text().await.ok(),
             Some("signature-file") if signature.is_none() => signature = field.text().await.ok(),
             Some("login-csrf") if login_csrf.is_none() => login_csrf = field.text().await.ok(),
-            _ => return login_error(&request_id.0, "", "The login response is not valid."),
+            _ => {
+                return rejected_login(
+                    state,
+                    &request_id.0,
+                    "",
+                    "The login response is not valid.",
+                )
+                .await;
+            }
         }
     }
     let (Some(username), Some(public_key), Some(challenge), Some(signature), Some(login_csrf)) =
         (username, public_key, challenge, signature, login_csrf)
     else {
-        return login_error(&request_id.0, "", "The login response is not valid.");
+        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
     };
     if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
-        return login_error(&request_id.0, &username, "The login response is not valid.");
+        return rejected_login(
+            state,
+            &request_id.0,
+            &username,
+            "The login response is not valid.",
+        )
+        .await;
     }
     complete_login(
         state,
@@ -474,8 +517,16 @@
 ) -> Response {
     let display_username = username.clone();
     let secure = state.secure_cookies;
+    let correlation_id = request_id.to_owned();
     let result = login_job(state, move |login| {
-        login.verify(&username, &public_key, &challenge, &signature, &login_csrf)
+        login.verify(
+            &username,
+            &public_key,
+            &challenge,
+            &signature,
+            &login_csrf,
+            &correlation_id,
+        )
     })
     .await;
     match result {
@@ -699,6 +750,21 @@
             has_error: true,
         },
     )
+}
+
+async fn rejected_login(
+    state: WebState,
+    request_id: &str,
+    username: &str,
+    error: &str,
+) -> Response {
+    let username_for_audit = username.to_owned();
+    let correlation_id = request_id.to_owned();
+    let _ = login_job(state, move |login| {
+        login.record_login_failure(&username_for_audit, &correlation_id)
+    })
+    .await;
+    login_error(request_id, username, error)
 }
 
 async fn account_job<T: Send + 'static>(

src/main.rs

Mode 100644100644; object b2aea38cd3c5c9ebea1ff23f

@@ -109,7 +109,37 @@
             Some(Command::Admin {
                 command: AdminCommand::Account { command },
             }) => run_account_command(&config.instance_dir, command),
+            Some(Command::Admin {
+                command: AdminCommand::Audit { limit },
+            }) => run_audit_command(&config.instance_dir, limit),
         },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run_audit_command(instance_dir: &std::path::Path, limit: usize) -> ExitCode {
+    let result = (|| -> Result<(), Box<dyn std::error::Error>> {
+        let _lock = instance::InstanceLock::acquire(instance_dir)?;
+        let database = instance::prepare_database(instance_dir)?;
+        let events = store::Store::open(&database)?.audit_events(limit)?;
+        let mut output = io::stdout().lock();
+        for event in events {
+            writeln!(output, "id={}", event.id)?;
+            writeln!(output, "action={}", event.action)?;
+            writeln!(output, "actor={}", event.actor)?;
+            writeln!(output, "target={}", event.target)?;
+            writeln!(output, "outcome={}", event.outcome)?;
+            writeln!(output, "correlation-id={}", event.correlation_id)?;
+            writeln!(output, "created-at={}", event.created_at)?;
+            writeln!(output)?;
+        }
+        Ok(())
+    })();
+    match result {
+        Ok(()) => ExitCode::SUCCESS,
         Err(error) => {
             eprintln!("tit: {error}");
             ExitCode::FAILURE
@@ -122,28 +152,35 @@
         let _lock = instance::InstanceLock::acquire(instance_dir)?;
         let database = instance::prepare_database(instance_dir)?;
         let accounts = account::AccountService::new(database);
+        let correlation_id = format!("{:032x}", rand::random::<u128>());
         match command {
             AccountCommand::KeyAdd {
                 username,
                 label,
                 ssh_public_key,
             } => {
-                let fingerprint = accounts.add_key(&username, &label, &ssh_public_key)?;
+                let fingerprint = accounts.add_key(
+                    &username,
+                    &label,
+                    &ssh_public_key,
+                    "admin-cli",
+                    &correlation_id,
+                )?;
                 Ok(Some(fingerprint))
             }
             AccountCommand::KeyRevoke {
                 username,
                 fingerprint,
             } => {
-                accounts.revoke_key(&username, &fingerprint)?;
+                accounts.revoke_key(&username, &fingerprint, "admin-cli", &correlation_id)?;
                 Ok(None)
             }
             AccountCommand::Suspend { username } => {
-                accounts.suspend(&username, true)?;
+                accounts.suspend(&username, true, "admin-cli", &correlation_id)?;
                 Ok(None)
             }
             AccountCommand::Resume { username } => {
-                accounts.suspend(&username, false)?;
+                accounts.suspend(&username, false, "admin-cli", &correlation_id)?;
                 Ok(None)
             }
         }

src/session.rs

Mode 100644100644; object 6fd36f88f62b7e140161e642

@@ -9,7 +9,9 @@
 use crate::auth::{
     AuthError, SshPublicKey, format_login_challenge, login_origin, verify_login_challenge,
 };
-use crate::store::{NewLoginNonce, NewWebSession, Store, StoreError, WebSessionRecord};
+use crate::store::{
+    NewAuditEvent, NewLoginNonce, NewWebSession, Store, StoreError, WebSessionRecord,
+};
 
 const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
 const SESSION_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
@@ -71,37 +73,53 @@
         challenge: &str,
         signature: &str,
         login_csrf: &str,
+        correlation_id: &str,
     ) -> Result<NewSession, SessionError> {
-        validate_token(login_csrf)?;
-        let key = SshPublicKey::parse(public_key)?;
         let created_at = now()?;
-        let verified = verify_login_challenge(
-            &self.origin,
-            challenge,
-            signature,
-            username,
-            &key,
-            u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
-        )?;
-        let session = encode_hex(&random_bytes()?);
-        let csrf = encode_hex(&random_bytes()?);
-        let expires_at = created_at
-            .checked_add(SESSION_LIFETIME_SECONDS)
-            .ok_or(SessionError::Clock)?;
-        Store::open(&self.database)?.consume_login_nonce(&NewWebSession {
-            nonce_hash: &verified.nonce_hash,
-            login_csrf_hash: &hash(login_csrf.as_bytes()),
-            username: &verified.username,
-            fingerprint: &verified.fingerprint,
-            session_hash: &hash(session.as_bytes()),
-            csrf_hash: &hash(csrf.as_bytes()),
-            created_at,
-            expires_at,
-        })?;
-        Ok(NewSession {
-            token: session,
-            csrf,
-        })
+        let result = (|| {
+            validate_token(login_csrf)?;
+            let key = SshPublicKey::parse(public_key)?;
+            let verified = verify_login_challenge(
+                &self.origin,
+                challenge,
+                signature,
+                username,
+                &key,
+                u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
+            )?;
+            let session = encode_hex(&random_bytes()?);
+            let csrf = encode_hex(&random_bytes()?);
+            let expires_at = created_at
+                .checked_add(SESSION_LIFETIME_SECONDS)
+                .ok_or(SessionError::Clock)?;
+            Store::open(&self.database)?.consume_login_nonce(&NewWebSession {
+                nonce_hash: &verified.nonce_hash,
+                login_csrf_hash: &hash(login_csrf.as_bytes()),
+                username: &verified.username,
+                fingerprint: &verified.fingerprint,
+                session_hash: &hash(session.as_bytes()),
+                csrf_hash: &hash(csrf.as_bytes()),
+                created_at,
+                expires_at,
+                correlation_id,
+            })?;
+            Ok(NewSession {
+                token: session,
+                csrf,
+            })
+        })();
+        if result.is_err() {
+            let account = audit_account(username);
+            Store::open(&self.database)?.record_audit_event(&NewAuditEvent {
+                action: "login",
+                actor: account,
+                target: account,
+                outcome: "failure",
+                correlation_id,
+                created_at,
+            })?;
+        }
+        result
     }
 
     pub(crate) fn authenticate(
@@ -122,9 +140,38 @@
             .map_err(Into::into)
     }
 
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile login without HTTP handlers"
+    )]
+    pub(crate) fn record_login_failure(
+        &self,
+        username: &str,
+        correlation_id: &str,
+    ) -> Result<(), SessionError> {
+        let account = audit_account(username);
+        Store::open(&self.database)?.record_audit_event(&NewAuditEvent {
+            action: "login",
+            actor: account,
+            target: account,
+            outcome: "failure",
+            correlation_id,
+            created_at: now()?,
+        })?;
+        Ok(())
+    }
+
     pub(crate) fn end_all(&self, username: &str) -> Result<(), SessionError> {
         Store::open(&self.database)?.end_account_sessions(username, now()?)?;
         Ok(())
+    }
+}
+
+fn audit_account(username: &str) -> &str {
+    if !username.is_empty() && username.len() <= 40 && username.is_ascii() {
+        username
+    } else {
+        "invalid-account"
     }
 }
 

src/ssh.rs

Mode 100644100644; object cea4c4aeb8fda76bc9a81d0b

@@ -869,7 +869,13 @@
             Ok(response) => Ok(response),
             Err(error) => {
                 let response = service.rejection_response(&commands, &error);
-                Err((error, response))
+                match service.record_rejection() {
+                    Ok(()) => Err((error, response)),
+                    Err(audit_error) => {
+                        let response = service.rejection_response(&commands, &audit_error);
+                        Err((audit_error, response))
+                    }
+                }
             }
         })
     })

src/store/migrations/010_audit_history.sql

Mode 100644; object 49ffc1e1c171

@@ -1,0 +1,15 @@
+CREATE TABLE audit_event (
+    id INTEGER PRIMARY KEY,
+    action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 80),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    target TEXT NOT NULL CHECK (length(target) BETWEEN 1 AND 512),
+    outcome TEXT NOT NULL CHECK (outcome IN ('success', 'failure')),
+    correlation_id TEXT NOT NULL CHECK (length(correlation_id) BETWEEN 1 AND 128),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX audit_event_history
+ON audit_event (id DESC);
+
+CREATE INDEX audit_event_correlation
+ON audit_event (correlation_id, id);

src/store/mod.rs

Mode 100644100644; object ecac897898771beabf69979e

@@ -9,7 +9,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 9;
+const SCHEMA_VERSION: i64 = 10;
 #[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; 9] = [
+const MIGRATIONS: [&str; 10] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -29,6 +29,7 @@
     include_str!("migrations/007_account_lifecycle.sql"),
     include_str!("migrations/008_web_sessions.sql"),
     include_str!("migrations/009_repository_authorization.sql"),
+    include_str!("migrations/010_audit_history.sql"),
 ];
 
 #[allow(
@@ -109,6 +110,8 @@
     EventLimit,
     #[error("stored Git reference event is malformed")]
     EventPayload,
+    #[error("audit event page limit is too large")]
+    AuditLimit,
 }
 
 pub(crate) struct Store {
@@ -116,6 +119,44 @@
 }
 
 impl Store {
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without audited services"
+    )]
+    pub(crate) fn record_audit_event(&self, event: &NewAuditEvent<'_>) -> Result<(), StoreError> {
+        insert_audit_event(&self.connection, event)?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without the audit CLI"
+    )]
+    pub(crate) fn audit_events(&self, limit: usize) -> Result<Vec<AuditEventRecord>, StoreError> {
+        if limit == 0 || limit > 1_000 {
+            return Err(StoreError::AuditLimit);
+        }
+        let limit = i64::try_from(limit).map_err(|_| StoreError::AuditLimit)?;
+        let mut statement = self.connection.prepare(
+            "SELECT id, action, actor, target, outcome, correlation_id, created_at
+             FROM audit_event ORDER BY id DESC LIMIT ?1",
+        )?;
+        statement
+            .query_map([limit], |row| {
+                Ok(AuditEventRecord {
+                    id: row.get(0)?,
+                    action: row.get(1)?,
+                    actor: row.get(2)?,
+                    target: row.get(3)?,
+                    outcome: row.get(4)?,
+                    correlation_id: row.get(5)?,
+                    created_at: row.get(6)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
     #[allow(
         dead_code,
         reason = "M1A proves migrations before the M2 server calls them"
@@ -340,6 +381,22 @@
         require_one_intent(id, changed)
     }
 
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without receive-pack"
+    )]
+    pub(crate) fn git_intent_completed(&self, id: &str) -> Result<bool, StoreError> {
+        Ok(self
+            .connection
+            .query_row(
+                "SELECT state = 'completed' FROM git_operation_intent WHERE id = ?1",
+                [id],
+                |row| row.get(0),
+            )
+            .optional()?
+            .unwrap_or(false))
+    }
+
     pub(crate) fn incomplete_git_intents(&self) -> Result<Vec<GitIntentRecord>, StoreError> {
         let mut statement = self.connection.prepare(
             "SELECT id, repository_path, initial_refs, proposed_refs, quarantine_path,
@@ -464,6 +521,17 @@
              VALUES (?1, ?2, ?3)",
             rusqlite::params![account_id, account.recovery_hash, account.created_at],
         )?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "account.signup",
+                actor: account.username,
+                target: account.username,
+                outcome: "success",
+                correlation_id: account.correlation_id,
+                created_at: account.created_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -525,6 +593,17 @@
             rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
         )?;
         end_sessions(&transaction, account_id, recovery.created_at)?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "account.recover",
+                actor: recovery.username,
+                target: recovery.username,
+                outcome: "success",
+                correlation_id: recovery.correlation_id,
+                created_at: recovery.created_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -538,6 +617,8 @@
         username: &str,
         key: &NewSshKey<'_>,
         created_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let transaction = self
             .connection
@@ -545,6 +626,18 @@
         let account_id = active_account_id(&transaction, username)?;
         insert_ssh_key(&transaction, account_id, key, created_at)?;
         end_sessions(&transaction, account_id, created_at)?;
+        let target = format!("{username}:{}", key.fingerprint);
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "key.add",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -558,6 +651,8 @@
         username: &str,
         fingerprint: &str,
         revoked_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let transaction = self
             .connection
@@ -580,6 +675,18 @@
             return Err(StoreError::KeyNotFound);
         }
         end_sessions(&transaction, account_id, revoked_at)?;
+        let target = format!("{username}:{fingerprint}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "key.revoke",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at: revoked_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -593,6 +700,8 @@
         username: &str,
         suspended: bool,
         changed_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let state = if suspended { "suspended" } else { "active" };
         let transaction = self
@@ -611,6 +720,21 @@
             |row| row.get(0),
         )?;
         end_sessions(&transaction, account_id, changed_at)?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: if suspended {
+                    "account.suspend"
+                } else {
+                    "account.resume"
+                },
+                actor,
+                target: username,
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -718,6 +842,17 @@
                 login.created_at,
                 login.expires_at,
             ],
+        )?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "login",
+                actor: login.username,
+                target: login.username,
+                outcome: "success",
+                correlation_id: login.correlation_id,
+                created_at: login.created_at,
+            },
         )?;
         transaction.commit()?;
         Ok(())
@@ -834,6 +969,18 @@
                         ],
                     )?;
                 }
+                let target = format!("{}/{}", repository.owner, repository.slug);
+                insert_audit_event(
+                    &transaction,
+                    &NewAuditEvent {
+                        action: repository.origin.audit_action(),
+                        actor: repository.actor,
+                        target: &target,
+                        outcome: "success",
+                        correlation_id: repository.correlation_id,
+                        created_at: repository.created_at,
+                    },
+                )?;
                 transaction.commit()?;
             }
             Ok(_) => unreachable!("an INSERT changes one row"),
@@ -862,6 +1009,9 @@
         owner: &str,
         old_slug: &str,
         new_slug: &str,
+        changed_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let transaction = self
             .connection
@@ -873,7 +1023,21 @@
             rusqlite::params![owner_id, old_slug, new_slug],
         );
         match result {
-            Ok(1) => transaction.commit()?,
+            Ok(1) => {
+                let target = format!("{owner}/{old_slug}->{new_slug}");
+                insert_audit_event(
+                    &transaction,
+                    &NewAuditEvent {
+                        action: "repository.rename",
+                        actor,
+                        target: &target,
+                        outcome: "success",
+                        correlation_id,
+                        created_at: changed_at,
+                    },
+                )?;
+                transaction.commit()?;
+            }
             Ok(0) => {
                 return Err(repository_state_error(
                     &transaction,
@@ -899,6 +1063,8 @@
         owner: &str,
         slug: &str,
         archived_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let transaction = self
             .connection
@@ -912,6 +1078,18 @@
         if changed == 0 {
             return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
         }
+        let target = format!("{owner}/{slug}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.archive",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at: archived_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -925,6 +1103,9 @@
         owner: &str,
         slug: &str,
         visibility: &str,
+        changed_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         if !matches!(visibility, "public" | "private") {
             return Err(StoreError::InvalidRepositoryVisibility);
@@ -941,6 +1122,18 @@
         if changed == 0 {
             return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
         }
+        let target = format!("{owner}/{slug}:{visibility}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.visibility",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -955,7 +1148,7 @@
         slug: &str,
         username: &str,
         role: &str,
-        created_at: i64,
+        audit: &AuditContext<'_>,
     ) -> Result<(), StoreError> {
         if !matches!(role, "maintainer" | "writer" | "reader") {
             return Err(StoreError::InvalidCollaboratorRole);
@@ -991,7 +1184,19 @@
              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],
+            rusqlite::params![repository_id, collaborator_id, role, audit.created_at],
+        )?;
+        let target = format!("{owner}/{slug}:{username}:{role}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "collaborator.set",
+                actor: audit.actor,
+                target: &target,
+                outcome: "success",
+                correlation_id: audit.correlation_id,
+                created_at: audit.created_at,
+            },
         )?;
         transaction.commit()?;
         Ok(())
@@ -1006,6 +1211,9 @@
         owner: &str,
         slug: &str,
         username: &str,
+        changed_at: i64,
+        actor: &str,
+        correlation_id: &str,
     ) -> Result<(), StoreError> {
         let transaction = self
             .connection
@@ -1043,6 +1251,18 @@
         if changed == 0 {
             return Err(StoreError::CollaboratorNotFound(username.to_owned()));
         }
+        let target = format!("{owner}/{slug}:{username}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "collaborator.remove",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
         transaction.commit()?;
         Ok(())
     }
@@ -1318,6 +1538,7 @@
     pub(crate) key: NewSshKey<'a>,
     pub(crate) recovery_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[allow(
@@ -1330,6 +1551,7 @@
     pub(crate) key: NewSshKey<'a>,
     pub(crate) new_recovery_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[allow(
@@ -1358,6 +1580,7 @@
     pub(crate) csrf_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
     pub(crate) expires_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[allow(
@@ -1378,6 +1601,8 @@
     pub(crate) created_at: i64,
     pub(crate) origin: RepositoryOrigin,
     pub(crate) initial_references: &'a [NewRepositoryReference],
+    pub(crate) actor: &'a str,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[derive(Clone, Copy)]
@@ -1395,6 +1620,13 @@
         match self {
             Self::Created => "repository-created",
             Self::Imported => "repository-imported",
+        }
+    }
+
+    fn audit_action(self) -> &'static str {
+        match self {
+            Self::Created => "repository.create",
+            Self::Imported => "repository.import",
         }
     }
 }
@@ -1460,6 +1692,35 @@
     pub(crate) created_at: i64,
 }
 
+pub(crate) struct NewAuditEvent<'a> {
+    pub(crate) action: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) target: &'a str,
+    pub(crate) outcome: &'a str,
+    pub(crate) correlation_id: &'a str,
+    pub(crate) created_at: i64,
+}
+
+pub(crate) struct AuditContext<'a> {
+    pub(crate) actor: &'a str,
+    pub(crate) correlation_id: &'a str,
+    pub(crate) created_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without the audit CLI"
+)]
+pub(crate) struct AuditEventRecord {
+    pub(crate) id: i64,
+    pub(crate) action: String,
+    pub(crate) actor: String,
+    pub(crate) target: String,
+    pub(crate) outcome: String,
+    pub(crate) correlation_id: String,
+    pub(crate) created_at: i64,
+}
+
 pub(crate) struct GitIntentRecord {
     pub(crate) id: String,
     pub(crate) repository_path: String,
@@ -1497,6 +1758,26 @@
         Err(error) => Err(error.into()),
         Ok(_) => unreachable!("an INSERT changes one row"),
     }
+}
+
+fn insert_audit_event(
+    connection: &Connection,
+    event: &NewAuditEvent<'_>,
+) -> Result<(), StoreError> {
+    connection.execute(
+        "INSERT INTO audit_event
+         (action, actor, target, outcome, correlation_id, created_at)
+         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+        rusqlite::params![
+            event.action,
+            event.actor,
+            event.target,
+            event.outcome,
+            event.correlation_id,
+            event.created_at,
+        ],
+    )?;
+    Ok(())
 }
 
 fn end_sessions(
@@ -1587,6 +1868,17 @@
             ],
         )?;
     }
+    insert_audit_event(
+        transaction,
+        &NewAuditEvent {
+            action: "ref.update",
+            actor,
+            target: repository_id,
+            outcome: "success",
+            correlation_id: intent_id,
+            created_at,
+        },
+    )?;
     Ok(())
 }
 

tests/account_lifecycle.rs

Mode 100644100644; object de9934ee416df6006a499371

@@ -45,11 +45,11 @@
     assert_ne!(stored_invitation, invitation.as_bytes());
 
     let recovery = accounts
-        .signup(&invitation, "alice", &first_key)
+        .signup(&invitation, "alice", &first_key, "test")
         .expect("create the account");
     assert!(recovery.starts_with("tit-recovery-v1:"));
     assert!(matches!(
-        accounts.signup(&invitation, "bob", &second_key),
+        accounts.signup(&invitation, "bob", &second_key, "test"),
         Err(AccountError::Store(StoreError::InvalidInvitation))
     ));
     let stored_recovery: Vec<u8> = Store::open(&database)
@@ -65,7 +65,7 @@
     assert_ne!(stored_recovery, recovery.as_bytes());
 
     let second_fingerprint = accounts
-        .add_key("alice", "workstation", &second_key)
+        .add_key("alice", "workstation", &second_key, "alice", "test")
         .expect("add a second key");
     let first_fingerprint: String = Store::open(&database)
         .expect("open the account database")
@@ -77,22 +77,22 @@
         )
         .expect("read the first fingerprint");
     accounts
-        .revoke_key("alice", &first_fingerprint)
+        .revoke_key("alice", &first_fingerprint, "alice", "test")
         .expect("revoke the first key");
     assert!(matches!(
-        accounts.revoke_key("alice", &second_fingerprint),
+        accounts.revoke_key("alice", &second_fingerprint, "alice", "test"),
         Err(AccountError::Store(StoreError::LastKey))
     ));
 
     let replacement = accounts
-        .recover("alice", &recovery, &third_key)
+        .recover("alice", &recovery, &third_key, "test")
         .expect("recover the account");
     assert!(matches!(
-        accounts.recover("alice", &recovery, &first_key),
+        accounts.recover("alice", &recovery, &first_key, "test"),
         Err(AccountError::Store(StoreError::InvalidRecovery))
     ));
     accounts
-        .recover("alice", &replacement, &first_key)
+        .recover("alice", &replacement, &first_key, "test")
         .expect("use the rotated recovery code");
     assert_eq!(
         Store::open(&database)
@@ -106,6 +106,29 @@
                 .to_owned()
         ]
     );
+    let audits = Store::open(&database)
+        .expect("open the account database")
+        .audit_events(20)
+        .expect("read account audit history");
+    assert!(
+        audits
+            .iter()
+            .any(|event| event.action == "key.add" && event.outcome == "success")
+    );
+    assert!(
+        audits
+            .iter()
+            .any(|event| event.action == "key.revoke" && event.outcome == "failure")
+    );
+    assert!(
+        audits
+            .iter()
+            .any(|event| event.action == "account.recover" && event.outcome == "success")
+    );
+    for event in audits {
+        assert!(!event.target.contains(&recovery));
+        assert!(!event.target.contains(&replacement));
+    }
 }
 
 #[test]
@@ -116,19 +139,19 @@
     let accounts = AccountService::new(database.clone());
     let first = accounts.issue_invitation().expect("issue an invitation");
     accounts
-        .signup(&first, "alice", &public_key())
+        .signup(&first, "alice", &public_key(), "test")
         .expect("create the first account");
 
     let second = accounts.issue_invitation().expect("issue an invitation");
     assert!(matches!(
-        accounts.signup(&second, "alice", &public_key()),
+        accounts.signup(&second, "alice", &public_key(), "test"),
         Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
     ));
     accounts
-        .signup(&second, "bob", &public_key())
+        .signup(&second, "bob", &public_key(), "test")
         .expect("reuse the invitation after a rolled-back signup");
     accounts
-        .suspend("alice", true)
+        .suspend("alice", true, "admin-cli", "test")
         .expect("suspend the account");
     assert_eq!(
         Store::open(&database)
@@ -144,7 +167,7 @@
     );
     let third = accounts.issue_invitation().expect("issue an invitation");
     assert!(matches!(
-        accounts.signup(&third, "alice", &public_key()),
+        accounts.signup(&third, "alice", &public_key(), "test"),
         Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
     ));
     let consumed: Option<i64> = Store::open(&database)
@@ -174,7 +197,7 @@
         .expect("store an expired invitation");
     let accounts = AccountService::new(database.clone());
     assert!(matches!(
-        accounts.signup(invitation, "alice", &public_key()),
+        accounts.signup(invitation, "alice", &public_key(), "test"),
         Err(AccountError::Store(StoreError::InvalidInvitation))
     ));
     assert_eq!(

tests/cli.rs

Mode 100644100644; object b7b85a5486c8d9ecb27c142a

@@ -12,13 +12,14 @@
 };
 use tempfile::TempDir;
 
-const V9_DATABASE: &str = concat!(
+const CURRENT_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"),
     include_str!("../src/store/migrations/009_repository_authorization.sql"),
-    "PRAGMA user_version = 9;\n",
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    "PRAGMA user_version = 10;\n",
 );
 
 #[test]
@@ -74,7 +75,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V9_DATABASE)
+        .execute_batch(CURRENT_DATABASE)
         .expect("create the current database");
     drop(database);
 
@@ -126,7 +127,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V9_DATABASE)
+        .execute_batch(CURRENT_DATABASE)
         .expect("create the current database");
     database
         .pragma_update(None, "foreign_keys", false)
@@ -581,6 +582,31 @@
         })
         .expect("count repository collaborators");
     assert_eq!(collaborators, 0);
+    drop(database);
+
+    let rejected_remove = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "collaborator-remove",
+        "alice",
+        "project",
+        "bob",
+    ]);
+    assert_eq!(rejected_remove.status.code(), Some(1));
+
+    let audit = instance.run(&["--config", config, "admin", "audit", "--limit", "10"]);
+    assert!(audit.status.success());
+    let audit = String::from_utf8(audit.stdout).expect("read the audit history");
+    assert!(audit.contains("action=repository.create\n"));
+    assert!(audit.contains("action=repository.visibility\n"));
+    assert!(audit.contains("action=collaborator.set\n"));
+    assert!(audit.contains("action=collaborator.remove\n"));
+    assert!(audit.contains("actor=admin-cli\n"));
+    assert!(audit.contains("outcome=success\n"));
+    assert!(audit.contains("outcome=failure\n"));
+    assert!(audit.contains("correlation-id="));
 }
 
 #[test]

tests/public_routes.rs

Mode 100644100644; object e070864333e26096f4832bf5

@@ -667,6 +667,8 @@
                 created_at: 2,
                 origin: RepositoryOrigin::Imported,
                 initial_references: &[],
+                actor: "admin-cli",
+                correlation_id: "test-import",
             })
             .expect("create the repository record");
         store
@@ -678,6 +680,8 @@
                 created_at: 2,
                 origin: RepositoryOrigin::Created,
                 initial_references: &[],
+                actor: "admin-cli",
+                correlation_id: "test-create",
             })
             .expect("create the empty repository record");
         drop(store);

tests/repository_policy.rs

Mode 100644100644; object 7785dbed723093d0a58beef0

@@ -5,7 +5,7 @@
 mod store;
 
 use policy::{PolicyError, RepositoryOperation, RepositoryPolicy};
-use store::{NewRepository, RepositoryOrigin, Store, StoreError};
+use store::{AuditContext, NewRepository, RepositoryOrigin, Store, StoreError};
 use tempfile::TempDir;
 
 #[test]
@@ -39,6 +39,8 @@
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
+            actor: "admin-cli",
+            correlation_id: "test",
         })
         .expect("create a policy repository");
     for (username, role) in [
@@ -48,18 +50,18 @@
         ("suspended", "writer"),
     ] {
         store
-            .set_repository_collaborator("owner", "project", username, role, 3)
+            .set_repository_collaborator("owner", "project", username, role, &audit(3))
             .expect("set a collaborator role");
     }
     store
-        .suspend_account("suspended", true, 4)
+        .suspend_account("suspended", true, 4, "admin-cli", "test")
         .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")
+        .set_repository_visibility("owner", "project", "private", 5, "admin-cli", "test")
         .expect("make the repository private");
 
     for operation in operations() {
@@ -112,6 +114,8 @@
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
+            actor: "admin-cli",
+            correlation_id: "test",
         })
         .expect("create a policy repository");
     let policy = RepositoryPolicy::new(&database);
@@ -124,7 +128,7 @@
     );
 
     store
-        .set_repository_visibility("owner", "project", "private")
+        .set_repository_visibility("owner", "project", "private", 3, "admin-cli", "test")
         .expect("make the repository private");
     assert!(
         policy
@@ -133,24 +137,24 @@
             .is_empty()
     );
     store
-        .set_repository_collaborator("owner", "project", "member", "writer", 3)
+        .set_repository_collaborator("owner", "project", "member", "writer", &audit(3))
         .expect("add a writer");
     assert_allowed(&policy, Some("member"), RepositoryOperation::Write);
     store
-        .set_repository_collaborator("owner", "project", "member", "reader", 4)
+        .set_repository_collaborator("owner", "project", "member", "reader", &audit(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")
+        .remove_repository_collaborator("owner", "project", "member", 5, "admin-cli", "test")
         .expect("remove the collaborator");
     assert_denied(&policy, Some("member"), RepositoryOperation::Read);
     assert!(matches!(
-        store.set_repository_collaborator("owner", "project", "owner", "reader", 5),
+        store.set_repository_collaborator("owner", "project", "owner", "reader", &audit(5)),
         Err(StoreError::OwnerCollaborator)
     ));
     store
-        .archive_repository("owner", "project", 6)
+        .archive_repository("owner", "project", 6, "admin-cli", "test")
         .expect("archive the repository");
     for operation in operations() {
         assert_denied(&policy, Some("owner"), operation);
@@ -164,6 +168,14 @@
         RepositoryOperation::Maintain,
         RepositoryOperation::Own,
     ]
+}
+
+fn audit(created_at: i64) -> AuditContext<'static> {
+    AuditContext {
+        actor: "admin-cli",
+        correlation_id: "test",
+        created_at,
+    }
 }
 
 fn assert_allowed(policy: &RepositoryPolicy, actor: Option<&str>, operation: RepositoryOperation) {

tests/serve.rs

Mode 100644100644; object 478be87ac15f6968bb1a3aef

@@ -101,6 +101,7 @@
         &[("Cookie", &login_csrf_cookies)],
     );
     assert!(rejected_login.starts_with("HTTP/1.1 400"));
+    let rejected_login_id = response_header(&rejected_login, "x-request-id").to_owned();
     let login = http_form_with_headers(
         http,
         "/login/verify",
@@ -114,6 +115,7 @@
         &[("Cookie", &login_csrf_cookies)],
     );
     assert!(login.starts_with("HTTP/1.1 303"), "{login}");
+    let login_id = response_header(&login, "x-request-id").to_owned();
     let cookies = response_cookies(&login);
     let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
     assert!(account.starts_with("HTTP/1.1 200"));
@@ -272,6 +274,7 @@
         ],
     );
     assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
+    let recovery_id = response_header(&recovered, "x-request-id").to_owned();
     assert!(!ssh_clone_succeeds(
         ssh,
         &member_key,
@@ -282,6 +285,50 @@
         &replacement_key,
         &instance.path().join("replacement-clone")
     ));
+
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the audit database");
+    let mut statement = database
+        .prepare(
+            "SELECT action, actor, target, outcome, correlation_id
+             FROM audit_event ORDER BY id",
+        )
+        .expect("prepare the audit query");
+    let audits = statement
+        .query_map([], |row| {
+            Ok((
+                row.get::<_, String>(0)?,
+                row.get::<_, String>(1)?,
+                row.get::<_, String>(2)?,
+                row.get::<_, String>(3)?,
+                row.get::<_, String>(4)?,
+            ))
+        })
+        .expect("query audit history")
+        .collect::<Result<Vec<_>, _>>()
+        .expect("read audit history");
+    assert!(audits.iter().any(|event| {
+        event.0 == "login" && event.3 == "failure" && event.4 == rejected_login_id
+    }));
+    assert!(
+        audits
+            .iter()
+            .any(|event| event.0 == "login" && event.3 == "success" && event.4 == login_id)
+    );
+    assert!(audits.iter().any(|event| {
+        event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
+    }));
+    for event in &audits {
+        let visible = format!(
+            "{} {} {} {} {}",
+            event.0, event.1, event.2, event.3, event.4
+        );
+        assert!(!visible.contains(recovery));
+        assert!(!visible.contains(challenge));
+        assert!(!visible.contains(&signature));
+    }
+    drop(statement);
+    drop(database);
 
     let ssh_clone = instance.path().join("ssh-clone");
     let ssh_command = format!(
@@ -609,6 +656,26 @@
     drop(database);
     assert!(!git_push(&writer_key, &writer_clone, &["main"]).success());
     server.terminate();
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the push audit database");
+    let successful: i64 = database
+        .query_row(
+            "SELECT count(*) FROM audit_event
+             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'success'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count successful push audit events");
+    let failed: i64 = database
+        .query_row(
+            "SELECT count(*) FROM audit_event
+             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'failure'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count failed push audit events");
+    assert_eq!(successful, 1);
+    assert!(failed >= 2);
 }
 
 fn spawn_server(config: &Path) -> ChildGuard {
@@ -774,6 +841,15 @@
         })
         .collect::<Vec<_>>()
         .join("; ")
+}
+
+fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
+    response
+        .lines()
+        .filter_map(|line| line.split_once(':'))
+        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
+        .map(|(_, value)| value.trim())
+        .expect("read a response header")
 }
 
 fn http_multipart(

tests/sqlite.rs

Mode 100644100644; object 96fa80994ea64a33b211e1f4

@@ -10,7 +10,7 @@
 
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
 use store::{
-    GitOperationIntent, InitialAdministrator, NewRepository, NewRepositoryReference,
+    GitOperationIntent, InitialAdministrator, NewAuditEvent, NewRepository, NewRepositoryReference,
     RepositoryOrigin, Store, StoreError,
 };
 use tempfile::TempDir;
@@ -26,6 +26,12 @@
     include_str!("fixtures/sqlite/v7.sql"),
     include_str!("../src/store/migrations/008_web_sessions.sql"),
     "PRAGMA user_version = 8;\n",
+);
+const V9_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    "PRAGMA user_version = 9;\n",
 );
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
@@ -144,7 +150,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"), 9);
+    assert_eq!(store.schema_version().expect("read the schema version"), 10);
     assert_eq!(
         store
             .connection()
@@ -333,6 +339,8 @@
         created_at: 20,
         origin: RepositoryOrigin::Imported,
         initial_references: &initial_references,
+        actor: "admin-cli",
+        correlation_id: "test-create",
     };
     store
         .create_repository(&repository)
@@ -383,6 +391,11 @@
     store
         .complete_git_intent(push.id)
         .expect("complete a managed push");
+    assert!(
+        store
+            .git_intent_completed(push.id)
+            .expect("read the completed intent")
+    );
     let (_, pushed_events) = store
         .public_repository_events("alice", "project", None, 10)
         .expect("read pushed repository events");
@@ -393,6 +406,30 @@
         Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
     );
     assert_eq!(pushed_events[1].kind, "push");
+    let audits = store.audit_events(10).expect("read audit history");
+    assert_eq!(audits.len(), 2);
+    assert_eq!(audits[0].action, "ref.update");
+    assert_eq!(audits[0].actor, "alice");
+    assert_eq!(audits[0].target, repository.id);
+    assert_eq!(audits[0].outcome, "success");
+    assert_eq!(audits[0].correlation_id, push.id);
+    assert_eq!(audits[1].action, "repository.import");
+    assert_eq!(audits[1].created_at, 20);
+    assert!(matches!(store.audit_events(0), Err(StoreError::AuditLimit)));
+    store
+        .record_audit_event(&NewAuditEvent {
+            action: "repository.rename",
+            actor: "admin-cli",
+            target: "alice/missing",
+            outcome: "failure",
+            correlation_id: "test-failure",
+            created_at: 22,
+        })
+        .expect("record an audit failure");
+    assert_eq!(
+        store.audit_events(1).expect("read the newest audit event")[0].outcome,
+        "failure"
+    );
 
     assert!(matches!(
         store.create_repository(&repository),
@@ -415,6 +452,8 @@
         created_at: 20,
         origin: RepositoryOrigin::Created,
         initial_references: &[],
+        actor: "admin-cli",
+        correlation_id: "test-missing",
     };
     assert!(matches!(
         store.create_repository(&missing_owner),
@@ -422,14 +461,21 @@
     ));
 
     store
-        .rename_repository("alice", "project", "renamed")
+        .rename_repository(
+            "alice",
+            "project",
+            "renamed",
+            25,
+            "admin-cli",
+            "test-rename",
+        )
         .expect("rename a repository");
     assert!(matches!(
         store.repository("alice", "project"),
         Err(StoreError::RepositoryNotFound(_, _))
     ));
     store
-        .archive_repository("alice", "renamed", 30)
+        .archive_repository("alice", "renamed", 30, "admin-cli", "test-archive")
         .expect("archive a repository");
     let archived = store
         .repository("alice", "renamed")
@@ -437,11 +483,18 @@
     assert_eq!(archived.state, "archived");
     assert_eq!(archived.archived_at, Some(30));
     assert!(matches!(
-        store.archive_repository("alice", "renamed", 31),
+        store.archive_repository("alice", "renamed", 31, "admin-cli", "test-archive-fail"),
         Err(StoreError::RepositoryArchived(_, _))
     ));
     assert!(matches!(
-        store.rename_repository("alice", "renamed", "again"),
+        store.rename_repository(
+            "alice",
+            "renamed",
+            "again",
+            32,
+            "admin-cli",
+            "test-rename-fail"
+        ),
         Err(StoreError::RepositoryArchived(_, _))
     ));
 }
@@ -782,13 +835,14 @@
         (V6_FIXTURE, 6),
         (V7_FIXTURE, 7),
         (V8_FIXTURE, 8),
+        (V9_FIXTURE, 9),
     ] {
         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"), 9);
+        assert_eq!(store.schema_version().expect("read the schema version"), 10);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -854,7 +908,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 9)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 10)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/web_session.rs

Mode 100644100644; object caa94aecfbfe1a622a415f34

@@ -70,6 +70,7 @@
             &issued.challenge,
             &signature,
             &issued.login_csrf,
+            "test-login",
         )
         .expect("verify the login challenge after restart");
     assert_eq!(
@@ -90,6 +91,7 @@
             &issued.challenge,
             &signature,
             &issued.login_csrf,
+            "test-replay",
         ),
         Err(SessionError::Store(StoreError::InvalidLoginChallenge))
     ));
@@ -118,7 +120,7 @@
     let second_public = fs::read_to_string(second_key.with_extension("pub"))
         .expect("read the second SSH public key");
     AccountService::new(database.clone())
-        .add_key("alice", "second", &second_public)
+        .add_key("alice", "second", &second_public, "alice", "test")
         .expect("change account privileges");
     assert!(matches!(
         restarted.authenticate(&session.token, None),
@@ -136,6 +138,7 @@
             &next.challenge,
             &next_signature,
             &next.login_csrf,
+            "test-login",
         )
         .expect("create another session");
     restarted.end_all("alice").expect("end all sessions");