From a0e8759edefcff59c61c62a7d0b7fbf862fe4488 Mon Sep 17 00:00:00 2001 From: Kepler Vital Date: Wed, 20 Nov 2024 11:49:26 +0000 Subject: [PATCH] feat(station): requestor can cancel pending requests --- apps/wallet/src/generated/station/station.did | 24 + .../src/generated/station/station.did.d.ts | 7 + .../src/generated/station/station.did.js | 471 +++++++++--------- core/station/api/spec.did | 24 + core/station/api/src/request.rs | 11 + core/station/impl/src/controllers/request.rs | 28 +- core/station/impl/src/errors/request.rs | 7 + .../station/impl/src/mappers/authorization.rs | 10 + core/station/impl/src/models/request.rs | 94 ++++ core/station/impl/src/repositories/request.rs | 23 +- core/station/impl/src/services/request.rs | 138 ++++- 11 files changed, 601 insertions(+), 236 deletions(-) diff --git a/apps/wallet/src/generated/station/station.did b/apps/wallet/src/generated/station/station.did index 296840b1f..a2fbdbd4f 100644 --- a/apps/wallet/src/generated/station/station.did +++ b/apps/wallet/src/generated/station/station.did @@ -1106,6 +1106,23 @@ type CreateRequestResult = variant { Err : Error; }; +// The input type for canceling a request. +type CancelRequestInput = record { + // The request id to cancel. + request_id : UUID; + // The reason for canceling the request. + reason : opt text; +}; + +// The result type for canceling a request. +type CancelRequestResult = variant { + Ok : record { + // The request that was canceled. + request : Request; + }; + Err : Error; +}; + type ListRequestsOperationType = variant { // A new transfer of funds from a given account. Transfer : opt UUID; @@ -2708,6 +2725,13 @@ service : (opt SystemInstall) -> { // // The request will be created and the caller will be added as the requester. create_request : (input : CreateRequestInput) -> (CreateRequestResult); + // Cancel a request if the request is in a cancelable state. + // + // Cancelable conditions: + // + // - The request is in the `Created` state. + // - The caller is the requester of the request. + cancel_request : (input : CancelRequestInput) -> (CancelRequestResult); // Get the list of requests. // // Only requests that the caller has access to will be returned. diff --git a/apps/wallet/src/generated/station/station.did.d.ts b/apps/wallet/src/generated/station/station.did.d.ts index 6d44176c5..4655876e1 100644 --- a/apps/wallet/src/generated/station/station.did.d.ts +++ b/apps/wallet/src/generated/station/station.did.d.ts @@ -144,6 +144,12 @@ export interface CallExternalCanisterResourceTarget { 'execution_method' : ExecutionMethodResourceTarget, 'validation_method' : ValidationMethodResourceTarget, } +export interface CancelRequestInput { + 'request_id' : UUID, + 'reason' : [] | [string], +} +export type CancelRequestResult = { 'Ok' : { 'request' : Request } } | + { 'Err' : Error }; export interface CanisterExecutionAndValidationMethodPair { 'execution_method' : string, 'validation_method' : ValidationMethodResourceTarget, @@ -1259,6 +1265,7 @@ export interface WasmModuleExtraChunks { 'extra_chunks_key' : string, } export interface _SERVICE { + 'cancel_request' : ActorMethod<[CancelRequestInput], CancelRequestResult>, 'canister_status' : ActorMethod<[CanisterStatusInput], CanisterStatusResult>, 'capabilities' : ActorMethod<[], CapabilitiesResult>, 'create_request' : ActorMethod<[CreateRequestInput], CreateRequestResult>, diff --git a/apps/wallet/src/generated/station/station.did.js b/apps/wallet/src/generated/station/station.did.js index 2aad89553..11a797576 100644 --- a/apps/wallet/src/generated/station/station.did.js +++ b/apps/wallet/src/generated/station/station.did.js @@ -31,72 +31,31 @@ export const idlFactory = ({ IDL }) => { 'Upgrade' : SystemUpgrade, 'Init' : SystemInit, }); - const CanisterStatusInput = IDL.Record({ 'canister_id' : IDL.Principal }); - const LogVisibility = IDL.Variant({ - 'controllers' : IDL.Null, - 'public' : IDL.Null, - }); - const DefiniteCanisterSettings = IDL.Record({ - 'freezing_threshold' : IDL.Nat, - 'controllers' : IDL.Vec(IDL.Principal), - 'reserved_cycles_limit' : IDL.Nat, - 'log_visibility' : LogVisibility, - 'wasm_memory_limit' : IDL.Nat, - 'memory_allocation' : IDL.Nat, - 'compute_allocation' : IDL.Nat, - }); - const CanisterStatusResponse = IDL.Record({ - 'status' : IDL.Variant({ - 'stopped' : IDL.Null, - 'stopping' : IDL.Null, - 'running' : IDL.Null, - }), - 'memory_size' : IDL.Nat, - 'cycles' : IDL.Nat, - 'settings' : DefiniteCanisterSettings, - 'query_stats' : IDL.Record({ - 'response_payload_bytes_total' : IDL.Nat, - 'num_instructions_total' : IDL.Nat, - 'num_calls_total' : IDL.Nat, - 'request_payload_bytes_total' : IDL.Nat, - }), - 'idle_cycles_burned_per_day' : IDL.Nat, - 'module_hash' : IDL.Opt(IDL.Vec(IDL.Nat8)), - 'reserved_cycles' : IDL.Nat, - }); - const Error = IDL.Record({ - 'code' : IDL.Text, - 'message' : IDL.Opt(IDL.Text), - 'details' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))), - }); - const CanisterStatusResult = IDL.Variant({ - 'Ok' : CanisterStatusResponse, - 'Err' : Error, - }); - const AssetMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); - const AssetSymbol = IDL.Text; - const Asset = IDL.Record({ - 'metadata' : IDL.Vec(AssetMetadata), - 'name' : IDL.Text, - 'blockchain' : IDL.Text, - 'standard' : IDL.Text, - 'symbol' : AssetSymbol, - }); - const Capabilities = IDL.Record({ - 'name' : IDL.Text, - 'version' : IDL.Text, - 'supported_assets' : IDL.Vec(Asset), - }); - const CapabilitiesResult = IDL.Variant({ - 'Ok' : IDL.Record({ 'capabilities' : Capabilities }), - 'Err' : Error, + const CancelRequestInput = IDL.Record({ + 'request_id' : UUID, + 'reason' : IDL.Opt(IDL.Text), }); const TimestampRFC3339 = IDL.Text; + const RequestStatus = IDL.Variant({ + 'Failed' : IDL.Record({ 'reason' : IDL.Opt(IDL.Text) }), + 'Approved' : IDL.Null, + 'Rejected' : IDL.Null, + 'Scheduled' : IDL.Record({ 'scheduled_at' : TimestampRFC3339 }), + 'Cancelled' : IDL.Record({ 'reason' : IDL.Opt(IDL.Text) }), + 'Processing' : IDL.Record({ 'started_at' : TimestampRFC3339 }), + 'Created' : IDL.Null, + 'Completed' : IDL.Record({ 'completed_at' : TimestampRFC3339 }), + }); const RequestExecutionSchedule = IDL.Variant({ 'Immediate' : IDL.Null, 'Scheduled' : IDL.Record({ 'execution_time' : TimestampRFC3339 }), }); + const UserGroup = IDL.Record({ 'id' : UUID, 'name' : IDL.Text }); const AddUserGroupOperationInput = IDL.Record({ 'name' : IDL.Text }); + const AddUserGroupOperation = IDL.Record({ + 'user_group' : IDL.Opt(UserGroup), + 'input' : AddUserGroupOperationInput, + }); const ResourceId = IDL.Variant({ 'Id' : UUID, 'Any' : IDL.Null }); const RequestResourceAction = IDL.Variant({ 'List' : IDL.Null, @@ -187,6 +146,9 @@ export const idlFactory = ({ IDL }) => { 'auth_scope' : IDL.Opt(AuthScope), 'users' : IDL.Opt(IDL.Vec(UUID)), }); + const EditPermissionOperation = IDL.Record({ + 'input' : EditPermissionOperationInput, + }); const Allow = IDL.Record({ 'user_groups' : IDL.Vec(UUID), 'auth_scope' : AuthScope, @@ -313,6 +275,10 @@ export const idlFactory = ({ IDL }) => { 'state' : IDL.Opt(ExternalCanisterState), 'change_metadata' : IDL.Opt(ChangeExternalCanisterMetadata), }); + const LogVisibility = IDL.Variant({ + 'controllers' : IDL.Null, + 'public' : IDL.Null, + }); const DefiniteCanisterSettingsInput = IDL.Record({ 'freezing_threshold' : IDL.Opt(IDL.Nat), 'controllers' : IDL.Opt(IDL.Vec(IDL.Principal)), @@ -332,42 +298,53 @@ export const idlFactory = ({ IDL }) => { 'kind' : ConfigureExternalCanisterOperationKind, 'canister_id' : IDL.Principal, }); - const WasmModuleExtraChunks = IDL.Record({ - 'wasm_module_hash' : IDL.Vec(IDL.Nat8), - 'store_canister' : IDL.Principal, - 'extra_chunks_key' : IDL.Text, - }); + const ConfigureExternalCanisterOperation = ConfigureExternalCanisterOperationInput; const CanisterInstallMode = IDL.Variant({ 'reinstall' : IDL.Null, 'upgrade' : IDL.Null, 'install' : IDL.Null, }); - const ChangeExternalCanisterOperationInput = IDL.Record({ - 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), - 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + const Sha256Hash = IDL.Text; + const ChangeExternalCanisterOperation = IDL.Record({ 'mode' : CanisterInstallMode, 'canister_id' : IDL.Principal, - 'module' : IDL.Vec(IDL.Nat8), + 'module_checksum' : Sha256Hash, + 'arg_checksum' : IDL.Opt(Sha256Hash), }); const UserStatus = IDL.Variant({ 'Inactive' : IDL.Null, 'Active' : IDL.Null, }); + const User = IDL.Record({ + 'id' : UUID, + 'status' : UserStatus, + 'groups' : IDL.Vec(UserGroup), + 'name' : IDL.Text, + 'last_modification_timestamp' : TimestampRFC3339, + 'identities' : IDL.Vec(IDL.Principal), + }); const AddUserOperationInput = IDL.Record({ 'status' : UserStatus, 'groups' : IDL.Vec(UUID), 'name' : IDL.Text, 'identities' : IDL.Vec(IDL.Principal), }); + const AddUserOperation = IDL.Record({ + 'user' : IDL.Opt(User), + 'input' : AddUserOperationInput, + }); const EditUserGroupOperationInput = IDL.Record({ 'name' : IDL.Text, 'user_group_id' : UUID, }); + const EditUserGroupOperation = IDL.Record({ + 'input' : EditUserGroupOperationInput, + }); const DisasterRecoveryCommittee = IDL.Record({ 'user_group_id' : UUID, 'quorum' : IDL.Nat16, }); - const SetDisasterRecoveryOperationInput = IDL.Record({ + const SetDisasterRecoveryOperation = IDL.Record({ 'committee' : IDL.Opt(DisasterRecoveryCommittee), }); const ResourceSpecifier = IDL.Variant({ @@ -404,20 +381,28 @@ export const idlFactory = ({ IDL }) => { 'specifier' : IDL.Opt(RequestSpecifier), 'policy_id' : UUID, }); + const EditRequestPolicyOperation = IDL.Record({ + 'input' : EditRequestPolicyOperationInput, + }); const RemoveRequestPolicyOperationInput = IDL.Record({ 'policy_id' : UUID }); + const RemoveRequestPolicyOperation = IDL.Record({ + 'input' : RemoveRequestPolicyOperationInput, + }); const SystemUpgradeTarget = IDL.Variant({ 'UpgradeUpgrader' : IDL.Null, 'UpgradeStation' : IDL.Null, }); - const SystemUpgradeOperationInput = IDL.Record({ - 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), - 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + const SystemUpgradeOperation = IDL.Record({ + 'module_checksum' : Sha256Hash, 'target' : SystemUpgradeTarget, - 'module' : IDL.Vec(IDL.Nat8), + 'arg_checksum' : IDL.Opt(Sha256Hash), }); const RemoveAddressBookEntryOperationInput = IDL.Record({ 'address_book_entry_id' : UUID, }); + const RemoveAddressBookEntryOperation = IDL.Record({ + 'input' : RemoveAddressBookEntryOperationInput, + }); const ExternalCanisterPermissions = IDL.Record({ 'calls' : IDL.Vec(ExternalCanisterCallPermission), 'read' : Allow, @@ -453,6 +438,10 @@ export const idlFactory = ({ IDL }) => { 'description' : IDL.Opt(IDL.Text), 'request_policies' : ExternalCanisterRequestPoliciesCreateInput, }); + const CreateExternalCanisterOperation = IDL.Record({ + 'canister_id' : IDL.Opt(IDL.Principal), + 'input' : CreateExternalCanisterOperationInput, + }); const ChangeAddressBookMetadata = IDL.Variant({ 'OverrideSpecifiedBy' : IDL.Vec(AddressBookMetadata), 'RemoveKeys' : IDL.Vec(IDL.Text), @@ -464,6 +453,9 @@ export const idlFactory = ({ IDL }) => { 'address_book_entry_id' : UUID, 'address_owner' : IDL.Opt(IDL.Text), }); + const EditAddressBookEntryOperation = IDL.Record({ + 'input' : EditAddressBookEntryOperationInput, + }); const FundExternalCanisterSendCyclesInput = IDL.Record({ 'cycles' : IDL.Nat64, }); @@ -474,6 +466,7 @@ export const idlFactory = ({ IDL }) => { 'kind' : FundExternalCanisterOperationKind, 'canister_id' : IDL.Principal, }); + const FundExternalCanisterOperation = FundExternalCanisterOperationInput; const EditUserOperationInput = IDL.Record({ 'id' : UUID, 'status' : IDL.Opt(UserStatus), @@ -482,6 +475,7 @@ export const idlFactory = ({ IDL }) => { 'name' : IDL.Opt(IDL.Text), 'identities' : IDL.Opt(IDL.Vec(IDL.Principal)), }); + const EditUserOperation = IDL.Record({ 'input' : EditUserOperationInput }); const CycleObtainStrategyInput = IDL.Variant({ 'Disabled' : IDL.Null, 'MintFromNativeToken' : IDL.Record({ 'account_id' : UUID }), @@ -490,170 +484,17 @@ export const idlFactory = ({ IDL }) => { 'name' : IDL.Opt(IDL.Text), 'cycle_obtain_strategy' : IDL.Opt(CycleObtainStrategyInput), }); - const TransferMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); - const NetworkId = IDL.Text; - const Network = IDL.Record({ 'id' : NetworkId, 'name' : IDL.Text }); - const TransferOperationInput = IDL.Record({ - 'to' : IDL.Text, - 'fee' : IDL.Opt(IDL.Nat), - 'from_account_id' : UUID, - 'metadata' : IDL.Vec(TransferMetadata), - 'network' : IDL.Opt(Network), - 'amount' : IDL.Nat, - }); - const RequestPolicyRuleInput = IDL.Variant({ - 'Set' : RequestPolicyRule, - 'Remove' : IDL.Null, - }); - const EditAccountOperationInput = IDL.Record({ - 'account_id' : UUID, - 'configs_request_policy' : IDL.Opt(RequestPolicyRuleInput), - 'read_permission' : IDL.Opt(Allow), - 'configs_permission' : IDL.Opt(Allow), - 'name' : IDL.Opt(IDL.Text), - 'transfer_request_policy' : IDL.Opt(RequestPolicyRuleInput), - 'transfer_permission' : IDL.Opt(Allow), - }); - const AddAddressBookEntryOperationInput = IDL.Record({ - 'metadata' : IDL.Vec(AddressBookMetadata), - 'labels' : IDL.Vec(IDL.Text), - 'blockchain' : IDL.Text, - 'address' : IDL.Text, - 'address_owner' : IDL.Text, - }); - const AddRequestPolicyOperationInput = IDL.Record({ - 'rule' : RequestPolicyRule, - 'specifier' : RequestSpecifier, - }); - const RemoveUserGroupOperationInput = IDL.Record({ 'user_group_id' : UUID }); - const CallExternalCanisterOperationInput = IDL.Record({ - 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), - 'execution_method' : CanisterMethod, - 'validation_method' : IDL.Opt(CanisterMethod), - 'execution_method_cycles' : IDL.Opt(IDL.Nat64), - }); - const AddAccountOperationInput = IDL.Record({ - 'configs_request_policy' : IDL.Opt(RequestPolicyRule), - 'read_permission' : Allow, - 'configs_permission' : Allow, - 'metadata' : IDL.Vec(AccountMetadata), - 'name' : IDL.Text, - 'blockchain' : IDL.Text, - 'transfer_request_policy' : IDL.Opt(RequestPolicyRule), - 'transfer_permission' : Allow, - 'standard' : IDL.Text, - }); - const RequestOperationInput = IDL.Variant({ - 'AddUserGroup' : AddUserGroupOperationInput, - 'EditPermission' : EditPermissionOperationInput, - 'ConfigureExternalCanister' : ConfigureExternalCanisterOperationInput, - 'ChangeExternalCanister' : ChangeExternalCanisterOperationInput, - 'AddUser' : AddUserOperationInput, - 'EditUserGroup' : EditUserGroupOperationInput, - 'SetDisasterRecovery' : SetDisasterRecoveryOperationInput, - 'EditRequestPolicy' : EditRequestPolicyOperationInput, - 'RemoveRequestPolicy' : RemoveRequestPolicyOperationInput, - 'SystemUpgrade' : SystemUpgradeOperationInput, - 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperationInput, - 'CreateExternalCanister' : CreateExternalCanisterOperationInput, - 'EditAddressBookEntry' : EditAddressBookEntryOperationInput, - 'FundExternalCanister' : FundExternalCanisterOperationInput, - 'EditUser' : EditUserOperationInput, - 'ManageSystemInfo' : ManageSystemInfoOperationInput, - 'Transfer' : TransferOperationInput, - 'EditAccount' : EditAccountOperationInput, - 'AddAddressBookEntry' : AddAddressBookEntryOperationInput, - 'AddRequestPolicy' : AddRequestPolicyOperationInput, - 'RemoveUserGroup' : RemoveUserGroupOperationInput, - 'CallExternalCanister' : CallExternalCanisterOperationInput, - 'AddAccount' : AddAccountOperationInput, - }); - const CreateRequestInput = IDL.Record({ - 'title' : IDL.Opt(IDL.Text), - 'execution_plan' : IDL.Opt(RequestExecutionSchedule), - 'expiration_dt' : IDL.Opt(TimestampRFC3339), - 'summary' : IDL.Opt(IDL.Text), - 'operation' : RequestOperationInput, - }); - const RequestCallerPrivileges = IDL.Record({ - 'id' : UUID, - 'can_approve' : IDL.Bool, - }); - const RequestStatus = IDL.Variant({ - 'Failed' : IDL.Record({ 'reason' : IDL.Opt(IDL.Text) }), - 'Approved' : IDL.Null, - 'Rejected' : IDL.Null, - 'Scheduled' : IDL.Record({ 'scheduled_at' : TimestampRFC3339 }), - 'Cancelled' : IDL.Record({ 'reason' : IDL.Opt(IDL.Text) }), - 'Processing' : IDL.Record({ 'started_at' : TimestampRFC3339 }), - 'Created' : IDL.Null, - 'Completed' : IDL.Record({ 'completed_at' : TimestampRFC3339 }), - }); - const UserGroup = IDL.Record({ 'id' : UUID, 'name' : IDL.Text }); - const AddUserGroupOperation = IDL.Record({ - 'user_group' : IDL.Opt(UserGroup), - 'input' : AddUserGroupOperationInput, - }); - const EditPermissionOperation = IDL.Record({ - 'input' : EditPermissionOperationInput, - }); - const ConfigureExternalCanisterOperation = ConfigureExternalCanisterOperationInput; - const Sha256Hash = IDL.Text; - const ChangeExternalCanisterOperation = IDL.Record({ - 'mode' : CanisterInstallMode, - 'canister_id' : IDL.Principal, - 'module_checksum' : Sha256Hash, - 'arg_checksum' : IDL.Opt(Sha256Hash), - }); - const User = IDL.Record({ - 'id' : UUID, - 'status' : UserStatus, - 'groups' : IDL.Vec(UserGroup), - 'name' : IDL.Text, - 'last_modification_timestamp' : TimestampRFC3339, - 'identities' : IDL.Vec(IDL.Principal), - }); - const AddUserOperation = IDL.Record({ - 'user' : IDL.Opt(User), - 'input' : AddUserOperationInput, - }); - const EditUserGroupOperation = IDL.Record({ - 'input' : EditUserGroupOperationInput, - }); - const SetDisasterRecoveryOperation = IDL.Record({ - 'committee' : IDL.Opt(DisasterRecoveryCommittee), - }); - const EditRequestPolicyOperation = IDL.Record({ - 'input' : EditRequestPolicyOperationInput, - }); - const RemoveRequestPolicyOperation = IDL.Record({ - 'input' : RemoveRequestPolicyOperationInput, - }); - const SystemUpgradeOperation = IDL.Record({ - 'module_checksum' : Sha256Hash, - 'target' : SystemUpgradeTarget, - 'arg_checksum' : IDL.Opt(Sha256Hash), - }); - const RemoveAddressBookEntryOperation = IDL.Record({ - 'input' : RemoveAddressBookEntryOperationInput, - }); - const CreateExternalCanisterOperation = IDL.Record({ - 'canister_id' : IDL.Opt(IDL.Principal), - 'input' : CreateExternalCanisterOperationInput, - }); - const EditAddressBookEntryOperation = IDL.Record({ - 'input' : EditAddressBookEntryOperationInput, - }); - const FundExternalCanisterOperation = FundExternalCanisterOperationInput; - const EditUserOperation = IDL.Record({ 'input' : EditUserOperationInput }); const ManageSystemInfoOperation = IDL.Record({ 'input' : ManageSystemInfoOperationInput, }); + const NetworkId = IDL.Text; + const Network = IDL.Record({ 'id' : NetworkId, 'name' : IDL.Text }); const AccountBalanceInfo = IDL.Record({ 'decimals' : IDL.Nat32, 'balance' : IDL.Nat, 'last_update_timestamp' : TimestampRFC3339, }); + const AssetSymbol = IDL.Text; const Account = IDL.Record({ 'id' : UUID, 'configs_request_policy' : IDL.Opt(RequestPolicyRule), @@ -668,6 +509,15 @@ export const idlFactory = ({ IDL }) => { 'standard' : IDL.Text, 'symbol' : AssetSymbol, }); + const TransferMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const TransferOperationInput = IDL.Record({ + 'to' : IDL.Text, + 'fee' : IDL.Opt(IDL.Nat), + 'from_account_id' : UUID, + 'metadata' : IDL.Vec(TransferMetadata), + 'network' : IDL.Opt(Network), + 'amount' : IDL.Nat, + }); const TransferOperation = IDL.Record({ 'fee' : IDL.Opt(IDL.Nat), 'network' : Network, @@ -675,6 +525,19 @@ export const idlFactory = ({ IDL }) => { 'from_account' : IDL.Opt(Account), 'input' : TransferOperationInput, }); + const RequestPolicyRuleInput = IDL.Variant({ + 'Set' : RequestPolicyRule, + 'Remove' : IDL.Null, + }); + const EditAccountOperationInput = IDL.Record({ + 'account_id' : UUID, + 'configs_request_policy' : IDL.Opt(RequestPolicyRuleInput), + 'read_permission' : IDL.Opt(Allow), + 'configs_permission' : IDL.Opt(Allow), + 'name' : IDL.Opt(IDL.Text), + 'transfer_request_policy' : IDL.Opt(RequestPolicyRuleInput), + 'transfer_permission' : IDL.Opt(Allow), + }); const EditAccountOperation = IDL.Record({ 'input' : EditAccountOperationInput, }); @@ -687,14 +550,26 @@ export const idlFactory = ({ IDL }) => { 'last_modification_timestamp' : IDL.Text, 'address_owner' : IDL.Text, }); + const AddAddressBookEntryOperationInput = IDL.Record({ + 'metadata' : IDL.Vec(AddressBookMetadata), + 'labels' : IDL.Vec(IDL.Text), + 'blockchain' : IDL.Text, + 'address' : IDL.Text, + 'address_owner' : IDL.Text, + }); const AddAddressBookEntryOperation = IDL.Record({ 'address_book_entry' : IDL.Opt(AddressBookEntry), 'input' : AddAddressBookEntryOperationInput, }); + const AddRequestPolicyOperationInput = IDL.Record({ + 'rule' : RequestPolicyRule, + 'specifier' : RequestSpecifier, + }); const AddRequestPolicyOperation = IDL.Record({ 'input' : AddRequestPolicyOperationInput, 'policy_id' : IDL.Opt(UUID), }); + const RemoveUserGroupOperationInput = IDL.Record({ 'user_group_id' : UUID }); const RemoveUserGroupOperation = IDL.Record({ 'input' : RemoveUserGroupOperationInput, }); @@ -707,6 +582,17 @@ export const idlFactory = ({ IDL }) => { 'arg_rendering' : IDL.Opt(IDL.Text), 'execution_method_reply' : IDL.Opt(IDL.Vec(IDL.Nat8)), }); + const AddAccountOperationInput = IDL.Record({ + 'configs_request_policy' : IDL.Opt(RequestPolicyRule), + 'read_permission' : Allow, + 'configs_permission' : Allow, + 'metadata' : IDL.Vec(AccountMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'transfer_request_policy' : IDL.Opt(RequestPolicyRule), + 'transfer_permission' : Allow, + 'standard' : IDL.Text, + }); const AddAccountOperation = IDL.Record({ 'account' : IDL.Opt(Account), 'input' : AddAccountOperationInput, @@ -758,6 +644,128 @@ export const idlFactory = ({ IDL }) => { 'operation' : RequestOperation, 'approvals' : IDL.Vec(RequestApproval), }); + const Error = IDL.Record({ + 'code' : IDL.Text, + 'message' : IDL.Opt(IDL.Text), + 'details' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))), + }); + const CancelRequestResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'request' : Request }), + 'Err' : Error, + }); + const CanisterStatusInput = IDL.Record({ 'canister_id' : IDL.Principal }); + const DefiniteCanisterSettings = IDL.Record({ + 'freezing_threshold' : IDL.Nat, + 'controllers' : IDL.Vec(IDL.Principal), + 'reserved_cycles_limit' : IDL.Nat, + 'log_visibility' : LogVisibility, + 'wasm_memory_limit' : IDL.Nat, + 'memory_allocation' : IDL.Nat, + 'compute_allocation' : IDL.Nat, + }); + const CanisterStatusResponse = IDL.Record({ + 'status' : IDL.Variant({ + 'stopped' : IDL.Null, + 'stopping' : IDL.Null, + 'running' : IDL.Null, + }), + 'memory_size' : IDL.Nat, + 'cycles' : IDL.Nat, + 'settings' : DefiniteCanisterSettings, + 'query_stats' : IDL.Record({ + 'response_payload_bytes_total' : IDL.Nat, + 'num_instructions_total' : IDL.Nat, + 'num_calls_total' : IDL.Nat, + 'request_payload_bytes_total' : IDL.Nat, + }), + 'idle_cycles_burned_per_day' : IDL.Nat, + 'module_hash' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'reserved_cycles' : IDL.Nat, + }); + const CanisterStatusResult = IDL.Variant({ + 'Ok' : CanisterStatusResponse, + 'Err' : Error, + }); + const AssetMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const Asset = IDL.Record({ + 'metadata' : IDL.Vec(AssetMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'standard' : IDL.Text, + 'symbol' : AssetSymbol, + }); + const Capabilities = IDL.Record({ + 'name' : IDL.Text, + 'version' : IDL.Text, + 'supported_assets' : IDL.Vec(Asset), + }); + const CapabilitiesResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'capabilities' : Capabilities }), + 'Err' : Error, + }); + const WasmModuleExtraChunks = IDL.Record({ + 'wasm_module_hash' : IDL.Vec(IDL.Nat8), + 'store_canister' : IDL.Principal, + 'extra_chunks_key' : IDL.Text, + }); + const ChangeExternalCanisterOperationInput = IDL.Record({ + 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + 'mode' : CanisterInstallMode, + 'canister_id' : IDL.Principal, + 'module' : IDL.Vec(IDL.Nat8), + }); + const SetDisasterRecoveryOperationInput = IDL.Record({ + 'committee' : IDL.Opt(DisasterRecoveryCommittee), + }); + const SystemUpgradeOperationInput = IDL.Record({ + 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + 'target' : SystemUpgradeTarget, + 'module' : IDL.Vec(IDL.Nat8), + }); + const CallExternalCanisterOperationInput = IDL.Record({ + 'arg' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'execution_method' : CanisterMethod, + 'validation_method' : IDL.Opt(CanisterMethod), + 'execution_method_cycles' : IDL.Opt(IDL.Nat64), + }); + const RequestOperationInput = IDL.Variant({ + 'AddUserGroup' : AddUserGroupOperationInput, + 'EditPermission' : EditPermissionOperationInput, + 'ConfigureExternalCanister' : ConfigureExternalCanisterOperationInput, + 'ChangeExternalCanister' : ChangeExternalCanisterOperationInput, + 'AddUser' : AddUserOperationInput, + 'EditUserGroup' : EditUserGroupOperationInput, + 'SetDisasterRecovery' : SetDisasterRecoveryOperationInput, + 'EditRequestPolicy' : EditRequestPolicyOperationInput, + 'RemoveRequestPolicy' : RemoveRequestPolicyOperationInput, + 'SystemUpgrade' : SystemUpgradeOperationInput, + 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperationInput, + 'CreateExternalCanister' : CreateExternalCanisterOperationInput, + 'EditAddressBookEntry' : EditAddressBookEntryOperationInput, + 'FundExternalCanister' : FundExternalCanisterOperationInput, + 'EditUser' : EditUserOperationInput, + 'ManageSystemInfo' : ManageSystemInfoOperationInput, + 'Transfer' : TransferOperationInput, + 'EditAccount' : EditAccountOperationInput, + 'AddAddressBookEntry' : AddAddressBookEntryOperationInput, + 'AddRequestPolicy' : AddRequestPolicyOperationInput, + 'RemoveUserGroup' : RemoveUserGroupOperationInput, + 'CallExternalCanister' : CallExternalCanisterOperationInput, + 'AddAccount' : AddAccountOperationInput, + }); + const CreateRequestInput = IDL.Record({ + 'title' : IDL.Opt(IDL.Text), + 'execution_plan' : IDL.Opt(RequestExecutionSchedule), + 'expiration_dt' : IDL.Opt(TimestampRFC3339), + 'summary' : IDL.Opt(IDL.Text), + 'operation' : RequestOperationInput, + }); + const RequestCallerPrivileges = IDL.Record({ + 'id' : UUID, + 'can_approve' : IDL.Bool, + }); const EvaluationStatus = IDL.Variant({ 'Approved' : IDL.Null, 'Rejected' : IDL.Null, @@ -1381,6 +1389,11 @@ export const idlFactory = ({ IDL }) => { 'Err' : Error, }); return IDL.Service({ + 'cancel_request' : IDL.Func( + [CancelRequestInput], + [CancelRequestResult], + [], + ), 'canister_status' : IDL.Func( [CanisterStatusInput], [CanisterStatusResult], diff --git a/core/station/api/spec.did b/core/station/api/spec.did index 296840b1f..a2fbdbd4f 100644 --- a/core/station/api/spec.did +++ b/core/station/api/spec.did @@ -1106,6 +1106,23 @@ type CreateRequestResult = variant { Err : Error; }; +// The input type for canceling a request. +type CancelRequestInput = record { + // The request id to cancel. + request_id : UUID; + // The reason for canceling the request. + reason : opt text; +}; + +// The result type for canceling a request. +type CancelRequestResult = variant { + Ok : record { + // The request that was canceled. + request : Request; + }; + Err : Error; +}; + type ListRequestsOperationType = variant { // A new transfer of funds from a given account. Transfer : opt UUID; @@ -2708,6 +2725,13 @@ service : (opt SystemInstall) -> { // // The request will be created and the caller will be added as the requester. create_request : (input : CreateRequestInput) -> (CreateRequestResult); + // Cancel a request if the request is in a cancelable state. + // + // Cancelable conditions: + // + // - The request is in the `Created` state. + // - The caller is the requester of the request. + cancel_request : (input : CancelRequestInput) -> (CancelRequestResult); // Get the list of requests. // // Only requests that the caller has access to will be returned. diff --git a/core/station/api/src/request.rs b/core/station/api/src/request.rs index 019eb591b..cfe1a8e97 100644 --- a/core/station/api/src/request.rs +++ b/core/station/api/src/request.rs @@ -211,6 +211,17 @@ pub struct CreateRequestInput { pub expiration_dt: Option, } +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct CancelRequestInput { + pub request_id: UuidDTO, + pub reason: Option, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct CancelRequestResponse { + pub request: RequestDTO, +} + #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct SubmitRequestApprovalInput { pub decision: RequestApprovalStatusDTO, diff --git a/core/station/impl/src/controllers/request.rs b/core/station/impl/src/controllers/request.rs index e10b76b62..6934a27cd 100644 --- a/core/station/impl/src/controllers/request.rs +++ b/core/station/impl/src/controllers/request.rs @@ -16,10 +16,10 @@ use orbit_essentials::api::ApiResult; use orbit_essentials::types::UUID; use orbit_essentials::with_middleware; use station_api::{ - CreateRequestInput, CreateRequestResponse, GetNextApprovableRequestInput, - GetNextApprovableRequestResponse, GetRequestInput, GetRequestResponse, ListRequestsInput, - ListRequestsResponse, RequestAdditionalInfoDTO, RequestCallerPrivilegesDTO, - SubmitRequestApprovalInput, SubmitRequestApprovalResponse, + CancelRequestInput, CancelRequestResponse, CreateRequestInput, CreateRequestResponse, + GetNextApprovableRequestInput, GetNextApprovableRequestResponse, GetRequestInput, + GetRequestResponse, ListRequestsInput, ListRequestsResponse, RequestAdditionalInfoDTO, + RequestCallerPrivilegesDTO, SubmitRequestApprovalInput, SubmitRequestApprovalResponse, }; use std::cell::RefCell; use std::collections::HashMap; @@ -56,6 +56,11 @@ async fn create_request(input: CreateRequestInput) -> ApiResult ApiResult { + CONTROLLER.cancel_request(input).await +} + #[update(name = "try_execute_request", hidden = true)] async fn try_execute_request(id: UUID) -> Result<(), RequestExecuteError> { CONTROLLER.try_execute_request(id).await @@ -163,6 +168,21 @@ impl RequestController { }) } + #[with_middleware(guard = authorize(&call_context(), &[Resource::from(&input)]))] + #[with_middleware(tail = use_canister_call_metric("cancel_request", &result))] + async fn cancel_request(&self, input: CancelRequestInput) -> ApiResult { + let ctx = &call_context(); + let request = self.request_service.cancel_request( + HelperMapper::to_uuid(input.request_id)?.as_bytes(), + input.reason, + ctx, + )?; + + Ok(CancelRequestResponse { + request: request.to_dto(), + }) + } + #[with_middleware(guard = authorize(&call_context(), &[Resource::from(&input)]))] async fn get_request(&self, input: GetRequestInput) -> ApiResult { let ctx = &call_context(); diff --git a/core/station/impl/src/errors/request.rs b/core/station/impl/src/errors/request.rs index 67de6fd07..12eefbb8d 100644 --- a/core/station/impl/src/errors/request.rs +++ b/core/station/impl/src/errors/request.rs @@ -35,6 +35,9 @@ pub enum RequestError { /// Request policy not found for id `{id}`. #[error(r#"Request policy not found for id `{id}`"#)] PolicyNotFound { id: String }, + /// Request cancellation not allowed. + #[error(r#"Request cancellation not allowed."#)] + CancellationNotAllowed { reason: String }, } impl DetailableError for RequestError { @@ -69,6 +72,10 @@ impl DetailableError for RequestError { details.insert("id".to_string(), id.to_string()); Some(details) } + RequestError::CancellationNotAllowed { reason } => { + details.insert("reason".to_string(), reason.to_string()); + Some(details) + } _ => None, } } diff --git a/core/station/impl/src/mappers/authorization.rs b/core/station/impl/src/mappers/authorization.rs index c9cd6db90..fdd8c2fa8 100644 --- a/core/station/impl/src/mappers/authorization.rs +++ b/core/station/impl/src/mappers/authorization.rs @@ -170,6 +170,16 @@ impl From<&station_api::ListNotificationsInput> for Resource { } } +impl From<&station_api::CancelRequestInput> for Resource { + fn from(input: &station_api::CancelRequestInput) -> Self { + Resource::Request(RequestResourceAction::Read(ResourceId::Id( + *HelperMapper::to_uuid(input.request_id.to_owned()) + .expect("Invalid request id") + .as_bytes(), + ))) + } +} + impl From<&station_api::CreateRequestInput> for Resource { fn from(input: &station_api::CreateRequestInput) -> Self { match &input.operation { diff --git a/core/station/impl/src/models/request.rs b/core/station/impl/src/models/request.rs index 17853ddaf..a9aa28593 100644 --- a/core/station/impl/src/models/request.rs +++ b/core/station/impl/src/models/request.rs @@ -132,6 +132,39 @@ fn validate_expiration_dt(expiration_dt: &Timestamp) -> ModelValidatorResult ModelValidatorResult { + match status { + RequestStatus::Cancelled { + reason: Some(reason), + } => { + if reason.trim().is_empty() { + return Err(RequestError::ValidationError { + info: "The reason for the cancellation must not be empty".to_owned(), + }); + } + + if reason.len() > Request::MAX_CANCEL_REASON_LEN as usize { + return Err(RequestError::ValidationError { + info: format!( + "The reason for the cancellation exceeds the maximum allowed: {}", + Request::MAX_CANCEL_REASON_LEN + ), + }); + } + + Ok(()) + } + RequestStatus::Created + | RequestStatus::Rejected + | RequestStatus::Approved + | RequestStatus::Completed { .. } + | RequestStatus::Failed { .. } + | RequestStatus::Scheduled { .. } + | RequestStatus::Processing { .. } + | RequestStatus::Cancelled { reason: None } => Ok(()), + } +} + fn validate_execution_plan( execution_plan: &RequestExecutionPlan, ) -> ModelValidatorResult { @@ -296,6 +329,7 @@ impl ModelValidator for Request { validate_requested_by(&self.requested_by)?; validate_expiration_dt(&self.expiration_dt)?; validate_execution_plan(&self.execution_plan)?; + validate_status(&self.status)?; validate_request_operation_foreign_keys(&self.operation)?; Ok(()) @@ -304,6 +338,7 @@ impl ModelValidator for Request { impl Request { pub const MAX_TITLE_LEN: u8 = 255; + pub const MAX_CANCEL_REASON_LEN: u16 = 1000; pub const MAX_SUMMARY_LEN: u16 = 1000; /// Creates a new request key from the given key components. @@ -459,6 +494,65 @@ mod tests { use super::request_test_utils::mock_request; use super::*; + #[test] + fn fail_request_cancel_reason_too_big() { + let mut request = mock_request(); + request.status = RequestStatus::Cancelled { + reason: Some("a".repeat(Request::MAX_CANCEL_REASON_LEN as usize + 1)), + }; + + let result = validate_status(&request.status); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + RequestError::ValidationError { + info: format!( + "The reason for the cancellation exceeds the maximum allowed: {}", + Request::MAX_CANCEL_REASON_LEN + ) + } + ) + } + + #[test] + fn fail_request_cancel_reason_empty() { + let mut request = mock_request(); + request.status = RequestStatus::Cancelled { + reason: Some("".to_owned()), + }; + + let result = validate_status(&request.status); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + RequestError::ValidationError { + info: "The reason for the cancellation must not be empty".to_owned() + } + ); + + request.status = RequestStatus::Cancelled { + reason: Some(" ".to_owned()), + }; + + let result = validate_status(&request.status); + + assert!(result.is_err()); + } + + #[test] + fn test_request_cancel_reason_is_valid() { + let mut request = mock_request(); + request.status = RequestStatus::Cancelled { + reason: Some("a".repeat(Request::MAX_CANCEL_REASON_LEN as usize)), + }; + + let result = validate_status(&request.status); + + assert!(result.is_ok()); + } + #[test] fn fail_request_title_too_big() { let mut request = mock_request(); diff --git a/core/station/impl/src/repositories/request.rs b/core/station/impl/src/repositories/request.rs index 9ba3d7823..6f26c53d5 100644 --- a/core/station/impl/src/repositories/request.rs +++ b/core/station/impl/src/repositories/request.rs @@ -400,12 +400,31 @@ impl RequestRepository { mut request: Request, reason: String, request_cancellation_time: u64, - ) { + ) -> Request { + let mut maybe_reason = match reason.trim() { + "" => None, + _ => Some(reason.trim().to_string()), + }; + + if reason.is_empty() { + maybe_reason = None; + } else if reason.len() > Request::MAX_CANCEL_REASON_LEN as usize { + maybe_reason = Some( + reason + .trim() + .chars() + .take(Request::MAX_CANCEL_REASON_LEN as usize) + .collect::(), + ); + } + request.status = RequestStatus::Cancelled { - reason: Some(reason), + reason: maybe_reason, }; request.last_modification_timestamp = request_cancellation_time; self.insert(request.to_key(), request.to_owned()); + + request } #[cfg(test)] diff --git a/core/station/impl/src/services/request.rs b/core/station/impl/src/services/request.rs index 404002463..04ca27e51 100644 --- a/core/station/impl/src/services/request.rs +++ b/core/station/impl/src/services/request.rs @@ -407,6 +407,37 @@ impl RequestService { } } + /// Cancels a request if the request is in the created status and the caller is the requester. + pub fn cancel_request( + &self, + request_id: &UUID, + reason: Option, + ctx: &CallContext, + ) -> ServiceResult { + let caller = ctx.user().ok_or(RequestError::Unauthorized)?; + let request = self.get_request(request_id)?; + + if request.status != RequestStatus::Created { + Err(RequestError::CancellationNotAllowed { + reason: "Only requests in the created status can be cancelled.".to_string(), + })? + } + + if request.requested_by != caller.id { + Err(RequestError::CancellationNotAllowed { + reason: "Only the requester can cancel the request.".to_string(), + })? + } + + let request = self.request_repository.cancel_request( + request, + reason.unwrap_or("Request cancelled by requester.".to_string()), + next_time(), + ); + + Ok(request) + } + pub async fn submit_request_approval( &self, input: SubmitRequestApprovalInput, @@ -531,7 +562,7 @@ mod tests { services::AccountService, }; use candid::Principal; - use orbit_essentials::model::ModelKey; + use orbit_essentials::{api::ApiError, model::ModelKey}; use station_api::{ ListRequestsOperationTypeDTO, RequestApprovalStatusDTO, RequestStatusCodeDTO, }; @@ -765,6 +796,111 @@ mod tests { assert!(!request.approvals.is_empty()); } + #[tokio::test] + async fn user_can_cancel_their_own_pending_request() { + let ctx = setup(); + + let mut request = mock_request(); + request.requested_by = ctx.caller_user.id; + request.status = RequestStatus::Created; + request.operation = RequestOperation::Transfer(TransferOperation { + transfer_id: None, + fee: None, + input: TransferOperationInput { + from_account_id: [9; 16], + amount: candid::Nat(100u32.into()), + fee: None, + metadata: Metadata::default(), + network: "mainnet".to_string(), + to: "0x1234".to_string(), + }, + }); + + ctx.repository.insert(request.to_key(), request.to_owned()); + + let result = + ctx.service + .cancel_request(&request.id, Some("testing".to_string()), &ctx.call_context); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap().status, + RequestStatus::Cancelled { + reason: Some("testing".to_string()) + } + ); + } + + #[tokio::test] + async fn fail_to_cancel_another_user_request() { + let ctx = setup(); + + let mut request = mock_request(); + request.requested_by = [93; 16]; + request.status = RequestStatus::Created; + request.operation = RequestOperation::Transfer(TransferOperation { + transfer_id: None, + fee: None, + input: TransferOperationInput { + from_account_id: [9; 16], + amount: candid::Nat(100u32.into()), + fee: None, + metadata: Metadata::default(), + network: "mainnet".to_string(), + to: "0x1234".to_string(), + }, + }); + + ctx.repository.insert(request.to_key(), request.to_owned()); + + let result = + ctx.service + .cancel_request(&request.id, Some("testing".to_string()), &ctx.call_context); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + ApiError::from(RequestError::CancellationNotAllowed { + reason: "Only the requester can cancel the request.".to_string() + }) + ) + } + + #[tokio::test] + async fn fail_cancel_request_not_in_created_status() { + let ctx = setup(); + + let mut request = mock_request(); + request.requested_by = ctx.caller_user.id; + request.status = RequestStatus::Processing { started_at: 10 }; + request.operation = RequestOperation::Transfer(TransferOperation { + transfer_id: None, + fee: None, + input: TransferOperationInput { + from_account_id: [9; 16], + amount: candid::Nat(100u32.into()), + fee: None, + metadata: Metadata::default(), + network: "mainnet".to_string(), + to: "0x1234".to_string(), + }, + }); + + ctx.repository.insert(request.to_key(), request.to_owned()); + + let result = + ctx.service + .cancel_request(&request.id, Some("testing".to_string()), &ctx.call_context); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + ApiError::from(RequestError::CancellationNotAllowed { + reason: "Only requests in the created status can be cancelled.".to_string() + }) + ) + } + #[tokio::test] async fn users_with_approval_rights_can_view_request() { let requester = mock_user();