diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2a94850..f5dfe65bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ 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. 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 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..815cf6b97 --- /dev/null +++ b/crates/astrid-core/src/identity/ownership.rs @@ -0,0 +1,529 @@ +//! 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 non-owner members and principals; owner membership is owner-controlled. + 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(), + "4678a23b161f8867c20b32adbca86e58754aaf5fc225d64286563e790077b535" + ); + 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(), + "c46c84da942d4c7fe48e04e6f298e4cf39285a1c5b096cb4985c01bdcfec3709" + ); + 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/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index 35147b0eb..ec471285c 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,6 +374,52 @@ async fn agent_delete(kernel: &Arc, principal: PrincipalId) -> Ad Ok(user) => user, Err(e) => return err_internal(format!("identity store resolve failed: {e}")), }; + 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) => { + return err_internal(format!( + "identity store principal identity lookup failed: {e}" + )); + }, + }; + if let Some(identity) = identity { + match kernel + .ownership_store + .guard_principal_deletion_for_alias(identity.uid, principal.clone()) + .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 { + 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 // a dangling user id in the narrow window between the two calls. if let Err(e) = kernel @@ -381,10 +429,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 edf64ee72..489118327 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; @@ -80,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, @@ -523,6 +559,169 @@ 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; + 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" + ); + + // 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 + .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/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 2eb4b64ca..e037ffe29 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,15 @@ impl Kernel { "Failed to load durable principal identities: {error}" )) })?; + let ownership_store = Arc::new( + 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}")) + })?; // Load group config (issue #670). Boot-loaded once, then swapped // atomically by Layer 6 admin topics (issue #672). Missing file @@ -799,11 +816,22 @@ 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) - .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_principal_identity, + ) + .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 +876,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, @@ -2510,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; @@ -2568,10 +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 (identity_store, ownership_store) = open_test_identity_stores(&kv); let groups = Arc::new(ArcSwap::from_pointee( GroupConfig::load(&home).expect("test kernel: load groups"), @@ -2615,6 +2662,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 +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_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. @@ -3509,24 +3560,93 @@ 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(()); + return Ok((user, identity)); } - // No CLI link exists. Create or find the root user. - let user = store - .create_principal(principal, initial_public_key) - .await?; - 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?; tracing::info!(user_id = %user.id, "Linked CLI root user (cli/local)"); - Ok(()) + Ok((user, identity)) +} + +async fn bootstrap_cli_root_ownership( + store: &astrid_storage::OwnershipStore, + principal_directory: &astrid_storage::PrincipalDirectory, + root_user: astrid_core::AstridUserId, + 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, + root_principal_identity.genesis.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)?; + if store.load().await?.principal_owner(principal_uid).is_some() { + return Ok(()); + } + store + .assign_principal(astrid_core::PrincipalOwnership { + principal_uid, + fleet_uid: fleet.uid, + assigned_by: user.uid, + }) + .await } fn principal_initial_public_key( @@ -3847,6 +3967,158 @@ 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 = + Arc::new(astrid_storage::MemoryKvStore::new()); + 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), + 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(), + principal_identity.clone(), + ) + .await + .unwrap(); + let first = ownership_store.load().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] fn persistent_idle_monitor_stops_after_ephemeral_mode_is_enabled() { let ephemeral = AtomicBool::new(false); 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/lib.rs b/crates/astrid-storage/src/lib.rs index cbc126720..714aae4e2 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,9 @@ pub use kv::{ KvEntry, KvPrincipalResolver, KvQuotaResolver, KvStore, MemoryKvStore, PrincipalKvStore, ScopedKvStore, TreeKvStore, }; +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 new file mode 100644 index 000000000..1a1c2f7fc --- /dev/null +++ b/crates/astrid-storage/src/ownership.rs @@ -0,0 +1,814 @@ +//! 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, PrincipalId, + PrincipalOwnership, PrincipalUid, UserIdentity, UserUid, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; + +use crate::{KvStore, PrincipalDirectory, 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, + #[serde(default)] + principal_deletions: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrincipalDeletionReservation { + alias: Option, +} + +impl Default for OwnershipSnapshot { + fn default() -> Self { + Self { + format_version: GRAPH_FORMAT_VERSION, + users: BTreeMap::new(), + fleets: BTreeMap::new(), + principal_ownership: BTreeMap::new(), + principal_deletions: 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, principals: &PrincipalDirectory) -> 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 !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) + { + return Err(OwnershipError::CorruptGraph(format!( + "principal {principal_uid} ownership references an absent identity" + ))); + } + } + 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(()) + } +} + +/// Persistent, optimistic-concurrency owner of the Astrid ownership graph. +#[derive(Clone, Debug)] +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 { + 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. + /// + /// # Errors + /// + /// Returns [`OwnershipError::Storage`] if the reserved namespace is invalid. + pub fn new( + storage: Arc, + principals: PrincipalDirectory, + ) -> Result { + Ok(Self { + storage: ScopedKvStore::new(storage, OWNERSHIP_NAMESPACE)?, + principals, + mutation_lock: Arc::new(AsyncMutex::new(())), + }) + } + + /// 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()) + } + + /// Reserve an unowned principal for durable identity deletion. + /// + /// 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 + /// + /// 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 { + 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) + }); + 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()) + }) + .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| { + if let Some(ownership) = graph.principal_owner(principal_uid) { + return Err(OwnershipError::PrincipalAlreadyOwned { + principal: principal_uid, + fleet: ownership.fleet_uid, + }); + } + if let Some(requested) = &alias + && let Ok(live_alias) = self.principals.alias_for(principal_uid) + && &live_alias != requested + { + 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(), + }, + ); + } + Ok(()) + }) + .await?; + Ok(PrincipalDeletionGuard { + store: self.clone(), + principal_uid, + _guard: guard, + }) + } + + /// 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| { + if graph + .principal_deletions + .contains_key(&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)); + } + 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, + { + 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())?; + let output = apply(&mut graph)?; + graph.validate(&self.principals)?; + 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(&self, 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(&self.principals)?; + 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), + /// 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), + /// 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 { + /// 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, +} + +#[cfg(test)] +#[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..2ec1f1d12 --- /dev/null +++ b/crates/astrid-storage/src/ownership_tests.rs @@ -0,0 +1,590 @@ +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 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.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(); + + let guard = store + .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); + + 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_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(); + 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()); + 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() + ); +} 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 new file mode 100644 index 000000000..45779c890 --- /dev/null +++ b/docs/astrid-user-fleet-ownership.md @@ -0,0 +1,81 @@ +# 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. 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: + +- 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. 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 +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.