lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit d7c4fb685ceace71277b5d2d6275c2ea50122deb
parent 39c5400f4f5d86cffced69bc91906a4f607af3d9
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 13:22:21 -0700

authority: require explicit actor provenance

- replace ambiguous actor sources with explicit provenance variants
- add account-aware actor contexts and selector requests
- reject malformed actor account identifiers
- update authority and trade tests for the new constructors

Diffstat:
Mcrates/authority/src/actor.rs | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/authority/src/authorization.rs | 4++--
Mcrates/authority/src/error.rs | 12++++++++++++
Mcrates/trade/src/listing/draft.rs | 4++--
4 files changed, 237 insertions(+), 23 deletions(-)

diff --git a/crates/authority/src/actor.rs b/crates/authority/src/actor.rs @@ -5,34 +5,58 @@ use radroots_events::contract::RadrootsActorRole; use radroots_events::ids::RadrootsPublicKey; #[cfg(not(feature = "std"))] -use alloc::collections::BTreeSet; +use alloc::{collections::BTreeSet, string::String}; #[cfg(feature = "std")] -use std::collections::BTreeSet; +use std::{collections::BTreeSet, string::String}; + +pub const MAX_ACTOR_ACCOUNT_ID_LEN: usize = 128; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsActorSource { - Direct, - Account, - GroupMembership, - RelayAuth, + LocalAccount, + ExplicitPubkey, + RemoteSigner, + Service, + Test, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsActorSelector { - Pubkey(RadrootsPublicKey), + SelectedAccount, + AccountId(String), + PublicKey(RadrootsPublicKey), + DraftExpectedPubkey, +} + +impl RadrootsActorSelector { + pub fn account_id(account_id: impl Into<String>) -> Result<Self, RadrootsAuthorityError> { + Ok(Self::AccountId(validate_account_id(account_id)?)) + } + + pub fn public_key(pubkey: impl AsRef<str>) -> Result<Self, RadrootsAuthorityError> { + let pubkey = RadrootsPublicKey::parse(pubkey.as_ref()) + .map_err(|_| RadrootsAuthorityError::InvalidActorPubkey)?; + Ok(Self::PublicKey(pubkey)) + } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsActorResolutionRequest { pub selector: RadrootsActorSelector, pub required_role: RadrootsActorRole, + pub contract_id: Option<String>, } impl RadrootsActorResolutionRequest { - pub fn new(selector: RadrootsActorSelector, required_role: RadrootsActorRole) -> Self { + pub fn new( + selector: RadrootsActorSelector, + required_role: RadrootsActorRole, + contract_id: Option<String>, + ) -> Self { Self { selector, required_role, + contract_id, } } } @@ -41,23 +65,79 @@ impl RadrootsActorResolutionRequest { pub struct RadrootsActorContext { pub pubkey: RadrootsPublicKey, pub roles: BTreeSet<RadrootsActorRole>, + pub account_id: Option<String>, pub source: RadrootsActorSource, } impl RadrootsActorContext { - pub fn new(pubkey: impl AsRef<str>) -> Result<Self, RadrootsAuthorityError> { - Self::with_roles(pubkey, []) + pub fn explicit_pubkey<I>( + pubkey: impl AsRef<str>, + roles: I, + ) -> Result<Self, RadrootsAuthorityError> + where + I: IntoIterator<Item = RadrootsActorRole>, + { + Self::with_provenance(pubkey, None, RadrootsActorSource::ExplicitPubkey, roles) + } + + pub fn local_account<I>( + pubkey: impl AsRef<str>, + account_id: impl Into<String>, + roles: I, + ) -> Result<Self, RadrootsAuthorityError> + where + I: IntoIterator<Item = RadrootsActorRole>, + { + Self::with_provenance( + pubkey, + Some(validate_account_id(account_id)?), + RadrootsActorSource::LocalAccount, + roles, + ) + } + + pub fn remote_signer<I>( + pubkey: impl AsRef<str>, + account_id: impl Into<String>, + roles: I, + ) -> Result<Self, RadrootsAuthorityError> + where + I: IntoIterator<Item = RadrootsActorRole>, + { + Self::with_provenance( + pubkey, + Some(validate_account_id(account_id)?), + RadrootsActorSource::RemoteSigner, + roles, + ) + } + + pub fn service<I>( + pubkey: impl AsRef<str>, + account_id: impl Into<String>, + roles: I, + ) -> Result<Self, RadrootsAuthorityError> + where + I: IntoIterator<Item = RadrootsActorRole>, + { + Self::with_provenance( + pubkey, + Some(validate_account_id(account_id)?), + RadrootsActorSource::Service, + roles, + ) } - pub fn with_roles<I>(pubkey: impl AsRef<str>, roles: I) -> Result<Self, RadrootsAuthorityError> + pub fn test<I>(pubkey: impl AsRef<str>, roles: I) -> Result<Self, RadrootsAuthorityError> where I: IntoIterator<Item = RadrootsActorRole>, { - Self::with_source_and_roles(pubkey, RadrootsActorSource::Direct, roles) + Self::with_provenance(pubkey, None, RadrootsActorSource::Test, roles) } - pub fn with_source_and_roles<I>( + fn with_provenance<I>( pubkey: impl AsRef<str>, + account_id: Option<String>, source: RadrootsActorSource, roles: I, ) -> Result<Self, RadrootsAuthorityError> @@ -69,6 +149,7 @@ impl RadrootsActorContext { Ok(Self { pubkey, roles: roles.into_iter().collect(), + account_id, source, }) } @@ -81,6 +162,10 @@ impl RadrootsActorContext { &self.roles } + pub fn account_id(&self) -> Option<&str> { + self.account_id.as_deref() + } + pub fn source(&self) -> RadrootsActorSource { self.source } @@ -90,6 +175,25 @@ impl RadrootsActorContext { } } +fn validate_account_id(account_id: impl Into<String>) -> Result<String, RadrootsAuthorityError> { + let account_id = account_id.into(); + if account_id.is_empty() { + return Err(RadrootsAuthorityError::InvalidActorAccountIdEmpty); + } + if account_id.as_str() != account_id.trim() { + return Err(RadrootsAuthorityError::InvalidActorAccountIdUntrimmed); + } + if account_id.chars().any(char::is_control) { + return Err(RadrootsAuthorityError::InvalidActorAccountIdControlCharacter); + } + if account_id.chars().count() > MAX_ACTOR_ACCOUNT_ID_LEN { + return Err(RadrootsAuthorityError::InvalidActorAccountIdTooLong { + max_len: MAX_ACTOR_ACCOUNT_ID_LEN, + }); + } + Ok(account_id) +} + pub fn role_satisfies( actor_roles: &BTreeSet<RadrootsActorRole>, required_role: RadrootsActorRole, @@ -110,15 +214,15 @@ mod tests { #[test] fn any_is_satisfied_by_any_actor_context() { - let actor = RadrootsActorContext::new(hex_64('a')).expect("actor"); + let actor = RadrootsActorContext::test(hex_64('a'), []).expect("actor"); assert!(actor.satisfies(RadrootsActorRole::Any)); } #[test] fn specific_roles_require_explicit_membership() { - let actor = RadrootsActorContext::with_roles(hex_64('a'), [RadrootsActorRole::Farmer]) - .expect("actor"); + let actor = + RadrootsActorContext::test(hex_64('a'), [RadrootsActorRole::Farmer]).expect("actor"); assert!(actor.satisfies(RadrootsActorRole::Farmer)); assert!(!actor.satisfies(RadrootsActorRole::Seller)); @@ -126,15 +230,15 @@ mod tests { #[test] fn farmer_does_not_globally_satisfy_seller() { - let actor = RadrootsActorContext::with_roles(hex_64('a'), [RadrootsActorRole::Farmer]) - .expect("actor"); + let actor = + RadrootsActorContext::test(hex_64('a'), [RadrootsActorRole::Farmer]).expect("actor"); assert!(!actor.satisfies(RadrootsActorRole::Seller)); } #[test] fn multi_role_actors_satisfy_each_assigned_role() { - let actor = RadrootsActorContext::with_roles( + let actor = RadrootsActorContext::test( hex_64('a'), [RadrootsActorRole::Farmer, RadrootsActorRole::Seller], ) @@ -144,4 +248,102 @@ mod tests { assert!(actor.satisfies(RadrootsActorRole::Seller)); assert!(!actor.satisfies(RadrootsActorRole::Buyer)); } + + #[test] + fn local_account_context_carries_validated_account_id() { + let actor = RadrootsActorContext::local_account( + hex_64('a'), + "acct-field-01", + [RadrootsActorRole::Farmer], + ) + .expect("actor"); + + assert_eq!(actor.source(), RadrootsActorSource::LocalAccount); + assert_eq!(actor.account_id(), Some("acct-field-01")); + } + + #[test] + fn explicit_pubkey_context_has_no_account_id() { + let actor = RadrootsActorContext::explicit_pubkey(hex_64('a'), [RadrootsActorRole::Seller]) + .expect("actor"); + + assert_eq!(actor.source(), RadrootsActorSource::ExplicitPubkey); + assert_eq!(actor.account_id(), None); + } + + #[test] + fn remote_signer_and_service_contexts_carry_account_ids() { + let remote = RadrootsActorContext::remote_signer( + hex_64('a'), + "acct-remote", + [RadrootsActorRole::Buyer], + ) + .expect("remote actor"); + let service = + RadrootsActorContext::service(hex_64('b'), "acct-service", [RadrootsActorRole::Any]) + .expect("service actor"); + + assert_eq!(remote.source(), RadrootsActorSource::RemoteSigner); + assert_eq!(remote.account_id(), Some("acct-remote")); + assert_eq!(service.source(), RadrootsActorSource::Service); + assert_eq!(service.account_id(), Some("acct-service")); + } + + #[test] + fn account_id_rejects_invalid_values() { + assert!(matches!( + RadrootsActorContext::local_account(hex_64('a'), "", []), + Err(RadrootsAuthorityError::InvalidActorAccountIdEmpty) + )); + assert!(matches!( + RadrootsActorContext::local_account(hex_64('a'), " account ", []), + Err(RadrootsAuthorityError::InvalidActorAccountIdUntrimmed) + )); + assert!(matches!( + RadrootsActorContext::local_account(hex_64('a'), "account\nid", []), + Err(RadrootsAuthorityError::InvalidActorAccountIdControlCharacter) + )); + assert!(matches!( + RadrootsActorContext::local_account( + hex_64('a'), + core::iter::repeat_n('a', MAX_ACTOR_ACCOUNT_ID_LEN + 1).collect::<String>(), + [] + ), + Err(RadrootsAuthorityError::InvalidActorAccountIdTooLong { + max_len: MAX_ACTOR_ACCOUNT_ID_LEN + }) + )); + } + + #[test] + fn selector_supports_account_and_draft_resolution() { + let selector = RadrootsActorSelector::account_id("acct-field-01").expect("selector"); + let request = RadrootsActorResolutionRequest::new( + selector, + RadrootsActorRole::Seller, + Some("radroots.listing.published.v1".to_owned()), + ); + + assert!(matches!( + request.selector, + RadrootsActorSelector::AccountId(ref account_id) if account_id == "acct-field-01" + )); + assert_eq!(request.required_role, RadrootsActorRole::Seller); + assert_eq!( + request.contract_id.as_deref(), + Some("radroots.listing.published.v1") + ); + assert!(matches!( + RadrootsActorSelector::SelectedAccount, + RadrootsActorSelector::SelectedAccount + )); + assert!(matches!( + RadrootsActorSelector::public_key(hex_64('b')).expect("selector"), + RadrootsActorSelector::PublicKey(_) + )); + assert!(matches!( + RadrootsActorSelector::DraftExpectedPubkey, + RadrootsActorSelector::DraftExpectedPubkey + )); + } } diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -164,11 +164,11 @@ mod tests { } fn seller_actor(pubkey: &str) -> RadrootsActorContext { - RadrootsActorContext::with_roles(pubkey, [RadrootsActorRole::Seller]).expect("seller") + RadrootsActorContext::explicit_pubkey(pubkey, [RadrootsActorRole::Seller]).expect("seller") } fn buyer_actor(pubkey: &str) -> RadrootsActorContext { - RadrootsActorContext::with_roles(pubkey, [RadrootsActorRole::Buyer]).expect("buyer") + RadrootsActorContext::explicit_pubkey(pubkey, [RadrootsActorRole::Buyer]).expect("buyer") } fn listing_draft(pubkey: &str) -> RadrootsFrozenEventDraft { diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs @@ -12,6 +12,18 @@ pub enum RadrootsAuthorityError { #[error("invalid actor public key")] InvalidActorPubkey, + #[error("invalid actor account id: empty")] + InvalidActorAccountIdEmpty, + + #[error("invalid actor account id: contains leading or trailing whitespace")] + InvalidActorAccountIdUntrimmed, + + #[error("invalid actor account id: contains a control character")] + InvalidActorAccountIdControlCharacter, + + #[error("invalid actor account id: longer than {max_len} characters")] + InvalidActorAccountIdTooLong { max_len: usize }, + #[error("invalid signer public key")] InvalidSignerPubkey, diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs @@ -219,11 +219,11 @@ mod tests { } fn seller_actor() -> RadrootsActorContext { - RadrootsActorContext::with_roles(SELLER, [RadrootsActorRole::Seller]).expect("actor") + RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor") } fn buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::with_roles(SELLER, [RadrootsActorRole::Buyer]).expect("actor") + RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor") } #[test]