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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.

### Security

- **Bootstrap authority boundaries now fail closed.** Kernel management requests
require a valid principal instead of falling back to the `default` bootstrap
administrator, malformed connection lifecycle identities cannot move its
counter, bootstrap profile/key and legacy-profile migration failures abort
kernel construction, and agent modification cannot remove `default` from the
built-in `admin` group. The local CLI retains its intentional active-principal
default before requests reach the kernel. Closes #1256.

- **Device attenuation now applies to every kernel authority view.** Capsule,
agent, and group inventory checks use the authenticating device scope, and a
scoped pair request containing bare `*` requires effective pair-admin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,23 @@ async fn send_admin(
caller: &PrincipalId,
suffix: &str,
req: AdminKernelRequest,
) -> serde_json::Value {
send_admin_with_raw_principal(kernel, Some(caller.as_str()), suffix, req).await
}

async fn send_admin_with_raw_principal(
kernel: &Arc<Kernel>,
principal: Option<&str>,
suffix: &str,
req: AdminKernelRequest,
) -> serde_json::Value {
let topic = Topic::admin_request(suffix);
let response_topic = Topic::admin_response(suffix);
let mut rx = kernel.event_bus.subscribe_topic(response_topic.as_str());

let payload = serde_json::to_value(&req).expect("serialize admin request");
let mut msg = IpcMessage::new(topic, IpcPayload::RawJson(payload), kernel.session_id.0);
msg.principal = Some(caller.as_str().to_string());
msg.principal = principal.map(str::to_string);
let _ = kernel.event_bus.publish(astrid_events::AstridEvent::Ipc {
metadata: astrid_events::EventMetadata::new("test"),
message: msg,
Expand Down Expand Up @@ -283,6 +292,63 @@ async fn admin_request_id_echoed_on_deny_path_too() {
assert_eq!(resp["status"], "Error");
}

#[tokio::test(flavor = "multi_thread")]
async fn admin_router_denies_missing_and_invalid_principals_deterministically() {
let (_dir, kernel) = fixture().await;

for (suffix, principal) in [
("caller.missing", None),
("caller.invalid", Some("alice@evil.example")),
] {
let request_id = format!("req-{suffix}");
let response = send_admin_with_raw_principal(
&kernel,
principal,
suffix,
AdminKernelRequest::with_request_id(&request_id, AdminRequestKind::AgentList),
)
.await;
assert_eq!(response["request_id"], request_id);
assert_eq!(response["status"], "Error");
assert_eq!(response["data"], super::super::MANAGEMENT_CALLER_REQUIRED);
}

let entries = kernel
.audit_log
.get_session_entries(&kernel.session_id)
.await
.expect("read audit chain");
let denials = entries
.iter()
.filter(|entry| {
entry.principal.as_ref() == Some(&PrincipalId::anonymous())
&& matches!(
&entry.action,
AuditAction::AdminRequest { method, .. }
if method == "admin.agent.list"
)
})
.collect::<Vec<_>>();
assert_eq!(
denials.len(),
2,
"missing and malformed callers must each produce one terminal audit row"
);
for (entry, expected_reason) in denials
.iter()
.zip(["missing principal", "invalid principal"])
{
let AuthorizationProof::Denied { reason } = &entry.authorization else {
panic!("caller-boundary denial must carry a denied authorization proof");
};
assert!(reason.contains(expected_reason), "got: {reason}");
let AuditOutcome::Failure { error } = &entry.outcome else {
panic!("caller-boundary denial must carry a failure outcome");
};
assert!(error.contains(expected_reason), "got: {error}");
}
}

// ── Per-device scope attenuation at the cap-gate ────────────────────

/// Like [`send_admin`] but stamps a host-derived `device_key_id` on the IPC
Expand Down
12 changes: 12 additions & 0 deletions crates/astrid-kernel/src/kernel_router/admin/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,18 @@ async fn agent_modify_from_req(
Ok(changed) => changed,
Err(e) => return err_bad_input(format!("group delta rejected: {e}")),
};
if principal == PrincipalId::default()
&& !profile
.groups
.iter()
.any(|group| group == astrid_core::groups::BUILTIN_ADMIN)
{
return err_bad_input(
"cannot remove the built-in `admin` group from the `default` principal — it is the \
single-tenant bootstrap anchor"
.to_string(),
);
}
let capsules_changed = match apply_set_delta::<CapsuleGrant>(
&mut profile.capsules,
&add_capsules,
Expand Down
68 changes: 67 additions & 1 deletion crates/astrid-kernel/src/kernel_router/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ use astrid_events::kernel_api::{
};
use tracing::warn;

use super::caller::{CallerResolutionError, MANAGEMENT_CALLER_REQUIRED};
use super::{
AdminAuditEntry, AuthorityScope, authorize_request, publish_response, record_admin_audit,
resolve_caller, resolve_device_key_id,
Expand Down Expand Up @@ -109,9 +110,33 @@ pub(crate) fn spawn_admin_router(kernel: Arc<crate::Kernel>) -> astrid_runtime::
// pure-read endpoints. (For an HTTP front that
// hosts thousands of agents the serial loop is
// unworkable.)
let caller = match resolve_caller(message) {
Ok(caller) => caller,
Err(error) => {
warn!(
security_event = true,
Comment thread
joshuajbouw marked this conversation as resolved.
topic = %message.topic,
reason = error.reason(),
"Rejected admin management request without a valid principal"
);
let kernel = Arc::clone(&kernel);
let response_topic = admin_response_topic(&message.topic);
let device_key_id = resolve_device_key_id(message);
astrid_runtime::spawn(async move {
reject_admin_request_without_caller(
&kernel,
response_topic,
device_key_id,
req,
error,
)
.await;
});
continue;
},
};
let kernel = Arc::clone(&kernel);
let topic = message.topic.clone();
let caller = resolve_caller(message);
let device_key_id = resolve_device_key_id(message);
astrid_runtime::spawn(async move {
handle_admin_request(&kernel, topic, caller, device_key_id, req).await;
Expand All @@ -129,6 +154,47 @@ pub(crate) fn spawn_admin_router(kernel: Arc<crate::Kernel>) -> astrid_runtime::
})
}

/// Record and reject an admin request that crossed the IPC boundary without a
/// valid authenticated principal. The reserved `anonymous` identity preserves
/// the audit trail without granting the malformed envelope a caller identity.
async fn reject_admin_request_without_caller(
kernel: &Arc<crate::Kernel>,
response_topic: Topic,
device_key_id: Option<String>,
req: AdminKernelRequest,
error: CallerResolutionError,
) {
let caller = PrincipalId::anonymous();
let method = admin_request_method(&req.kind);
let required_cap =
required_capability_for_admin_request(&req.kind, resolve_admin_scope(&req.kind, &caller));
let reason = format!("{MANAGEMENT_CALLER_REQUIRED}: {}", error.reason());
record_admin_audit(
kernel,
AdminAuditEntry {
caller: &caller,
method,
required_cap,
device_key_id: device_key_id.as_deref(),
target_principal: admin_target_principal(&req.kind).cloned(),
params: sanitize_admin_audit_params(&req.kind),
authorization: AuthorizationProof::Denied {
reason: reason.clone(),
},
outcome: AuditOutcome::failure(reason),
},
)
.await;
publish_response(
kernel,
response_topic,
AdminKernelResponse::for_request(
req.request_id,
AdminResponseBody::Error(MANAGEMENT_CALLER_REQUIRED.to_string()),
),
);
}

/// Compute the response topic for an incoming admin request topic.
fn admin_response_topic(input_topic: &str) -> Topic {
input_topic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use std::sync::Arc;

use astrid_core::dirs::AstridHome;
use astrid_core::groups::{BUILTIN_AGENT, BUILTIN_RESTRICTED};
use astrid_core::groups::{BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED};
use astrid_core::principal::PrincipalId;
use astrid_core::profile::PrincipalProfile;
use astrid_events::kernel_api::{AdminRequestKind, AdminResponseBody};
Expand Down Expand Up @@ -186,6 +186,59 @@ async fn agent_modify_empty_delta_verifies_target_without_writing_profile() {
assert_error_contains(&missing, "missing-target");
}

#[tokio::test(flavor = "multi_thread")]
async fn agent_modify_preserves_default_admin_bootstrap_anchor() {
let (_dir, kernel) = fixture().await;
let default = PrincipalId::default();
let path = PrincipalProfile::path_for(&kernel.astrid_home, &default);
PrincipalProfile {
groups: vec![BUILTIN_ADMIN.to_string()],
..Default::default()
}
.save_to_path(&path)
.expect("seed default admin profile");

let removal = handlers::dispatch(
&kernel,
&default,
AdminRequestKind::AgentModify {
principal: default.clone(),
add_groups: Vec::new(),
remove_groups: vec![BUILTIN_ADMIN.to_string()],
add_capsules: Vec::new(),
remove_capsules: Vec::new(),
},
)
.await;
assert_error_contains(&removal, "bootstrap anchor");
assert_eq!(
PrincipalProfile::load_from_path(&path)
.expect("reload default profile")
.groups,
vec![BUILTIN_ADMIN.to_string()]
);

let remove_and_add = handlers::dispatch(
&kernel,
&default,
AdminRequestKind::AgentModify {
principal: default.clone(),
add_groups: vec![BUILTIN_ADMIN.to_string()],
remove_groups: vec![BUILTIN_ADMIN.to_string()],
add_capsules: Vec::new(),
remove_capsules: Vec::new(),
},
)
.await;
assert_success(&remove_and_add);
assert_eq!(
PrincipalProfile::load_from_path(&path)
.expect("reload default profile")
.groups,
vec![BUILTIN_ADMIN.to_string()]
);
}

#[tokio::test(flavor = "multi_thread")]
async fn agent_modify_rejects_unknown_principal() {
let (_dir, kernel) = fixture().await;
Expand Down
49 changes: 49 additions & 0 deletions crates/astrid-kernel/src/kernel_router/caller.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use astrid_core::principal::PrincipalId;
use astrid_events::ipc::IpcMessage;

/// Stable outward denial for a management request with no authenticated caller.
pub(super) const MANAGEMENT_CALLER_REQUIRED: &str =
"management request denied: missing or invalid principal";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CallerResolutionError {
Missing,
Invalid,
}

impl CallerResolutionError {
pub(super) const fn reason(self) -> &'static str {
match self {
Self::Missing => "missing principal",
Self::Invalid => "invalid principal",
}
}
}

/// Resolve the authenticated caller from a management IPC envelope.
///
/// The kernel never supplies the interactive CLI's active-principal default:
/// that deliberate UX choice is made by the local client before it publishes.
/// Once a request reaches this authority boundary, an absent or malformed
/// principal must not acquire the bootstrap `default` principal's authority.
pub(super) fn resolve_caller(message: &IpcMessage) -> Result<PrincipalId, CallerResolutionError> {
let raw = message
.principal
.as_deref()
.ok_or(CallerResolutionError::Missing)?;
PrincipalId::new(raw).map_err(|_| CallerResolutionError::Invalid)
}

/// Resolve one connection-tracking identity without granting bootstrap authority.
///
/// A missing identity is the explicit no-capability `anonymous` principal used by
/// the legacy handshake. A malformed identity is rejected so a forged lifecycle
/// message cannot move any principal's counter.
pub(super) fn resolve_connection_principal(
message: &IpcMessage,
) -> Result<PrincipalId, CallerResolutionError> {
match message.principal.as_deref() {
Some(raw) => PrincipalId::new(raw).map_err(|_| CallerResolutionError::Invalid),
None => Ok(PrincipalId::anonymous()),
}
}
Loading
Loading