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
18 changes: 18 additions & 0 deletions crates/overslash-api/src/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,24 @@ impl FromRequestParts<AppState> for AuthContext {
.await?;
}

// Impersonation is the one authentication route that leaves
// no trace on the identity it acts as: the touch above stamps
// the *key's* identity, and an org service key is never a
// sub-agent. Without this, a sub-agent reached only through
// this header has `last_active_at` frozen at creation and the
// idle sweep archives it however busy it actually is.
//
// Only the leaf needs stamping — the sweep skips any identity
// with a live child, so a live leaf holds its ancestors up.
if target.kind == "sub_agent" {
let touch_scope =
OrgScope::new(key_row.org_id, state.db_pool(&parts.extensions));
let target_id = target.identity_id;
tokio::spawn(async move {
let _ = touch_scope.touch_identity_last_active(target_id).await;
});
}

(Some(target.identity_id), Some(key_row.identity_id))
}
Some(_) => {
Expand Down
8 changes: 8 additions & 0 deletions crates/overslash-api/src/impersonation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ use crate::error::AppError;
pub struct ResolvedTarget {
/// The effective identity to act as — the leaf of the path.
pub identity_id: Uuid,
/// The kind of the effective identity (`user`, `agent`, `sub_agent`).
///
/// Carried out so the extractor can stamp `last_active_at` on a
/// `sub_agent` target without a second lookup: impersonation is the one
/// authentication route that produces activity on an identity nobody
/// holds a key for, and the idle sweep reaps whatever it cannot see.
pub kind: String,
/// The root user whose display name the caller may still refresh, set only
/// when a name was supplied for a user root that already existed.
///
Expand Down Expand Up @@ -194,6 +201,7 @@ pub async fn resolve_target(

Ok(ResolvedTarget {
identity_id: current.id,
kind: current.kind,
renameable_root,
})
}
Expand Down
123 changes: 123 additions & 0 deletions crates/overslash-api/tests/impersonation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1152,3 +1152,126 @@ async fn name_header_rejects_an_agent_target() {
.await;
assert_eq!(status, 400);
}

// ── Activity tracking ────────────────────────────────────────────────────────

/// Create a `sub_agent` under `parent_id` and return its id.
async fn create_subagent(
base: &str,
client: &reqwest::Client,
admin_key: &str,
parent_id: Uuid,
name: &str,
) -> Uuid {
let sub: Value = client
.post(format!("{base}/v1/identities"))
.header("Authorization", format!("Bearer {admin_key}"))
.json(&json!({"name": name, "kind": "sub_agent", "parent_id": parent_id}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
sub["id"].as_str().unwrap().parse().unwrap()
}

/// The touch is fire-and-forget (`tokio::spawn`), so poll for it rather than
/// racing it. Returns the observed `last_active_at`.
async fn await_last_active_after(
pool: &PgPool,
id: Uuid,
floor: time::OffsetDateTime,
) -> time::OffsetDateTime {
for _ in 0..50 {
let seen: time::OffsetDateTime =
sqlx::query_scalar("SELECT last_active_at FROM identities WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.unwrap();
if seen > floor {
return seen;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("last_active_at never advanced past {floor} for {id}");
}

#[tokio::test]
async fn impersonation_touches_sub_agent_target_last_active() {
let (base, client, pool, org_id, admin_key, sa_id, _, target_agent_id) = setup().await;
let imp_key = create_impersonation_key(&base, &client, &admin_key, org_id, sa_id).await;
let sub_id = create_subagent(&base, &client, &admin_key, target_agent_id, "worker").await;

// Backdate activity so any advance is unambiguously ours.
let floor: time::OffsetDateTime = sqlx::query_scalar(
"UPDATE identities SET last_active_at = now() - interval '2 hours'
WHERE id = $1 RETURNING last_active_at",
)
.bind(sub_id)
.fetch_one(&pool)
.await
.unwrap();

let resp = client
.get(format!("{base}/v1/whoami"))
.header("Authorization", format!("Bearer {imp_key}"))
.header("X-Overslash-As", sub_id.to_string())
.send()
.await
.unwrap();
assert!(
resp.status().is_success(),
"impersonated call should succeed"
);

// Impersonation is the only route that reaches this identity — nobody
// holds a key for it — so if the header does not stamp it, nothing does.
await_last_active_after(&pool, sub_id, floor).await;
}

#[tokio::test]
async fn impersonated_sub_agent_survives_the_idle_sweep() {
let (base, client, pool, org_id, admin_key, sa_id, _, target_agent_id) = setup().await;
let imp_key = create_impersonation_key(&base, &client, &admin_key, org_id, sa_id).await;
let sub_id = create_subagent(&base, &client, &admin_key, target_agent_id, "busy").await;

sqlx::query("UPDATE orgs SET subagent_idle_timeout_secs = 60 WHERE id = $1")
.bind(org_id)
.execute(&pool)
.await
.unwrap();
let floor: time::OffsetDateTime = sqlx::query_scalar(
"UPDATE identities SET last_active_at = now() - interval '2 hours'
WHERE id = $1 RETURNING last_active_at",
)
.bind(sub_id)
.fetch_one(&pool)
.await
.unwrap();

let resp = client
.get(format!("{base}/v1/whoami"))
.header("Authorization", format!("Bearer {imp_key}"))
.header("X-Overslash-As", sub_id.to_string())
.send()
.await
.unwrap();
assert!(resp.status().is_success());
await_last_active_after(&pool, sub_id, floor).await;

overslash_db::repos::identity::archive_idle_subagents(&pool)
.await
.unwrap();

let row = overslash_db::repos::identity::get_by_id(&pool, org_id, sub_id)
.await
.unwrap()
.unwrap();
assert!(
row.archived_at.is_none(),
"a sub-agent used this second must not be reaped as idle (reason: {:?})",
row.archived_reason
);
}
Loading