Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion crates/credentials-core/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,39 @@ pub enum AuthEventKind {
GithubAppPermissionsChanged,
}

/// The consumer-asserted, unverified source of a reported authentication failure.
///
/// This is separate from vault-observed `AuthEventKind` and `detail`: the vault records
/// what the reporter claims about its own path, without vouching for that claim. The
/// closed set ensures consumer input can never become durable plaintext.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReporterSource {
Direct,
RelayStatusField,
RelayMessageParse,
Unrecognised,
}

impl ReporterSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::RelayStatusField => "relay_status_field",
Self::RelayMessageParse => "relay_message_parse",
Self::Unrecognised => "unrecognised",
}
}

pub fn from_wire(value: &str) -> Self {
match value {
"direct" => Self::Direct,
"relay_status_field" => Self::RelayStatusField,
"relay_message_parse" => Self::RelayMessageParse,
_ => Self::Unrecognised,
}
}
}

impl AuthEventKind {
/// The stable storage string for this authentication diagnostic kind.
pub fn as_str(self) -> &'static str {
Expand Down Expand Up @@ -503,7 +536,7 @@ mod tests {

#[cfg(test)]
mod vocabulary_documentation_tests {
use super::{AlarmReason, AuditOp, AuthEventKind};
use super::{AlarmReason, AuditOp, AuthEventKind, ReporterSource};

const RUNBOOK: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
Expand Down Expand Up @@ -667,4 +700,38 @@ mod vocabulary_documentation_tests {
value(AuthEventKind::GithubAppPermissionsChanged),
);
}

#[test]
fn reporter_source_rejects_unrecognised_wire_values() {
assert_eq!(
ReporterSource::from_wire(&"a".repeat(40)),
ReporterSource::Unrecognised
);
}

#[test]
fn reporter_source_values_are_documented() {
let section = documented_subsection(
"#### `auth_events.reporter_source`",
"**Table:** `auth_events`",
);

fn value(source: ReporterSource) -> &'static str {
match source {
ReporterSource::Direct => ReporterSource::Direct.as_str(),
ReporterSource::RelayStatusField => ReporterSource::RelayStatusField.as_str(),
ReporterSource::RelayMessageParse => ReporterSource::RelayMessageParse.as_str(),
ReporterSource::Unrecognised => ReporterSource::Unrecognised.as_str(),
}
}

for source in [
ReporterSource::Direct,
ReporterSource::RelayStatusField,
ReporterSource::RelayMessageParse,
ReporterSource::Unrecognised,
] {
assert_documented(section, "auth_events.reporter_source", value(source));
}
}
}
3 changes: 3 additions & 0 deletions crates/credentials-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ impl RefreshEngine {
kind: AuthEventKind::StaleNonrefreshableLatch.as_str(),
provider_status: None,
detail: None,
reporter_source: None,
}),
)?;
// If a concurrent write moved the version, return the replacement rather
Expand Down Expand Up @@ -435,6 +436,7 @@ impl RefreshEngine {
kind: AuthEventKind::RefreshFailed.as_str(),
provider_status: None,
detail: Some(e.variant_name()),
reporter_source: None,
}),
)?;
Err(EngineError::RefreshFailed(e))
Expand All @@ -459,6 +461,7 @@ impl RefreshEngine {
kind: AuthEventKind::RefreshFailed.as_str(),
provider_status: other.provider_status(),
detail: Some(other.variant_name()),
reporter_source: None,
},
Some(record.record_version),
);
Expand Down
2 changes: 2 additions & 0 deletions crates/credentials-core/src/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ async fn report_stale_then_invalid_grant_latches_needs_reauth() {
kind: "consumer_report_stale",
provider_status: Some(401),
detail: None,
reporter_source: None,
},
)
.expect("report marks the current token stale");
Expand Down Expand Up @@ -735,6 +736,7 @@ async fn stale_pending_clears_in_the_refresh_commit() {
kind: "consumer_report_stale",
provider_status: Some(401),
detail: None,
reporter_source: None,
},
)
.expect("report marks the current token stale");
Expand Down
2 changes: 1 addition & 1 deletion crates/credentials-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub use admin_auth::{
ADMIN_NONCE_LEN, ADMIN_TAG_LEN, VAULT_ID_LEN,
};
pub use admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP_SCHEMA_V1};
pub use audit::{AlarmReason, AuditEntry, AuditOp, AuditRecord, AuthEventKind};
pub use audit::{AlarmReason, AuditEntry, AuditOp, AuditRecord, AuthEventKind, ReporterSource};
pub use contract::{keychain_service_for, vault_id_for, MODULE_ID, STORAGE_NAMESPACE};
pub use credential_id::{
default_refresh_adapter, parse_credential_id, AuthMethod, ParsedCredentialId,
Expand Down
53 changes: 40 additions & 13 deletions crates/credentials-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ use rusqlite::OptionalExtension;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;

use crate::audit::{self, AlarmReason, AuditCtx, AuditEntry, AuditOp, AuditRecord, AuthEventKind};
use crate::audit::{
self, AlarmReason, AuditCtx, AuditEntry, AuditOp, AuditRecord, AuthEventKind, ReporterSource,
};
use crate::envelope::{self, EnvelopeError, RecordBinding};
use crate::key::{KeyId, MasterKey};
pub use crate::record::RecordState;
Expand Down Expand Up @@ -232,6 +234,10 @@ const MIGRATIONS: &[Migration] = &[
version: 7,
statements: "ALTER TABLE credentials ADD COLUMN last_github_app_permissions TEXT;",
},
Migration {
version: 8,
statements: "ALTER TABLE auth_events ADD COLUMN reporter_source TEXT;",
},
];

/// The newest store migration THIS BINARY knows how to apply.
Expand Down Expand Up @@ -2037,6 +2043,7 @@ impl EncryptedStore {
kind: AuthEventKind::GithubAppPermissionsChanged.as_str(),
provider_status: None,
detail: Some(&detail),
reporter_source: None,
},
Some(record_version),
true,
Expand Down Expand Up @@ -2928,26 +2935,34 @@ pub fn read_auth_events_read_only(
/// are two readers (leased and lease-free), and duplicating the pair would mean two
/// places that have to be changed together with nothing to catch a miss.
const AUTH_EVENTS_SELECT: &str =
"SELECT ts_ms, credential_id, kind, provider_status, detail, record_version, applied, \
"SELECT ts_ms, credential_id, kind, provider_status, detail, reporter_source, record_version, applied, \
principal_kind, principal_id \
FROM auth_events ORDER BY seq DESC LIMIT ?1";

// A read-only CLI can run before the daemon has restarted to apply migration 4. Keep
// its existing diagnostics readable by projecting absent principal fields as NULL.
const AUTH_EVENTS_SELECT_PRE_PRINCIPAL: &str =
"SELECT ts_ms, credential_id, kind, provider_status, detail, record_version, applied, \
"SELECT ts_ms, credential_id, kind, provider_status, detail, NULL AS reporter_source, record_version, applied, \
NULL AS principal_kind, NULL AS principal_id \
FROM auth_events ORDER BY seq DESC LIMIT ?1";

const AUTH_EVENTS_SELECT_PRE_REPORTER_SOURCE: &str =
"SELECT ts_ms, credential_id, kind, provider_status, detail, NULL AS reporter_source, record_version, applied, \
principal_kind, principal_id \
FROM auth_events ORDER BY seq DESC LIMIT ?1";

fn auth_events_select(conn: &rusqlite::Connection) -> rusqlite::Result<&'static str> {
let mut stmt = conn.prepare("PRAGMA table_info(auth_events)")?;
let columns = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
if columns.iter().any(|column| column == "principal_kind")
&& columns.iter().any(|column| column == "principal_id")
{
let has_principal = columns.iter().any(|column| column == "principal_kind")
&& columns.iter().any(|column| column == "principal_id");
let has_reporter_source = columns.iter().any(|column| column == "reporter_source");
if has_principal && has_reporter_source {
Ok(AUTH_EVENTS_SELECT)
} else if has_principal {
Ok(AUTH_EVENTS_SELECT_PRE_REPORTER_SOURCE)
} else {
Ok(AUTH_EVENTS_SELECT_PRE_PRINCIPAL)
}
Expand All @@ -2962,10 +2977,11 @@ fn auth_event_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<AuthEvent> {
kind: r.get(2)?,
provider_status: r.get::<_, Option<i64>>(3)?.map(|s| s as u16),
detail: r.get(4)?,
record_version: r.get::<_, Option<i64>>(5)?.map(|v| v as u64),
applied: r.get::<_, i64>(6)? != 0,
principal_kind: r.get(7)?,
principal_id: r.get(8)?,
reporter_source: r.get(5)?,
record_version: r.get::<_, Option<i64>>(6)?.map(|v| v as u64),
applied: r.get::<_, i64>(7)? != 0,
principal_kind: r.get(8)?,
principal_id: r.get(9)?,
})
}

Expand Down Expand Up @@ -3003,6 +3019,8 @@ pub struct AuthEvent {
pub kind: String,
pub provider_status: Option<u16>,
pub detail: Option<String>,
/// Consumer-asserted, unverified; from `ReporterSource::as_str`, never raw consumer input.
pub reporter_source: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ck auth events drops AuthEvent::reporter_source from its output, so operators cannot see the source this field records through the documented event workflow. Update the CLI event rendering to include the optional reporter source.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/store.rs, line 3023:

<comment>`ck auth events` drops `AuthEvent::reporter_source` from its output, so operators cannot see the source this field records through the documented event workflow. Update the CLI event rendering to include the optional reporter source.</comment>

<file context>
@@ -3003,6 +3019,8 @@ pub struct AuthEvent {
     pub provider_status: Option<u16>,
     pub detail: Option<String>,
+    /// Consumer-asserted, unverified; from `ReporterSource::as_str`, never raw consumer input.
+    pub reporter_source: Option<String>,
     pub record_version: Option<u64>,
     /// Whether this observation actually changed the credential. False for a report
</file context>

pub record_version: Option<u64>,
/// Whether this observation actually changed the credential. False for a report
/// against a superseded version, and for events that authorise no change.
Expand Down Expand Up @@ -3096,6 +3114,8 @@ pub struct AuthObservation<'a> {
pub provider_status: Option<u16>,
/// A typed variant or locally rendered safe metadata. Never response text.
pub detail: Option<&'a str>,
/// Consumer-asserted, unverified; raw consumer input is unrepresentable here.
pub reporter_source: Option<ReporterSource>,
}

/// Append one `auth_events` row. Diagnostics only: not MAC-chained, prunable, and
Expand All @@ -3113,14 +3133,15 @@ pub(crate) fn append_auth_event_tx(
) -> rusqlite::Result<()> {
tx.execute(
"INSERT INTO auth_events \
(ts_ms, credential_id, kind, provider_status, detail, record_version, applied) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(ts_ms, credential_id, kind, provider_status, detail, reporter_source, record_version, applied) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
now_ms(),
credential_id,
obs.kind,
obs.provider_status.map(|s| s as i64),
obs.detail,
obs.reporter_source.map(ReporterSource::as_str),
record_version.map(|v| v as i64),
applied as i64,
],
Expand Down Expand Up @@ -3783,6 +3804,7 @@ mod tests {
kind: "consumer_report",
provider_status: Some(401),
detail: None,
reporter_source: None,
},
Some(1),
)
Expand Down Expand Up @@ -4771,6 +4793,7 @@ mod tests {
kind: "consumer_report_stale",
provider_status: Some(401),
detail: None,
reporter_source: None,
},
)
.expect("stale report is accepted");
Expand Down Expand Up @@ -4818,6 +4841,7 @@ mod tests {
kind: "consumer_report",
provider_status: Some(401),
detail: None,
reporter_source: None,
};

// Stale report against v1 while the store holds v2.
Expand Down Expand Up @@ -5060,6 +5084,7 @@ mod tests {
kind: "consumer_report",
provider_status: Some(401),
detail: None,
reporter_source: None,
};

// First report at the served version: a real transition.
Expand Down Expand Up @@ -5133,6 +5158,7 @@ mod tests {
kind: "refresh_failed",
provider_status: Some(503),
detail: Some("status"),
reporter_source: None,
},
Some(1),
)
Expand All @@ -5148,6 +5174,7 @@ mod tests {
kind: "consumer_report",
provider_status: Some(401),
detail: None,
reporter_source: None,
},
Some(1),
)
Expand Down Expand Up @@ -5273,7 +5300,7 @@ mod tests {
fn the_newest_migration_version_is_pinned_because_the_manifest_declares_it() {
assert_eq!(
newest_migration_version(),
7,
8,
"the newest migration changed. This value is DECLARED in the module manifest \
as store_schema_version, so a supervisor comparing declared-against-actual \
sees it. Update the literal, and note the manifest consequence."
Expand Down
9 changes: 8 additions & 1 deletion crates/credentials-module/src/bin/credentials_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2955,8 +2955,15 @@ fn cmd_events(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> {
.record_version
.map(|v| format!("v{v}"))
.unwrap_or_else(|| "-".into());
// The consumer-asserted source rides at the end and only when present, so
// legacy NULL rows render exactly as before this column existed.
let source = e
.reporter_source
.as_deref()
.map(|s| format!(" src={s}"))
.unwrap_or_default();
println!(
"{when} {:34} {:16} {principal:24} {what:22} {version:6} applied={}",
"{when} {:34} {:16} {principal:24} {what:22} {version:6} applied={}{source}",
e.credential_id,
e.kind,
if e.applied { "yes" } else { "no" }
Expand Down
Loading
Loading