diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eae7b5b3..e569ad36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,14 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. Signed `.shuttle` grant composition remains deferred and fails explicitly. Closes #1195. +- **Capability-registry revision 1 now has fixed semantics and BLAKE3 digest + vectors.** All 51 kernel entries bind scope, target kinds, delegability, + privileged status and provenance. Kernel/admin request mappings and the + current capsule-side secondary enforcement constants resolve through the + registry in tests, while the complete role partition is frozen independently. + Authorization and persisted state remain unchanged. Closes #1235. Refs #1228 + and #1233. + ### Changed - **Device key IDs now use BLAKE3.** The short per-device handle is derived from diff --git a/crates/astrid-capsule/src/access.rs b/crates/astrid-capsule/src/access.rs index eeee3be9e..2283efe53 100644 --- a/crates/astrid-capsule/src/access.rs +++ b/crates/astrid-capsule/src/access.rs @@ -361,6 +361,19 @@ mod tests { DeviceKey::new(seed.to_string().repeat(64), scope, None, 0) } + #[test] + fn unrestricted_capsule_access_enforcement_id_is_registered() { + assert_eq!(CAPSULE_ACCESS_ANY, "capsule:access:any"); + let registry = astrid_core::capability_registry::capability_registry_revision_1().unwrap(); + assert!( + registry + .entries() + .iter() + .any(|entry| entry.id().as_str() == CAPSULE_ACCESS_ANY), + "capsule access enforcement uses {CAPSULE_ACCESS_ANY:?} without a registry revision 1 entry" + ); + } + #[test] fn none_principal_denied() { let (_d, _h, r) = fixture(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs b/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs index 6257650a7..5edb3d149 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs @@ -21,7 +21,8 @@ fn audit_topic_literal_pinned() { // `pattern_covers_audit` would otherwise silently stop recognising // audit subscriptions and leave them on the unscoped firehose default // for the renamed topic — exactly the drift the doc comment promises - // is guarded. Mirrors `tests::audit_firehose_cap_literal_pinned`. + // is guarded. The separate firehose capability identifier is checked + // against registry revision 1 by `tests::secondary_enforcement_ids_are_registered`. assert_eq!(AUDIT_TOPIC, "astrid.v1.audit.entry"); } diff --git a/crates/astrid-capsule/src/engine/wasm/mod.rs b/crates/astrid-capsule/src/engine/wasm/mod.rs index ddab17ea2..0b98b77cc 100644 --- a/crates/astrid-capsule/src/engine/wasm/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/mod.rs @@ -670,7 +670,7 @@ pub(crate) fn resolve_exemption( /// Elevated). A capsule-local literal keeps the kernel/capsule dependency /// boundary clean (the capsule must not reach into the gateway or grow the /// core grammar surface for one internal reference); the value is pinned by -/// [`tests::audit_firehose_cap_literal_pinned`]. +/// [`tests::secondary_enforcement_ids_are_registered`]. const AUDIT_FIREHOSE_CAP: &str = "audit:read_all"; /// Pure decision: does this load principal's profile hold the audit @@ -3562,11 +3562,22 @@ mod tests { // pinned positively/negatively below. #[test] - fn audit_firehose_cap_literal_pinned() { - // The capsule-local literal must stay byte-equal to the gateway's - // `events::AUDIT_FIREHOSE_CAP` and the core grammar's `audit:read_all` - // so the three references can never drift. + fn secondary_enforcement_ids_are_registered() { assert_eq!(AUDIT_FIREHOSE_CAP, "audit:read_all"); + assert_eq!( + astrid_core::EXEMPT_CAPABILITIES, + ["system:resources:unbounded", "net_bind", "uplink"] + ); + let registry = astrid_core::capability_registry::capability_registry_revision_1().unwrap(); + for id in std::iter::once(AUDIT_FIREHOSE_CAP).chain(astrid_core::EXEMPT_CAPABILITIES) { + assert!( + registry + .entries() + .iter() + .any(|entry| entry.id().as_str() == id), + "capsule enforcement uses {id:?} without a registry revision 1 entry" + ); + } } #[test] diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index ed26e74ed..ce69c593d 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -16,6 +16,7 @@ use util::{ validate_digest_length, }; +mod revision_1; mod util; const ENTRY_DIGEST_DOMAIN: &[u8] = b"astrid-capability-entry\0"; @@ -60,7 +61,6 @@ impl CapabilityRegistryRevision { /// /// This set is authority-bearing and frozen for its registry schema revision. /// Expanding it requires an intentional schema revision and reviewed digest vectors. -#[cfg(test)] const CAPABILITY_REGISTRY_REVISION_1_IDS: [&str; 51] = [ "system:shutdown", "system:status", @@ -115,6 +115,141 @@ const CAPABILITY_REGISTRY_REVISION_1_IDS: [&str; 51] = [ "authority:repair", ]; +/// Schema revision for the 51-ID authority registry. +pub const CAPABILITY_REGISTRY_REVISION_1: CapabilityRegistryRevision = + CapabilityRegistryRevision::new(NonZeroU32::MIN); + +#[derive(Clone, Copy)] +struct RevisionSemantics { + scope: CapabilityScope, + target_kinds: &'static [AuthorityTargetKind], + delegable: bool, + privileged: bool, +} + +/// Build the content-addressed registry for the 51 fixed capability IDs. +/// +/// # Errors +/// +/// Returns an error if an ID lacks fixed semantics or display metadata, or if +/// any definition fails registry validation. +pub fn capability_registry_revision_1() -> Result +{ + let entries = CAPABILITY_REGISTRY_REVISION_1_IDS + .into_iter() + .map(|id| { + let semantics = revision_1_semantics(id).ok_or_else(|| { + AuthorityRegistryError::MissingRevisionDefinition { id: id.to_string() } + })?; + let danger = revision_1::danger(id).ok_or_else(|| { + AuthorityRegistryError::MissingRevisionDisplayMetadata { id: id.to_string() } + })?; + RegisteredCapability::new( + ExactCapabilityId::new(id.to_string())?, + semantics.scope, + semantics.target_kinds.iter().copied(), + danger, + semantics.delegable, + semantics.privileged, + CapabilitySource::Kernel, + ) + }) + .collect::, AuthorityRegistryError>>()?; + + CapabilityRegistryManifest::new(CAPABILITY_REGISTRY_REVISION_1, entries) +} + +fn revision_1_semantics(id: &str) -> Option { + use AuthorityTargetKind::{ + AuditScope, CapsuleInstance, CapsulePackage, Credential, Group, Principal, System, + }; + use CapabilityScope::{Global, Self_}; + + let semantics = match id { + "system:shutdown" => RevisionSemantics::new(Global, &[System], false, true), + "system:status" => RevisionSemantics::new(Global, &[System], false, false), + "capsule:install" => RevisionSemantics::new(Global, &[System, CapsulePackage], true, true), + "self:capsule:install" => { + RevisionSemantics::new(Self_, &[Principal, CapsulePackage], true, false) + }, + "capsule:reload" | "capsule:remove" => { + RevisionSemantics::new(Global, &[System, CapsuleInstance], true, true) + }, + "self:capsule:reload" + | "self:capsule:remove" + | "self:workspace:promote" + | "self:workspace:rollback" => { + RevisionSemantics::new(Self_, &[Principal, CapsuleInstance], true, false) + }, + "capsule:list" | "agent:list" | "group:list" | "invite:list" => { + RevisionSemantics::new(Global, &[System], true, true) + }, + "self:capsule:list" + | "self:agent:list" + | "self:group:list" + | "self:quota:get" + | "self:approval:respond" => RevisionSemantics::new(Self_, &[Principal], true, false), + "agent:create" | "agent:create:clone" | "agent:modify" => { + RevisionSemantics::new(Global, &[Principal, Group, CapsulePackage], true, true) + }, + "agent:create:inherit" + | "agent:delete" + | "agent:enable" + | "agent:disable" + | "quota:set" + | "quota:get" + | "caps:grant" + | "caps:revoke" + | "caps:token:list" => RevisionSemantics::new(Global, &[Principal], true, true), + "self:quota:set" => RevisionSemantics::new(Self_, &[Principal], true, true), + "group:create" | "group:delete" | "group:modify" => { + RevisionSemantics::new(Global, &[Group], true, true) + }, + "caps:token:mint" | "caps:token:revoke" => { + RevisionSemantics::new(Global, &[Principal, Credential], true, true) + }, + "invite:issue" => RevisionSemantics::new(Global, &[Group, Credential], true, true), + "invite:redeem" => { + RevisionSemantics::new(Global, &[Principal, Group, Credential], false, true) + }, + "invite:revoke" => RevisionSemantics::new(Global, &[Credential], true, true), + "audit:read_all" => RevisionSemantics::new(Global, &[AuditScope], true, true), + "self:auth:pair" => RevisionSemantics::new(Self_, &[Principal, Credential], true, true), + "self:auth:pair:admin" => { + RevisionSemantics::new(Self_, &[Principal, Credential], false, true) + }, + "auth:pair:redeem" => RevisionSemantics::new(Global, &[Principal, Credential], false, true), + "auth:pair" => RevisionSemantics::new(Global, &[Principal, Credential], true, true), + "system:resources:unbounded" | "net_bind" | "uplink" => { + RevisionSemantics::new(Self_, &[Principal, CapsuleInstance], false, true) + }, + "capsule:access:any" => { + RevisionSemantics::new(Self_, &[CapsulePackage, CapsuleInstance], false, true) + }, + "authority:profile:manage" | "authority:repair" => { + RevisionSemantics::new(Global, &[System, Principal, Group, Credential], false, true) + }, + _ => return None, + }; + Some(semantics) +} + +impl RevisionSemantics { + const fn new( + scope: CapabilityScope, + target_kinds: &'static [AuthorityTargetKind], + delegable: bool, + privileged: bool, + ) -> Self { + Self { + scope, + target_kinds, + delegable, + privileged, + } + } +} + /// A validated capability identifier containing no wildcard segment. /// /// The generic storage parameter permits borrowed views at validation and @@ -709,6 +844,18 @@ pub enum AuthorityRegistryError { /// Recomputed digest. actual: String, }, + /// A fixed capability ID has no authorization definition. + #[error("capability-registry revision 1 entry {id:?} has no authorization definition")] + MissingRevisionDefinition { + /// Capability identifier. + id: String, + }, + /// A fixed capability ID has no danger classification. + #[error("capability-registry revision 1 entry {id:?} has no display metadata")] + MissingRevisionDisplayMetadata { + /// Capability identifier. + id: String, + }, } fn digest_entry( diff --git a/crates/astrid-core/src/capability_registry/revision_1.rs b/crates/astrid-core/src/capability_registry/revision_1.rs new file mode 100644 index 000000000..20d05bca1 --- /dev/null +++ b/crates/astrid-core/src/capability_registry/revision_1.rs @@ -0,0 +1,60 @@ +use super::CapabilityDanger; + +pub(super) fn danger(id: &str) -> Option { + use CapabilityDanger::{Elevated, Extreme, Normal, Safe}; + + Some(match id { + "system:status" + | "capsule:list" + | "self:capsule:list" + | "agent:list" + | "self:agent:list" + | "quota:get" + | "self:quota:get" + | "group:list" + | "self:group:list" + | "caps:token:list" + | "invite:list" + | "self:approval:respond" => Safe, + "capsule:reload" + | "self:capsule:reload" + | "self:capsule:remove" + | "self:workspace:rollback" + | "agent:create" + | "agent:enable" + | "quota:set" + | "self:quota:set" + | "invite:redeem" + | "invite:revoke" + | "self:auth:pair" + | "auth:pair:redeem" => Normal, + "self:capsule:install" + | "capsule:remove" + | "self:workspace:promote" + | "agent:delete" + | "agent:disable" + | "agent:modify" + | "group:create" + | "group:delete" + | "group:modify" + | "caps:revoke" + | "caps:token:revoke" + | "invite:issue" + | "audit:read_all" + | "self:auth:pair:admin" + | "auth:pair" => Elevated, + "system:shutdown" + | "capsule:install" + | "agent:create:inherit" + | "agent:create:clone" + | "caps:grant" + | "caps:token:mint" + | "system:resources:unbounded" + | "net_bind" + | "uplink" + | "capsule:access:any" + | "authority:profile:manage" + | "authority:repair" => Extreme, + _ => return None, + }) +} diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index e437514d4..61efbd926 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -71,6 +71,330 @@ fn capability_registry_revision_1_freezes_current_and_dormant_exact_ids() { ); } +#[test] +fn capability_registry_revision_1_contains_every_fixed_definition() { + let manifest = capability_registry_revision_1().unwrap(); + let ids = manifest + .entries() + .iter() + .map(|entry| entry.id().as_str()) + .collect::>(); + let expected = CAPABILITY_REGISTRY_REVISION_1_IDS + .iter() + .copied() + .collect::>(); + + assert_eq!(manifest.schema_revision(), CAPABILITY_REGISTRY_REVISION_1); + assert_eq!(manifest.entries().len(), 51); + assert_eq!(ids, expected); + assert!( + manifest + .entries() + .iter() + .all(|entry| entry.source() == CapabilitySource::Kernel) + ); + manifest.verify().unwrap(); +} + +#[test] +fn capability_registry_revision_1_preserves_catalog_scope_and_danger() { + let manifest = capability_registry_revision_1().unwrap(); + for catalog_entry in CAPABILITY_CATALOG { + let registered = manifest + .entries() + .iter() + .find(|entry| entry.id().as_str() == catalog_entry.id) + .unwrap_or_else(|| panic!("missing catalog capability {}", catalog_entry.id)); + assert_eq!( + registered.scope(), + catalog_entry.scope, + "{}", + catalog_entry.id + ); + assert_eq!( + registered.danger(), + catalog_entry.danger, + "{}", + catalog_entry.id + ); + } + + for id in [ + CAP_RESOURCES_UNBOUNDED, + CAP_NET_BIND, + CAP_UPLINK, + "capsule:access:any", + "authority:profile:manage", + "authority:repair", + ] { + let registered = manifest + .entries() + .iter() + .find(|entry| entry.id().as_str() == id) + .unwrap_or_else(|| panic!("missing revision 1 addition {id}")); + assert_eq!(registered.danger(), CapabilityDanger::Extreme, "{id}"); + } +} + +#[test] +fn capability_registry_revision_1_semantics_cover_sensitive_edges() { + let manifest = capability_registry_revision_1().unwrap(); + let entry = |id: &str| { + manifest + .entries() + .iter() + .find(|entry| entry.id().as_str() == id) + .unwrap_or_else(|| panic!("missing revision 1 capability {id}")) + }; + + assert!(!entry("system:status").delegable()); + assert!(!entry("system:status").privileged()); + assert!(entry("self:auth:pair").delegable()); + assert!(entry("self:auth:pair").privileged()); + assert!(!entry("self:auth:pair:admin").delegable()); + assert!(!entry("capsule:access:any").delegable()); + assert!(entry("capsule:access:any").privileged()); + assert_eq!( + entry("authority:repair").target_kinds(), + &BTreeSet::from([ + AuthorityTargetKind::System, + AuthorityTargetKind::Principal, + AuthorityTargetKind::Group, + AuthorityTargetKind::Credential, + ]) + ); +} + +const CAPABILITY_REGISTRY_REVISION_1_DIGESTS: &[(&str, &str)] = &[ + ( + "agent:create", + "4e8a0890fe5a2cedd3dfdaa20329a54a28714541677290b0104ad649094ee58e", + ), + ( + "agent:create:clone", + "0e68aaaa67c638ea41abd52a6c38e55dfffaaa98bd7a64c89a0c0f7d54d25b7e", + ), + ( + "agent:create:inherit", + "7c6dc890286f51fc02f6294431007a7353a4454901f9bd05a638b0bb91f532a1", + ), + ( + "agent:delete", + "50b16eb3103205b8a43b23c9570482ed0fb2ef6de0d7d4d4290aeb52b42fd0c9", + ), + ( + "agent:disable", + "70f598abbde78c9fa59d7283204d3112ca90d575a8a1bb8e1e159c91b5b1ecf0", + ), + ( + "agent:enable", + "f369036099ab2ff2f4970d95a6ab3652921c96f7f335e6817350d96b2950436d", + ), + ( + "agent:list", + "e3288b4ff5930c8bb0dd5fb132424289d11245029521720933ee06b9d99a58be", + ), + ( + "agent:modify", + "2f0b08cbbbb0608e13c41cec8e60d7569b44ab8ce67507261947e2a37d300ff7", + ), + ( + "audit:read_all", + "d37700942d51a65ae02aef16edf6ab306d1814fe0845b52d2f07c76b19acd3de", + ), + ( + "auth:pair", + "02d0db72af63a1bda4688296daa5979ca89148a3208d23641a0eafeb21c9691d", + ), + ( + "auth:pair:redeem", + "ecf9c2e6c34b9f6d26bba43040febe57c955e53290ec17516a0ca97c22932dc4", + ), + ( + "authority:profile:manage", + "f5647f9424ca66e8ad86b3bbc6cd69abe769bcfaeef0afdcf1dc32ec04e66d48", + ), + ( + "authority:repair", + "fa83a7e2fe53adccdfb8f22ec76b16442ad348a821f91f200897385b873cc770", + ), + ( + "caps:grant", + "ab418eaca028c77daee3cf24e603e2981249ba480605f9dad666abfa91d1d01f", + ), + ( + "caps:revoke", + "12e19d80b91efad946ed33a3a75588e4f63b157a3f025a38450c3ec3e79a4970", + ), + ( + "caps:token:list", + "bc57c6774958f6df5216d77153983462c59c89a7f21db693546bbf1ed2f7de20", + ), + ( + "caps:token:mint", + "78e21ccbbe92d40efd26d8a4ae2dc9bdfb8ec3b9182a6182ebccd1477f6d11e5", + ), + ( + "caps:token:revoke", + "2ed0188f7ab75bb99147848c25f8847021dd59ce40bf952c36490b50f136e9af", + ), + ( + "capsule:access:any", + "c2e5c9eaac898896dc7c0a2bd39ad84ec7cfcc5555ddc50c0b5d8cdf981a1e18", + ), + ( + "capsule:install", + "8c6934700efda9c3077c8f2b3cb5dbc563dad62421d0a4725a982c5e5686d878", + ), + ( + "capsule:list", + "4a57fb6dba5592c6d0c7f046b8373fe99de315e82f783fdae86e6b99ebed1e09", + ), + ( + "capsule:reload", + "2a8d975f2c82f0c510baf700352f38ad61aaa3244ad4341845a06184d38a6ae5", + ), + ( + "capsule:remove", + "4758193d4d2aa4ec284792ee4de016aadd51ff28ff802e7bcbf684f68243edc2", + ), + ( + "group:create", + "d8261e0b62963c7a75e47c73c7a8dfd71c898d55af212b7bbb7b1eedbfaccfbe", + ), + ( + "group:delete", + "6c1d7bb11fcb15fcbdb5cc00892dc66b24b0721c83e2afb230fd6bd14632c9a8", + ), + ( + "group:list", + "9a2e4502bc8b79b62eb7d0515486582ffb55d2e33ddd547706aebed0a209f3b9", + ), + ( + "group:modify", + "906832c4748cdef984c2c20a066bb11614438c1197406c984fa3e18ba169f79c", + ), + ( + "invite:issue", + "6304a695cf77a53846a66993940fd7fd5505aa71c65b92cb3fc003814e3eeac9", + ), + ( + "invite:list", + "7f62b4b325d5a03b70b21f29ce5d3489d64e8d45572288e5b9c4ca2084b42f80", + ), + ( + "invite:redeem", + "a6f9b37568ef8d649cbb1652408a640eac085d574313877a288aed1abe158d36", + ), + ( + "invite:revoke", + "71bfda3067d8bdb9d70eef16381bbff69866329f3cf1fae7942cd82fa246e44e", + ), + ( + "net_bind", + "de912396886486562595f29f9cdb9e4e86d7cade76aa50c2b99e997cd90852bf", + ), + ( + "quota:get", + "9eafffe692d740029403f1cc4f1b997f00a30aacaa4248febde92794e899c575", + ), + ( + "quota:set", + "83a27029dde977fc3075653552e5a53fd3e04566297ce6c7ac3ff12fce4c7fab", + ), + ( + "self:agent:list", + "4ae3e25b7eb21956fceb73eee59d9304e9a0830397e55633216e08eebc5cfdfe", + ), + ( + "self:approval:respond", + "2e225891a674aee4205da30f5bbc72454df1b5b24f978acec688ddde7950cfd8", + ), + ( + "self:auth:pair", + "9ab37c94ad77fc70b34ad1283b4e254573fb73d068c064c365ca3828ae679cb0", + ), + ( + "self:auth:pair:admin", + "5a4867fa2fc29d4e6607b149e92e2fcb83023f31962b4817f2b3b98ffc974469", + ), + ( + "self:capsule:install", + "5383c1090fa3e204fc20f3c04d5b3acc3325566d9d49c91756af05353759c709", + ), + ( + "self:capsule:list", + "c0880b629be8f453b9087a1d18f5820750e371e320f09fb7ece038117e3bd59c", + ), + ( + "self:capsule:reload", + "3a0882f7d4969fd06ff42ef722062ddfee384a73e0cce2328045d5dc18384089", + ), + ( + "self:capsule:remove", + "40da32931da6f54e2045c97c936c0a7cbb6e3c04a509b92e869b9adf6462dbeb", + ), + ( + "self:group:list", + "e774470a69d80d46dfee25a4029a12f52bb4792e8802bc1309c589e69a484536", + ), + ( + "self:quota:get", + "ca7311bb4622f366a71d12b33f1b3633a58bcd5af9c167a25cc2ba314d4dcbda", + ), + ( + "self:quota:set", + "5cc56b5ba3c8232f2022b7730bb0a36ffd944dc0a7eb1eafe800c44ab7060944", + ), + ( + "self:workspace:promote", + "c2e85a4b1738922a63d7be3f708982474dea52105e69ea4604d26cd0dee49cfc", + ), + ( + "self:workspace:rollback", + "50817838952523b74ec779178cd6ac1ecaa9b649137e4fd09f1767f72f4520f0", + ), + ( + "system:resources:unbounded", + "541ff0a36f55450dda506f8d07127e9ec585fd77a78d9895d1fe0d8befca7f91", + ), + ( + "system:shutdown", + "b357447f17a3e8f5821542e96ccef03bb9ebe0be4cd55b57657e4eb8e0fefdc8", + ), + ( + "system:status", + "86b76eb96e06c806d3599620f8af6e539407f7b667641fe40e52a35bfb752c6d", + ), + ( + "uplink", + "89561b0ac228a3c5ef22b08059b4b832bd988b038e37cf548aa5d4c8fddceaf5", + ), +]; + +#[test] +fn capability_registry_revision_1_digest_vectors_are_stable() { + let manifest = capability_registry_revision_1().unwrap(); + let actual = manifest + .entries() + .iter() + .map(|entry| (entry.id().as_str(), entry.entry_digest().to_hex())) + .collect::>(); + + assert_eq!(actual.len(), CAPABILITY_REGISTRY_REVISION_1_DIGESTS.len()); + for ((actual_id, actual_digest), (expected_id, expected_digest)) in actual + .iter() + .zip(CAPABILITY_REGISTRY_REVISION_1_DIGESTS.iter().copied()) + { + assert_eq!(*actual_id, expected_id); + assert_eq!(actual_digest.as_str(), expected_digest, "{expected_id}"); + } + assert_eq!( + manifest.digest().to_hex(), + "111cf3fe35104ccd25767d3f0b85778c0bb2561d10016f56ac868de4607940e6" + ); +} + #[test] fn target_order_does_not_change_entry_digest() { let left = registered( diff --git a/crates/astrid-kernel/src/kernel_router/admin/tests.rs b/crates/astrid-kernel/src/kernel_router/admin/tests.rs index 528d962d3..96e8cd33a 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/tests.rs @@ -23,83 +23,12 @@ use super::{ AuthorityScope, admin_request_method, admin_response_topic, admin_target_principal, required_capability_for_admin_request, resolve_admin_scope, }; +use crate::kernel_router::test_util::all_admin_request_variants; fn pid(name: &str) -> PrincipalId { PrincipalId::new(name).unwrap() } -fn all_admin_variants() -> Vec { - vec![ - AdminRequestKind::AgentCreate { - name: "n".into(), - groups: Vec::new(), - grants: Vec::new(), - inherit_from: None, - clone_from: None, - allow_admin_clone: false, - }, - AdminRequestKind::AgentDelete { - principal: pid("a"), - }, - AdminRequestKind::AgentEnable { - principal: pid("a"), - }, - AdminRequestKind::AgentDisable { - principal: pid("a"), - }, - AdminRequestKind::AgentModify { - principal: pid("a"), - add_groups: vec!["agent".into()], - remove_groups: Vec::new(), - add_capsules: Vec::new(), - remove_capsules: Vec::new(), - }, - AdminRequestKind::AgentList, - AdminRequestKind::QuotaSet { - principal: pid("a"), - quotas: astrid_core::profile::Quotas::default(), - }, - AdminRequestKind::QuotaGet { - principal: pid("a"), - }, - AdminRequestKind::GroupCreate { - name: "ops".into(), - capabilities: vec!["capsule:install".into()], - description: None, - unsafe_admin: false, - }, - AdminRequestKind::GroupDelete { name: "ops".into() }, - AdminRequestKind::GroupModify { - name: "ops".into(), - capabilities: None, - description: None, - unsafe_admin: None, - }, - AdminRequestKind::GroupList, - AdminRequestKind::CapsGrant { - principal: pid("a"), - capabilities: vec!["self:capsule:install".into()], - unsafe_admin: false, - }, - AdminRequestKind::CapsRevoke { - principal: pid("a"), - capabilities: vec!["self:*".into()], - }, - AdminRequestKind::CapsTokenMint { - principal: pid("a"), - resource: "mcp://server:tool".into(), - permission: None, - ttl_secs: None, - }, - AdminRequestKind::CapsTokenRevoke { - token_id: "00000000-0000-0000-0000-000000000000".into(), - }, - AdminRequestKind::CapsTokenList { - principal: pid("a"), - }, - ] -} - fn agent_profile() -> PrincipalProfile { PrincipalProfile { groups: vec!["agent".to_string()], @@ -129,7 +58,7 @@ fn authorize_with( #[test] fn every_variant_has_non_empty_mapping_in_both_scopes() { - for req in all_admin_variants() { + for req in all_admin_request_variants() { for scope in [AuthorityScope::Self_, AuthorityScope::Global] { let cap = required_capability_for_admin_request(&req, scope); assert!( @@ -253,7 +182,7 @@ fn agent_create_mapping_uses_granular_clone_and_inherit_caps() { #[test] fn every_variant_has_a_method_label() { - for req in all_admin_variants() { + for req in all_admin_request_variants() { let m = admin_request_method(&req); assert!( m.starts_with("admin."), @@ -363,7 +292,7 @@ fn agent_denied_cross_tenant_every_admin_topic() { let profile = agent_profile(); let caller = pid("agent_user"); - for req in all_admin_variants() { + for req in all_admin_request_variants() { let method = admin_request_method(&req); let scope = resolve_admin_scope(&req, &caller); let cap = required_capability_for_admin_request(&req, scope); @@ -390,7 +319,7 @@ fn admin_allowed_every_admin_topic() { let profile = admin_profile(); let caller = pid("admin_user"); - for req in all_admin_variants() { + for req in all_admin_request_variants() { let method = admin_request_method(&req); let scope = resolve_admin_scope(&req, &caller); let cap = required_capability_for_admin_request(&req, scope); diff --git a/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs b/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs index 7ec8e3a85..cc50a7119 100644 --- a/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs @@ -1,188 +1,139 @@ -//! Drift checks for `astrid_core::capability_grammar::CAPABILITY_CATALOG`. -//! -//! The structured catalog is what the HTTP gateway returns from -//! `/api/sys/capabilities`. If a new capability lands in -//! `required_capability` or `required_capability_for_admin_request` -//! without being added to the catalog, the gateway silently omits -//! it from discovery — breaking dashboards that build cap-grant UI -//! from the list. These tests enumerate every string returned by -//! both match tables (across both [`AuthorityScope`] variants) and -//! assert each appears in the catalog. +//! Drift checks for enforcement capability mappings and registry definitions. -use astrid_core::PrincipalId; use astrid_core::capability_grammar::known_capabilities; -use astrid_core::kernel_api::{AdminRequestKind, KernelRequest}; -use astrid_core::profile::Quotas; +use astrid_core::capability_registry::capability_registry_revision_1; use std::collections::BTreeSet; use crate::kernel_router::admin::required_capability_for_admin_request; +use crate::kernel_router::test_util::{all_admin_request_variants, all_kernel_request_variants}; use crate::kernel_router::{AuthorityScope, required_capability}; -/// Mirror of `tests::all_request_variants` from the parent module — -/// duplicated here because the parent's helper lives inside a -/// `#[cfg(test)] mod tests` block and isn't visible to sibling test -/// modules. Adding a `KernelRequest` variant requires updating both -/// lists; the size is small enough that the duplication beats -/// plumbing visibility through. -fn all_kernel_request_variants() -> Vec { - vec![ - KernelRequest::Shutdown { reason: None }, - KernelRequest::GetStatus, - KernelRequest::ReloadCapsules, - KernelRequest::ReloadCapsule { - id: "x".to_string(), - }, - KernelRequest::UnloadCapsule { - id: "x".to_string(), - }, - KernelRequest::InstallCapsule { - source: "x".to_string(), - workspace: false, - }, - KernelRequest::ListCapsules, - KernelRequest::GetCommands, - KernelRequest::GetCapsuleMetadata, - KernelRequest::ApproveCapability { - request_id: "r".to_string(), - signature: "s".to_string(), - }, - ] -} - #[test] -fn known_capabilities_covers_every_kernel_request_cap() { +fn registry_revision_1_covers_every_kernel_request_cap() { + let registry = capability_registry_revision_1().unwrap(); + let registered = registry + .entries() + .iter() + .map(|entry| entry.id().as_str()) + .collect::>(); let scopes = [AuthorityScope::Self_, AuthorityScope::Global]; for req in all_kernel_request_variants() { for scope in scopes { let cap = required_capability(&req, scope); assert!( - known_capabilities().any(|c| c == cap), - "kernel returns capability {cap:?} not in \ - astrid_core::capability_grammar::CAPABILITY_CATALOG — \ - update the catalog when adding a capability" + registered.contains(cap), + "kernel returns capability {cap:?} without a registry revision 1 entry" ); } } } -fn all_admin_request_variants() -> Vec { - let p = PrincipalId::default(); - vec![ - AdminRequestKind::AgentCreate { - name: "alice".into(), - groups: vec![], - grants: vec![], - inherit_from: None, - clone_from: None, - allow_admin_clone: false, - }, - AdminRequestKind::AgentDelete { - principal: p.clone(), - }, - AdminRequestKind::AgentEnable { - principal: p.clone(), - }, - AdminRequestKind::AgentDisable { - principal: p.clone(), - }, - AdminRequestKind::AgentModify { - principal: p.clone(), - add_groups: vec![], - remove_groups: vec![], - add_capsules: vec![], - remove_capsules: vec![], - }, - AdminRequestKind::AgentList, - AdminRequestKind::QuotaSet { - principal: p.clone(), - quotas: Quotas::default(), - }, - AdminRequestKind::QuotaGet { - principal: p.clone(), - }, - AdminRequestKind::GroupCreate { - name: "g".into(), - capabilities: vec![], - description: None, - unsafe_admin: false, - }, - AdminRequestKind::GroupDelete { name: "g".into() }, - AdminRequestKind::GroupModify { - name: "g".into(), - capabilities: None, - description: None, - unsafe_admin: None, - }, - AdminRequestKind::GroupList, - AdminRequestKind::CapsGrant { - principal: p.clone(), - capabilities: vec![], - unsafe_admin: false, - }, - AdminRequestKind::CapsRevoke { - principal: p.clone(), - capabilities: vec![], - }, - AdminRequestKind::CapsTokenMint { - principal: p.clone(), - resource: "mcp://server:tool".into(), - permission: None, - ttl_secs: None, - }, - AdminRequestKind::CapsTokenRevoke { - token_id: "00000000-0000-0000-0000-000000000000".into(), - }, - AdminRequestKind::CapsTokenList { - principal: p.clone(), - }, - AdminRequestKind::InviteIssue { - group: "agent".into(), - expires_secs: None, - max_uses: 1, - metadata: None, - }, - AdminRequestKind::InviteRedeem { - token: "x".into(), - public_key: String::new(), - display_name: None, - }, - AdminRequestKind::InviteList, - AdminRequestKind::InviteRevoke { token: "x".into() }, - AdminRequestKind::PairDeviceIssue { - expires_secs: None, - label: None, - scope: astrid_core::kernel_api::PairScopeArg::Full, - }, - AdminRequestKind::PairDeviceRedeem { - token: "x".into(), - public_key: String::new(), - }, - AdminRequestKind::PairDeviceList { - principal: p.clone(), - }, - AdminRequestKind::PairDeviceRevoke { - principal: p, - key_id: "k".into(), - }, - ] -} - #[test] -fn known_capabilities_covers_every_admin_request_cap() { +fn registry_revision_1_covers_every_admin_request_cap() { + let registry = capability_registry_revision_1().unwrap(); + let registered = registry + .entries() + .iter() + .map(|entry| entry.id().as_str()) + .collect::>(); let scopes = [AuthorityScope::Self_, AuthorityScope::Global]; for req in &all_admin_request_variants() { for scope in scopes { let cap = required_capability_for_admin_request(req, scope); assert!( - known_capabilities().any(|c| c == cap), - "admin op returns capability {cap:?} not in \ - astrid_core::capability_grammar::CAPABILITY_CATALOG — \ - update the catalog when adding a capability" + registered.contains(cap), + "admin op returns capability {cap:?} without a registry revision 1 entry" ); } } } +#[test] +fn registry_revision_1_freezes_the_complete_role_partition() { + let primary = BTreeSet::from([ + "system:shutdown", + "system:status", + "capsule:install", + "self:capsule:install", + "capsule:reload", + "self:capsule:reload", + "self:capsule:remove", + "self:workspace:promote", + "self:workspace:rollback", + "self:capsule:list", + "agent:create", + "agent:create:inherit", + "agent:create:clone", + "agent:delete", + "agent:enable", + "agent:disable", + "agent:modify", + "self:agent:list", + "quota:set", + "self:quota:set", + "quota:get", + "self:quota:get", + "group:create", + "group:delete", + "group:modify", + "self:group:list", + "caps:grant", + "caps:revoke", + "caps:token:mint", + "caps:token:revoke", + "caps:token:list", + "invite:issue", + "invite:list", + "invite:revoke", + "self:approval:respond", + "self:auth:pair", + "auth:pair", + ]); + let secondary = BTreeSet::from([ + "capsule:list", + "agent:list", + "group:list", + "audit:read_all", + "self:auth:pair:admin", + "system:resources:unbounded", + "net_bind", + "uplink", + "capsule:access:any", + ]); + let token_authenticated = BTreeSet::from(["invite:redeem", "auth:pair:redeem"]); + let dormant = BTreeSet::from(["authority:profile:manage", "authority:repair"]); + let mapping_only = BTreeSet::from(["capsule:remove"]); + + let classes = [ + &primary, + &secondary, + &token_authenticated, + &dormant, + &mapping_only, + ]; + for (index, class) in classes.iter().enumerate() { + for other in classes.iter().skip(index + 1) { + assert!( + class.is_disjoint(other), + "baseline enforcement classes overlap: {:?}", + class.intersection(other).collect::>() + ); + } + } + + let classified = classes + .into_iter() + .flat_map(|class| class.iter().copied()) + .collect::>(); + let revision = capability_registry_revision_1().unwrap(); + let revision_ids = revision + .entries() + .iter() + .map(|entry| entry.id().as_str()) + .collect::>(); + assert_eq!(classified, revision_ids); +} + #[test] fn e2e_capability_manifest_covers_catalog() { let manifest: toml::Value = diff --git a/crates/astrid-kernel/src/kernel_router/mod.rs b/crates/astrid-kernel/src/kernel_router/mod.rs index 2b6346698..e52219faa 100644 --- a/crates/astrid-kernel/src/kernel_router/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/mod.rs @@ -36,6 +36,8 @@ use device_scope::resolve_device_scope; mod capability_catalog_tests; #[cfg(test)] mod connection_tracker_tests; +#[cfg(test)] +mod test_util; /// Spawns background tasks for the kernel management API and connection tracking. /// diff --git a/crates/astrid-kernel/src/kernel_router/test_util.rs b/crates/astrid-kernel/src/kernel_router/test_util.rs new file mode 100644 index 000000000..7c4b17812 --- /dev/null +++ b/crates/astrid-kernel/src/kernel_router/test_util.rs @@ -0,0 +1,148 @@ +use astrid_core::PrincipalId; +use astrid_core::kernel_api::{AdminRequestKind, KernelRequest, PairScopeArg}; +use astrid_core::profile::Quotas; + +pub(crate) fn all_kernel_request_variants() -> Vec { + vec![ + KernelRequest::Shutdown { reason: None }, + KernelRequest::GetStatus, + KernelRequest::ReloadCapsules, + KernelRequest::ReloadCapsule { id: "x".into() }, + KernelRequest::UnloadCapsule { id: "x".into() }, + KernelRequest::PromoteWorkspace { id: "x".into() }, + KernelRequest::RollbackWorkspace { id: "x".into() }, + KernelRequest::InstallCapsule { + source: "x".into(), + workspace: false, + }, + KernelRequest::ListCapsules, + KernelRequest::GetCommands, + KernelRequest::GetCapsuleMetadata, + KernelRequest::GetAgentReadiness, + KernelRequest::ApproveCapability { + request_id: "r".into(), + signature: "s".into(), + }, + ] +} + +pub(crate) fn all_admin_request_variants() -> Vec { + let principal = PrincipalId::default(); + identity_and_policy_variants(&principal) + .into_iter() + .chain(credential_variants(&principal)) + .collect() +} + +fn identity_and_policy_variants(principal: &PrincipalId) -> Vec { + vec![ + AdminRequestKind::AgentCreate { + name: "alice".into(), + groups: vec![], + grants: vec![], + inherit_from: None, + clone_from: None, + allow_admin_clone: false, + }, + AdminRequestKind::AgentDelete { + principal: principal.clone(), + }, + AdminRequestKind::AgentEnable { + principal: principal.clone(), + }, + AdminRequestKind::AgentDisable { + principal: principal.clone(), + }, + AdminRequestKind::AgentModify { + principal: principal.clone(), + add_groups: vec![], + remove_groups: vec![], + add_capsules: vec![], + remove_capsules: vec![], + }, + AdminRequestKind::AgentList, + AdminRequestKind::QuotaSet { + principal: principal.clone(), + quotas: Quotas::default(), + }, + AdminRequestKind::QuotaGet { + principal: principal.clone(), + }, + AdminRequestKind::UsageGet { + principal: principal.clone(), + }, + AdminRequestKind::GroupCreate { + name: "group".into(), + capabilities: vec![], + description: None, + unsafe_admin: false, + }, + AdminRequestKind::GroupDelete { + name: "group".into(), + }, + AdminRequestKind::GroupModify { + name: "group".into(), + capabilities: None, + description: None, + unsafe_admin: None, + }, + AdminRequestKind::GroupList, + AdminRequestKind::CapsGrant { + principal: principal.clone(), + capabilities: vec![], + unsafe_admin: false, + }, + AdminRequestKind::CapsRevoke { + principal: principal.clone(), + capabilities: vec![], + }, + ] +} + +fn credential_variants(principal: &PrincipalId) -> Vec { + vec![ + AdminRequestKind::CapsTokenMint { + principal: principal.clone(), + resource: "mcp://server:tool".into(), + permission: None, + ttl_secs: None, + }, + AdminRequestKind::CapsTokenRevoke { + token_id: "00000000-0000-0000-0000-000000000000".into(), + }, + AdminRequestKind::CapsTokenList { + principal: principal.clone(), + }, + AdminRequestKind::InviteIssue { + group: "agent".into(), + expires_secs: None, + max_uses: 1, + metadata: None, + }, + AdminRequestKind::InviteRedeem { + token: "token".into(), + public_key: String::new(), + display_name: None, + }, + AdminRequestKind::InviteList, + AdminRequestKind::InviteRevoke { + token: "token".into(), + }, + AdminRequestKind::PairDeviceIssue { + expires_secs: None, + label: None, + scope: PairScopeArg::Full, + }, + AdminRequestKind::PairDeviceRedeem { + token: "token".into(), + public_key: String::new(), + }, + AdminRequestKind::PairDeviceList { + principal: principal.clone(), + }, + AdminRequestKind::PairDeviceRevoke { + principal: principal.clone(), + key_id: "key".into(), + }, + ] +} diff --git a/crates/astrid-kernel/src/kernel_router/tests.rs b/crates/astrid-kernel/src/kernel_router/tests.rs index 0fefa90a1..50b72a83c 100644 --- a/crates/astrid-kernel/src/kernel_router/tests.rs +++ b/crates/astrid-kernel/src/kernel_router/tests.rs @@ -12,6 +12,8 @@ use astrid_core::kernel_api::CommandKind; use astrid_core::profile::{AuthMethod, DeviceKey, DeviceScope, PrincipalProfile}; use std::sync::atomic::AtomicBool; +use super::test_util::all_kernel_request_variants; + struct InventoryCapsule { id: CapsuleId, manifest: CapsuleManifest, @@ -225,41 +227,9 @@ fn rate_limit_for_request_returns_correct_limits() { // ── Capability mapping (issue #670) ────────────────────────────── -fn all_request_variants() -> Vec { - vec![ - KernelRequest::Shutdown { reason: None }, - KernelRequest::GetStatus, - KernelRequest::ReloadCapsules, - KernelRequest::ReloadCapsule { - id: "x".to_string(), - }, - KernelRequest::UnloadCapsule { - id: "x".to_string(), - }, - KernelRequest::PromoteWorkspace { - id: "x".to_string(), - }, - KernelRequest::RollbackWorkspace { - id: "x".to_string(), - }, - KernelRequest::InstallCapsule { - source: "x".to_string(), - workspace: false, - }, - KernelRequest::ListCapsules, - KernelRequest::GetCommands, - KernelRequest::GetCapsuleMetadata, - KernelRequest::GetAgentReadiness, - KernelRequest::ApproveCapability { - request_id: "r".to_string(), - signature: "s".to_string(), - }, - ] -} - #[test] fn required_capability_every_variant_has_non_empty_mapping() { - for req in all_request_variants() { + for req in all_kernel_request_variants() { let cap = required_capability(&req, AuthorityScope::Self_); assert!( !cap.is_empty(), @@ -376,7 +346,7 @@ fn required_capability_mapping_global_scope() { #[test] fn resolve_scope_defaults_to_self_except_daemon_capsule_lifecycle() { let caller = PrincipalId::new("alice").unwrap(); - for req in all_request_variants() { + for req in all_kernel_request_variants() { if matches!( req, KernelRequest::ReloadCapsules