diff --git a/candid/evm_rpc.did b/candid/evm_rpc.did index 11f001ef..1ea95c9f 100644 --- a/candid/evm_rpc.did +++ b/candid/evm_rpc.did @@ -1,4 +1,4 @@ -type Auth = variant { Rpc; RegisterProvider; FreeRpc; ManageService }; +type Auth = variant { FreeRpc; PriorityRpc; RegisterProvider; ManageCanister }; type Block = record { miner : text; totalDifficulty : nat; diff --git a/src/auth.rs b/src/auth.rs index 919f8f0e..63522021 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,7 +14,7 @@ pub fn is_authorized(principal: &Principal, auth: Auth) -> bool { pub fn require_admin_or_controller() -> Result<(), String> { let caller = ic_cdk::caller(); - if is_authorized(&caller, Auth::ManageService) || ic_cdk::api::is_controller(&caller) { + if is_authorized(&caller, Auth::ManageCanister) || ic_cdk::api::is_controller(&caller) { Ok(()) } else { Err("You are not authorized".to_string()) @@ -30,7 +30,7 @@ pub fn require_register_provider() -> Result<(), String> { } pub fn is_rpc_allowed(caller: &Principal) -> bool { - METADATA.with(|m| m.borrow().get().open_rpc_access) || is_authorized(caller, Auth::Rpc) + METADATA.with(|m| m.borrow().get().open_rpc_access) || is_authorized(caller, Auth::PriorityRpc) } pub fn do_authorize(principal: Principal, auth: Auth) { @@ -69,16 +69,16 @@ fn test_authorization() { let principal2 = Principal::from_text("yxhtl-jlpgx-wqnzc-ysego-h6yqe-3zwfo-o3grn-gvuhm-nz3kv-ainub-6ae") .unwrap(); - assert!(!is_authorized(&principal1, Auth::Rpc)); - assert!(!is_authorized(&principal2, Auth::Rpc)); + assert!(!is_authorized(&principal1, Auth::PriorityRpc)); + assert!(!is_authorized(&principal2, Auth::PriorityRpc)); - do_authorize(principal1, Auth::Rpc); - assert!(is_authorized(&principal1, Auth::Rpc)); - assert!(!is_authorized(&principal2, Auth::Rpc)); + do_authorize(principal1, Auth::PriorityRpc); + assert!(is_authorized(&principal1, Auth::PriorityRpc)); + assert!(!is_authorized(&principal2, Auth::PriorityRpc)); - do_deauthorize(principal1, Auth::Rpc); - assert!(!is_authorized(&principal1, Auth::Rpc)); - assert!(!is_authorized(&principal2, Auth::Rpc)); + do_deauthorize(principal1, Auth::PriorityRpc); + assert!(!is_authorized(&principal1, Auth::PriorityRpc)); + assert!(!is_authorized(&principal2, Auth::PriorityRpc)); do_authorize(principal1, Auth::RegisterProvider); assert!(is_authorized(&principal1, Auth::RegisterProvider)); @@ -87,11 +87,10 @@ fn test_authorization() { do_deauthorize(principal1, Auth::RegisterProvider); assert!(!is_authorized(&principal1, Auth::RegisterProvider)); - do_authorize(principal2, Auth::ManageService); - assert!(!is_authorized(&principal1, Auth::ManageService)); - assert!(is_authorized(&principal2, Auth::ManageService)); + do_authorize(principal2, Auth::ManageCanister); + assert!(!is_authorized(&principal1, Auth::ManageCanister)); + assert!(is_authorized(&principal2, Auth::ManageCanister)); - assert!(!is_authorized(&principal2, Auth::Rpc)); - assert!(!is_authorized(&principal2, Auth::FreeRpc)); + assert!(!is_authorized(&principal2, Auth::PriorityRpc)); assert!(!is_authorized(&principal2, Auth::RegisterProvider)); } diff --git a/src/providers.rs b/src/providers.rs index 33e59735..3701277f 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -124,7 +124,7 @@ pub fn do_unregister_provider(caller: Principal, provider_id: u64) -> bool { PROVIDERS.with(|p| { let mut p = p.borrow_mut(); if let Some(provider) = p.get(&provider_id) { - if provider.owner == caller || is_authorized(&caller, Auth::ManageService) { + if provider.owner == caller || is_authorized(&caller, Auth::ManageCanister) { p.remove(&provider_id).is_some() } else { ic_cdk::trap("Not authorized"); @@ -140,7 +140,7 @@ pub fn do_update_provider(caller: Principal, update: UpdateProviderArgs) { let mut p = p.borrow_mut(); match p.get(&update.provider_id) { Some(mut provider) => { - if provider.owner != caller && !is_authorized(&caller, Auth::ManageService) { + if provider.owner != caller && !is_authorized(&caller, Auth::ManageCanister) { ic_cdk::trap("Provider owner != caller"); } if let Some(hostname) = update.hostname { diff --git a/src/types.rs b/src/types.rs index cefbcb41..b5cb5cac 100644 --- a/src/types.rs +++ b/src/types.rs @@ -147,9 +147,9 @@ pub struct Metrics { #[derive(Clone, Copy, Debug, PartialEq, CandidType, Serialize, Deserialize)] pub enum Auth { - ManageService, + ManageCanister, RegisterProvider, - Rpc, + PriorityRpc, FreeRpc, } diff --git a/tests/tests.rs b/tests/tests.rs index 7ebd7fe1..c061a79e 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -172,8 +172,19 @@ impl EvmRpcSetup { self.call_query("getProviders", Encode!().unwrap()) } - pub fn register_provider(&self, args: RegisterProviderArgs) -> CallFlow { + pub fn register_provider(&self, args: RegisterProviderArgs) -> u64 { self.call_update("registerProvider", Encode!(&args).unwrap()) + .wait() + } + + pub fn unregister_provider(&self, provider_id: u64) -> bool { + self.call_update("unregisterProvider", Encode!(&provider_id).unwrap()) + .wait() + } + + pub fn update_provider(&self, args: UpdateProviderArgs) { + self.call_update("updateProvider", Encode!(&args).unwrap()) + .wait() } pub fn authorize_caller(self, auth: Auth) -> Self { @@ -267,7 +278,7 @@ impl EvmRpcSetup { pub fn eth_send_raw_transaction( &self, source: RpcSource, - signed_raw_transaction_hex: String, + signed_raw_transaction_hex: &str, ) -> CallFlow> { self.call_update( "eth_sendRawTransaction", @@ -441,29 +452,25 @@ fn should_register_provider() { .collect::>() ); let n_providers = 2; - let a_id = setup - .register_provider(RegisterProviderArgs { - chain_id: 1, - hostname: ANKR_HOSTNAME.to_string(), - credential_path: "".to_string(), - credential_headers: None, - cycles_per_call: 0, - cycles_per_message_byte: 0, - }) - .wait(); - let b_id = setup - .register_provider(RegisterProviderArgs { - chain_id: 5, - hostname: CLOUDFLARE_HOSTNAME.to_string(), - credential_path: "/test-path".to_string(), - credential_headers: Some(vec![HttpHeader { - name: "Test-Authorization".to_string(), - value: "---".to_string(), - }]), - cycles_per_call: 0, - cycles_per_message_byte: 0, - }) - .wait(); + let a_id = setup.register_provider(RegisterProviderArgs { + chain_id: 1, + hostname: ANKR_HOSTNAME.to_string(), + credential_path: "".to_string(), + credential_headers: None, + cycles_per_call: 0, + cycles_per_message_byte: 0, + }); + let b_id = setup.register_provider(RegisterProviderArgs { + chain_id: 5, + hostname: CLOUDFLARE_HOSTNAME.to_string(), + credential_path: "/test-path".to_string(), + credential_headers: Some(vec![HttpHeader { + name: "Test-Authorization".to_string(), + value: "---".to_string(), + }]), + cycles_per_call: 0, + cycles_per_message_byte: 0, + }); assert_eq!(a_id + 1, b_id); let providers = setup.get_providers(); assert_eq!(providers.len(), get_default_providers().len() + n_providers); @@ -493,6 +500,44 @@ fn should_register_provider() { ) } +#[test] +#[should_panic(expected = "You are not authorized")] +fn should_panic_if_unauthorized_register_provider() { + let setup = EvmRpcSetup::new(); + setup.register_provider(RegisterProviderArgs { + chain_id: 1, + hostname: ANKR_HOSTNAME.to_string(), + credential_path: "".to_string(), + credential_headers: None, + cycles_per_call: 0, + cycles_per_message_byte: 0, + }); +} + +#[test] +#[should_panic(expected = "Provider owner != caller")] +fn should_panic_if_unauthorized_update_provider() { + // Providers can only be updated by the original owner + let setup = EvmRpcSetup::new().authorize_caller(Auth::RegisterProvider); + setup.update_provider(UpdateProviderArgs { + provider_id: 0, + hostname: Some("unauthorized.host".to_string()), + credential_path: None, + credential_headers: None, + cycles_per_call: None, + cycles_per_message_byte: None, + primary: None, + }); +} + +#[test] +#[should_panic(expected = "Not authorized")] +fn should_panic_if_unauthorized_unregister_provider() { + // Only the `ManageCanister` authorization may unregister a provider + let setup = EvmRpcSetup::new().authorize_caller(Auth::RegisterProvider); + setup.unregister_provider(0); +} + fn mock_request(builder_fn: impl Fn(MockOutcallBuilder) -> MockOutcallBuilder) { let setup = EvmRpcSetup::new().authorize_caller(Auth::FreeRpc); @@ -845,7 +890,7 @@ fn eth_send_raw_transaction_should_succeed() { let response = setup .eth_send_raw_transaction( RpcSource::EthMainnet(None), - "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83".to_string(), + "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83", ) .mock_http(MockOutcallBuilder::new( 200, @@ -1030,7 +1075,7 @@ fn candid_rpc_should_represent_inconsistent_results() { let results = setup .eth_send_raw_transaction( RpcSource::EthMainnet(Some(vec![EthMainnetService::Ankr, EthMainnetService::Cloudflare])), - "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83".to_string(), + "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83", ) .mock_http_once(MockOutcallBuilder::new( 200, @@ -1078,7 +1123,7 @@ fn candid_rpc_should_handle_already_known() { let result = setup .eth_send_raw_transaction( RpcSource::EthMainnet(Some(vec![EthMainnetService::Ankr, EthMainnetService::Cloudflare])), - "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83".to_string(), + "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83", ) .mock_http_once(MockOutcallBuilder::new( 200, @@ -1107,3 +1152,61 @@ fn candid_rpc_should_handle_already_known() { } ); } + +#[test] +#[should_panic(expected = "You are not authorized")] +fn should_panic_if_unauthorized_set_rpc_access() { + // Only `ManageCanister` can restrict RPC access + let setup = EvmRpcSetup::new(); + setup.set_open_rpc_access(false); +} + +#[test] +fn should_restrict_rpc_access() { + let mut setup = EvmRpcSetup::new().authorize_caller(Auth::FreeRpc); + setup.clone().as_controller().set_open_rpc_access(false); + let result = setup + .eth_get_transaction_count( + RpcSource::EthMainnet(Some(vec![EthMainnetService::Ankr])), + candid_types::GetTransactionCountArgs { + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(), + block: candid_types::BlockTag::Latest, + }, + ) + .wait() + .expect_consistent(); + assert_eq!( + result, + Err(RpcError::ProviderError(ProviderError::NoPermission)) + ); + setup = setup.authorize_caller(Auth::PriorityRpc); + let result = setup + .eth_get_transaction_count( + RpcSource::EthMainnet(Some(vec![EthMainnetService::Ankr])), + candid_types::GetTransactionCountArgs { + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(), + block: candid_types::BlockTag::Latest, + }, + ) + .mock_http(MockOutcallBuilder::new( + 200, + r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#, + )) + .wait() + .expect_consistent(); + assert_eq!(result, Ok(1.into())); + let rpc_method = || RpcMethod("eth_getTransactionCount".to_string()); + assert_eq!( + setup.get_metrics(), + Metrics { + requests: hashmap! { + (rpc_method(), RpcHost(ANKR_HOSTNAME.to_string())) => 1, + }, + responses: hashmap! { + (rpc_method(), RpcHost(ANKR_HOSTNAME.to_string())) => 1, + }, + err_no_permission: 1, + ..Default::default() + } + ); +}