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

### Security

- **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
authority. Explicit pair-scope allow and deny patterns pass canonical
validation before persistence, and structural no-escalation denials produce
a failure audit row without a preceding success row. Malformed, unknown, and
revoked device IDs fail closed, and cache invalidation prevents stale
pre-revocation profiles from being republished. Closes #1237.

- Bumped the locked `crossbeam-epoch` transitive dependency to `0.9.20`, clearing `RUSTSEC-2026-0204` (published 2026-07-06 — an invalid pointer dereference in the `fmt::Pointer` / pre-0.9 `fmt::Display` impls for `Atomic` and `Shared` when the underlying pointer is null or invalid) from the RustSec audit. The advisory landed the day after the 0.9.3 release and turned the Security Audit CI check red on `main` and every open PR; nothing in our own code changed. Lockfile-only, same-minor patch — no other dependency moved. Closes #1161.
## [0.9.3] - 2026-07-06

Expand Down
14 changes: 8 additions & 6 deletions crates/astrid-audit/src/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,14 @@ pub enum AuditAction {
/// Configuration was reloaded.
ConfigReloaded,

/// Kernel management-API request (admin surface) — allowed or denied
/// by the [`CapabilityCheck`](astrid_capabilities::CapabilityCheck)
/// enforcement preamble. Pair this action with
/// [`AuthorizationProof::Denied`] + [`AuditOutcome::failure`] for the
/// deny path, or any of the positive authorization variants +
/// [`AuditOutcome::success`] for the allow path.
/// Kernel management-API request (admin surface) — allowed or denied by
/// the [`CapabilityCheck`](astrid_capabilities::CapabilityCheck)
/// enforcement preamble and any request-specific no-escalation checks.
/// Pair this action with [`AuthorizationProof::Denied`] plus
/// [`AuditOutcome::failure`] for an authorization denial. A request that
/// passes authorization but fails request-shape validation carries a
/// positive authorization proof plus a failure outcome; a completed allow
/// path carries a positive proof plus [`AuditOutcome::success`].
AdminRequest {
/// Request variant name (e.g. `"Shutdown"`, `"ReloadCapsules"`).
method: String,
Expand Down
162 changes: 127 additions & 35 deletions crates/astrid-capsule/src/profile_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ use astrid_core::profile::{PrincipalProfile, ProfileError, ProfileResult};
/// One instance is created per kernel boot and shared (via `Arc`) through
/// the capsule load context into every [`WasmEngine`](crate::engine::wasm::WasmEngine).
/// Reads vastly outnumber writes (entries are populated on first use and
/// never mutated afterward), so the inner map sits behind a `RwLock`.
/// never mutated afterward), so profiles and their per-principal invalidation
/// generations share one `RwLock`.
#[derive(Debug)]
pub struct PrincipalProfileCache {
/// Root against which principal profile paths are resolved.
Expand All @@ -45,7 +46,21 @@ pub struct PrincipalProfileCache {
/// [`AstridHome::resolve`] once — matching the rest of the kernel's
/// one-shot home resolution at boot.
astrid_home: AstridHome,
cache: RwLock<HashMap<PrincipalId, Arc<PrincipalProfile>>>,
state: RwLock<ProfileCacheState>,
}

#[derive(Debug, Default)]
struct ProfileCacheState {
profiles: HashMap<PrincipalId, Arc<PrincipalProfile>>,
generations: HashMap<PrincipalId, u64>,
}

impl ProfileCacheState {
fn invalidate(&mut self, principal: &PrincipalId) {
self.profiles.remove(principal);
let generation = self.generations.entry(principal.clone()).or_default();
*generation = generation.wrapping_add(1);
}
}

impl PrincipalProfileCache {
Expand Down Expand Up @@ -75,7 +90,7 @@ impl PrincipalProfileCache {
pub fn with_home(astrid_home: AstridHome) -> Self {
Self {
astrid_home,
cache: RwLock::new(HashMap::new()),
state: RwLock::new(ProfileCacheState::default()),
}
}

Expand All @@ -98,41 +113,50 @@ impl PrincipalProfileCache {
/// The caller is expected to deny the invocation on any of these errors
/// (see Layer 3 design doc, issue #666).
pub fn resolve(&self, principal: &PrincipalId) -> ProfileResult<Arc<PrincipalProfile>> {
// Fast path: a concurrent reader should never take the write lock.
// RwLock poisoning can happen only if a writer panicked mid-insert;
// recover and continue — the map is a simple key → Arc mapping
// with no partial-write window.
if let Some(profile) = self
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(principal)
{
return Ok(Arc::clone(profile));
loop {
let state = self
.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(profile) = state.profiles.get(principal) {
return Ok(Arc::clone(profile));
}
let generation = state.generations.get(principal).copied().unwrap_or(0);
drop(state);
let profile = Arc::new(PrincipalProfile::load(&self.astrid_home, principal)?);
if let Some(profile) = self.publish_loaded(principal, profile, generation) {
return Ok(profile);
}
}
}

let profile = Arc::new(PrincipalProfile::load(&self.astrid_home, principal)?);

let mut w = self
.cache
fn publish_loaded(
&self,
principal: &PrincipalId,
profile: Arc<PrincipalProfile>,
generation: u64,
) -> Option<Arc<PrincipalProfile>> {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Two threads may race to resolve the same principal; the first
// writer wins and the second returns the already-inserted value.
let entry = w.entry(principal.clone()).or_insert(profile);
Ok(Arc::clone(entry))
if state.generations.get(principal).copied().unwrap_or(0) != generation {
return None;
}
let entry = state.profiles.entry(principal.clone()).or_insert(profile);
Some(Arc::clone(entry))
}

/// Drop the cached entry for `principal`, forcing a reload on the next
/// [`resolve`](Self::resolve) call.
///
/// Reserved for Layer 6 management IPC (`astrid.v1.admin.quota.set`).
/// Unused today — the invalidation model is kernel restart.
/// A concurrent pre-invalidation load cannot repopulate the cache.
pub fn invalidate(&self, principal: &PrincipalId) {
self.cache
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(principal);
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.invalidate(principal);
}

/// Persist an operator-consented local-egress endpoint to `principal`'s
Expand Down Expand Up @@ -183,7 +207,7 @@ impl PrincipalProfileCache {
// the disk write, or snapshot-then-swap) only if this becomes a
// measurable `resolve()` latency source.
let mut guard = self
.cache
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);

Expand All @@ -197,30 +221,43 @@ impl PrincipalProfileCache {
.or_default();

if entries.iter().any(|e| e.eq_ignore_ascii_case(endpoint)) {
// Already persisted for this capsule — idempotent. Drop the stale
// cache entry so a reload reflects on-disk state, then return
// success.
guard.remove(principal);
// Already persisted for this capsule — idempotent. The profile was
// just loaded from disk while holding the cache write lock, so it
// is the current system-of-record value and can refresh the cache
// directly without a generation bump or another disk read.
guard.profiles.insert(principal.clone(), Arc::new(profile));
return Ok(());
}

entries.push(endpoint.to_string());
// `save_to_path` re-runs `validate()` before writing, so a malformed
// profile never reaches disk.
profile.save_to_path(&path)?;
guard.remove(principal);
guard.invalidate(principal);
Ok(())
}

/// Number of principals currently cached. Test-only introspection.
#[cfg(test)]
#[must_use]
pub(crate) fn len(&self) -> usize {
self.cache
self.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.profiles
.len()
}

#[cfg(test)]
fn generation_for(&self, principal: &PrincipalId) -> u64 {
self.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.generations
.get(principal)
.copied()
.unwrap_or(0)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -384,6 +421,56 @@ mod tests {
assert_eq!(second.quotas.max_memory_bytes, 8_388_608);
}

#[test]
fn invalidation_prevents_stale_load_publication() {
let (dir, cache) = fixture();
let p = principal("generation-race");
write_profile(
&dir,
&p,
&format!(
"profile_version = {CURRENT_PROFILE_VERSION}\n\
enabled = true\n"
),
);
let generation = cache.generation_for(&p);
let stale = Arc::new(
PrincipalProfile::load(&cache.astrid_home, &p).expect("load pre-invalidation profile"),
);

write_profile(
&dir,
&p,
&format!(
"profile_version = {CURRENT_PROFILE_VERSION}\n\
enabled = false\n"
),
);
cache.invalidate(&p);

assert!(cache.publish_loaded(&p, stale, generation).is_none());
assert_eq!(cache.len(), 0);
assert!(!cache.resolve(&p).expect("resolve current profile").enabled);
}

#[test]
fn invalidating_one_principal_does_not_reject_another_principal_load() {
let (_dir, cache) = fixture();
let alice = principal("alice-invalidation");
let bob = principal("bob-load");
let bob_generation = 0;
let bob_profile = Arc::new(PrincipalProfile::default());

cache.invalidate(&alice);

assert!(
cache
.publish_loaded(&bob, bob_profile, bob_generation)
.is_some()
);
assert_eq!(cache.len(), 1);
}

#[test]
fn concurrent_readers_do_not_race() {
// Lightweight contention check — not a loom model, just a sanity
Expand Down Expand Up @@ -488,17 +575,22 @@ mod tests {
fn persist_egress_is_idempotent() {
let (_dir, cache) = fixture();
let p = principal("bob");
let initial_generation = cache.generation_for(&p);
// No file on disk → starts from default (empty capsule_egress).
cache
.persist_egress(&p, "react", "10.0.0.5:8080")
.expect("first persist");
let persisted_generation = cache.generation_for(&p);
assert_eq!(persisted_generation, initial_generation + 1);
// A second persist of the SAME endpoint under the SAME capsule
// (case-insensitive) is a no-op success, not a duplicate.
cache
.persist_egress(&p, "react", "10.0.0.5:8080")
.expect("idempotent persist");
assert_eq!(cache.generation_for(&p), persisted_generation);
assert_eq!(cache.len(), 1, "idempotent persist refreshes the cache");

let profile = cache.resolve(&p).expect("reload");
let profile = cache.resolve(&p).expect("resolve refreshed profile");
assert_eq!(
profile.network.capsule_egress.get("react"),
Some(&vec!["10.0.0.5:8080".to_string()]),
Expand Down
26 changes: 14 additions & 12 deletions crates/astrid-core/src/kernel_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ impl From<AdminRequestKind> for AdminKernelRequest {
/// on `PairDeviceIssue` defaults to [`PairScopeArg::Full`] when omitted, so
/// pre-scope callers (and single-tenant admin flows) keep their existing
/// behaviour — but minting a `Full` device additionally requires the issuer to
/// hold `self:auth:pair:admin`, enforced in the handler.
/// hold `self:auth:pair:admin`, enforced by the authorization preamble and
/// rechecked with the issuer's pinned policy snapshot before persistence.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum PairScopeArg {
Expand Down Expand Up @@ -357,8 +358,8 @@ pub enum PairScopeArg {

/// Serde default for [`AdminRequestKind::PairDeviceIssue::scope`] — `Full`,
/// for back-compat with callers that predate the `scope` field. A `Full` mint
/// is independently gated on `self:auth:pair:admin` in the handler, so the
/// permissive *default* does not relax the *authority* required to use it.
/// is independently gated on `self:auth:pair:admin`, so the permissive
/// *default* does not relax the *authority* required to use it.
fn default_pair_scope() -> PairScopeArg {
PairScopeArg::Full
}
Expand Down Expand Up @@ -666,12 +667,13 @@ pub enum AdminRequestKind {
/// The opaque token to invalidate.
token: String,
},
/// Issue a pair-device token. Gated by `self:auth:pair` (the
/// caller can only mint pair-tokens for their own principal —
/// the kernel ignores any target field on the wire and ties the
/// token to the caller). Used to add a new device's ed25519
/// public key to an existing principal's `AuthConfig.public_keys`
/// without minting a separate principal.
/// Issue a pair-device token. Scoped issuance is gated by
/// `self:auth:pair`; unattenuated issuance additionally requires
/// `self:auth:pair:admin`. The caller can only mint pair-tokens for their
/// own principal — the kernel ignores any target field on the wire and
/// ties the token to the caller. Used to add a new device's ed25519 public
/// key to an existing principal's `AuthConfig.public_keys` without minting
/// a separate principal.
PairDeviceIssue {
/// Seconds until the token expires. Capped server-side to
/// 1 hour — pair-tokens are intended for immediate use on a
Expand All @@ -686,9 +688,9 @@ pub enum AdminRequestKind {
/// Capability scope the redeemed device will authenticate under.
/// Defaults to [`PairScopeArg::Full`] when omitted, for back-compat
/// with pre-scope callers; a `Full` mint is independently gated on
/// `self:auth:pair:admin` in the handler, and an `Explicit`/`Preset`
/// scope is validated to be a subset of the issuer's effective
/// capabilities (no escalation).
/// `self:auth:pair:admin`, and an `Explicit`/`Preset` scope is validated
/// to be a subset of the issuer's effective capabilities (no
/// escalation).
#[serde(default = "default_pair_scope")]
scope: PairScopeArg,
},
Expand Down
Loading
Loading