diff --git a/CHANGELOG.md b/CHANGELOG.md index e88536343..9dfb842ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. manifests. Existing profile persistence, wildcard evaluation, bootstrap, socket and wire behavior remain unchanged. Closes #1233. Refs #1228. +- **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; request mappings and current enforcement + roles fail tests if they drift outside the registry. 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-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index ed26e74ed..266dccbe3 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -9,7 +9,8 @@ use std::num::NonZeroU32; use thiserror::Error; use crate::capability_grammar::{ - CapabilityDanger, CapabilityGrammarError, CapabilityScope, validate_capability, + CAPABILITY_CATALOG, CapabilityDanger, CapabilityGrammarError, CapabilityScope, + validate_capability, }; use util::{ domain_hash, encode_array_len, encode_bool, encode_bytes, encode_text, encode_unsigned, @@ -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,160 @@ 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_danger(id: &str) -> Option { + CAPABILITY_CATALOG + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.danger) + .or_else(|| { + matches!( + id, + "system:resources:unbounded" + | "net_bind" + | "uplink" + | "capsule:access:any" + | "authority:profile:manage" + | "authority:repair" + ) + .then_some(CapabilityDanger::Extreme) + }) +} + +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 @@ -653,6 +807,18 @@ impl CapabilityRegistryManifest { #[derive(Debug, Error)] #[non_exhaustive] pub enum AuthorityRegistryError { + /// 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, + }, /// A capability ID failed the existing static capability grammar. #[error("invalid capability id {id:?}: {source}")] InvalidCapabilityId { diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index e437514d4..3381bc5a3 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -71,6 +71,327 @@ 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, + ]) + ); +} + +#[test] +fn capability_registry_revision_1_digest_vectors_are_stable() { + let expected = [ + ( + "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", + ), + ]; + 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(), expected.len()); + for ((actual_id, actual_digest), (expected_id, expected_digest)) in actual.iter().zip(expected) + { + 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 9781cb34c..3b276a92b 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/tests.rs @@ -47,6 +47,13 @@ fn all_admin_variants() -> Vec { AdminRequestKind::AgentDisable { principal: pid("a"), }, + AdminRequestKind::AgentModify { + principal: pid("a"), + add_groups: Vec::new(), + remove_groups: Vec::new(), + add_capsules: Vec::new(), + remove_capsules: Vec::new(), + }, AdminRequestKind::AgentList, AdminRequestKind::QuotaSet { principal: pid("a"), @@ -55,6 +62,9 @@ fn all_admin_variants() -> Vec { AdminRequestKind::QuotaGet { principal: pid("a"), }, + AdminRequestKind::UsageGet { + principal: pid("a"), + }, AdminRequestKind::GroupCreate { name: "ops".into(), capabilities: vec!["capsule:install".into()], @@ -90,6 +100,35 @@ fn all_admin_variants() -> Vec { AdminRequestKind::CapsTokenList { principal: pid("a"), }, + 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: pid("a"), + }, + AdminRequestKind::PairDeviceRevoke { + principal: pid("a"), + key_id: "k".into(), + }, ] } 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..0c51f3a8d 100644 --- a/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/capability_catalog_tests.rs @@ -1,16 +1,8 @@ -//! 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::capability_registry::capability_registry_revision_1; use astrid_core::kernel_api::{AdminRequestKind, KernelRequest}; use astrid_core::profile::Quotas; use std::collections::BTreeSet; @@ -35,6 +27,12 @@ fn all_kernel_request_variants() -> Vec { 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, @@ -42,6 +40,7 @@ fn all_kernel_request_variants() -> Vec { KernelRequest::ListCapsules, KernelRequest::GetCommands, KernelRequest::GetCapsuleMetadata, + KernelRequest::GetAgentReadiness, KernelRequest::ApproveCapability { request_id: "r".to_string(), signature: "s".to_string(), @@ -50,16 +49,20 @@ fn all_kernel_request_variants() -> Vec { } #[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" ); } } @@ -100,6 +103,9 @@ fn all_admin_request_variants() -> Vec { AdminRequestKind::QuotaGet { principal: p.clone(), }, + AdminRequestKind::UsageGet { + principal: p.clone(), + }, AdminRequestKind::GroupCreate { name: "g".into(), capabilities: vec![], @@ -168,21 +174,114 @@ fn all_admin_request_variants() -> Vec { } #[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_classifies_every_current_enforcement_role() { + 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", + ]); + let token_authenticated = BTreeSet::from(["invite:redeem", "auth:pair:redeem"]); + let dormant = BTreeSet::from([ + "capsule:access:any", + "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 =