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
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

245 changes: 228 additions & 17 deletions crates/starstats-server/src/device_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ use uuid::Uuid;
/// and `redeem` needs both device + user stores so it gets its own.
///
/// The `audit_log` argument wires the hash-chained audit backend into
/// the `set_sync` handler via an `Extension` layer. Only `list_router`
/// receives the layer because `set_sync` (the only audit-emitting
/// handler) lives there; `start_router` and `redeem_router` don't need
/// it and axum silently ignores missing extensions on handlers that
/// don't extract them.
/// both the `list_router` (where `revoke` and `set_sync` emit
/// `device.revoked` / `device.sync_enabled` / `device.sync_disabled`)
/// AND the `redeem_router` (where `redeem` emits `device.paired`).
/// `start_router` doesn't need it — `start` only mints a pairing
/// code and emits no audit.
pub fn routes(
devices: Arc<PostgresDeviceStore>,
users: Arc<PostgresUserStore>,
Expand All @@ -60,14 +60,15 @@ pub fn routes(
post(set_sync::<PostgresDeviceStore>),
)
.with_state(devices.clone())
.layer(Extension(audit_log));
.layer(Extension(audit_log.clone()));

let redeem_router = Router::new()
.route(
"/v1/auth/devices/redeem",
post(redeem::<PostgresDeviceStore, PostgresUserStore>),
)
.with_state((devices, users));
.with_state((devices, users))
.layer(Extension(audit_log));

start_router.merge(list_router).merge(redeem_router)
}
Expand Down Expand Up @@ -203,6 +204,7 @@ pub async fn start<D: DeviceStore>(
pub async fn redeem<D: DeviceStore, U: UserStore>(
State((devices, users)): State<(Arc<D>, Arc<U>)>,
Extension(issuer): Extension<Arc<TokenIssuer>>,
Extension(audit): Extension<Arc<dyn AuditLog>>,
Json(req): Json<RedeemRequest>,
) -> impl IntoResponse {
let code = req.code.trim().to_uppercase();
Expand Down Expand Up @@ -250,15 +252,44 @@ pub async fn redeem<D: DeviceStore, U: UserStore>(
&user.claimed_handle,
redeemed.device_id,
) {
Ok(token) => (
StatusCode::OK,
Json(RedeemResponse {
token,
device_id: redeemed.device_id.to_string(),
label: redeemed.label,
}),
)
.into_response(),
Ok(token) => {
// Audit: device pairing — the forensic counterpart to
// `device.revoked`. The redeem endpoint is unauthenticated
// (the desktop client is being paired because it doesn't
// yet hold a token), so actor identity comes from the
// user looked up via the pairing's user_id rather than a
// bearer claim. Best-effort emission per the CLAUDE.md
// invariant. Surfaced 2026-05-28 alongside the missing
// `device.revoked` emit — the device-lifecycle audit
// trail was a forensic black hole.
if let Err(e) = audit
.append(AuditEntry {
actor_sub: Some(user.id.to_string()),
actor_handle: Some(user.claimed_handle.clone()),
action: "device.paired".to_string(),
payload: serde_json::json!({
"device_id": redeemed.device_id.to_string(),
"label": redeemed.label,
}),
})
.await
{
tracing::warn!(
error = %e,
device_id = %redeemed.device_id,
"audit log append failed (device.paired)"
);
}
(
StatusCode::OK,
Json(RedeemResponse {
token,
device_id: redeemed.device_id.to_string(),
label: redeemed.label,
}),
)
.into_response()
}
Err(e) => {
tracing::error!(error = %e, "sign device token failed");
error(StatusCode::INTERNAL_SERVER_ERROR, "sign_failed", None)
Expand Down Expand Up @@ -333,6 +364,7 @@ pub async fn list<D: DeviceStore>(
)]
pub async fn revoke<D: DeviceStore>(
State(devices): State<Arc<D>>,
Extension(audit): Extension<Arc<dyn AuditLog>>,
user: AuthenticatedUser,
Path(device_id): Path<Uuid>,
) -> impl IntoResponse {
Expand All @@ -344,7 +376,33 @@ pub async fn revoke<D: DeviceStore>(
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "bad_subject", None),
};
match devices.revoke(user_id, device_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Ok(()) => {
// Audit: explicit user-initiated device revocation — the
// forensic counterpart to `device.paired` (in the redeem
// handler). Without this emit, every revocation was
// recorded only as a `revoked_at` timestamp on the
// `devices` row with no trail of who clicked it or when —
// surfaced 2026-05-28 when a tray auth-loss investigation
// hit a dead end because the audit log had zero
// device-lifecycle entries. Best-effort emission per the
// CLAUDE.md invariant.
if let Err(e) = audit
.append(AuditEntry {
actor_sub: Some(user.sub.clone()),
actor_handle: Some(user.preferred_username.clone()),
action: "device.revoked".to_string(),
payload: serde_json::json!({ "device_id": device_id.to_string() }),
})
.await
{
tracing::warn!(
error = %e,
device_id = %device_id,
"audit log append failed (device.revoked)"
);
}
StatusCode::NO_CONTENT.into_response()
}
Err(DeviceError::DeviceNotFound) => error(StatusCode::NOT_FOUND, "device_not_found", None),
Err(e) => {
tracing::error!(error = %e, "revoke device failed");
Expand Down Expand Up @@ -512,12 +570,14 @@ mod tests {
let (issuer, verifier) = fresh_pair();
let issuer_arc = Arc::new(issuer);

let audit_arc: Arc<dyn AuditLog> = Arc::new(MemoryAuditLog::default());
let app = Router::new()
.route(
"/v1/auth/devices/redeem",
post(redeem::<MemoryDeviceStore, MemoryUserStore>),
)
.layer(Extension(issuer_arc))
.layer(Extension(audit_arc))
.with_state((devices.clone(), users.clone()));

(app, verifier, users, devices, user)
Expand Down Expand Up @@ -717,6 +777,7 @@ mod tests {
};
let resp = revoke(
State(devices.clone()),
noop_audit(),
auth,
axum::extract::Path(r1.device_id),
)
Expand Down Expand Up @@ -946,6 +1007,7 @@ mod tests {
};
let resp = revoke(
State(devices.clone()),
noop_audit(),
auth,
axum::extract::Path(r.device_id),
)
Expand Down Expand Up @@ -1040,4 +1102,153 @@ mod tests {
assert_eq!(entries.len(), 1, "expected exactly one audit entry");
assert_eq!(entries[0].action, "device.sync_disabled");
}

/// `revoke` must emit a `device.revoked` audit entry naming the
/// caller and the revoked device. Regression for the 2026-05-28
/// investigation that found device revocations were a forensic
/// black hole — the only record was the `revoked_at` timestamp
/// on the `devices` row with no actor trail.
#[tokio::test]
async fn revoke_emits_device_revoked_audit_entry() {
let devices = Arc::new(MemoryDeviceStore::new());
let user_id = Uuid::new_v4();
let device_id = devices.seed_paired_device(user_id, "PC").await.unwrap();

let audit_log = Arc::new(MemoryAuditLog::default());
let audit_ext: Extension<Arc<dyn AuditLog>> =
Extension(audit_log.clone() as Arc<dyn AuditLog>);

let auth = AuthenticatedUser {
sub: user_id.to_string(),
preferred_username: "TheCodeSaiyan".to_string(),
token_type: TokenType::User,
device_id: None,
};
let resp = revoke(
State(devices.clone()),
audit_ext,
auth,
axum::extract::Path(device_id),
)
.await
.into_response();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);

let entries = audit_log.snapshot();
assert_eq!(entries.len(), 1, "expected exactly one audit entry");
let entry = &entries[0];
assert_eq!(entry.action, "device.revoked");
assert_eq!(
entry.actor_sub.as_deref(),
Some(user_id.to_string().as_str())
);
assert_eq!(entry.actor_handle.as_deref(), Some("TheCodeSaiyan"));
assert_eq!(
entry.payload.get("device_id").and_then(|v| v.as_str()),
Some(device_id.to_string().as_str()),
);
}

/// A 404 revoke (device not owned by caller) MUST NOT emit an
/// audit entry — we don't want pre-auth probes filling the log.
#[tokio::test]
async fn revoke_404_does_not_emit_audit_entry() {
let (_, _, _, devices, user) = fixture().await;
let other_user_id = Uuid::new_v4();
let p = devices
.create_pairing(other_user_id, "Other's PC", crate::devices::PAIRING_TTL)
.await
.unwrap();
let r = devices.redeem(&p.code).await.unwrap();

let audit_log = Arc::new(MemoryAuditLog::default());
let audit_ext: Extension<Arc<dyn AuditLog>> =
Extension(audit_log.clone() as Arc<dyn AuditLog>);

let auth = AuthenticatedUser {
sub: user.id.to_string(),
preferred_username: user.claimed_handle.clone(),
token_type: TokenType::User,
device_id: None,
};
let resp = revoke(
State(devices.clone()),
audit_ext,
auth,
axum::extract::Path(r.device_id),
)
.await
.into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);

let entries = audit_log.snapshot();
assert!(
entries.is_empty(),
"404 revoke (not owned by caller) must not emit audit; got: {:?}",
entries,
);
}

/// `redeem` must emit a `device.paired` audit entry attributed to
/// the user the pairing belongs to (the redeem endpoint is
/// unauthenticated — actor identity comes from the looked-up
/// user, not a bearer claim).
#[tokio::test]
async fn redeem_emits_device_paired_audit_entry() {
// Build a router with an inspectable audit log. Mirrors the
// shape of `fixture()` but keeps a handle on the Arc so we
// can `.snapshot()` it post-redeem.
let users = Arc::new(MemoryUserStore::new());
let devices = Arc::new(MemoryDeviceStore::new());
let phc = hash_password("supersecret-1234").unwrap();
let user = users
.create("daisy@example.com", &phc, "TheCodeSaiyan")
.await
.unwrap();
let (issuer, _verifier) = fresh_pair();
let audit_log = Arc::new(MemoryAuditLog::default());
let audit_ext: Extension<Arc<dyn AuditLog>> =
Extension(audit_log.clone() as Arc<dyn AuditLog>);
let app = Router::new()
.route(
"/v1/auth/devices/redeem",
post(redeem::<MemoryDeviceStore, MemoryUserStore>),
)
.layer(Extension(Arc::new(issuer)))
.layer(audit_ext)
.with_state((devices.clone(), users.clone()));

let pairing = devices
.create_pairing(user.id, "Daisy's PC", crate::devices::PAIRING_TTL)
.await
.unwrap();

let (status, _) = post_json(
&app,
"/v1/auth/devices/redeem",
&serde_json::json!({"code": pairing.code}),
None,
)
.await;
assert_eq!(status, StatusCode::OK);

let entries = audit_log.snapshot();
assert_eq!(
entries.len(),
1,
"expected exactly one audit entry; got: {:?}",
entries
);
let entry = &entries[0];
assert_eq!(entry.action, "device.paired");
assert_eq!(
entry.actor_sub.as_deref(),
Some(user.id.to_string().as_str()),
);
assert_eq!(entry.actor_handle.as_deref(), Some("TheCodeSaiyan"));
assert_eq!(
entry.payload.get("label").and_then(|v| v.as_str()),
Some("Daisy's PC"),
);
}
}
Loading