michal/tit
Browse tree · Show commit · Download archive
Diff
7324ba4a3566 → 21129e04cd2c
README.md
Mode 100644 → 100644; object 4cac34e9c07e → 438e26dcbc79
@@ -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 100644 → 100644; object 10bf2111644e → 6a886e646682
@@ -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 100644 → 100644; object fd27875c1dbb → 92019bbddc84
@@ -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 100644 → 100644; object d8508db5202c → d07e501a78ef
@@ -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 100644 → 100644; object 184112d6775e → df36e3ecf8ff
@@ -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 100644 → 100644; object 04dd1dfc20c4 → e5546004dd2d
@@ -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 100644 → 100644; object b2aea38cd3c5 → c9ebea1ff23f
@@ -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 100644 → 100644; object 6fd36f88f62b → 7e140161e642
@@ -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 100644 → 100644; object cea4c4aeb8fd → a76bc9a81d0b
@@ -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 100644 → 100644; object ecac89789877 → 1beabf69979e
@@ -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 100644 → 100644; object de9934ee416d → f6006a499371
@@ -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 100644 → 100644; object b7b85a5486c8 → d9ecb27c142a
@@ -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 100644 → 100644; object e070864333e2 → 6096f4832bf5
@@ -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 100644 → 100644; object 7785dbed7230 → 93d0a58beef0
@@ -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 100644 → 100644; object 478be87ac15f → 6968bb1a3aef
@@ -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 100644 → 100644; object 96fa80994ea6 → 4a33b211e1f4
@@ -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 100644 → 100644; object caa94aecfbfe → 1a622a415f34
@@ -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");