From 25e27ad765a40aa0f7af360ed408517baf7e0af8 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 6 Aug 2026 23:15:01 +0400 Subject: [PATCH 01/10] feat(identity): add user and fleet ownership Signed-off-by: Joshua J. Bouw --- CHANGELOG.md | 9 + crates/astrid-core/src/identity/mod.rs | 6 + crates/astrid-core/src/identity/ownership.rs | 523 ++++++++++++ crates/astrid-core/src/lib.rs | 5 +- crates/astrid-kernel/src/lib.rs | 114 ++- crates/astrid-storage/src/lib.rs | 2 + crates/astrid-storage/src/ownership.rs | 833 +++++++++++++++++++ docs/astrid-user-fleet-ownership.md | 77 ++ 8 files changed, 1563 insertions(+), 6 deletions(-) create mode 100644 crates/astrid-core/src/identity/ownership.rs create mode 100644 crates/astrid-storage/src/ownership.rs create mode 100644 docs/astrid-user-fleet-ownership.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2a94850..b84a4e31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added +- **Astrid now has durable human and fleet ownership identities independent of + executable principals and capability groups.** Canonical `UserUid` and + `FleetUid` genesis records feed an atomically persisted ownership graph with + owner-safe membership changes, exclusive principal assignment, explicit + cross-fleet transfers, concurrent-writer protection, and fail-closed boot + validation. Existing native installations deterministically acquire a + default user, fleet, and ownership edge without changing CLI, HTTP, profile, + group, or `StateOwnerCodecV1` behavior. Closes #1469. + - **Bulk publication reuses closure evidence earned during authoritative staging.** A bounded, batch-local dependency walk proves admitted owning closures without retaining write-history state; later publication skips only diff --git a/crates/astrid-core/src/identity/mod.rs b/crates/astrid-core/src/identity/mod.rs index e70de6997..4bddf7413 100644 --- a/crates/astrid-core/src/identity/mod.rs +++ b/crates/astrid-core/src/identity/mod.rs @@ -4,11 +4,17 @@ //! platforms, and [`FrontendLink`], a mapping from platform-specific identities //! to Astrid users. +/// Durable human and fleet ownership identities. +pub mod ownership; /// Stable principal identity. pub mod principal; /// Core identity types. pub mod types; +pub use ownership::{ + FleetGenesis, FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, + PrincipalOwnership, UserGenesis, UserIdentity, UserUid, +}; pub use principal::{PrincipalGenesis, PrincipalIdentity, PrincipalIdentityError, PrincipalUid}; pub use types::{AstridUserId, FrontendLink, normalize_platform}; diff --git a/crates/astrid-core/src/identity/ownership.rs b/crates/astrid-core/src/identity/ownership.rs new file mode 100644 index 000000000..c4173adc2 --- /dev/null +++ b/crates/astrid-core/src/identity/ownership.rs @@ -0,0 +1,523 @@ +//! Durable human authority and fleet ownership identities. +//! +//! A [`UserUid`] names human authority independently of login frontends and +//! mutable display names. A [`FleetUid`] names an ownership boundary. Runtime +//! principals remain execution identities and may be attached to one fleet +//! through [`PrincipalOwnership`]. Capability groups are intentionally +//! outside this model: they remain reusable permission bundles. + +use std::fmt; +use std::str::FromStr; + +use chrono::{DateTime, Timelike, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use uuid::Uuid; + +use super::PrincipalUid; + +const USER_UID_DERIVE_KEY: &str = "astrid user uid v1"; +const FLEET_UID_DERIVE_KEY: &str = "astrid fleet uid v1"; +const GENESIS_VERSION: u16 = 1; +const ED25519_ALGORITHM: u16 = 1; +const ED25519_PUBLIC_KEY_BYTES: u32 = 32; + +macro_rules! opaque_uid { + ($name:ident, $label:literal) => { + #[doc = concat!("Stable opaque identity of one Astrid ", $label, ".")] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 32]); + + impl $name { + /// Construct an identity from its exact durable bytes. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrow the exact durable bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple(stringify!($name)) + .field(&self.to_string()) + .finish() + } + } + + impl FromStr for $name { + type Err = OwnershipIdentityError; + + fn from_str(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(OwnershipIdentityError::InvalidUidText($label)); + } + let decoded = hex::decode(value) + .map_err(|_| OwnershipIdentityError::InvalidUidText($label))?; + let bytes = <[u8; 32]>::try_from(decoded) + .map_err(|_| OwnershipIdentityError::InvalidUidText($label))?; + Ok(Self(bytes)) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(serde::de::Error::custom) + } + } + }; +} + +opaque_uid!(UserUid, "user"); +opaque_uid!(FleetUid, "fleet"); + +/// Immutable creation record from which a [`UserUid`] is derived. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UserGenesis { + /// Canonical genesis-record grammar. + pub format_version: u16, + /// UUID of the authoritative human identity record. + pub identity_id: Uuid, + /// Whole UTC seconds since the Unix epoch. + pub created_at_seconds: i64, + /// Nanosecond fraction in `0..1_000_000_000`. + pub created_at_nanoseconds: u32, + /// Initial human-controlled Ed25519 public key. + #[serde( + serialize_with = "serialize_public_key", + deserialize_with = "deserialize_public_key" + )] + pub initial_public_key: [u8; 32], +} + +impl UserGenesis { + /// Construct a new immutable user genesis record. + #[must_use] + pub fn new(initial_public_key: [u8; 32], created_at: DateTime) -> Self { + Self::from_parts(Uuid::new_v4(), created_at, initial_public_key) + } + + /// Construct a user genesis record from reproducible inputs. + #[must_use] + pub fn from_parts( + identity_id: Uuid, + created_at: DateTime, + initial_public_key: [u8; 32], + ) -> Self { + Self { + format_version: GENESIS_VERSION, + identity_id, + created_at_seconds: created_at.timestamp(), + created_at_nanoseconds: created_at.nanosecond(), + initial_public_key, + } + } + + /// Validate the record and derive its stable UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] when the record is non-canonical. + pub fn uid(&self) -> Result { + let canonical = self.canonical_bytes()?; + let mut hasher = blake3::Hasher::new_derive_key(USER_UID_DERIVE_KEY); + hasher.update(&canonical); + Ok(UserUid(*hasher.finalize().as_bytes())) + } + + /// Return the byte-exact genesis encoding hashed into the UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] when the record is non-canonical. + pub fn canonical_bytes(&self) -> Result<[u8; 68], OwnershipIdentityError> { + validate_genesis(self.format_version, self.created_at_nanoseconds, "user")?; + let mut bytes = [0_u8; 68]; + bytes[0..2].copy_from_slice(&self.format_version.to_le_bytes()); + bytes[2..4].copy_from_slice(&ED25519_ALGORITHM.to_le_bytes()); + bytes[4..8].copy_from_slice(&ED25519_PUBLIC_KEY_BYTES.to_le_bytes()); + bytes[8..24].copy_from_slice(self.identity_id.as_bytes()); + bytes[24..32].copy_from_slice(&self.created_at_seconds.to_le_bytes()); + bytes[32..36].copy_from_slice(&self.created_at_nanoseconds.to_le_bytes()); + bytes[36..68].copy_from_slice(&self.initial_public_key); + Ok(bytes) + } +} + +/// Durable record for one human authority identity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UserIdentity { + /// Stable UID repeated so storage corruption is detected on load. + pub uid: UserUid, + /// Canonical creation record that must derive `uid`. + pub genesis: UserGenesis, +} + +impl UserIdentity { + /// Bind a canonical genesis record to its derived UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] if the genesis record is invalid. + pub fn from_genesis(genesis: UserGenesis) -> Result { + let uid = genesis.uid()?; + Ok(Self { uid, genesis }) + } + + /// Verify the repeated UID against the canonical genesis record. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError::UserUidMismatch`] on disagreement. + pub fn validate(&self) -> Result<(), OwnershipIdentityError> { + let computed = self.genesis.uid()?; + if computed != self.uid { + return Err(OwnershipIdentityError::UserUidMismatch { + declared: self.uid, + computed, + }); + } + Ok(()) + } +} + +/// Immutable creation record from which a [`FleetUid`] is derived. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetGenesis { + /// Canonical genesis-record grammar. + pub format_version: u16, + /// UUID of the authoritative fleet record. + pub identity_id: Uuid, + /// Whole UTC seconds since the Unix epoch. + pub created_at_seconds: i64, + /// Nanosecond fraction in `0..1_000_000_000`. + pub created_at_nanoseconds: u32, + /// Human authority that created the fleet. + pub created_by: UserUid, +} + +impl FleetGenesis { + /// Construct a new immutable fleet genesis record. + #[must_use] + pub fn new(created_by: UserUid, created_at: DateTime) -> Self { + Self::from_parts(Uuid::new_v4(), created_at, created_by) + } + + /// Construct a fleet genesis record from reproducible inputs. + #[must_use] + pub fn from_parts(identity_id: Uuid, created_at: DateTime, created_by: UserUid) -> Self { + Self { + format_version: GENESIS_VERSION, + identity_id, + created_at_seconds: created_at.timestamp(), + created_at_nanoseconds: created_at.nanosecond(), + created_by, + } + } + + /// Validate the record and derive its stable UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] when the record is non-canonical. + pub fn uid(&self) -> Result { + let canonical = self.canonical_bytes()?; + let mut hasher = blake3::Hasher::new_derive_key(FLEET_UID_DERIVE_KEY); + hasher.update(&canonical); + Ok(FleetUid(*hasher.finalize().as_bytes())) + } + + /// Return the byte-exact genesis encoding hashed into the UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] when the record is non-canonical. + pub fn canonical_bytes(&self) -> Result<[u8; 64], OwnershipIdentityError> { + validate_genesis(self.format_version, self.created_at_nanoseconds, "fleet")?; + let mut bytes = [0_u8; 64]; + bytes[0..2].copy_from_slice(&self.format_version.to_le_bytes()); + bytes[2..4].copy_from_slice(&0_u16.to_le_bytes()); + bytes[4..20].copy_from_slice(self.identity_id.as_bytes()); + bytes[20..28].copy_from_slice(&self.created_at_seconds.to_le_bytes()); + bytes[28..32].copy_from_slice(&self.created_at_nanoseconds.to_le_bytes()); + bytes[32..64].copy_from_slice(self.created_by.as_bytes()); + Ok(bytes) + } +} + +/// Durable record for one fleet ownership boundary. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetIdentity { + /// Stable UID repeated so storage corruption is detected on load. + pub uid: FleetUid, + /// Canonical creation record that must derive `uid`. + pub genesis: FleetGenesis, +} + +impl FleetIdentity { + /// Bind a canonical genesis record to its derived UID. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError`] if the genesis record is invalid. + pub fn from_genesis(genesis: FleetGenesis) -> Result { + let uid = genesis.uid()?; + Ok(Self { uid, genesis }) + } + + /// Verify the repeated UID against the canonical genesis record. + /// + /// # Errors + /// + /// Returns [`OwnershipIdentityError::FleetUidMismatch`] on disagreement. + pub fn validate(&self) -> Result<(), OwnershipIdentityError> { + let computed = self.genesis.uid()?; + if computed != self.uid { + return Err(OwnershipIdentityError::FleetUidMismatch { + declared: self.uid, + computed, + }); + } + Ok(()) + } +} + +/// A user's administrative relationship to a fleet. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FleetRole { + /// Controls ownership, membership, and principal assignment. + Owner, + /// Manages members and principal assignment but cannot remove the last owner. + Administrator, + /// Uses fleet resources without changing ownership. + Member, +} + +impl FleetRole { + /// Whether this role may manage fleet membership and principals. + #[must_use] + pub const fn can_manage(self) -> bool { + matches!(self, Self::Owner | Self::Administrator) + } +} + +/// One user's membership in one fleet. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetMembership { + /// Fleet containing the membership. + pub fleet_uid: FleetUid, + /// Human authority receiving the role. + pub user_uid: UserUid, + /// Permission level inside the fleet ownership boundary. + pub role: FleetRole, + /// Human authority that granted the membership. + pub granted_by: UserUid, +} + +/// Durable assignment of an executable principal to one fleet. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrincipalOwnership { + /// Executable principal receiving an owner. + pub principal_uid: PrincipalUid, + /// Sole fleet that owns the principal. + pub fleet_uid: FleetUid, + /// Human authority that made the assignment. + pub assigned_by: UserUid, +} + +/// Rejection raised by canonical user and fleet identity handling. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum OwnershipIdentityError { + /// Text was not the canonical lowercase-hex UID spelling. + #[error("{0} uid must be exactly 64 lowercase hexadecimal characters")] + InvalidUidText(&'static str), + /// The genesis grammar version is unknown. + #[error("unsupported {kind} genesis version {version}")] + UnsupportedGenesisVersion { + /// Kind of identity being validated. + kind: &'static str, + /// Unsupported version carried by the record. + version: u16, + }, + /// The timestamp fraction is not a valid nanosecond value. + #[error("{kind} genesis nanoseconds must be below one billion, got {value}")] + InvalidNanoseconds { + /// Kind of identity being validated. + kind: &'static str, + /// Invalid nanosecond value. + value: u32, + }, + /// A user record's repeated UID did not match its genesis. + #[error("user uid mismatch: declared {declared}, computed {computed}")] + UserUidMismatch { + /// UID carried by the persisted record. + declared: UserUid, + /// UID derived from canonical genesis bytes. + computed: UserUid, + }, + /// A fleet record's repeated UID did not match its genesis. + #[error("fleet uid mismatch: declared {declared}, computed {computed}")] + FleetUidMismatch { + /// UID carried by the persisted record. + declared: FleetUid, + /// UID derived from canonical genesis bytes. + computed: FleetUid, + }, +} + +fn validate_genesis( + version: u16, + nanoseconds: u32, + kind: &'static str, +) -> Result<(), OwnershipIdentityError> { + if version != GENESIS_VERSION { + return Err(OwnershipIdentityError::UnsupportedGenesisVersion { kind, version }); + } + if nanoseconds >= 1_000_000_000 { + return Err(OwnershipIdentityError::InvalidNanoseconds { + kind, + value: nanoseconds, + }); + } + Ok(()) +} + +fn serialize_public_key(key: &[u8; 32], serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&hex::encode(key)) +} + +fn deserialize_public_key<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error> +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(serde::de::Error::custom( + "initial public key must be exactly 64 lowercase hexadecimal characters", + )); + } + let decoded = hex::decode(value).map_err(serde::de::Error::custom)?; + <[u8; 32]>::try_from(decoded) + .map_err(|_| serde::de::Error::custom("initial public key must contain 32 bytes")) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + fn created_at() -> DateTime { + Utc.timestamp_opt(1_700_000_000, 123_456_789) + .single() + .unwrap() + } + + fn user() -> UserIdentity { + UserIdentity::from_genesis(UserGenesis::from_parts( + Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(), + created_at(), + [0x5a; 32], + )) + .unwrap() + } + + #[test] + fn user_uid_is_stable_and_canonical() { + let identity = user(); + assert_eq!(identity.uid.to_string().len(), 64); + assert_eq!(identity.uid.to_string().parse(), Ok(identity.uid)); + assert_eq!(identity.validate(), Ok(())); + assert!( + identity + .uid + .to_string() + .to_uppercase() + .parse::() + .is_err() + ); + } + + #[test] + fn fleet_uid_binds_creator_and_genesis() { + let creator = user().uid; + let identity = FleetIdentity::from_genesis(FleetGenesis::from_parts( + Uuid::parse_str("ffeeddcc-bbaa-9988-7766-554433221100").unwrap(), + created_at(), + creator, + )) + .unwrap(); + assert_eq!(identity.uid.to_string().len(), 64); + assert_eq!(identity.validate(), Ok(())); + + let other_creator = UserUid::from_bytes([0x11; 32]); + let mut altered = identity.clone(); + altered.genesis.created_by = other_creator; + assert!(matches!( + altered.validate(), + Err(OwnershipIdentityError::FleetUidMismatch { .. }) + )); + } + + #[test] + fn serde_rejects_non_canonical_uid_text() { + let encoded = serde_json::to_string(&user()).unwrap(); + let upper = encoded.replace( + &user().uid.to_string(), + &user().uid.to_string().to_uppercase(), + ); + assert!(serde_json::from_str::(&upper).is_err()); + } + + #[test] + fn administrator_can_manage_but_member_cannot() { + assert!(FleetRole::Owner.can_manage()); + assert!(FleetRole::Administrator.can_manage()); + assert!(!FleetRole::Member.can_manage()); + } +} diff --git a/crates/astrid-core/src/lib.rs b/crates/astrid-core/src/lib.rs index 535e581e1..3f14c6e01 100644 --- a/crates/astrid-core/src/lib.rs +++ b/crates/astrid-core/src/lib.rs @@ -72,8 +72,9 @@ pub use utils::truncate_to_boundary; // Identity types pub use identity::{ - AstridUserId, FrontendLink, PrincipalGenesis, PrincipalIdentity, PrincipalIdentityError, - PrincipalUid, normalize_platform, + AstridUserId, FleetGenesis, FleetIdentity, FleetMembership, FleetRole, FleetUid, FrontendLink, + OwnershipIdentityError, PrincipalGenesis, PrincipalIdentity, PrincipalIdentityError, + PrincipalOwnership, PrincipalUid, UserGenesis, UserIdentity, UserUid, normalize_platform, }; // Uplink types diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 2eb4b64ca..10b60b391 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -242,6 +242,8 @@ pub struct Kernel { pub allowance_store: Arc, /// System-wide identity store for platform user resolution. identity_store: Arc, + /// Durable human, fleet, and exclusive principal ownership graph. + ownership_store: Arc, /// System-wide per-principal profile cache (Layer 3 quota enforcement). /// /// One instance per kernel boot. Every capsule load plumbs this into @@ -348,6 +350,12 @@ impl KernelResources { } impl Kernel { + /// Astrid's authoritative human-to-fleet ownership store. + #[must_use] + pub fn ownership_store(&self) -> &Arc { + &self.ownership_store + } + /// Per-project runtime layout selected at boot. #[must_use] pub fn workspace_layout(&self) -> &WorkspaceLayout { @@ -773,6 +781,14 @@ impl Kernel { "Failed to load durable principal identities: {error}" )) })?; + let ownership_store = Arc::new( + astrid_storage::OwnershipStore::new(Arc::clone(&kv)).map_err(|error| { + std::io::Error::other(format!("Failed to create ownership store: {error}")) + })?, + ); + ownership_store.load().await.map_err(|error| { + std::io::Error::other(format!("Failed to load ownership graph: {error}")) + })?; // Load group config (issue #670). Boot-loaded once, then swapped // atomically by Layer 6 admin topics (issue #672). Missing file @@ -799,11 +815,21 @@ impl Kernel { // Bootstrap the CLI root user (idempotent). Also seeds the // default principal's profile with `groups = ["admin"]` so // single-tenant deployments get full management-API access. - bootstrap_cli_root_user(&identity_store, &home) + let (root_user, root_public_key) = bootstrap_cli_root_user(&identity_store, &home) .await .map_err(|e| { std::io::Error::other(format!("Failed to bootstrap CLI root user: {e}")) })?; + bootstrap_cli_root_ownership( + &ownership_store, + &principal_directory, + root_user, + root_public_key, + ) + .await + .map_err(|error| { + std::io::Error::other(format!("Failed to bootstrap CLI root ownership: {error}")) + })?; // Apply pre-configured identity links from config. apply_identity_config(&identity_store, &workspace_root, &workspace_layout).await; @@ -848,6 +874,7 @@ impl Kernel { token_path, allowance_store, identity_store, + ownership_store, profile_cache: profile_cache .unwrap_or_else(|| Arc::new(PrincipalProfileCache::with_home(home.clone()))), groups, @@ -2572,6 +2599,9 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - .expect("test kernel: identity kv scope"); let identity_store: Arc = Arc::new(astrid_storage::KvIdentityStore::new(identity_kv)); + let ownership_store = Arc::new( + astrid_storage::OwnershipStore::new(Arc::clone(&kv)).expect("test kernel: ownership store"), + ); let groups = Arc::new(ArcSwap::from_pointee( GroupConfig::load(&home).expect("test kernel: load groups"), @@ -2615,6 +2645,7 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - token_path: home.token_path(), allowance_store, identity_store, + ownership_store, profile_cache: Arc::new(PrincipalProfileCache::with_home(home.clone())), groups, astrid_home: home, @@ -3494,7 +3525,7 @@ fn warn_agent_loop_readiness(manifests: &[&astrid_capsule_types::manifest::Capsu async fn bootstrap_cli_root_user( store: &Arc, home: &astrid_core::dirs::AstridHome, -) -> Result<(), astrid_storage::IdentityError> { +) -> Result<(astrid_core::AstridUserId, [u8; 32]), astrid_storage::IdentityError> { // Seed the default principal profile with the admin group. Runs // before the identity-link short-circuit below so a deleted profile // between boots is restored even when the identity record persists. @@ -3513,7 +3544,7 @@ async fn bootstrap_cli_root_user( .bind_principal_identity(user.id, principal, initial_public_key) .await?; tracing::debug!("CLI root user already linked"); - return Ok(()); + return Ok((user, initial_public_key)); } // No CLI link exists. Create or find the root user. @@ -3526,7 +3557,42 @@ async fn bootstrap_cli_root_user( store.link("cli", "local", user.id, "system").await?; tracing::info!(user_id = %user.id, "Linked CLI root user (cli/local)"); - Ok(()) + Ok((user, initial_public_key)) +} + +async fn bootstrap_cli_root_ownership( + store: &astrid_storage::OwnershipStore, + principal_directory: &astrid_storage::PrincipalDirectory, + root_user: astrid_core::AstridUserId, + initial_public_key: [u8; 32], +) -> Result<(), astrid_storage::OwnershipError> { + let user = astrid_core::UserIdentity::from_genesis(astrid_core::UserGenesis::from_parts( + root_user.id, + root_user.created_at, + initial_public_key, + ))?; + store.create_user(user.clone()).await?; + + // Reuse the legacy root UUID and timestamp as deterministic fleet genesis + // inputs. User/fleet UID derivation is domain-separated, so their durable + // identifiers remain distinct while every boot derives the same records. + let fleet = astrid_core::FleetIdentity::from_genesis(astrid_core::FleetGenesis::from_parts( + root_user.id, + root_user.created_at, + user.uid, + ))?; + store.create_fleet(fleet.clone()).await?; + + let principal_uid = principal_directory + .uid_for(&astrid_core::PrincipalId::default()) + .map_err(astrid_storage::OwnershipError::Storage)?; + store + .assign_principal(astrid_core::PrincipalOwnership { + principal_uid, + fleet_uid: fleet.uid, + assigned_by: user.uid, + }) + .await } fn principal_initial_public_key( @@ -3847,6 +3913,46 @@ mod tests { use astrid_capsule_types::error::CapsuleResult; use astrid_capsule_types::manifest::CapsuleManifest; + #[tokio::test] + async fn legacy_root_ownership_bootstrap_is_deterministic_and_idempotent() { + let backend: Arc = + Arc::new(astrid_storage::MemoryKvStore::new()); + let ownership_store = astrid_storage::OwnershipStore::new(backend).unwrap(); + let directory = astrid_storage::PrincipalDirectory::default(); + let principal_identity = astrid_core::PrincipalIdentity::from_genesis( + astrid_core::PrincipalGenesis::from_parts( + uuid::Uuid::from_u128(2), + chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + [2; 32], + ), + ) + .unwrap(); + directory + .register(astrid_core::PrincipalId::default(), principal_identity.uid) + .unwrap(); + let root_user = astrid_core::AstridUserId { + id: uuid::Uuid::from_u128(1), + principal: astrid_core::PrincipalId::default(), + public_key: None, + display_name: None, + created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + }; + + bootstrap_cli_root_ownership(&ownership_store, &directory, root_user.clone(), [1; 32]) + .await + .unwrap(); + let first = ownership_store.load().await.unwrap(); + bootstrap_cli_root_ownership(&ownership_store, &directory, root_user, [1; 32]) + .await + .unwrap(); + let second = ownership_store.load().await.unwrap(); + + assert_eq!(first, second); + let principal_owner = second.principal_owner(principal_identity.uid).unwrap(); + assert_eq!(second.fleets().count(), 1); + assert!(second.fleet(principal_owner.fleet_uid).is_some()); + } + #[test] fn persistent_idle_monitor_stops_after_ephemeral_mode_is_enabled() { let ephemeral = AtomicBool::new(false); diff --git a/crates/astrid-storage/src/lib.rs b/crates/astrid-storage/src/lib.rs index cbc126720..dd3c65633 100644 --- a/crates/astrid-storage/src/lib.rs +++ b/crates/astrid-storage/src/lib.rs @@ -36,6 +36,7 @@ pub mod content; pub mod error; pub mod identity; pub mod kv; +pub mod ownership; mod principal_directory; mod principal_graph; #[cfg(not(target_family = "wasm"))] @@ -65,6 +66,7 @@ pub use kv::{ KvEntry, KvPrincipalResolver, KvQuotaResolver, KvStore, MemoryKvStore, PrincipalKvStore, ScopedKvStore, TreeKvStore, }; +pub use ownership::{FleetRecord, OwnershipError, OwnershipSnapshot, OwnershipStore}; pub use principal_directory::PrincipalDirectory; pub use secret::{ DenySecretStore, FileSecretStore, KvSecretStore, ReadThroughSecretStore, SecretStore, diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs new file mode 100644 index 000000000..56908a615 --- /dev/null +++ b/crates/astrid-storage/src/ownership.rs @@ -0,0 +1,833 @@ +//! Atomic persistence for Astrid's human-to-fleet ownership graph. +//! +//! The complete graph is stored behind one compare-and-swap key. This keeps +//! cross-record invariants crash-safe: a fleet and its initial owner appear +//! together, and a principal can never be observed in two fleets. The format +//! can be sharded behind the same API if graph size later warrants it. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use astrid_core::{ + FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, + PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, +}; +use serde::{Deserialize, Serialize}; + +use crate::{KvStore, ScopedKvStore, StorageError}; + +/// Namespace reserved for the authoritative ownership graph. +pub const OWNERSHIP_NAMESPACE: &str = "system:ownership"; +const GRAPH_KEY: &str = "graph-v1"; +const GRAPH_FORMAT_VERSION: u16 = 1; +const MAX_CAS_ATTEMPTS: usize = 64; + +/// One fleet identity and all current human memberships. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetRecord { + identity: FleetIdentity, + memberships: BTreeMap, +} + +impl FleetRecord { + /// Fleet's immutable identity. + #[must_use] + pub const fn identity(&self) -> &FleetIdentity { + &self.identity + } + + /// Look up one user's current membership. + #[must_use] + pub fn membership(&self, user_uid: UserUid) -> Option<&FleetMembership> { + self.memberships.get(&user_uid) + } + + /// Iterate over current memberships in stable UID order. + pub fn memberships(&self) -> impl Iterator { + self.memberships.values() + } +} + +/// Validated point-in-time view of all ownership state. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OwnershipSnapshot { + format_version: u16, + users: BTreeMap, + fleets: BTreeMap, + principal_ownership: BTreeMap, +} + +impl Default for OwnershipSnapshot { + fn default() -> Self { + Self { + format_version: GRAPH_FORMAT_VERSION, + users: BTreeMap::new(), + fleets: BTreeMap::new(), + principal_ownership: BTreeMap::new(), + } + } +} + +impl OwnershipSnapshot { + /// Look up one human identity. + #[must_use] + pub fn user(&self, uid: UserUid) -> Option<&UserIdentity> { + self.users.get(&uid) + } + + /// Look up one fleet. + #[must_use] + pub fn fleet(&self, uid: FleetUid) -> Option<&FleetRecord> { + self.fleets.get(&uid) + } + + /// Resolve the sole fleet owner of an executable principal. + #[must_use] + pub fn principal_owner(&self, uid: PrincipalUid) -> Option<&PrincipalOwnership> { + self.principal_ownership.get(&uid) + } + + /// Iterate over users in stable UID order. + pub fn users(&self) -> impl Iterator { + self.users.values() + } + + /// Iterate over fleets in stable UID order. + pub fn fleets(&self) -> impl Iterator { + self.fleets.values() + } + + /// Iterate over principal assignments in stable UID order. + pub fn principal_owners(&self) -> impl Iterator { + self.principal_ownership.values() + } + + fn validate(&self) -> Result<(), OwnershipError> { + if self.format_version != GRAPH_FORMAT_VERSION { + return Err(OwnershipError::UnsupportedFormat(self.format_version)); + } + for (uid, identity) in &self.users { + identity.validate()?; + if *uid != identity.uid { + return Err(OwnershipError::CorruptGraph(format!( + "user map key {uid} does not match record {}", + identity.uid + ))); + } + } + for (uid, fleet) in &self.fleets { + fleet.identity.validate()?; + if *uid != fleet.identity.uid { + return Err(OwnershipError::CorruptGraph(format!( + "fleet map key {uid} does not match record {}", + fleet.identity.uid + ))); + } + if !self.users.contains_key(&fleet.identity.genesis.created_by) { + return Err(OwnershipError::CorruptGraph(format!( + "fleet {uid} creator {} is absent", + fleet.identity.genesis.created_by + ))); + } + let mut owner_count = 0_usize; + for (member_uid, membership) in &fleet.memberships { + if *member_uid != membership.user_uid || membership.fleet_uid != *uid { + return Err(OwnershipError::CorruptGraph(format!( + "membership index disagrees inside fleet {uid}" + ))); + } + if !self.users.contains_key(member_uid) + || !self.users.contains_key(&membership.granted_by) + { + return Err(OwnershipError::CorruptGraph(format!( + "membership in fleet {uid} references an absent user" + ))); + } + if membership.role == FleetRole::Owner { + owner_count = owner_count.checked_add(1).ok_or_else(|| { + OwnershipError::CorruptGraph(format!( + "fleet {uid} owner count exceeds platform limits" + )) + })?; + } + } + if owner_count == 0 { + return Err(OwnershipError::CorruptGraph(format!( + "fleet {uid} has no owner" + ))); + } + } + for (principal_uid, ownership) in &self.principal_ownership { + if *principal_uid != ownership.principal_uid { + return Err(OwnershipError::CorruptGraph(format!( + "principal ownership key {principal_uid} disagrees with its record" + ))); + } + if !self.fleets.contains_key(&ownership.fleet_uid) + || !self.users.contains_key(&ownership.assigned_by) + { + return Err(OwnershipError::CorruptGraph(format!( + "principal {principal_uid} ownership references an absent identity" + ))); + } + } + Ok(()) + } +} + +/// Persistent, optimistic-concurrency owner of the Astrid ownership graph. +#[derive(Clone, Debug)] +pub struct OwnershipStore { + storage: ScopedKvStore, +} + +impl OwnershipStore { + /// Construct an ownership store over a raw Astrid KV backend. + /// + /// # Errors + /// + /// Returns [`OwnershipError::Storage`] if the reserved namespace is invalid. + pub fn new(storage: Arc) -> Result { + Ok(Self { + storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, + }) + } + + /// Load and validate the latest ownership snapshot. + /// + /// # Errors + /// + /// Fails closed on storage errors, malformed bytes, or broken invariants. + pub async fn load(&self) -> Result { + let raw = self.storage.get(GRAPH_KEY).await?; + Self::decode(raw.as_deref()) + } + + /// Register one durable human identity, idempotently. + /// + /// # Errors + /// + /// Rejects invalid identity material and persistence conflicts. + pub async fn create_user(&self, identity: UserIdentity) -> Result<(), OwnershipError> { + identity.validate()?; + self.mutate(|graph| match graph.users.get(&identity.uid) { + Some(existing) if existing == &identity => Ok(()), + Some(_) => Err(OwnershipError::IdentityConflict( + "user", + identity.uid.to_string(), + )), + None => { + graph.users.insert(identity.uid, identity.clone()); + Ok(()) + }, + }) + .await + } + + /// Create a fleet and its initial owner in one atomic mutation. + /// + /// The fleet's genesis creator must already be a registered user. + /// + /// # Errors + /// + /// Rejects unknown creators, invalid identities, and UID conflicts. + pub async fn create_fleet(&self, identity: FleetIdentity) -> Result<(), OwnershipError> { + identity.validate()?; + self.mutate(|graph| { + let creator = identity.genesis.created_by; + if !graph.users.contains_key(&creator) { + return Err(OwnershipError::UserNotFound(creator)); + } + if let Some(existing) = graph.fleets.get(&identity.uid) { + return if existing.identity == identity { + Ok(()) + } else { + Err(OwnershipError::IdentityConflict( + "fleet", + identity.uid.to_string(), + )) + }; + } + let owner = FleetMembership { + fleet_uid: identity.uid, + user_uid: creator, + role: FleetRole::Owner, + granted_by: creator, + }; + graph.fleets.insert( + identity.uid, + FleetRecord { + identity: identity.clone(), + memberships: BTreeMap::from([(creator, owner)]), + }, + ); + Ok(()) + }) + .await + } + + /// Add a user to a fleet or change their role. + /// + /// Owners and administrators may perform this operation. Demoting the + /// fleet's last owner is rejected. + /// + /// # Errors + /// + /// Rejects unknown identities, insufficient authority, and last-owner loss. + pub async fn set_membership( + &self, + fleet_uid: FleetUid, + user_uid: UserUid, + role: FleetRole, + actor: UserUid, + ) -> Result<(), OwnershipError> { + self.mutate(|graph| { + if !graph.users.contains_key(&user_uid) { + return Err(OwnershipError::UserNotFound(user_uid)); + } + let fleet = graph + .fleets + .get_mut(&fleet_uid) + .ok_or(OwnershipError::FleetNotFound(fleet_uid))?; + Self::require_manager(fleet, actor)?; + let existing_role = fleet + .memberships + .get(&user_uid) + .map(|membership| membership.role); + if (role == FleetRole::Owner || existing_role == Some(FleetRole::Owner)) + && Self::role(fleet, actor) != Some(FleetRole::Owner) + { + return Err(OwnershipError::OwnerAuthorityRequired(fleet_uid)); + } + if existing_role == Some(FleetRole::Owner) + && role != FleetRole::Owner + && Self::owner_count(fleet) == 1 + { + return Err(OwnershipError::LastOwner(fleet_uid)); + } + fleet.memberships.insert( + user_uid, + FleetMembership { + fleet_uid, + user_uid, + role, + granted_by: actor, + }, + ); + Ok(()) + }) + .await + } + + /// Remove one user from a fleet. + /// + /// # Errors + /// + /// Rejects insufficient authority and removal of the fleet's last owner. + pub async fn remove_member( + &self, + fleet_uid: FleetUid, + user_uid: UserUid, + actor: UserUid, + ) -> Result { + self.mutate(|graph| { + let fleet = graph + .fleets + .get_mut(&fleet_uid) + .ok_or(OwnershipError::FleetNotFound(fleet_uid))?; + Self::require_manager(fleet, actor)?; + let existing_role = fleet + .memberships + .get(&user_uid) + .map(|membership| membership.role); + if existing_role == Some(FleetRole::Owner) + && Self::role(fleet, actor) != Some(FleetRole::Owner) + { + return Err(OwnershipError::OwnerAuthorityRequired(fleet_uid)); + } + if existing_role == Some(FleetRole::Owner) && Self::owner_count(fleet) == 1 { + return Err(OwnershipError::LastOwner(fleet_uid)); + } + Ok(fleet.memberships.remove(&user_uid).is_some()) + }) + .await + } + + /// Assign a previously unowned executable principal to a fleet. + /// + /// Repeating the same assignment is idempotent. Moving a principal uses + /// [`transfer_principal`](Self::transfer_principal), which checks both + /// ownership boundaries explicitly. + /// + /// # Errors + /// + /// Rejects insufficient authority and any implicit reassignment. + pub async fn assign_principal( + &self, + ownership: PrincipalOwnership, + ) -> Result<(), OwnershipError> { + self.mutate(|graph| { + let fleet = graph + .fleets + .get(&ownership.fleet_uid) + .ok_or(OwnershipError::FleetNotFound(ownership.fleet_uid))?; + Self::require_manager(fleet, ownership.assigned_by)?; + match graph.principal_ownership.get(&ownership.principal_uid) { + Some(existing) if existing.fleet_uid == ownership.fleet_uid => Ok(()), + Some(existing) => Err(OwnershipError::PrincipalAlreadyOwned { + principal: ownership.principal_uid, + fleet: existing.fleet_uid, + }), + None => { + graph + .principal_ownership + .insert(ownership.principal_uid, ownership.clone()); + Ok(()) + }, + } + }) + .await + } + + /// Move a principal between fleets with authority in both boundaries. + /// + /// # Errors + /// + /// Rejects missing assignments, stale source fleets, or insufficient + /// authority in either fleet. + pub async fn transfer_principal( + &self, + principal_uid: PrincipalUid, + source_fleet: FleetUid, + destination_fleet: FleetUid, + actor: UserUid, + ) -> Result<(), OwnershipError> { + self.mutate(|graph| { + let current = graph + .principal_ownership + .get(&principal_uid) + .ok_or(OwnershipError::PrincipalNotOwned(principal_uid))?; + if current.fleet_uid != source_fleet { + return Err(OwnershipError::PrincipalAlreadyOwned { + principal: principal_uid, + fleet: current.fleet_uid, + }); + } + let source = graph + .fleets + .get(&source_fleet) + .ok_or(OwnershipError::FleetNotFound(source_fleet))?; + Self::require_manager(source, actor)?; + let destination = graph + .fleets + .get(&destination_fleet) + .ok_or(OwnershipError::FleetNotFound(destination_fleet))?; + Self::require_manager(destination, actor)?; + graph.principal_ownership.insert( + principal_uid, + PrincipalOwnership { + principal_uid, + fleet_uid: destination_fleet, + assigned_by: actor, + }, + ); + Ok(()) + }) + .await + } + + async fn mutate(&self, apply: F) -> Result + where + T: Clone, + F: Fn(&mut OwnershipSnapshot) -> Result, + { + for _ in 0..MAX_CAS_ATTEMPTS { + let current = self.storage.get(GRAPH_KEY).await?; + let mut graph = Self::decode(current.as_deref())?; + let output = apply(&mut graph)?; + graph.validate()?; + let encoded = serde_json::to_vec(&graph) + .map_err(|error| OwnershipError::Serialization(error.to_string()))?; + if self + .storage + .compare_and_swap(GRAPH_KEY, current.as_deref(), encoded) + .await? + { + return Ok(output); + } + } + Err(OwnershipError::ConcurrentModification) + } + + fn decode(raw: Option<&[u8]>) -> Result { + let graph = raw.map_or_else( + || Ok(OwnershipSnapshot::default()), + |bytes| { + serde_json::from_slice(bytes) + .map_err(|error| OwnershipError::Serialization(error.to_string())) + }, + )?; + graph.validate()?; + Ok(graph) + } + + fn require_manager(fleet: &FleetRecord, actor: UserUid) -> Result<(), OwnershipError> { + let role = Self::role(fleet, actor); + if role.is_some_and(FleetRole::can_manage) { + Ok(()) + } else { + Err(OwnershipError::NotFleetManager { + user: actor, + fleet: fleet.identity.uid, + }) + } + } + + fn role(fleet: &FleetRecord, user: UserUid) -> Option { + fleet + .memberships + .get(&user) + .map(|membership| membership.role) + } + + fn owner_count(fleet: &FleetRecord) -> usize { + fleet + .memberships + .values() + .filter(|membership| membership.role == FleetRole::Owner) + .count() + } +} + +/// Rejection from ownership graph persistence or invariant enforcement. +#[derive(Debug, thiserror::Error)] +pub enum OwnershipError { + /// Canonical identity material was invalid. + #[error(transparent)] + Identity(#[from] OwnershipIdentityError), + /// Raw persistence failed. + #[error(transparent)] + Storage(#[from] StorageError), + /// JSON encoding or decoding failed. + #[error("ownership graph serialization failed: {0}")] + Serialization(String), + /// Stored graph uses an unknown format. + #[error("unsupported ownership graph format version {0}")] + UnsupportedFormat(u16), + /// Persisted relationships failed invariant validation. + #[error("corrupt ownership graph: {0}")] + CorruptGraph(String), + /// A UID was reused with different genesis identity. + #[error("conflicting {0} identity for uid {1}")] + IdentityConflict(&'static str, String), + /// A referenced user does not exist. + #[error("user not found: {0}")] + UserNotFound(UserUid), + /// A referenced fleet does not exist. + #[error("fleet not found: {0}")] + FleetNotFound(FleetUid), + /// The acting user cannot administer the fleet. + #[error("user {user} is not a manager of fleet {fleet}")] + NotFleetManager { + /// User that attempted the mutation. + user: UserUid, + /// Fleet whose ownership boundary rejected it. + fleet: FleetUid, + }, + /// A mutation would leave a fleet without an owner. + #[error("fleet {0} must retain at least one owner")] + LastOwner(FleetUid), + /// A fleet ownership transition was attempted by a non-owner manager. + #[error("only a fleet owner may change owner membership in fleet {0}")] + OwnerAuthorityRequired(FleetUid), + /// A principal already belongs to a different fleet. + #[error("principal {principal} is already owned by fleet {fleet}")] + PrincipalAlreadyOwned { + /// Principal whose exclusive assignment blocked the mutation. + principal: PrincipalUid, + /// Current owning fleet. + fleet: FleetUid, + }, + /// A principal has no current fleet assignment. + #[error("principal has no fleet owner: {0}")] + PrincipalNotOwned(PrincipalUid), + /// Sustained concurrent writes prevented an atomic commit. + #[error("ownership graph changed concurrently too many times")] + ConcurrentModification, +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use uuid::Uuid; + + use super::*; + use crate::MemoryKvStore; + use astrid_core::{FleetGenesis, PrincipalGenesis, PrincipalIdentity, UserGenesis}; + + fn at(seconds: i64) -> chrono::DateTime { + Utc.timestamp_opt(seconds, 0).single().unwrap() + } + + fn user(id: u128, key: u8) -> UserIdentity { + UserIdentity::from_genesis(UserGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_000), + [key; 32], + )) + .unwrap() + } + + fn fleet(id: u128, creator: UserUid) -> FleetIdentity { + FleetIdentity::from_genesis(FleetGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_001), + creator, + )) + .unwrap() + } + + fn principal(id: u128, key: u8) -> PrincipalUid { + PrincipalIdentity::from_genesis(PrincipalGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_002), + [key; 32], + )) + .unwrap() + .uid + } + + fn store() -> OwnershipStore { + OwnershipStore::new(Arc::new(MemoryKvStore::new())).unwrap() + } + + #[tokio::test] + async fn fleet_creation_atomically_bootstraps_its_owner() { + let store = store(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let graph = store.load().await.unwrap(); + let membership = graph + .fleet(owned_fleet.uid) + .unwrap() + .membership(owner.uid) + .unwrap(); + assert_eq!(membership.role, FleetRole::Owner); + assert_eq!(membership.granted_by, owner.uid); + } + + #[tokio::test] + async fn principal_cannot_be_silently_reassigned() { + let store = store(); + let owner = user(1, 1); + let first = fleet(10, owner.uid); + let second = fleet(11, owner.uid); + let principal_uid = principal(20, 2); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(first.clone()).await.unwrap(); + store.create_fleet(second.clone()).await.unwrap(); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: first.uid, + assigned_by: owner.uid, + }) + .await + .unwrap(); + + let error = store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: second.uid, + assigned_by: owner.uid, + }) + .await + .unwrap_err(); + assert!(matches!( + error, + OwnershipError::PrincipalAlreadyOwned { .. } + )); + assert_eq!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .unwrap() + .fleet_uid, + first.uid + ); + } + + #[tokio::test] + async fn explicit_transfer_requires_management_of_both_fleets() { + let store = store(); + let first_owner = user(1, 1); + let second_owner = user(2, 2); + let first = fleet(10, first_owner.uid); + let second = fleet(11, second_owner.uid); + let principal_uid = principal(20, 3); + store.create_user(first_owner.clone()).await.unwrap(); + store.create_user(second_owner.clone()).await.unwrap(); + store.create_fleet(first.clone()).await.unwrap(); + store.create_fleet(second.clone()).await.unwrap(); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: first.uid, + assigned_by: first_owner.uid, + }) + .await + .unwrap(); + + let denied = store + .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) + .await + .unwrap_err(); + assert!(matches!(denied, OwnershipError::NotFleetManager { .. })); + + store + .set_membership( + second.uid, + first_owner.uid, + FleetRole::Administrator, + second_owner.uid, + ) + .await + .unwrap(); + store + .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) + .await + .unwrap(); + assert_eq!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .unwrap() + .fleet_uid, + second.uid + ); + } + + #[tokio::test] + async fn last_owner_cannot_be_demoted_or_removed() { + let store = store(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + assert!(matches!( + store + .set_membership(owned_fleet.uid, owner.uid, FleetRole::Member, owner.uid) + .await, + Err(OwnershipError::LastOwner(_)) + )); + assert!(matches!( + store + .remove_member(owned_fleet.uid, owner.uid, owner.uid) + .await, + Err(OwnershipError::LastOwner(_)) + )); + } + + #[tokio::test] + async fn administrator_cannot_escalate_to_owner_or_remove_one() { + let store = store(); + let owner = user(1, 1); + let administrator = user(2, 2); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_user(administrator.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + store + .set_membership( + owned_fleet.uid, + administrator.uid, + FleetRole::Administrator, + owner.uid, + ) + .await + .unwrap(); + + assert!(matches!( + store + .set_membership( + owned_fleet.uid, + administrator.uid, + FleetRole::Owner, + administrator.uid, + ) + .await, + Err(OwnershipError::OwnerAuthorityRequired(_)) + )); + assert!(matches!( + store + .remove_member(owned_fleet.uid, owner.uid, administrator.uid) + .await, + Err(OwnershipError::OwnerAuthorityRequired(_)) + )); + } + + #[tokio::test] + async fn malformed_persisted_graph_fails_closed() { + let backend = Arc::new(MemoryKvStore::new()); + let raw: Arc = backend.clone(); + let store = OwnershipStore::new(raw).unwrap(); + backend + .set(OWNERSHIP_NAMESPACE, GRAPH_KEY, b"not-json".to_vec()) + .await + .unwrap(); + assert!(matches!( + store.load().await, + Err(OwnershipError::Serialization(_)) + )); + } + + #[tokio::test] + async fn concurrent_principal_assignments_do_not_lose_updates() { + let backend = Arc::new(MemoryKvStore::new()); + let raw: Arc = backend; + let store = OwnershipStore::new(raw).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let first = principal(20, 2); + let second = principal(21, 3); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let first_store = store.clone(); + let second_store = store.clone(); + let (first_result, second_result) = tokio::join!( + first_store.assign_principal(PrincipalOwnership { + principal_uid: first, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }), + second_store.assign_principal(PrincipalOwnership { + principal_uid: second, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + ); + first_result.unwrap(); + second_result.unwrap(); + + let graph = store.load().await.unwrap(); + assert_eq!( + graph.principal_owner(first).unwrap().fleet_uid, + owned_fleet.uid + ); + assert_eq!( + graph.principal_owner(second).unwrap().fleet_uid, + owned_fleet.uid + ); + } +} diff --git a/docs/astrid-user-fleet-ownership.md b/docs/astrid-user-fleet-ownership.md new file mode 100644 index 000000000..7f1e68fe7 --- /dev/null +++ b/docs/astrid-user-fleet-ownership.md @@ -0,0 +1,77 @@ +# Astrid user and fleet ownership + +Status: implemented foundation. CLI and HTTP management surfaces are not yet +exposed. AOS is not part of this change. + +## Model + +Astrid now separates identity, ownership, execution, and permission: + +| Concept | Meaning | Stable identifier | +|---|---|---| +| User | Human authority that can move between frontends and devices | `UserUid` | +| Fleet | Ownership boundary containing users and executable principals | `FleetUid` | +| Principal | Executable identity used by an agent, service, or legacy process | `PrincipalUid` | +| Group | Reusable capability-permission bundle | Existing `GroupName` | + +A principal has at most one fleet owner. It cannot be silently assigned to a +second fleet. Moving it is an explicit transfer authorized in both the source +and destination fleets. Groups remain independent of fleets: changing fleet +membership does not rewrite a principal's capability groups, and assigning a +group does not convey ownership. + +Fleet membership has three roles: + +- owners control owner membership, ordinary membership, and principals; +- administrators control ordinary membership and principals, but cannot make + themselves an owner or remove or demote an owner; and +- members hold no ownership-management authority. + +Every fleet must retain at least one owner. + +## Persistence and recovery + +The ownership graph lives under the reserved `system:ownership` namespace. A +single compare-and-swap record currently contains users, fleets, memberships, +and principal assignments. This deliberately favors atomic invariants over +premature sharding: a new fleet and its first owner commit together, and no +crash or concurrent writer can expose a principal in two fleets. Reads validate +canonical user and fleet genesis records and every graph edge before admitting +the state. + +`UserUid` and `FleetUid` are domain-separated BLAKE3 derivations over canonical +genesis bytes. Mutable aliases, display names, current frontend links, and +future key rotation do not change either identifier. + +The existing `StateOwnerCodecV1` remains unchanged. Principal-owned KV and +content roots therefore preserve their byte format and behavior. Connecting +fleet accounting or user-owned state to that codec requires an explicit new +format or a separate index; this implementation does not smuggle new tags into +version one. + +## Existing installations + +Native kernel boot keeps the legacy `default` operator path working. After the +existing CLI root principal identity is loaded, Astrid deterministically and +idempotently creates: + +1. a user from the existing root UUID, creation time, and initial public key; +2. a default fleet owned by that user; and +3. an ownership edge from the existing stable `PrincipalUid` to that fleet. + +The `default` alias, `cli/local` link, admin group, profile, keys, home, and +current CLI/API behavior do not change. Corrupt ownership state fails kernel +boot instead of being ignored or overwritten. + +## Intentionally not included yet + +- no interactive onboarding or new CLI commands; +- no HTTP ownership-management endpoints; +- no AOS plugin or downstream migration; +- no automatic fleet assignment for newly created non-root principals; +- no change to capability evaluation, storage quota ownership, or capsule IPC; +- no claim that a fleet is a capability group. + +Those surfaces should be added only after the substrate has shipped with a +read-only inspection API and the migration behavior has been exercised against +real existing homes. From 4815a628054922af33895498447a22e02f448be7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 02:05:43 +0400 Subject: [PATCH 02/10] fix(identity): harden ownership migration invariants Signed-off-by: Joshua J. Bouw --- crates/astrid-core/src/identity/ownership.rs | 2 +- crates/astrid-kernel/src/lib.rs | 150 ++++++++++++++---- crates/astrid-storage/src/identity.rs | 7 + .../astrid-storage/src/identity/contract.rs | 18 +++ crates/astrid-storage/src/ownership.rs | 106 +++++++++++-- .../astrid-storage/src/principal_directory.rs | 6 + docs/astrid-user-fleet-ownership.md | 4 +- 7 files changed, 245 insertions(+), 48 deletions(-) diff --git a/crates/astrid-core/src/identity/ownership.rs b/crates/astrid-core/src/identity/ownership.rs index c4173adc2..6afa3d59f 100644 --- a/crates/astrid-core/src/identity/ownership.rs +++ b/crates/astrid-core/src/identity/ownership.rs @@ -323,7 +323,7 @@ impl FleetIdentity { pub enum FleetRole { /// Controls ownership, membership, and principal assignment. Owner, - /// Manages members and principal assignment but cannot remove the last owner. + /// Manages non-owner members and principals; owner membership is owner-controlled. Administrator, /// Uses fleet resources without changing ownership. Member, diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 10b60b391..a41b34bf6 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -782,9 +782,10 @@ impl Kernel { )) })?; let ownership_store = Arc::new( - astrid_storage::OwnershipStore::new(Arc::clone(&kv)).map_err(|error| { - std::io::Error::other(format!("Failed to create ownership store: {error}")) - })?, + astrid_storage::OwnershipStore::new(Arc::clone(&kv), principal_directory.clone()) + .map_err(|error| { + std::io::Error::other(format!("Failed to create ownership store: {error}")) + })?, ); ownership_store.load().await.map_err(|error| { std::io::Error::other(format!("Failed to load ownership graph: {error}")) @@ -815,16 +816,17 @@ impl Kernel { // Bootstrap the CLI root user (idempotent). Also seeds the // default principal's profile with `groups = ["admin"]` so // single-tenant deployments get full management-API access. - let (root_user, root_public_key) = bootstrap_cli_root_user(&identity_store, &home) - .await - .map_err(|e| { - std::io::Error::other(format!("Failed to bootstrap CLI root user: {e}")) - })?; + let (root_user, root_principal_identity) = + bootstrap_cli_root_user(&identity_store, &home) + .await + .map_err(|e| { + std::io::Error::other(format!("Failed to bootstrap CLI root user: {e}")) + })?; bootstrap_cli_root_ownership( &ownership_store, &principal_directory, root_user, - root_public_key, + root_principal_identity, ) .await .map_err(|error| { @@ -2537,6 +2539,27 @@ async fn open_test_runtime_kv( .expect("test kernel: open authoritative principal store") } +#[cfg(test)] +fn open_test_identity_stores( + kv: &Arc, +) -> ( + Arc, + Arc, +) { + let identity_kv = astrid_storage::ScopedKvStore::new(Arc::clone(kv), "system:identity") + .expect("test kernel: identity kv scope"); + let principal_directory = astrid_storage::PrincipalDirectory::default(); + let identity_store = Arc::new(astrid_storage::KvIdentityStore::with_principal_directory( + identity_kv, + principal_directory.clone(), + )); + let ownership_store = Arc::new( + astrid_storage::OwnershipStore::new(Arc::clone(kv), principal_directory) + .expect("test kernel: ownership store"), + ); + (identity_store, ownership_store) +} + #[cfg(test)] pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) -> Arc { use astrid_capsule::profile_cache::PrincipalProfileCache; @@ -2595,13 +2618,7 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - )); let allowance_store = Arc::new(astrid_approval::AllowanceStore::new()); - let identity_kv = astrid_storage::ScopedKvStore::new(Arc::clone(&kv), "system:identity") - .expect("test kernel: identity kv scope"); - let identity_store: Arc = - Arc::new(astrid_storage::KvIdentityStore::new(identity_kv)); - let ownership_store = Arc::new( - astrid_storage::OwnershipStore::new(Arc::clone(&kv)).expect("test kernel: ownership store"), - ); + let (identity_store, ownership_store) = open_test_identity_stores(&kv); let groups = Arc::new(ArcSwap::from_pointee( GroupConfig::load(&home).expect("test kernel: load groups"), @@ -3525,7 +3542,10 @@ fn warn_agent_loop_readiness(manifests: &[&astrid_capsule_types::manifest::Capsu async fn bootstrap_cli_root_user( store: &Arc, home: &astrid_core::dirs::AstridHome, -) -> Result<(astrid_core::AstridUserId, [u8; 32]), astrid_storage::IdentityError> { +) -> Result< + (astrid_core::AstridUserId, astrid_core::PrincipalIdentity), + astrid_storage::IdentityError, +> { // Seed the default principal profile with the admin group. Runs // before the identity-link short-circuit below so a deleted profile // between boots is restored even when the identity record persists. @@ -3540,36 +3560,44 @@ async fn bootstrap_cli_root_user( // Check if root user already exists by trying to resolve the CLI link. if let Some(user) = store.resolve("cli", "local").await? { - store + let identity = store .bind_principal_identity(user.id, principal, initial_public_key) .await?; tracing::debug!("CLI root user already linked"); - return Ok((user, initial_public_key)); + return Ok((user, identity)); } // No CLI link exists. Create or find the root user. let user = store .create_principal(principal, initial_public_key) .await?; + let identity = store + .get_principal_identity(user.id) + .await? + .ok_or_else(|| { + astrid_storage::IdentityError::InvalidInput( + "new CLI root principal is missing immutable identity".to_owned(), + ) + })?; tracing::info!(user_id = %user.id, "Created CLI root user"); // Link the CLI platform identity. store.link("cli", "local", user.id, "system").await?; tracing::info!(user_id = %user.id, "Linked CLI root user (cli/local)"); - Ok((user, initial_public_key)) + Ok((user, identity)) } async fn bootstrap_cli_root_ownership( store: &astrid_storage::OwnershipStore, principal_directory: &astrid_storage::PrincipalDirectory, root_user: astrid_core::AstridUserId, - initial_public_key: [u8; 32], + root_principal_identity: astrid_core::PrincipalIdentity, ) -> Result<(), astrid_storage::OwnershipError> { let user = astrid_core::UserIdentity::from_genesis(astrid_core::UserGenesis::from_parts( root_user.id, root_user.created_at, - initial_public_key, + root_principal_identity.genesis.initial_public_key, ))?; store.create_user(user.clone()).await?; @@ -3586,6 +3614,9 @@ async fn bootstrap_cli_root_ownership( let principal_uid = principal_directory .uid_for(&astrid_core::PrincipalId::default()) .map_err(astrid_storage::OwnershipError::Storage)?; + if store.load().await?.principal_owner(principal_uid).is_some() { + return Ok(()); + } store .assign_principal(astrid_core::PrincipalOwnership { principal_uid, @@ -3917,8 +3948,9 @@ mod tests { async fn legacy_root_ownership_bootstrap_is_deterministic_and_idempotent() { let backend: Arc = Arc::new(astrid_storage::MemoryKvStore::new()); - let ownership_store = astrid_storage::OwnershipStore::new(backend).unwrap(); let directory = astrid_storage::PrincipalDirectory::default(); + let ownership_store = + astrid_storage::OwnershipStore::new(backend, directory.clone()).unwrap(); let principal_identity = astrid_core::PrincipalIdentity::from_genesis( astrid_core::PrincipalGenesis::from_parts( uuid::Uuid::from_u128(2), @@ -3938,19 +3970,79 @@ mod tests { created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), }; - bootstrap_cli_root_ownership(&ownership_store, &directory, root_user.clone(), [1; 32]) - .await - .unwrap(); + bootstrap_cli_root_ownership( + &ownership_store, + &directory, + root_user.clone(), + principal_identity.clone(), + ) + .await + .unwrap(); let first = ownership_store.load().await.unwrap(); - bootstrap_cli_root_ownership(&ownership_store, &directory, root_user, [1; 32]) - .await - .unwrap(); + bootstrap_cli_root_ownership( + &ownership_store, + &directory, + root_user.clone(), + principal_identity.clone(), + ) + .await + .unwrap(); let second = ownership_store.load().await.unwrap(); assert_eq!(first, second); let principal_owner = second.principal_owner(principal_identity.uid).unwrap(); + let root_user_uid = principal_owner.assigned_by; + let initial_fleet_uid = principal_owner.fleet_uid; + let expected_user = + astrid_core::UserIdentity::from_genesis(astrid_core::UserGenesis::from_parts( + root_user.id, + root_user.created_at, + principal_identity.genesis.initial_public_key, + )) + .unwrap(); + assert_eq!(root_user_uid, expected_user.uid); assert_eq!(second.fleets().count(), 1); assert!(second.fleet(principal_owner.fleet_uid).is_some()); + + let destination = + astrid_core::FleetIdentity::from_genesis(astrid_core::FleetGenesis::from_parts( + uuid::Uuid::from_u128(3), + chrono::DateTime::from_timestamp(1_700_000_001, 0).unwrap(), + root_user_uid, + )) + .unwrap(); + ownership_store + .create_fleet(destination.clone()) + .await + .unwrap(); + ownership_store + .transfer_principal( + principal_identity.uid, + initial_fleet_uid, + destination.uid, + root_user_uid, + ) + .await + .unwrap(); + + bootstrap_cli_root_ownership( + &ownership_store, + &directory, + root_user, + principal_identity.clone(), + ) + .await + .unwrap(); + assert_eq!( + ownership_store + .load() + .await + .unwrap() + .principal_owner(principal_identity.uid) + .unwrap() + .fleet_uid, + destination.uid + ); } #[test] diff --git a/crates/astrid-storage/src/identity.rs b/crates/astrid-storage/src/identity.rs index 979ce4a1b..e60fc7ebf 100644 --- a/crates/astrid-storage/src/identity.rs +++ b/crates/astrid-storage/src/identity.rs @@ -340,6 +340,13 @@ impl IdentityStore for KvIdentityStore { Ok(identity) } + async fn get_principal_identity( + &self, + id: Uuid, + ) -> Result, IdentityError> { + KvIdentityStore::get_principal_identity(self, id).await + } + async fn load_principal_directory(&self) -> Result<(), IdentityError> { let keys = self .kv diff --git a/crates/astrid-storage/src/identity/contract.rs b/crates/astrid-storage/src/identity/contract.rs index 95c2ae6da..77bc0b49e 100644 --- a/crates/astrid-storage/src/identity/contract.rs +++ b/crates/astrid-storage/src/identity/contract.rs @@ -72,6 +72,24 @@ pub trait IdentityStore: Send + Sync + fmt::Debug { )) } + /// Read the immutable principal identity bound to one user record. + /// + /// Frontend-only users legitimately return `None`. + /// + /// # Errors + /// + /// Returns an identity or storage error if the persisted record is + /// malformed. + async fn get_principal_identity( + &self, + id: Uuid, + ) -> Result, IdentityError> { + let _ = id; + Err(IdentityError::InvalidInput( + "this identity store does not expose durable principal identities".to_owned(), + )) + } + /// Validate every persisted principal identity and populate the live /// alias directory. /// diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 56908a615..5dfaa7a24 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -14,7 +14,7 @@ use astrid_core::{ }; use serde::{Deserialize, Serialize}; -use crate::{KvStore, ScopedKvStore, StorageError}; +use crate::{KvStore, PrincipalDirectory, ScopedKvStore, StorageError}; /// Namespace reserved for the authoritative ownership graph. pub const OWNERSHIP_NAMESPACE: &str = "system:ownership"; @@ -104,7 +104,7 @@ impl OwnershipSnapshot { self.principal_ownership.values() } - fn validate(&self) -> Result<(), OwnershipError> { + fn validate(&self, principals: &PrincipalDirectory) -> Result<(), OwnershipError> { if self.format_version != GRAPH_FORMAT_VERSION { return Err(OwnershipError::UnsupportedFormat(self.format_version)); } @@ -165,6 +165,11 @@ impl OwnershipSnapshot { "principal ownership key {principal_uid} disagrees with its record" ))); } + if !principals.contains_uid(*principal_uid) { + return Err(OwnershipError::CorruptGraph(format!( + "principal {principal_uid} is absent from the admitted principal directory" + ))); + } if !self.fleets.contains_key(&ownership.fleet_uid) || !self.users.contains_key(&ownership.assigned_by) { @@ -181,6 +186,7 @@ impl OwnershipSnapshot { #[derive(Clone, Debug)] pub struct OwnershipStore { storage: ScopedKvStore, + principals: PrincipalDirectory, } impl OwnershipStore { @@ -189,9 +195,13 @@ impl OwnershipStore { /// # Errors /// /// Returns [`OwnershipError::Storage`] if the reserved namespace is invalid. - pub fn new(storage: Arc) -> Result { + pub fn new( + storage: Arc, + principals: PrincipalDirectory, + ) -> Result { Ok(Self { storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, + principals, }) } @@ -202,7 +212,7 @@ impl OwnershipStore { /// Fails closed on storage errors, malformed bytes, or broken invariants. pub async fn load(&self) -> Result { let raw = self.storage.get(GRAPH_KEY).await?; - Self::decode(raw.as_deref()) + self.decode(raw.as_deref()) } /// Register one durable human identity, idempotently. @@ -368,6 +378,9 @@ impl OwnershipStore { &self, ownership: PrincipalOwnership, ) -> Result<(), OwnershipError> { + if !self.principals.contains_uid(ownership.principal_uid) { + return Err(OwnershipError::PrincipalNotFound(ownership.principal_uid)); + } self.mutate(|graph| { let fleet = graph .fleets @@ -445,9 +458,9 @@ impl OwnershipStore { { for _ in 0..MAX_CAS_ATTEMPTS { let current = self.storage.get(GRAPH_KEY).await?; - let mut graph = Self::decode(current.as_deref())?; + let mut graph = self.decode(current.as_deref())?; let output = apply(&mut graph)?; - graph.validate()?; + graph.validate(&self.principals)?; let encoded = serde_json::to_vec(&graph) .map_err(|error| OwnershipError::Serialization(error.to_string()))?; if self @@ -461,7 +474,7 @@ impl OwnershipStore { Err(OwnershipError::ConcurrentModification) } - fn decode(raw: Option<&[u8]>) -> Result { + fn decode(&self, raw: Option<&[u8]>) -> Result { let graph = raw.map_or_else( || Ok(OwnershipSnapshot::default()), |bytes| { @@ -469,7 +482,7 @@ impl OwnershipStore { .map_err(|error| OwnershipError::Serialization(error.to_string())) }, )?; - graph.validate()?; + graph.validate(&self.principals)?; Ok(graph) } @@ -553,6 +566,9 @@ pub enum OwnershipError { /// A principal has no current fleet assignment. #[error("principal has no fleet owner: {0}")] PrincipalNotOwned(PrincipalUid), + /// A principal UID is not present in the admitted durable directory. + #[error("principal not found: {0}")] + PrincipalNotFound(PrincipalUid), /// Sustained concurrent writes prevented an atomic commit. #[error("ownership graph changed concurrently too many times")] ConcurrentModification, @@ -599,13 +615,23 @@ mod tests { .uid } - fn store() -> OwnershipStore { - OwnershipStore::new(Arc::new(MemoryKvStore::new())).unwrap() + fn store() -> (OwnershipStore, PrincipalDirectory) { + let principals = PrincipalDirectory::default(); + ( + OwnershipStore::new(Arc::new(MemoryKvStore::new()), principals.clone()).unwrap(), + principals, + ) + } + + fn admit_principal(directory: &PrincipalDirectory, alias: &str, uid: PrincipalUid) { + directory + .register(astrid_core::PrincipalId::new(alias).unwrap(), uid) + .unwrap(); } #[tokio::test] async fn fleet_creation_atomically_bootstraps_its_owner() { - let store = store(); + let (store, _) = store(); let owner = user(1, 1); let owned_fleet = fleet(10, owner.uid); store.create_user(owner.clone()).await.unwrap(); @@ -623,11 +649,12 @@ mod tests { #[tokio::test] async fn principal_cannot_be_silently_reassigned() { - let store = store(); + let (store, principals) = store(); let owner = user(1, 1); let first = fleet(10, owner.uid); let second = fleet(11, owner.uid); let principal_uid = principal(20, 2); + admit_principal(&principals, "test-principal", principal_uid); store.create_user(owner.clone()).await.unwrap(); store.create_fleet(first.clone()).await.unwrap(); store.create_fleet(second.clone()).await.unwrap(); @@ -666,12 +693,13 @@ mod tests { #[tokio::test] async fn explicit_transfer_requires_management_of_both_fleets() { - let store = store(); + let (store, principals) = store(); let first_owner = user(1, 1); let second_owner = user(2, 2); let first = fleet(10, first_owner.uid); let second = fleet(11, second_owner.uid); let principal_uid = principal(20, 3); + admit_principal(&principals, "test-principal", principal_uid); store.create_user(first_owner.clone()).await.unwrap(); store.create_user(second_owner.clone()).await.unwrap(); store.create_fleet(first.clone()).await.unwrap(); @@ -718,7 +746,7 @@ mod tests { #[tokio::test] async fn last_owner_cannot_be_demoted_or_removed() { - let store = store(); + let (store, _) = store(); let owner = user(1, 1); let owned_fleet = fleet(10, owner.uid); store.create_user(owner.clone()).await.unwrap(); @@ -740,7 +768,7 @@ mod tests { #[tokio::test] async fn administrator_cannot_escalate_to_owner_or_remove_one() { - let store = store(); + let (store, _) = store(); let owner = user(1, 1); let administrator = user(2, 2); let owned_fleet = fleet(10, owner.uid); @@ -780,7 +808,7 @@ mod tests { async fn malformed_persisted_graph_fails_closed() { let backend = Arc::new(MemoryKvStore::new()); let raw: Arc = backend.clone(); - let store = OwnershipStore::new(raw).unwrap(); + let store = OwnershipStore::new(raw, PrincipalDirectory::default()).unwrap(); backend .set(OWNERSHIP_NAMESPACE, GRAPH_KEY, b"not-json".to_vec()) .await @@ -795,11 +823,14 @@ mod tests { async fn concurrent_principal_assignments_do_not_lose_updates() { let backend = Arc::new(MemoryKvStore::new()); let raw: Arc = backend; - let store = OwnershipStore::new(raw).unwrap(); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(raw, principals.clone()).unwrap(); let owner = user(1, 1); let owned_fleet = fleet(10, owner.uid); let first = principal(20, 2); let second = principal(21, 3); + admit_principal(&principals, "first-principal", first); + admit_principal(&principals, "second-principal", second); store.create_user(owner.clone()).await.unwrap(); store.create_fleet(owned_fleet.clone()).await.unwrap(); @@ -830,4 +861,45 @@ mod tests { owned_fleet.uid ); } + + #[tokio::test] + async fn unknown_principals_are_rejected_on_assignment_and_reopen() { + let backend = Arc::new(MemoryKvStore::new()); + let admitted = PrincipalDirectory::default(); + let raw: Arc = backend.clone(); + let store = OwnershipStore::new(raw, admitted.clone()).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let principal_uid = principal(20, 2); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + assert!(matches!( + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await, + Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid + )); + + admit_principal(&admitted, "admitted-principal", principal_uid); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await + .unwrap(); + + let reopened = OwnershipStore::new(backend, PrincipalDirectory::default()).unwrap(); + assert!(matches!( + reopened.load().await, + Err(OwnershipError::CorruptGraph(message)) + if message.contains("absent from the admitted principal directory") + )); + } } diff --git a/crates/astrid-storage/src/principal_directory.rs b/crates/astrid-storage/src/principal_directory.rs index 6ed4bf636..be7256f38 100644 --- a/crates/astrid-storage/src/principal_directory.rs +++ b/crates/astrid-storage/src/principal_directory.rs @@ -117,6 +117,12 @@ impl PrincipalDirectory { }) } + /// Whether a durable UID currently names an admitted live principal. + #[must_use] + pub fn contains_uid(&self, uid: PrincipalUid) -> bool { + self.inner.read().principals.contains_key(&uid) + } + /// Rebind one existing UID to a new validated alias. /// /// The old alias must currently name `uid`, and the replacement alias must diff --git a/docs/astrid-user-fleet-ownership.md b/docs/astrid-user-fleet-ownership.md index 7f1e68fe7..850e824a3 100644 --- a/docs/astrid-user-fleet-ownership.md +++ b/docs/astrid-user-fleet-ownership.md @@ -33,7 +33,9 @@ Every fleet must retain at least one owner. The ownership graph lives under the reserved `system:ownership` namespace. A single compare-and-swap record currently contains users, fleets, memberships, -and principal assignments. This deliberately favors atomic invariants over +and principal assignments. Principal edges are additionally checked against +the kernel's admitted durable principal directory during mutation and load. +This deliberately favors atomic invariants over premature sharding: a new fleet and its first owner commit together, and no crash or concurrent writer can expose a principal in two fleets. Reads validate canonical user and fleet genesis records and every graph edge before admitting From cb97f2cb234c5ee69c1409f5976f851ed6e75a94 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 17:28:26 +0400 Subject: [PATCH 03/10] fix(identity): protect owned principal deletion Signed-off-by: Joshua J. Bouw --- CHANGELOG.md | 8 +- .../src/kernel_router/admin/handlers.rs | 22 ++++ .../src/kernel_router/admin/state_tests.rs | 101 ++++++++++++++++++ docs/astrid-user-fleet-ownership.md | 4 +- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84a4e31f..f5dfe65bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,11 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. `FleetUid` genesis records feed an atomically persisted ownership graph with owner-safe membership changes, exclusive principal assignment, explicit cross-fleet transfers, concurrent-writer protection, and fail-closed boot - validation. Existing native installations deterministically acquire a - default user, fleet, and ownership edge without changing CLI, HTTP, profile, - group, or `StateOwnerCodecV1` behavior. Closes #1469. + validation. Principal deletion is rejected while a fleet assignment exists, + preventing identity removal from leaving a dangling ownership edge. Existing + native installations deterministically acquire a default user, fleet, and + ownership edge without changing CLI, HTTP, profile, group, or + `StateOwnerCodecV1` behavior. Closes #1469. - **Bulk publication reuses closure evidence earned during authoritative staging.** A bounded, batch-local dependency walk proves admitted owning diff --git a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index 35147b0eb..3ca4b34a9 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs @@ -372,6 +372,28 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad Ok(user) => user, Err(e) => return err_internal(format!("identity store resolve failed: {e}")), }; + if let Some(user) = resolved.as_ref() { + let identity = match kernel.identity_store.get_principal_identity(user.id).await { + Ok(identity) => identity, + Err(e) => { + return err_internal(format!( + "identity store principal identity lookup failed: {e}" + )); + }, + }; + if let Some(identity) = identity { + let ownership = match kernel.ownership_store.load().await { + Ok(ownership) => ownership, + Err(e) => return err_internal(format!("ownership store load failed: {e}")), + }; + if let Some(owner) = ownership.principal_owner(identity.uid) { + return err_bad_input(format!( + "cannot delete principal `{principal}` while it is assigned to fleet {}", + owner.fleet_uid + )); + } + } + } // Unlink before delete_user so a concurrent `resolve` can't return // a dangling user id in the narrow window between the two calls. if let Err(e) = kernel diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs index edf64ee72..982c22181 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs @@ -16,6 +16,7 @@ use astrid_core::dirs::AstridHome; use astrid_core::groups::{BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED, GroupConfig}; use astrid_core::principal::PrincipalId; use astrid_core::profile::{AuthMethod, DeviceKey, DeviceScope, PrincipalProfile, Quotas}; +use astrid_core::{FleetGenesis, FleetIdentity, PrincipalOwnership, UserGenesis, UserIdentity}; use astrid_events::kernel_api::{AdminRequestKind, AdminResponseBody, AgentSummary, GroupSummary}; use tempfile::TempDir; @@ -523,6 +524,106 @@ async fn agent_delete_removes_identity_profile_and_invalidates_cache() { assert!(after.revokes.is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn agent_delete_rejects_a_fleet_owned_principal_without_partial_deletion() { + let (_dir, kernel) = fixture().await; + let principal = pid("owned-bob"); + let created = handlers::dispatch( + &kernel, + &astrid_core::PrincipalId::default(), + AdminRequestKind::AgentCreate { + name: principal.to_string(), + groups: Vec::new(), + grants: Vec::new(), + inherit_from: None, + clone_from: None, + allow_admin_clone: false, + }, + ) + .await; + assert_success(&created); + + let user = kernel + .identity_store + .resolve("cli", principal.as_str()) + .await + .unwrap() + .unwrap(); + let principal_identity = kernel + .identity_store + .get_principal_identity(user.id) + .await + .unwrap() + .unwrap(); + let owner = UserIdentity::from_genesis(UserGenesis::from_parts( + user.id, + user.created_at, + principal_identity.genesis.initial_public_key, + )) + .unwrap(); + let fleet = FleetIdentity::from_genesis(FleetGenesis::from_parts( + user.id, + user.created_at, + owner.uid, + )) + .unwrap(); + kernel + .ownership_store + .create_user(owner.clone()) + .await + .unwrap(); + kernel + .ownership_store + .create_fleet(fleet.clone()) + .await + .unwrap(); + kernel + .ownership_store + .assign_principal(PrincipalOwnership { + principal_uid: principal_identity.uid, + fleet_uid: fleet.uid, + assigned_by: owner.uid, + }) + .await + .unwrap(); + + let profile_path = PrincipalProfile::path_for(&kernel.astrid_home, &principal); + let deleted = handlers::dispatch( + &kernel, + &astrid_core::PrincipalId::default(), + AdminRequestKind::AgentDelete { + principal: principal.clone(), + }, + ) + .await; + assert_error_contains(&deleted, "assigned to fleet"); + + assert!( + profile_path.exists(), + "rejected deletion must retain profile" + ); + assert!( + kernel + .identity_store + .resolve("cli", principal.as_str()) + .await + .unwrap() + .is_some(), + "rejected deletion must retain the identity link" + ); + assert_eq!( + kernel + .ownership_store + .load() + .await + .unwrap() + .principal_owner(principal_identity.uid) + .unwrap() + .fleet_uid, + fleet.uid + ); +} + // ── Phantom-principal rejection (Gemini follow-up + R-thirteen) ── #[tokio::test(flavor = "multi_thread")] diff --git a/docs/astrid-user-fleet-ownership.md b/docs/astrid-user-fleet-ownership.md index 850e824a3..45779c890 100644 --- a/docs/astrid-user-fleet-ownership.md +++ b/docs/astrid-user-fleet-ownership.md @@ -18,7 +18,9 @@ A principal has at most one fleet owner. It cannot be silently assigned to a second fleet. Moving it is an explicit transfer authorized in both the source and destination fleets. Groups remain independent of fleets: changing fleet membership does not rewrite a principal's capability groups, and assigning a -group does not convey ownership. +group does not convey ownership. The existing `agent.delete` path rejects a +principal while it has a fleet assignment, so identity removal cannot leave a +dangling ownership edge. Fleet membership has three roles: From 5fb0e489e3a80e44ff76ec6ba3ea5027df5c26d9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 18:32:21 +0400 Subject: [PATCH 04/10] fix(identity): serialize principal deletion with ownership Signed-off-by: Joshua J. Bouw --- .../src/kernel_router/admin/handlers.rs | 31 ++++--- crates/astrid-storage/src/lib.rs | 4 +- crates/astrid-storage/src/ownership.rs | 91 ++++++++++++++++++- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index 3ca4b34a9..7fb478f1d 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs @@ -372,7 +372,7 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad Ok(user) => user, Err(e) => return err_internal(format!("identity store resolve failed: {e}")), }; - if let Some(user) = resolved.as_ref() { + let _ownership_guard = if let Some(user) = resolved.as_ref() { let identity = match kernel.identity_store.get_principal_identity(user.id).await { Ok(identity) => identity, Err(e) => { @@ -382,18 +382,27 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad }, }; if let Some(identity) = identity { - let ownership = match kernel.ownership_store.load().await { - Ok(ownership) => ownership, - Err(e) => return err_internal(format!("ownership store load failed: {e}")), - }; - if let Some(owner) = ownership.principal_owner(identity.uid) { - return err_bad_input(format!( - "cannot delete principal `{principal}` while it is assigned to fleet {}", - owner.fleet_uid - )); + match kernel + .ownership_store + .guard_principal_deletion(identity.uid) + .await + { + Ok(guard) => Some(guard), + Err(astrid_storage::OwnershipError::PrincipalAlreadyOwned { fleet, .. }) => { + return err_bad_input(format!( + "cannot delete principal `{principal}` while it is assigned to fleet {fleet}" + )); + }, + Err(e) => { + return err_internal(format!("ownership store deletion guard failed: {e}")); + }, } + } else { + None } - } + } else { + None + }; // Unlink before delete_user so a concurrent `resolve` can't return // a dangling user id in the narrow window between the two calls. if let Err(e) = kernel diff --git a/crates/astrid-storage/src/lib.rs b/crates/astrid-storage/src/lib.rs index dd3c65633..714aae4e2 100644 --- a/crates/astrid-storage/src/lib.rs +++ b/crates/astrid-storage/src/lib.rs @@ -66,7 +66,9 @@ pub use kv::{ KvEntry, KvPrincipalResolver, KvQuotaResolver, KvStore, MemoryKvStore, PrincipalKvStore, ScopedKvStore, TreeKvStore, }; -pub use ownership::{FleetRecord, OwnershipError, OwnershipSnapshot, OwnershipStore}; +pub use ownership::{ + FleetRecord, OwnershipError, OwnershipSnapshot, OwnershipStore, PrincipalDeletionGuard, +}; pub use principal_directory::PrincipalDirectory; pub use secret::{ DenySecretStore, FileSecretStore, KvSecretStore, ReadThroughSecretStore, SecretStore, diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 5dfaa7a24..a027ceb65 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -13,6 +13,7 @@ use astrid_core::{ PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, }; use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, OwnedMutexGuard}; use crate::{KvStore, PrincipalDirectory, ScopedKvStore, StorageError}; @@ -187,6 +188,18 @@ impl OwnershipSnapshot { pub struct OwnershipStore { storage: ScopedKvStore, principals: PrincipalDirectory, + mutation_lock: Arc>, +} + +/// Exclusive barrier held while an unowned principal is removed from the +/// durable identity directory. +/// +/// Dropping this guard allows ownership mutations to resume. Callers must keep +/// it alive until identity removal has either completed or been abandoned. +#[derive(Debug)] +#[must_use = "dropping the guard permits concurrent ownership assignment"] +pub struct PrincipalDeletionGuard { + _guard: OwnedMutexGuard<()>, } impl OwnershipStore { @@ -202,6 +215,7 @@ impl OwnershipStore { Ok(Self { storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, principals, + mutation_lock: Arc::new(Mutex::new(())), }) } @@ -215,6 +229,30 @@ impl OwnershipStore { self.decode(raw.as_deref()) } + /// Exclude ownership mutations while an unowned principal is deleted. + /// + /// The returned guard must remain alive through removal from the durable + /// identity store. This closes the check-to-delete race with concurrent + /// assignment through another clone of this store. + /// + /// # Errors + /// + /// Rejects a principal that already belongs to a fleet and fails closed on + /// invalid or unavailable ownership state. + pub async fn guard_principal_deletion( + &self, + principal_uid: PrincipalUid, + ) -> Result { + let guard = Arc::clone(&self.mutation_lock).lock_owned().await; + if let Some(ownership) = self.load().await?.principal_owner(principal_uid) { + return Err(OwnershipError::PrincipalAlreadyOwned { + principal: principal_uid, + fleet: ownership.fleet_uid, + }); + } + Ok(PrincipalDeletionGuard { _guard: guard }) + } + /// Register one durable human identity, idempotently. /// /// # Errors @@ -378,10 +416,10 @@ impl OwnershipStore { &self, ownership: PrincipalOwnership, ) -> Result<(), OwnershipError> { - if !self.principals.contains_uid(ownership.principal_uid) { - return Err(OwnershipError::PrincipalNotFound(ownership.principal_uid)); - } self.mutate(|graph| { + if !self.principals.contains_uid(ownership.principal_uid) { + return Err(OwnershipError::PrincipalNotFound(ownership.principal_uid)); + } let fleet = graph .fleets .get(&ownership.fleet_uid) @@ -456,6 +494,7 @@ impl OwnershipStore { T: Clone, F: Fn(&mut OwnershipSnapshot) -> Result, { + let _guard = self.mutation_lock.lock().await; for _ in 0..MAX_CAS_ATTEMPTS { let current = self.storage.get(GRAPH_KEY).await?; let mut graph = self.decode(current.as_deref())?; @@ -902,4 +941,50 @@ mod tests { if message.contains("absent from the admitted principal directory") )); } + + #[tokio::test] + async fn deletion_guard_serializes_assignment_with_directory_removal() { + let (store, principals) = store(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let principal_uid = principal(20, 2); + let alias = astrid_core::PrincipalId::new("deleting-principal").unwrap(); + principals.register(alias.clone(), principal_uid).unwrap(); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let deletion_guard = store.guard_principal_deletion(principal_uid).await.unwrap(); + let assigning_store = store.clone(); + let mut assignment = tokio::spawn(async move { + assigning_store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await + }); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut assignment) + .await + .is_err(), + "assignment must wait while principal deletion owns the mutation barrier" + ); + principals.unregister(&alias, principal_uid); + drop(deletion_guard); + + assert!(matches!( + assignment.await.unwrap(), + Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid + )); + assert!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .is_none() + ); + } } From 9b320a523533b5b6e4e15c6f581d43351411b5a6 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 19:05:23 +0400 Subject: [PATCH 05/10] test(identity): strengthen ownership durability coverage Signed-off-by: Joshua J. Bouw --- crates/astrid-core/src/identity/ownership.rs | 10 ++- crates/astrid-storage/src/ownership.rs | 89 +++++++++++++++++++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/crates/astrid-core/src/identity/ownership.rs b/crates/astrid-core/src/identity/ownership.rs index 6afa3d59f..815cf6b97 100644 --- a/crates/astrid-core/src/identity/ownership.rs +++ b/crates/astrid-core/src/identity/ownership.rs @@ -470,7 +470,10 @@ mod tests { #[test] fn user_uid_is_stable_and_canonical() { let identity = user(); - assert_eq!(identity.uid.to_string().len(), 64); + assert_eq!( + identity.uid.to_string(), + "4678a23b161f8867c20b32adbca86e58754aaf5fc225d64286563e790077b535" + ); assert_eq!(identity.uid.to_string().parse(), Ok(identity.uid)); assert_eq!(identity.validate(), Ok(())); assert!( @@ -492,7 +495,10 @@ mod tests { creator, )) .unwrap(); - assert_eq!(identity.uid.to_string().len(), 64); + assert_eq!( + identity.uid.to_string(), + "c46c84da942d4c7fe48e04e6f298e4cf39285a1c5b096cb4985c01bdcfec3709" + ); assert_eq!(identity.validate(), Ok(())); let other_creator = UserUid::from_bytes([0x11; 32]); diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index a027ceb65..07db49174 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -615,13 +615,93 @@ pub enum OwnershipError { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use async_trait::async_trait; use chrono::{TimeZone, Utc}; + use tokio::sync::Barrier; use uuid::Uuid; use super::*; use crate::MemoryKvStore; use astrid_core::{FleetGenesis, PrincipalGenesis, PrincipalIdentity, UserGenesis}; + #[derive(Debug)] + struct ReadBarrierKv { + inner: MemoryKvStore, + barrier: Barrier, + armed: AtomicBool, + ownership_reads: AtomicUsize, + } + + impl ReadBarrierKv { + fn new() -> Self { + Self { + inner: MemoryKvStore::new(), + barrier: Barrier::new(2), + armed: AtomicBool::new(false), + ownership_reads: AtomicUsize::new(0), + } + } + + fn arm(&self) { + self.ownership_reads.store(0, Ordering::SeqCst); + self.armed.store(true, Ordering::SeqCst); + } + } + + #[async_trait] + impl KvStore for ReadBarrierKv { + async fn get(&self, namespace: &str, key: &str) -> crate::StorageResult>> { + let value = self.inner.get(namespace, key).await?; + if self.armed.load(Ordering::SeqCst) + && namespace == OWNERSHIP_NAMESPACE + && key == GRAPH_KEY + && self.ownership_reads.fetch_add(1, Ordering::SeqCst) < 2 + { + self.barrier.wait().await; + } + Ok(value) + } + + async fn set( + &self, + namespace: &str, + key: &str, + value: Vec, + ) -> crate::StorageResult<()> { + self.inner.set(namespace, key, value).await + } + + async fn delete(&self, namespace: &str, key: &str) -> crate::StorageResult { + self.inner.delete(namespace, key).await + } + + async fn exists(&self, namespace: &str, key: &str) -> crate::StorageResult { + self.inner.exists(namespace, key).await + } + + async fn list_keys(&self, namespace: &str) -> crate::StorageResult> { + self.inner.list_keys(namespace).await + } + + async fn compare_and_swap( + &self, + namespace: &str, + key: &str, + expected: Option<&[u8]>, + new: Vec, + ) -> crate::StorageResult { + self.inner + .compare_and_swap(namespace, key, expected, new) + .await + } + + async fn clear_namespace(&self, namespace: &str) -> crate::StorageResult { + self.inner.clear_namespace(namespace).await + } + } + fn at(seconds: i64) -> chrono::DateTime { Utc.timestamp_opt(seconds, 0).single().unwrap() } @@ -860,8 +940,8 @@ mod tests { #[tokio::test] async fn concurrent_principal_assignments_do_not_lose_updates() { - let backend = Arc::new(MemoryKvStore::new()); - let raw: Arc = backend; + let backend = Arc::new(ReadBarrierKv::new()); + let raw: Arc = backend.clone(); let principals = PrincipalDirectory::default(); let store = OwnershipStore::new(raw, principals.clone()).unwrap(); let owner = user(1, 1); @@ -873,8 +953,9 @@ mod tests { store.create_user(owner.clone()).await.unwrap(); store.create_fleet(owned_fleet.clone()).await.unwrap(); - let first_store = store.clone(); - let second_store = store.clone(); + let first_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let second_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + backend.arm(); let (first_result, second_result) = tokio::join!( first_store.assign_principal(PrincipalOwnership { principal_uid: first, From 19556343af5444683229134e0cd4884a1d766cb9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 19:26:38 +0400 Subject: [PATCH 06/10] fix(identity): share deletion barrier across stores Signed-off-by: Joshua J. Bouw --- crates/astrid-storage/src/ownership.rs | 55 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 07db49174..9da5a5b31 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -5,15 +5,16 @@ //! together, and a principal can never be observed in two fleets. The format //! can be sharded behind the same API if graph size later warrants it. -use std::collections::BTreeMap; -use std::sync::Arc; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, OnceLock, Weak}; use astrid_core::{ FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, }; +use parking_lot::Mutex as SyncMutex; use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, OwnedMutexGuard}; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use crate::{KvStore, PrincipalDirectory, ScopedKvStore, StorageError}; @@ -23,6 +24,22 @@ const GRAPH_KEY: &str = "graph-v1"; const GRAPH_FORMAT_VERSION: u16 = 1; const MAX_CAS_ATTEMPTS: usize = 64; +fn mutation_lock_for(storage: &Arc) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + + let storage_identity = Arc::as_ptr(storage).cast::<()>() as usize; + let registry = LOCKS.get_or_init(|| SyncMutex::new(HashMap::new())); + let mut locks = registry.lock(); + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&storage_identity).and_then(Weak::upgrade) { + return lock; + } + + let lock = Arc::new(AsyncMutex::new(())); + locks.insert(storage_identity, Arc::downgrade(&lock)); + lock +} + /// One fleet identity and all current human memberships. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -188,7 +205,7 @@ impl OwnershipSnapshot { pub struct OwnershipStore { storage: ScopedKvStore, principals: PrincipalDirectory, - mutation_lock: Arc>, + mutation_lock: Arc>, } /// Exclusive barrier held while an unowned principal is removed from the @@ -212,10 +229,11 @@ impl OwnershipStore { storage: Arc, principals: PrincipalDirectory, ) -> Result { + let mutation_lock = mutation_lock_for(&storage); Ok(Self { storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, principals, - mutation_lock: Arc::new(Mutex::new(())), + mutation_lock, }) } @@ -742,6 +760,17 @@ mod tests { ) } + fn externally_coordinated_store( + storage: Arc, + principals: PrincipalDirectory, + ) -> OwnershipStore { + OwnershipStore { + storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE).unwrap(), + principals, + mutation_lock: Arc::new(AsyncMutex::new(())), + } + } + fn admit_principal(directory: &PrincipalDirectory, alias: &str, uid: PrincipalUid) { directory .register(astrid_core::PrincipalId::new(alias).unwrap(), uid) @@ -953,8 +982,11 @@ mod tests { store.create_user(owner.clone()).await.unwrap(); store.create_fleet(owned_fleet.clone()).await.unwrap(); - let first_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); - let second_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + // Distinct locks model writers outside this process. Production stores + // over the same backend share `mutation_lock_for`; CAS remains the + // durability boundary for independently coordinated writers. + let first_store = externally_coordinated_store(backend.clone(), principals.clone()); + let second_store = externally_coordinated_store(backend.clone(), principals.clone()); backend.arm(); let (first_result, second_result) = tokio::join!( first_store.assign_principal(PrincipalOwnership { @@ -1025,7 +1057,11 @@ mod tests { #[tokio::test] async fn deletion_guard_serializes_assignment_with_directory_removal() { - let (store, principals) = store(); + let backend = Arc::new(MemoryKvStore::new()); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let independently_opened = + OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); let owner = user(1, 1); let owned_fleet = fleet(10, owner.uid); let principal_uid = principal(20, 2); @@ -1035,9 +1071,8 @@ mod tests { store.create_fleet(owned_fleet.clone()).await.unwrap(); let deletion_guard = store.guard_principal_deletion(principal_uid).await.unwrap(); - let assigning_store = store.clone(); let mut assignment = tokio::spawn(async move { - assigning_store + independently_opened .assign_principal(PrincipalOwnership { principal_uid, fleet_uid: owned_fleet.uid, From fae1e6d7aba0719433d34c402ec2f45543abe250 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 19:54:46 +0400 Subject: [PATCH 07/10] fix(identity): make principal deletion durable Signed-off-by: Joshua J. Bouw --- .../src/kernel_router/admin/handlers.rs | 35 +- .../src/kernel_router/admin/state_tests.rs | 40 ++ crates/astrid-storage/src/ownership.rs | 582 +++--------------- crates/astrid-storage/src/ownership_tests.rs | 523 ++++++++++++++++ 4 files changed, 670 insertions(+), 510 deletions(-) create mode 100644 crates/astrid-storage/src/ownership_tests.rs diff --git a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index 7fb478f1d..aff8604be 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs @@ -363,8 +363,10 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad let _guard = kernel.admin_write_lock.lock().await; - // Resolve the link first so we know which user-record to delete. - let resolved = match kernel + // Prefer the frontend link, then fall back to the durable user record. A + // prior partial deletion may already have removed the link while leaving + // the principal identity and directory admission intact. + let linked = match kernel .identity_store .resolve(AGENT_IDENTITY_PLATFORM, principal.as_str()) .await @@ -372,7 +374,15 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad Ok(user) => user, Err(e) => return err_internal(format!("identity store resolve failed: {e}")), }; - let _ownership_guard = if let Some(user) = resolved.as_ref() { + let resolved = if linked.is_some() { + linked + } else { + match kernel.identity_store.list_users().await { + Ok(users) => users.into_iter().find(|user| user.principal == principal), + Err(e) => return err_internal(format!("identity store list_users failed: {e}")), + } + }; + let ownership_guard = if let Some(user) = resolved.as_ref() { let identity = match kernel.identity_store.get_principal_identity(user.id).await { Ok(identity) => identity, Err(e) => { @@ -412,10 +422,23 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad { return err_internal(format!("identity store unlink failed: {e}")); } - if let Some(user) = resolved - && let Err(e) = kernel.identity_store.delete_user(user.id).await + if let Some(user) = resolved { + match kernel.identity_store.delete_user(user.id).await { + Ok(true) => {}, + Ok(false) => { + return err_internal( + "identity store user disappeared during principal deletion".to_string(), + ); + }, + Err(e) => return err_internal(format!("identity store delete_user failed: {e}")), + } + } + if let Some(guard) = ownership_guard + && let Err(e) = guard.finish().await { - return err_internal(format!("identity store delete_user failed: {e}")); + return err_internal(format!( + "ownership store deletion reservation cleanup failed: {e}" + )); } // Remove the policy file. Without this, traffic claiming this diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs index 982c22181..35bc988a2 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs @@ -81,6 +81,41 @@ fn assert_error_contains(res: &AdminResponseBody, needle: &str) { } } +async fn assert_owned_delete_rejected_after_unlink( + kernel: &Arc, + principal: &PrincipalId, + user_id: uuid::Uuid, + profile_path: &std::path::Path, +) { + kernel + .identity_store + .unlink("cli", principal.as_str()) + .await + .unwrap(); + let retried = handlers::dispatch( + kernel, + &PrincipalId::default(), + AdminRequestKind::AgentDelete { + principal: principal.clone(), + }, + ) + .await; + assert_error_contains(&retried, "assigned to fleet"); + assert!( + profile_path.exists(), + "retried rejection must retain profile" + ); + assert!( + kernel + .identity_store + .get_user(user_id) + .await + .unwrap() + .is_some(), + "retried rejection must retain the durable user" + ); +} + fn agent_list_for(response: AdminResponseBody) -> Vec { match response { AdminResponseBody::AgentList(list) => list, @@ -611,6 +646,11 @@ async fn agent_delete_rejects_a_fleet_owned_principal_without_partial_deletion() .is_some(), "rejected deletion must retain the identity link" ); + + // Model a prior partial attempt that removed the frontend link but failed + // before deleting the durable user. A retry must recover the user by its + // stored principal alias and still enforce ownership. + assert_owned_delete_rejected_after_unlink(&kernel, &principal, user.id, &profile_path).await; assert_eq!( kernel .ownership_store diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 9da5a5b31..3ec23fd63 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -5,14 +5,13 @@ //! together, and a principal can never be observed in two fleets. The format //! can be sharded behind the same API if graph size later warrants it. -use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, OnceLock, Weak}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use astrid_core::{ FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, }; -use parking_lot::Mutex as SyncMutex; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; @@ -24,22 +23,6 @@ const GRAPH_KEY: &str = "graph-v1"; const GRAPH_FORMAT_VERSION: u16 = 1; const MAX_CAS_ATTEMPTS: usize = 64; -fn mutation_lock_for(storage: &Arc) -> Arc> { - static LOCKS: OnceLock>>>> = OnceLock::new(); - - let storage_identity = Arc::as_ptr(storage).cast::<()>() as usize; - let registry = LOCKS.get_or_init(|| SyncMutex::new(HashMap::new())); - let mut locks = registry.lock(); - locks.retain(|_, lock| lock.strong_count() > 0); - if let Some(lock) = locks.get(&storage_identity).and_then(Weak::upgrade) { - return lock; - } - - let lock = Arc::new(AsyncMutex::new(())); - locks.insert(storage_identity, Arc::downgrade(&lock)); - lock -} - /// One fleet identity and all current human memberships. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -75,6 +58,8 @@ pub struct OwnershipSnapshot { users: BTreeMap, fleets: BTreeMap, principal_ownership: BTreeMap, + #[serde(default)] + principal_deletions: BTreeSet, } impl Default for OwnershipSnapshot { @@ -84,6 +69,7 @@ impl Default for OwnershipSnapshot { users: BTreeMap::new(), fleets: BTreeMap::new(), principal_ownership: BTreeMap::new(), + principal_deletions: BTreeSet::new(), } } } @@ -196,6 +182,13 @@ impl OwnershipSnapshot { ))); } } + for principal_uid in &self.principal_deletions { + if self.principal_ownership.contains_key(principal_uid) { + return Err(OwnershipError::CorruptGraph(format!( + "principal {principal_uid} is both owned and reserved for deletion" + ))); + } + } Ok(()) } } @@ -216,9 +209,29 @@ pub struct OwnershipStore { #[derive(Debug)] #[must_use = "dropping the guard permits concurrent ownership assignment"] pub struct PrincipalDeletionGuard { + store: OwnershipStore, + principal_uid: PrincipalUid, _guard: OwnedMutexGuard<()>, } +impl PrincipalDeletionGuard { + /// Remove the durable reservation after identity removal completes. + /// + /// # Errors + /// + /// Fails closed when the latest graph cannot be read, validated, or + /// atomically updated. On failure the reservation remains durable. + pub async fn finish(self) -> Result<(), OwnershipError> { + let principal_uid = self.principal_uid; + self.store + .mutate_unlocked(|graph| { + graph.principal_deletions.remove(&principal_uid); + Ok(()) + }) + .await + } +} + impl OwnershipStore { /// Construct an ownership store over a raw Astrid KV backend. /// @@ -229,11 +242,10 @@ impl OwnershipStore { storage: Arc, principals: PrincipalDirectory, ) -> Result { - let mutation_lock = mutation_lock_for(&storage); Ok(Self { storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, principals, - mutation_lock, + mutation_lock: Arc::new(AsyncMutex::new(())), }) } @@ -247,11 +259,13 @@ impl OwnershipStore { self.decode(raw.as_deref()) } - /// Exclude ownership mutations while an unowned principal is deleted. + /// Reserve an unowned principal for durable identity deletion. /// - /// The returned guard must remain alive through removal from the durable - /// identity store. This closes the check-to-delete race with concurrent - /// assignment through another clone of this store. + /// The reservation changes the graph's CAS version, so a writer that read + /// the unowned graph before this call must retry and observe the deletion. + /// Call [`PrincipalDeletionGuard::finish`] only after durable identity + /// removal succeeds. Dropping the guard leaves the reservation in place so + /// a partial deletion fails closed and can be retried safely. /// /// # Errors /// @@ -262,13 +276,27 @@ impl OwnershipStore { principal_uid: PrincipalUid, ) -> Result { let guard = Arc::clone(&self.mutation_lock).lock_owned().await; - if let Some(ownership) = self.load().await?.principal_owner(principal_uid) { - return Err(OwnershipError::PrincipalAlreadyOwned { - principal: principal_uid, - fleet: ownership.fleet_uid, - }); - } - Ok(PrincipalDeletionGuard { _guard: guard }) + self.mutate_unlocked(|graph| { + if let Some(ownership) = graph.principal_owner(principal_uid) { + return Err(OwnershipError::PrincipalAlreadyOwned { + principal: principal_uid, + fleet: ownership.fleet_uid, + }); + } + if !graph.principal_deletions.contains(&principal_uid) + && !self.principals.contains_uid(principal_uid) + { + return Err(OwnershipError::PrincipalNotFound(principal_uid)); + } + graph.principal_deletions.insert(principal_uid); + Ok(()) + }) + .await?; + Ok(PrincipalDeletionGuard { + store: self.clone(), + principal_uid, + _guard: guard, + }) } /// Register one durable human identity, idempotently. @@ -435,6 +463,11 @@ impl OwnershipStore { ownership: PrincipalOwnership, ) -> Result<(), OwnershipError> { self.mutate(|graph| { + if graph.principal_deletions.contains(&ownership.principal_uid) { + return Err(OwnershipError::PrincipalDeletionInProgress( + ownership.principal_uid, + )); + } if !self.principals.contains_uid(ownership.principal_uid) { return Err(OwnershipError::PrincipalNotFound(ownership.principal_uid)); } @@ -513,6 +546,14 @@ impl OwnershipStore { F: Fn(&mut OwnershipSnapshot) -> Result, { let _guard = self.mutation_lock.lock().await; + self.mutate_unlocked(apply).await + } + + async fn mutate_unlocked(&self, apply: F) -> Result + where + T: Clone, + F: Fn(&mut OwnershipSnapshot) -> Result, + { for _ in 0..MAX_CAS_ATTEMPTS { let current = self.storage.get(GRAPH_KEY).await?; let mut graph = self.decode(current.as_deref())?; @@ -626,481 +667,14 @@ pub enum OwnershipError { /// A principal UID is not present in the admitted durable directory. #[error("principal not found: {0}")] PrincipalNotFound(PrincipalUid), + /// A principal cannot be assigned while durable identity deletion is active. + #[error("principal deletion is in progress: {0}")] + PrincipalDeletionInProgress(PrincipalUid), /// Sustained concurrent writes prevented an atomic commit. #[error("ownership graph changed concurrently too many times")] ConcurrentModification, } #[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - use async_trait::async_trait; - use chrono::{TimeZone, Utc}; - use tokio::sync::Barrier; - use uuid::Uuid; - - use super::*; - use crate::MemoryKvStore; - use astrid_core::{FleetGenesis, PrincipalGenesis, PrincipalIdentity, UserGenesis}; - - #[derive(Debug)] - struct ReadBarrierKv { - inner: MemoryKvStore, - barrier: Barrier, - armed: AtomicBool, - ownership_reads: AtomicUsize, - } - - impl ReadBarrierKv { - fn new() -> Self { - Self { - inner: MemoryKvStore::new(), - barrier: Barrier::new(2), - armed: AtomicBool::new(false), - ownership_reads: AtomicUsize::new(0), - } - } - - fn arm(&self) { - self.ownership_reads.store(0, Ordering::SeqCst); - self.armed.store(true, Ordering::SeqCst); - } - } - - #[async_trait] - impl KvStore for ReadBarrierKv { - async fn get(&self, namespace: &str, key: &str) -> crate::StorageResult>> { - let value = self.inner.get(namespace, key).await?; - if self.armed.load(Ordering::SeqCst) - && namespace == OWNERSHIP_NAMESPACE - && key == GRAPH_KEY - && self.ownership_reads.fetch_add(1, Ordering::SeqCst) < 2 - { - self.barrier.wait().await; - } - Ok(value) - } - - async fn set( - &self, - namespace: &str, - key: &str, - value: Vec, - ) -> crate::StorageResult<()> { - self.inner.set(namespace, key, value).await - } - - async fn delete(&self, namespace: &str, key: &str) -> crate::StorageResult { - self.inner.delete(namespace, key).await - } - - async fn exists(&self, namespace: &str, key: &str) -> crate::StorageResult { - self.inner.exists(namespace, key).await - } - - async fn list_keys(&self, namespace: &str) -> crate::StorageResult> { - self.inner.list_keys(namespace).await - } - - async fn compare_and_swap( - &self, - namespace: &str, - key: &str, - expected: Option<&[u8]>, - new: Vec, - ) -> crate::StorageResult { - self.inner - .compare_and_swap(namespace, key, expected, new) - .await - } - - async fn clear_namespace(&self, namespace: &str) -> crate::StorageResult { - self.inner.clear_namespace(namespace).await - } - } - - fn at(seconds: i64) -> chrono::DateTime { - Utc.timestamp_opt(seconds, 0).single().unwrap() - } - - fn user(id: u128, key: u8) -> UserIdentity { - UserIdentity::from_genesis(UserGenesis::from_parts( - Uuid::from_u128(id), - at(1_700_000_000), - [key; 32], - )) - .unwrap() - } - - fn fleet(id: u128, creator: UserUid) -> FleetIdentity { - FleetIdentity::from_genesis(FleetGenesis::from_parts( - Uuid::from_u128(id), - at(1_700_000_001), - creator, - )) - .unwrap() - } - - fn principal(id: u128, key: u8) -> PrincipalUid { - PrincipalIdentity::from_genesis(PrincipalGenesis::from_parts( - Uuid::from_u128(id), - at(1_700_000_002), - [key; 32], - )) - .unwrap() - .uid - } - - fn store() -> (OwnershipStore, PrincipalDirectory) { - let principals = PrincipalDirectory::default(); - ( - OwnershipStore::new(Arc::new(MemoryKvStore::new()), principals.clone()).unwrap(), - principals, - ) - } - - fn externally_coordinated_store( - storage: Arc, - principals: PrincipalDirectory, - ) -> OwnershipStore { - OwnershipStore { - storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE).unwrap(), - principals, - mutation_lock: Arc::new(AsyncMutex::new(())), - } - } - - fn admit_principal(directory: &PrincipalDirectory, alias: &str, uid: PrincipalUid) { - directory - .register(astrid_core::PrincipalId::new(alias).unwrap(), uid) - .unwrap(); - } - - #[tokio::test] - async fn fleet_creation_atomically_bootstraps_its_owner() { - let (store, _) = store(); - let owner = user(1, 1); - let owned_fleet = fleet(10, owner.uid); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - - let graph = store.load().await.unwrap(); - let membership = graph - .fleet(owned_fleet.uid) - .unwrap() - .membership(owner.uid) - .unwrap(); - assert_eq!(membership.role, FleetRole::Owner); - assert_eq!(membership.granted_by, owner.uid); - } - - #[tokio::test] - async fn principal_cannot_be_silently_reassigned() { - let (store, principals) = store(); - let owner = user(1, 1); - let first = fleet(10, owner.uid); - let second = fleet(11, owner.uid); - let principal_uid = principal(20, 2); - admit_principal(&principals, "test-principal", principal_uid); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(first.clone()).await.unwrap(); - store.create_fleet(second.clone()).await.unwrap(); - store - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: first.uid, - assigned_by: owner.uid, - }) - .await - .unwrap(); - - let error = store - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: second.uid, - assigned_by: owner.uid, - }) - .await - .unwrap_err(); - assert!(matches!( - error, - OwnershipError::PrincipalAlreadyOwned { .. } - )); - assert_eq!( - store - .load() - .await - .unwrap() - .principal_owner(principal_uid) - .unwrap() - .fleet_uid, - first.uid - ); - } - - #[tokio::test] - async fn explicit_transfer_requires_management_of_both_fleets() { - let (store, principals) = store(); - let first_owner = user(1, 1); - let second_owner = user(2, 2); - let first = fleet(10, first_owner.uid); - let second = fleet(11, second_owner.uid); - let principal_uid = principal(20, 3); - admit_principal(&principals, "test-principal", principal_uid); - store.create_user(first_owner.clone()).await.unwrap(); - store.create_user(second_owner.clone()).await.unwrap(); - store.create_fleet(first.clone()).await.unwrap(); - store.create_fleet(second.clone()).await.unwrap(); - store - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: first.uid, - assigned_by: first_owner.uid, - }) - .await - .unwrap(); - - let denied = store - .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) - .await - .unwrap_err(); - assert!(matches!(denied, OwnershipError::NotFleetManager { .. })); - - store - .set_membership( - second.uid, - first_owner.uid, - FleetRole::Administrator, - second_owner.uid, - ) - .await - .unwrap(); - store - .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) - .await - .unwrap(); - assert_eq!( - store - .load() - .await - .unwrap() - .principal_owner(principal_uid) - .unwrap() - .fleet_uid, - second.uid - ); - } - - #[tokio::test] - async fn last_owner_cannot_be_demoted_or_removed() { - let (store, _) = store(); - let owner = user(1, 1); - let owned_fleet = fleet(10, owner.uid); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - - assert!(matches!( - store - .set_membership(owned_fleet.uid, owner.uid, FleetRole::Member, owner.uid) - .await, - Err(OwnershipError::LastOwner(_)) - )); - assert!(matches!( - store - .remove_member(owned_fleet.uid, owner.uid, owner.uid) - .await, - Err(OwnershipError::LastOwner(_)) - )); - } - - #[tokio::test] - async fn administrator_cannot_escalate_to_owner_or_remove_one() { - let (store, _) = store(); - let owner = user(1, 1); - let administrator = user(2, 2); - let owned_fleet = fleet(10, owner.uid); - store.create_user(owner.clone()).await.unwrap(); - store.create_user(administrator.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - store - .set_membership( - owned_fleet.uid, - administrator.uid, - FleetRole::Administrator, - owner.uid, - ) - .await - .unwrap(); - - assert!(matches!( - store - .set_membership( - owned_fleet.uid, - administrator.uid, - FleetRole::Owner, - administrator.uid, - ) - .await, - Err(OwnershipError::OwnerAuthorityRequired(_)) - )); - assert!(matches!( - store - .remove_member(owned_fleet.uid, owner.uid, administrator.uid) - .await, - Err(OwnershipError::OwnerAuthorityRequired(_)) - )); - } - - #[tokio::test] - async fn malformed_persisted_graph_fails_closed() { - let backend = Arc::new(MemoryKvStore::new()); - let raw: Arc = backend.clone(); - let store = OwnershipStore::new(raw, PrincipalDirectory::default()).unwrap(); - backend - .set(OWNERSHIP_NAMESPACE, GRAPH_KEY, b"not-json".to_vec()) - .await - .unwrap(); - assert!(matches!( - store.load().await, - Err(OwnershipError::Serialization(_)) - )); - } - - #[tokio::test] - async fn concurrent_principal_assignments_do_not_lose_updates() { - let backend = Arc::new(ReadBarrierKv::new()); - let raw: Arc = backend.clone(); - let principals = PrincipalDirectory::default(); - let store = OwnershipStore::new(raw, principals.clone()).unwrap(); - let owner = user(1, 1); - let owned_fleet = fleet(10, owner.uid); - let first = principal(20, 2); - let second = principal(21, 3); - admit_principal(&principals, "first-principal", first); - admit_principal(&principals, "second-principal", second); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - - // Distinct locks model writers outside this process. Production stores - // over the same backend share `mutation_lock_for`; CAS remains the - // durability boundary for independently coordinated writers. - let first_store = externally_coordinated_store(backend.clone(), principals.clone()); - let second_store = externally_coordinated_store(backend.clone(), principals.clone()); - backend.arm(); - let (first_result, second_result) = tokio::join!( - first_store.assign_principal(PrincipalOwnership { - principal_uid: first, - fleet_uid: owned_fleet.uid, - assigned_by: owner.uid, - }), - second_store.assign_principal(PrincipalOwnership { - principal_uid: second, - fleet_uid: owned_fleet.uid, - assigned_by: owner.uid, - }) - ); - first_result.unwrap(); - second_result.unwrap(); - - let graph = store.load().await.unwrap(); - assert_eq!( - graph.principal_owner(first).unwrap().fleet_uid, - owned_fleet.uid - ); - assert_eq!( - graph.principal_owner(second).unwrap().fleet_uid, - owned_fleet.uid - ); - } - - #[tokio::test] - async fn unknown_principals_are_rejected_on_assignment_and_reopen() { - let backend = Arc::new(MemoryKvStore::new()); - let admitted = PrincipalDirectory::default(); - let raw: Arc = backend.clone(); - let store = OwnershipStore::new(raw, admitted.clone()).unwrap(); - let owner = user(1, 1); - let owned_fleet = fleet(10, owner.uid); - let principal_uid = principal(20, 2); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - - assert!(matches!( - store - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: owned_fleet.uid, - assigned_by: owner.uid, - }) - .await, - Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid - )); - - admit_principal(&admitted, "admitted-principal", principal_uid); - store - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: owned_fleet.uid, - assigned_by: owner.uid, - }) - .await - .unwrap(); - - let reopened = OwnershipStore::new(backend, PrincipalDirectory::default()).unwrap(); - assert!(matches!( - reopened.load().await, - Err(OwnershipError::CorruptGraph(message)) - if message.contains("absent from the admitted principal directory") - )); - } - - #[tokio::test] - async fn deletion_guard_serializes_assignment_with_directory_removal() { - let backend = Arc::new(MemoryKvStore::new()); - let principals = PrincipalDirectory::default(); - let store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); - let independently_opened = - OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); - let owner = user(1, 1); - let owned_fleet = fleet(10, owner.uid); - let principal_uid = principal(20, 2); - let alias = astrid_core::PrincipalId::new("deleting-principal").unwrap(); - principals.register(alias.clone(), principal_uid).unwrap(); - store.create_user(owner.clone()).await.unwrap(); - store.create_fleet(owned_fleet.clone()).await.unwrap(); - - let deletion_guard = store.guard_principal_deletion(principal_uid).await.unwrap(); - let mut assignment = tokio::spawn(async move { - independently_opened - .assign_principal(PrincipalOwnership { - principal_uid, - fleet_uid: owned_fleet.uid, - assigned_by: owner.uid, - }) - .await - }); - - assert!( - tokio::time::timeout(std::time::Duration::from_millis(50), &mut assignment) - .await - .is_err(), - "assignment must wait while principal deletion owns the mutation barrier" - ); - principals.unregister(&alias, principal_uid); - drop(deletion_guard); - - assert!(matches!( - assignment.await.unwrap(), - Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid - )); - assert!( - store - .load() - .await - .unwrap() - .principal_owner(principal_uid) - .is_none() - ); - } -} +#[path = "ownership_tests.rs"] +mod tests; diff --git a/crates/astrid-storage/src/ownership_tests.rs b/crates/astrid-storage/src/ownership_tests.rs new file mode 100644 index 000000000..bd4d86ab4 --- /dev/null +++ b/crates/astrid-storage/src/ownership_tests.rs @@ -0,0 +1,523 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use tokio::sync::{Barrier, Notify}; +use uuid::Uuid; + +use super::*; +use crate::MemoryKvStore; +use astrid_core::{FleetGenesis, PrincipalGenesis, PrincipalIdentity, UserGenesis}; + +#[derive(Debug)] +struct ReadBarrierKv { + inner: MemoryKvStore, + barrier: Barrier, + reads_armed: AtomicBool, + ownership_reads: AtomicUsize, + ordered_cas_armed: AtomicBool, + ownership_cas: AtomicUsize, + first_cas_waiting: Notify, + second_cas_done: Notify, +} + +impl ReadBarrierKv { + fn new() -> Self { + Self { + inner: MemoryKvStore::new(), + barrier: Barrier::new(2), + reads_armed: AtomicBool::new(false), + ownership_reads: AtomicUsize::new(0), + ordered_cas_armed: AtomicBool::new(false), + ownership_cas: AtomicUsize::new(0), + first_cas_waiting: Notify::new(), + second_cas_done: Notify::new(), + } + } + + fn arm_reads(&self) { + self.ownership_reads.store(0, Ordering::SeqCst); + self.reads_armed.store(true, Ordering::SeqCst); + } + + fn arm_ordered_cas(&self) { + self.ownership_cas.store(0, Ordering::SeqCst); + self.ordered_cas_armed.store(true, Ordering::SeqCst); + } + + async fn wait_for_first_cas(&self) { + self.first_cas_waiting.notified().await; + } +} + +#[async_trait] +impl KvStore for ReadBarrierKv { + async fn get(&self, namespace: &str, key: &str) -> crate::StorageResult>> { + let value = self.inner.get(namespace, key).await?; + if self.reads_armed.load(Ordering::SeqCst) + && namespace == OWNERSHIP_NAMESPACE + && key == GRAPH_KEY + && self.ownership_reads.fetch_add(1, Ordering::SeqCst) < 2 + { + self.barrier.wait().await; + } + Ok(value) + } + + async fn set(&self, namespace: &str, key: &str, value: Vec) -> crate::StorageResult<()> { + self.inner.set(namespace, key, value).await + } + + async fn delete(&self, namespace: &str, key: &str) -> crate::StorageResult { + self.inner.delete(namespace, key).await + } + + async fn exists(&self, namespace: &str, key: &str) -> crate::StorageResult { + self.inner.exists(namespace, key).await + } + + async fn list_keys(&self, namespace: &str) -> crate::StorageResult> { + self.inner.list_keys(namespace).await + } + + async fn compare_and_swap( + &self, + namespace: &str, + key: &str, + expected: Option<&[u8]>, + new: Vec, + ) -> crate::StorageResult { + if self.ordered_cas_armed.load(Ordering::SeqCst) + && namespace == OWNERSHIP_NAMESPACE + && key == GRAPH_KEY + { + match self.ownership_cas.fetch_add(1, Ordering::SeqCst) { + 0 => { + self.first_cas_waiting.notify_one(); + self.second_cas_done.notified().await; + }, + 1 => { + let result = self + .inner + .compare_and_swap(namespace, key, expected, new) + .await; + self.second_cas_done.notify_one(); + return result; + }, + _ => {}, + } + } + self.inner + .compare_and_swap(namespace, key, expected, new) + .await + } + + async fn clear_namespace(&self, namespace: &str) -> crate::StorageResult { + self.inner.clear_namespace(namespace).await + } +} + +fn at(seconds: i64) -> chrono::DateTime { + Utc.timestamp_opt(seconds, 0).single().unwrap() +} + +fn user(id: u128, key: u8) -> UserIdentity { + UserIdentity::from_genesis(UserGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_000), + [key; 32], + )) + .unwrap() +} + +fn fleet(id: u128, creator: UserUid) -> FleetIdentity { + FleetIdentity::from_genesis(FleetGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_001), + creator, + )) + .unwrap() +} + +fn principal(id: u128, key: u8) -> PrincipalUid { + PrincipalIdentity::from_genesis(PrincipalGenesis::from_parts( + Uuid::from_u128(id), + at(1_700_000_002), + [key; 32], + )) + .unwrap() + .uid +} + +fn store() -> (OwnershipStore, PrincipalDirectory) { + let principals = PrincipalDirectory::default(); + ( + OwnershipStore::new(Arc::new(MemoryKvStore::new()), principals.clone()).unwrap(), + principals, + ) +} + +fn admit_principal(directory: &PrincipalDirectory, alias: &str, uid: PrincipalUid) { + directory + .register(astrid_core::PrincipalId::new(alias).unwrap(), uid) + .unwrap(); +} + +#[tokio::test] +async fn fleet_creation_atomically_bootstraps_its_owner() { + let (store, _) = store(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let graph = store.load().await.unwrap(); + let membership = graph + .fleet(owned_fleet.uid) + .unwrap() + .membership(owner.uid) + .unwrap(); + assert_eq!(membership.role, FleetRole::Owner); + assert_eq!(membership.granted_by, owner.uid); +} + +#[tokio::test] +async fn principal_cannot_be_silently_reassigned() { + let (store, principals) = store(); + let owner = user(1, 1); + let first = fleet(10, owner.uid); + let second = fleet(11, owner.uid); + let principal_uid = principal(20, 2); + admit_principal(&principals, "test-principal", principal_uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(first.clone()).await.unwrap(); + store.create_fleet(second.clone()).await.unwrap(); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: first.uid, + assigned_by: owner.uid, + }) + .await + .unwrap(); + + let error = store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: second.uid, + assigned_by: owner.uid, + }) + .await + .unwrap_err(); + assert!(matches!( + error, + OwnershipError::PrincipalAlreadyOwned { .. } + )); + assert_eq!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .unwrap() + .fleet_uid, + first.uid + ); +} + +#[tokio::test] +async fn explicit_transfer_requires_management_of_both_fleets() { + let (store, principals) = store(); + let first_owner = user(1, 1); + let second_owner = user(2, 2); + let first = fleet(10, first_owner.uid); + let second = fleet(11, second_owner.uid); + let principal_uid = principal(20, 3); + admit_principal(&principals, "test-principal", principal_uid); + store.create_user(first_owner.clone()).await.unwrap(); + store.create_user(second_owner.clone()).await.unwrap(); + store.create_fleet(first.clone()).await.unwrap(); + store.create_fleet(second.clone()).await.unwrap(); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: first.uid, + assigned_by: first_owner.uid, + }) + .await + .unwrap(); + + let denied = store + .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) + .await + .unwrap_err(); + assert!(matches!(denied, OwnershipError::NotFleetManager { .. })); + + store + .set_membership( + second.uid, + first_owner.uid, + FleetRole::Administrator, + second_owner.uid, + ) + .await + .unwrap(); + store + .transfer_principal(principal_uid, first.uid, second.uid, first_owner.uid) + .await + .unwrap(); + assert_eq!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .unwrap() + .fleet_uid, + second.uid + ); +} + +#[tokio::test] +async fn last_owner_cannot_be_demoted_or_removed() { + let (store, _) = store(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + assert!(matches!( + store + .set_membership(owned_fleet.uid, owner.uid, FleetRole::Member, owner.uid) + .await, + Err(OwnershipError::LastOwner(_)) + )); + assert!(matches!( + store + .remove_member(owned_fleet.uid, owner.uid, owner.uid) + .await, + Err(OwnershipError::LastOwner(_)) + )); +} + +#[tokio::test] +async fn administrator_cannot_escalate_to_owner_or_remove_one() { + let (store, _) = store(); + let owner = user(1, 1); + let administrator = user(2, 2); + let owned_fleet = fleet(10, owner.uid); + store.create_user(owner.clone()).await.unwrap(); + store.create_user(administrator.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + store + .set_membership( + owned_fleet.uid, + administrator.uid, + FleetRole::Administrator, + owner.uid, + ) + .await + .unwrap(); + + assert!(matches!( + store + .set_membership( + owned_fleet.uid, + administrator.uid, + FleetRole::Owner, + administrator.uid, + ) + .await, + Err(OwnershipError::OwnerAuthorityRequired(_)) + )); + assert!(matches!( + store + .remove_member(owned_fleet.uid, owner.uid, administrator.uid) + .await, + Err(OwnershipError::OwnerAuthorityRequired(_)) + )); +} + +#[tokio::test] +async fn malformed_persisted_graph_fails_closed() { + let backend = Arc::new(MemoryKvStore::new()); + let raw: Arc = backend.clone(); + let store = OwnershipStore::new(raw, PrincipalDirectory::default()).unwrap(); + backend + .set(OWNERSHIP_NAMESPACE, GRAPH_KEY, b"not-json".to_vec()) + .await + .unwrap(); + assert!(matches!( + store.load().await, + Err(OwnershipError::Serialization(_)) + )); +} + +#[tokio::test] +async fn concurrent_principal_assignments_do_not_lose_updates() { + let backend = Arc::new(ReadBarrierKv::new()); + let raw: Arc = backend.clone(); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(raw, principals.clone()).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let first = principal(20, 2); + let second = principal(21, 3); + admit_principal(&principals, "first-principal", first); + admit_principal(&principals, "second-principal", second); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let first_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let second_store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + backend.arm_reads(); + let (first_result, second_result) = tokio::join!( + first_store.assign_principal(PrincipalOwnership { + principal_uid: first, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }), + second_store.assign_principal(PrincipalOwnership { + principal_uid: second, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + ); + first_result.unwrap(); + second_result.unwrap(); + + let graph = store.load().await.unwrap(); + assert_eq!( + graph.principal_owner(first).unwrap().fleet_uid, + owned_fleet.uid + ); + assert_eq!( + graph.principal_owner(second).unwrap().fleet_uid, + owned_fleet.uid + ); +} + +#[tokio::test] +async fn unknown_principals_are_rejected_on_assignment_and_reopen() { + let backend = Arc::new(MemoryKvStore::new()); + let admitted = PrincipalDirectory::default(); + let raw: Arc = backend.clone(); + let store = OwnershipStore::new(raw, admitted.clone()).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let principal_uid = principal(20, 2); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + assert!(matches!( + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await, + Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid + )); + + admit_principal(&admitted, "admitted-principal", principal_uid); + store + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await + .unwrap(); + + let reopened = OwnershipStore::new(backend, PrincipalDirectory::default()).unwrap(); + assert!(matches!( + reopened.load().await, + Err(OwnershipError::CorruptGraph(message)) + if message.contains("absent from the admitted principal directory") + )); +} + +#[tokio::test] +async fn deletion_guard_serializes_assignment_with_directory_removal() { + let backend = Arc::new(MemoryKvStore::new()); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let independently_opened = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let principal_uid = principal(20, 2); + let alias = astrid_core::PrincipalId::new("deleting-principal").unwrap(); + principals.register(alias.clone(), principal_uid).unwrap(); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + let deletion_guard = store.guard_principal_deletion(principal_uid).await.unwrap(); + let assignment = tokio::spawn(async move { + independently_opened + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await + }); + + assert!(matches!( + assignment.await.unwrap(), + Err(OwnershipError::PrincipalDeletionInProgress(uid)) if uid == principal_uid + )); + principals.unregister(&alias, principal_uid); + deletion_guard.finish().await.unwrap(); + assert!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .is_none() + ); +} + +#[tokio::test] +async fn stale_assignment_retries_and_observes_deletion_reservation() { + let backend = Arc::new(ReadBarrierKv::new()); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let independently_opened = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let owner = user(1, 1); + let owned_fleet = fleet(10, owner.uid); + let principal_uid = principal(20, 2); + let alias = astrid_core::PrincipalId::new("stale-assignment").unwrap(); + principals.register(alias.clone(), principal_uid).unwrap(); + store.create_user(owner.clone()).await.unwrap(); + store.create_fleet(owned_fleet.clone()).await.unwrap(); + + backend.arm_ordered_cas(); + let assignment = tokio::spawn(async move { + independently_opened + .assign_principal(PrincipalOwnership { + principal_uid, + fleet_uid: owned_fleet.uid, + assigned_by: owner.uid, + }) + .await + }); + backend.wait_for_first_cas().await; + + let deletion_guard = store.guard_principal_deletion(principal_uid).await.unwrap(); + assert!(matches!( + assignment.await.unwrap(), + Err(OwnershipError::PrincipalDeletionInProgress(uid)) if uid == principal_uid + )); + principals.unregister(&alias, principal_uid); + deletion_guard.finish().await.unwrap(); + assert!( + store + .load() + .await + .unwrap() + .principal_owner(principal_uid) + .is_none() + ); +} From f65f9827b430e1f9dca06085d57e18b9c0ca0e29 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 20:34:00 +0400 Subject: [PATCH 08/10] fix(identity): recover interrupted principal deletion Signed-off-by: Joshua J. Bouw --- .../src/kernel_router/admin/handlers.rs | 9 +- .../src/kernel_router/admin/state_tests.rs | 58 +++++++ crates/astrid-storage/src/ownership.rs | 146 ++++++++++++++++-- crates/astrid-storage/src/ownership_tests.rs | 60 +++++++ 4 files changed, 262 insertions(+), 11 deletions(-) diff --git a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index aff8604be..ec471285c 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs @@ -394,7 +394,7 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad if let Some(identity) = identity { match kernel .ownership_store - .guard_principal_deletion(identity.uid) + .guard_principal_deletion_for_alias(identity.uid, principal.clone()) .await { Ok(guard) => Some(guard), @@ -411,6 +411,13 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad None } } else { + if let Err(e) = kernel + .ownership_store + .finish_principal_deletion_by_alias(&principal) + .await + { + return err_internal(format!("ownership store deletion recovery failed: {e}")); + } None }; // Unlink before delete_user so a concurrent `resolve` can't return diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs index 35bc988a2..489118327 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs @@ -559,6 +559,64 @@ async fn agent_delete_removes_identity_profile_and_invalidates_cache() { assert!(after.revokes.is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn agent_delete_retry_clears_reservation_after_identity_was_removed() { + let (_dir, kernel) = fixture().await; + let principal = pid("recoverable-delete"); + let created = handlers::dispatch( + &kernel, + &astrid_core::PrincipalId::default(), + AdminRequestKind::AgentCreate { + name: principal.to_string(), + groups: Vec::new(), + grants: Vec::new(), + inherit_from: None, + clone_from: None, + allow_admin_clone: false, + }, + ) + .await; + assert_success(&created); + + let user = kernel + .identity_store + .resolve("cli", principal.as_str()) + .await + .unwrap() + .unwrap(); + let identity = kernel + .identity_store + .get_principal_identity(user.id) + .await + .unwrap() + .unwrap(); + let guard = kernel + .ownership_store + .guard_principal_deletion_for_alias(identity.uid, principal.clone()) + .await + .unwrap(); + assert!(kernel.identity_store.delete_user(user.id).await.unwrap()); + drop(guard); + + let retried = handlers::dispatch( + &kernel, + &astrid_core::PrincipalId::default(), + AdminRequestKind::AgentDelete { + principal: principal.clone(), + }, + ) + .await; + assert_success(&retried); + assert!(matches!( + kernel + .ownership_store + .guard_principal_deletion(identity.uid) + .await, + Err(astrid_storage::OwnershipError::PrincipalNotFound(uid)) if uid == identity.uid + )); + assert!(!PrincipalProfile::path_for(&kernel.astrid_home, &principal).exists()); +} + #[tokio::test(flavor = "multi_thread")] async fn agent_delete_rejects_a_fleet_owned_principal_without_partial_deletion() { let (_dir, kernel) = fixture().await; diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 3ec23fd63..42c8c2690 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -5,11 +5,11 @@ //! together, and a principal can never be observed in two fleets. The format //! can be sharded behind the same API if graph size later warrants it. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::sync::Arc; use astrid_core::{ - FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, + FleetIdentity, FleetMembership, FleetRole, FleetUid, OwnershipIdentityError, PrincipalId, PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, }; use serde::{Deserialize, Serialize}; @@ -59,7 +59,13 @@ pub struct OwnershipSnapshot { fleets: BTreeMap, principal_ownership: BTreeMap, #[serde(default)] - principal_deletions: BTreeSet, + principal_deletions: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrincipalDeletionReservation { + alias: Option, } impl Default for OwnershipSnapshot { @@ -69,7 +75,7 @@ impl Default for OwnershipSnapshot { users: BTreeMap::new(), fleets: BTreeMap::new(), principal_ownership: BTreeMap::new(), - principal_deletions: BTreeSet::new(), + principal_deletions: BTreeMap::new(), } } } @@ -182,12 +188,21 @@ impl OwnershipSnapshot { ))); } } - for principal_uid in &self.principal_deletions { + let mut deletion_aliases = BTreeMap::new(); + for (principal_uid, reservation) in &self.principal_deletions { if self.principal_ownership.contains_key(principal_uid) { return Err(OwnershipError::CorruptGraph(format!( "principal {principal_uid} is both owned and reserved for deletion" ))); } + if let Some(alias) = &reservation.alias + && let Some(existing_uid) = + deletion_aliases.insert(alias.as_str().to_owned(), *principal_uid) + { + return Err(OwnershipError::CorruptGraph(format!( + "principal deletion alias {alias} is reserved by both {existing_uid} and {principal_uid}" + ))); + } } Ok(()) } @@ -274,6 +289,62 @@ impl OwnershipStore { pub async fn guard_principal_deletion( &self, principal_uid: PrincipalUid, + ) -> Result { + self.guard_principal_deletion_inner(principal_uid, None) + .await + } + + /// Reserve an unowned principal and retain its alias for crash recovery. + /// + /// The alias allows a later deletion retry to remove the reservation even + /// when the durable identity record and live directory entry were already + /// deleted. + /// + /// # Errors + /// + /// Rejects an owned or unknown principal, a conflicting retry alias, and + /// invalid or unavailable ownership state. + pub async fn guard_principal_deletion_for_alias( + &self, + principal_uid: PrincipalUid, + alias: PrincipalId, + ) -> Result { + self.guard_principal_deletion_inner(principal_uid, Some(alias)) + .await + } + + /// Finish a previously interrupted deletion using its durable alias. + /// + /// Returns `true` when a matching reservation was removed and `false` + /// when no interrupted deletion exists for this alias. + /// + /// # Errors + /// + /// Fails closed when the graph cannot be read, validated, or atomically + /// updated. + pub async fn finish_principal_deletion_by_alias( + &self, + alias: &PrincipalId, + ) -> Result { + let alias = alias.clone(); + self.mutate(|graph| { + let principal_uid = graph + .principal_deletions + .iter() + .find_map(|(uid, reservation)| { + (reservation.alias.as_ref() == Some(&alias)).then_some(*uid) + }); + Ok(principal_uid + .and_then(|uid| graph.principal_deletions.remove(&uid)) + .is_some()) + }) + .await + } + + async fn guard_principal_deletion_inner( + &self, + principal_uid: PrincipalUid, + alias: Option, ) -> Result { let guard = Arc::clone(&self.mutation_lock).lock_owned().await; self.mutate_unlocked(|graph| { @@ -283,12 +354,48 @@ impl OwnershipStore { fleet: ownership.fleet_uid, }); } - if !graph.principal_deletions.contains(&principal_uid) - && !self.principals.contains_uid(principal_uid) + if let Some(requested) = &alias + && let Ok(live_alias) = self.principals.alias_for(principal_uid) + && &live_alias != requested { - return Err(OwnershipError::PrincipalNotFound(principal_uid)); + return Err(OwnershipError::DeletionReservationConflict { + principal: principal_uid, + alias: live_alias, + }); + } + if let Some(reservation) = graph.principal_deletions.get_mut(&principal_uid) { + match (&reservation.alias, &alias) { + (Some(existing), Some(requested)) if existing != requested => { + return Err(OwnershipError::DeletionReservationConflict { + principal: principal_uid, + alias: existing.clone(), + }); + }, + (None, Some(requested)) => reservation.alias = Some(requested.clone()), + _ => {}, + } + } else { + if !self.principals.contains_uid(principal_uid) { + return Err(OwnershipError::PrincipalNotFound(principal_uid)); + } + if let Some(requested) = &alias + && let Some((reserved_uid, _)) = graph + .principal_deletions + .iter() + .find(|(_, reservation)| reservation.alias.as_ref() == Some(requested)) + { + return Err(OwnershipError::DeletionAliasReserved { + alias: requested.clone(), + principal: *reserved_uid, + }); + } + graph.principal_deletions.insert( + principal_uid, + PrincipalDeletionReservation { + alias: alias.clone(), + }, + ); } - graph.principal_deletions.insert(principal_uid); Ok(()) }) .await?; @@ -463,7 +570,10 @@ impl OwnershipStore { ownership: PrincipalOwnership, ) -> Result<(), OwnershipError> { self.mutate(|graph| { - if graph.principal_deletions.contains(&ownership.principal_uid) { + if graph + .principal_deletions + .contains_key(&ownership.principal_uid) + { return Err(OwnershipError::PrincipalDeletionInProgress( ownership.principal_uid, )); @@ -670,6 +780,22 @@ pub enum OwnershipError { /// A principal cannot be assigned while durable identity deletion is active. #[error("principal deletion is in progress: {0}")] PrincipalDeletionInProgress(PrincipalUid), + /// A retry attempted to bind one deletion reservation to another alias. + #[error("principal deletion reservation for {principal} belongs to alias {alias}")] + DeletionReservationConflict { + /// Reserved durable principal UID. + principal: PrincipalUid, + /// Alias retained by the original deletion attempt. + alias: PrincipalId, + }, + /// An alias already identifies another interrupted deletion. + #[error("principal deletion alias {alias} is already reserved for {principal}")] + DeletionAliasReserved { + /// Alias retained by the interrupted deletion. + alias: PrincipalId, + /// Durable UID owned by the existing reservation. + principal: PrincipalUid, + }, /// Sustained concurrent writes prevented an atomic commit. #[error("ownership graph changed concurrently too many times")] ConcurrentModification, diff --git a/crates/astrid-storage/src/ownership_tests.rs b/crates/astrid-storage/src/ownership_tests.rs index bd4d86ab4..cc5886297 100644 --- a/crates/astrid-storage/src/ownership_tests.rs +++ b/crates/astrid-storage/src/ownership_tests.rs @@ -479,6 +479,66 @@ async fn deletion_guard_serializes_assignment_with_directory_removal() { ); } +#[tokio::test] +async fn deletion_reservation_can_be_finished_by_alias_after_identity_disappears() { + let backend = Arc::new(MemoryKvStore::new()); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(backend, principals.clone()).unwrap(); + let principal_uid = principal(20, 2); + let alias = astrid_core::PrincipalId::new("recoverable-deletion").unwrap(); + principals.register(alias.clone(), principal_uid).unwrap(); + + let guard = store + .guard_principal_deletion_for_alias(principal_uid, alias.clone()) + .await + .unwrap(); + principals.unregister(&alias, principal_uid); + drop(guard); + + assert!( + store + .finish_principal_deletion_by_alias(&alias) + .await + .unwrap() + ); + assert!( + !store + .finish_principal_deletion_by_alias(&alias) + .await + .unwrap() + ); + assert!(matches!( + store.guard_principal_deletion(principal_uid).await, + Err(OwnershipError::PrincipalNotFound(uid)) if uid == principal_uid + )); +} + +#[tokio::test] +async fn deletion_reservation_rejects_reusing_an_interrupted_alias() { + let backend = Arc::new(MemoryKvStore::new()); + let principals = PrincipalDirectory::default(); + let store = OwnershipStore::new(backend, principals.clone()).unwrap(); + let first = principal(20, 2); + let second = principal(21, 3); + let alias = astrid_core::PrincipalId::new("reserved-alias").unwrap(); + principals.register(alias.clone(), first).unwrap(); + + let guard = store + .guard_principal_deletion_for_alias(first, alias.clone()) + .await + .unwrap(); + principals.unregister(&alias, first); + drop(guard); + principals.register(alias.clone(), second).unwrap(); + + assert!(matches!( + store + .guard_principal_deletion_for_alias(second, alias.clone()) + .await, + Err(OwnershipError::DeletionAliasReserved { principal, .. }) if principal == first + )); +} + #[tokio::test] async fn stale_assignment_retries_and_observes_deletion_reservation() { let backend = Arc::new(ReadBarrierKv::new()); From 8bb8cebdd3992a00738c07bcac0eb793125368b3 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 21:48:46 +0400 Subject: [PATCH 09/10] fix(identity): keep live deletion reservations Signed-off-by: Joshua J. Bouw --- crates/astrid-storage/src/ownership.rs | 8 ++++++++ crates/astrid-storage/src/ownership_tests.rs | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/astrid-storage/src/ownership.rs b/crates/astrid-storage/src/ownership.rs index 42c8c2690..1a1c2f7fc 100644 --- a/crates/astrid-storage/src/ownership.rs +++ b/crates/astrid-storage/src/ownership.rs @@ -334,6 +334,11 @@ impl OwnershipStore { .find_map(|(uid, reservation)| { (reservation.alias.as_ref() == Some(&alias)).then_some(*uid) }); + if let Some(uid) = principal_uid + && self.principals.contains_uid(uid) + { + return Err(OwnershipError::PrincipalDeletionStillLive(uid)); + } Ok(principal_uid .and_then(|uid| graph.principal_deletions.remove(&uid)) .is_some()) @@ -780,6 +785,9 @@ pub enum OwnershipError { /// A principal cannot be assigned while durable identity deletion is active. #[error("principal deletion is in progress: {0}")] PrincipalDeletionInProgress(PrincipalUid), + /// Recovery cannot clear a reservation while its identity remains live. + #[error("principal deletion identity is still live: {0}")] + PrincipalDeletionStillLive(PrincipalUid), /// A retry attempted to bind one deletion reservation to another alias. #[error("principal deletion reservation for {principal} belongs to alias {alias}")] DeletionReservationConflict { diff --git a/crates/astrid-storage/src/ownership_tests.rs b/crates/astrid-storage/src/ownership_tests.rs index cc5886297..2ec1f1d12 100644 --- a/crates/astrid-storage/src/ownership_tests.rs +++ b/crates/astrid-storage/src/ownership_tests.rs @@ -483,7 +483,8 @@ async fn deletion_guard_serializes_assignment_with_directory_removal() { async fn deletion_reservation_can_be_finished_by_alias_after_identity_disappears() { let backend = Arc::new(MemoryKvStore::new()); let principals = PrincipalDirectory::default(); - let store = OwnershipStore::new(backend, principals.clone()).unwrap(); + let store = OwnershipStore::new(backend.clone(), principals.clone()).unwrap(); + let independently_opened = OwnershipStore::new(backend, principals.clone()).unwrap(); let principal_uid = principal(20, 2); let alias = astrid_core::PrincipalId::new("recoverable-deletion").unwrap(); principals.register(alias.clone(), principal_uid).unwrap(); @@ -492,6 +493,12 @@ async fn deletion_reservation_can_be_finished_by_alias_after_identity_disappears .guard_principal_deletion_for_alias(principal_uid, alias.clone()) .await .unwrap(); + assert!(matches!( + independently_opened + .finish_principal_deletion_by_alias(&alias) + .await, + Err(OwnershipError::PrincipalDeletionStillLive(uid)) if uid == principal_uid + )); principals.unregister(&alias, principal_uid); drop(guard); @@ -514,7 +521,7 @@ async fn deletion_reservation_can_be_finished_by_alias_after_identity_disappears } #[tokio::test] -async fn deletion_reservation_rejects_reusing_an_interrupted_alias() { +async fn deletion_reservation_rejects_a_second_deletion_for_the_same_alias() { let backend = Arc::new(MemoryKvStore::new()); let principals = PrincipalDirectory::default(); let store = OwnershipStore::new(backend, principals.clone()).unwrap(); From 8d9a7d9dccde2a36857d91c051f12c2dbd90945b Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 7 Aug 2026 21:59:09 +0400 Subject: [PATCH 10/10] fix(identity): recover interrupted root bootstrap Signed-off-by: Joshua J. Bouw --- crates/astrid-kernel/src/lib.rs | 100 +++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index a41b34bf6..e037ffe29 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -3567,19 +3567,42 @@ async fn bootstrap_cli_root_user( return Ok((user, identity)); } - // No CLI link exists. Create or find the root user. - let user = store - .create_principal(principal, initial_public_key) - .await?; - let identity = store - .get_principal_identity(user.id) - .await? - .ok_or_else(|| { - astrid_storage::IdentityError::InvalidInput( - "new CLI root principal is missing immutable identity".to_owned(), - ) - })?; - tracing::info!(user_id = %user.id, "Created CLI root user"); + // No CLI link exists. Recover a durable principal left by an interrupted + // first boot, or create it when no such record exists yet. + let mut recovered = None; + for user in store.list_users().await? { + if user.principal != principal { + continue; + } + let Some(identity) = store.get_principal_identity(user.id).await? else { + continue; + }; + if recovered.is_some() { + return Err(astrid_storage::IdentityError::InvalidInput( + "multiple durable CLI root principals exist".to_owned(), + )); + } + recovered = Some((user, identity)); + } + + let (user, identity) = if let Some(existing) = recovered { + tracing::info!(user_id = %existing.0.id, "Recovered unlinked CLI root user"); + existing + } else { + let user = store + .create_principal(principal, initial_public_key) + .await?; + let identity = store + .get_principal_identity(user.id) + .await? + .ok_or_else(|| { + astrid_storage::IdentityError::InvalidInput( + "new CLI root principal is missing immutable identity".to_owned(), + ) + })?; + tracing::info!(user_id = %user.id, "Created CLI root user"); + (user, identity) + }; // Link the CLI platform identity. store.link("cli", "local", user.id, "system").await?; @@ -3944,6 +3967,57 @@ mod tests { use astrid_capsule_types::error::CapsuleResult; use astrid_capsule_types::manifest::CapsuleManifest; + #[tokio::test] + async fn cli_root_bootstrap_recovers_a_durable_principal_without_its_link() { + let (_dir, home) = scratch_home(); + seed_default_principal_admin_profile(&home).unwrap(); + let principal = astrid_core::PrincipalId::default(); + let initial_public_key = principal_initial_public_key(&home, &principal).unwrap(); + let backend: Arc = + Arc::new(astrid_storage::MemoryKvStore::new()); + let directory = astrid_storage::PrincipalDirectory::default(); + let identity_store: Arc = + Arc::new(astrid_storage::KvIdentityStore::with_principal_directory( + astrid_storage::ScopedKvStore::new(backend, "system:identity").unwrap(), + directory, + )); + let stranded = identity_store + .create_principal(principal, initial_public_key) + .await + .unwrap(); + assert!( + identity_store + .resolve("cli", "local") + .await + .unwrap() + .is_none() + ); + + let (recovered, recovered_identity) = bootstrap_cli_root_user(&identity_store, &home) + .await + .unwrap(); + + assert_eq!(recovered.id, stranded.id); + assert_eq!( + identity_store + .resolve("cli", "local") + .await + .unwrap() + .unwrap() + .id, + stranded.id + ); + assert_eq!(identity_store.list_users().await.unwrap().len(), 1); + assert_eq!( + identity_store + .get_principal_identity(stranded.id) + .await + .unwrap() + .unwrap(), + recovered_identity + ); + } + #[tokio::test] async fn legacy_root_ownership_bootstrap_is_deterministic_and_idempotent() { let backend: Arc =