diff --git a/doc/schemas/gov_openapi.json b/doc/schemas/gov_openapi.json index 35493b397e3b..75942a47ae00 100644 --- a/doc/schemas/gov_openapi.json +++ b/doc/schemas/gov_openapi.json @@ -349,11 +349,6 @@ "$ref": "#/components/schemas/boolean" } }, - "required": [ - "max_heap_bytes", - "max_stack_bytes", - "max_execution_time_ms" - ], "type": "object" }, "JwtIssuerKeyFilter": { @@ -1335,7 +1330,7 @@ "info": { "description": "This API is used to submit and query proposals which affect CCF's public governance tables.", "title": "CCF Governance API", - "version": "4.1.6" + "version": "4.1.7" }, "openapi": "3.0.0", "paths": { diff --git a/include/ccf/js/registry.h b/include/ccf/js/registry.h index eac8f3e25cad..3617276e659c 100644 --- a/include/ccf/js/registry.h +++ b/include/ccf/js/registry.h @@ -103,6 +103,21 @@ namespace ccf::js ccf::ApiResult get_custom_endpoint_module_v1( std::string& code, kv::ReadOnlyTx& tx, const std::string& module_name); + /** + * Set options to control JS execution. Some hard limits may be applied to + * bound any values specified here. + */ + ccf::ApiResult set_js_runtime_options_v1( + kv::Tx& tx, const ccf::JSRuntimeOptions& options); + + /** + * Get the options which currently control JS execution. If no value has + * been populated in the KV, this will return the default runtime options + * which will be applied instead. + */ + ccf::ApiResult get_js_runtime_options_v1( + ccf::JSRuntimeOptions& options, kv::ReadOnlyTx& tx); + /// \defgroup Overrides for base EndpointRegistry functions, looking up JS /// endpoints before delegating to base implementation. ///@{ diff --git a/include/ccf/service/tables/jsengine.h b/include/ccf/service/tables/jsengine.h index d5779cbf9ae9..cca3c1de25e4 100644 --- a/include/ccf/service/tables/jsengine.h +++ b/include/ccf/service/tables/jsengine.h @@ -3,6 +3,7 @@ #pragma once #include "ccf/ds/json.h" +#include "ccf/ds/openapi.h" #include "ccf/service/map.h" namespace ccf @@ -38,14 +39,69 @@ namespace ccf size_t max_cached_interpreters = Defaults::max_cached_interpreters; }; - DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JSRuntimeOptions) - DECLARE_JSON_REQUIRED_FIELDS( - JSRuntimeOptions, max_heap_bytes, max_stack_bytes, max_execution_time_ms) - DECLARE_JSON_OPTIONAL_FIELDS( - JSRuntimeOptions, - log_exception_details, - return_exception_details, - max_cached_interpreters); +#define FOREACH_JSENGINE_FIELD(XX) \ + XX(max_heap_bytes, decltype(JSRuntimeOptions::max_heap_bytes)) \ + XX(max_stack_bytes, decltype(JSRuntimeOptions::max_stack_bytes)) \ + XX(max_execution_time_ms, decltype(JSRuntimeOptions::max_execution_time_ms)) \ + XX(log_exception_details, decltype(JSRuntimeOptions::log_exception_details)) \ + XX( \ + return_exception_details, \ + decltype(JSRuntimeOptions::return_exception_details)) \ + XX( \ + max_cached_interpreters, \ + decltype(JSRuntimeOptions::max_cached_interpreters)) + + // Manually implemented to_json and from_json, so that we are maximally + // permissive in deserialisation (use defaults), but maximally verbose in + // serialisation (describe all fields) + inline void to_json(nlohmann::json& j, const JSRuntimeOptions& options) + { + j = nlohmann::json::object(); +#define XX(field, field_type) j[#field] = options.field; + + FOREACH_JSENGINE_FIELD(XX) +#undef XX + } + + inline void from_json(const nlohmann::json& j, JSRuntimeOptions& options) + { +#define XX(field, field_type) \ + { \ + const auto it = j.find(#field); \ + if (it != j.end()) \ + { \ + options.field = it->get(); \ + } \ + } + + FOREACH_JSENGINE_FIELD(XX) +#undef XX + } + + inline std::string schema_name(const JSRuntimeOptions*) + { + return "JSRuntimeOptions"; + } + + inline void fill_json_schema(nlohmann::json& schema, const JSRuntimeOptions*) + { + schema = nlohmann::json::object(); + schema["type"] = "object"; + + auto properties = nlohmann::json::object(); + { +#define XX(field, field_type) \ + properties[#field] = \ + ds::openapi::components_ref_object(ds::json::schema_name()); + + FOREACH_JSENGINE_FIELD(XX) +#undef XX + } + + schema["properties"] = properties; + } + +#undef FOREACH_JSENGINE_FIELD using JSEngine = ServiceValue; diff --git a/samples/apps/basic/basic.cpp b/samples/apps/basic/basic.cpp index f078088a175e..47a8b79c743c 100644 --- a/samples/apps/basic/basic.cpp +++ b/samples/apps/basic/basic.cpp @@ -255,6 +255,113 @@ namespace basicapp {ccf::empty_auth_policy}) .add_query_parameter("module_name") .install(); + + auto patch_runtime_options = + [this](ccf::endpoints::EndpointContext& ctx) { + const auto& caller_identity = + ctx.template get_caller(); + + // Authorization Check + nlohmann::json user_data = nullptr; + auto result = + get_user_data_v1(ctx.tx, caller_identity.user_id, user_data); + if (result == ccf::ApiResult::InternalError) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to get user data for user {}: {}", + caller_identity.user_id, + ccf::api_result_to_str(result))); + return; + } + const auto is_admin_it = user_data.find("isAdmin"); + + // Not every user gets to define custom endpoints, only users with + // isAdmin + if ( + !user_data.is_object() || is_admin_it == user_data.end() || + !is_admin_it.value().get()) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_FORBIDDEN, + ccf::errors::AuthorizationFailed, + "Only admins may access this endpoint."); + return; + } + // End of Authorization Check + + // Implement patch semantics. + // - Fetch current options + ccf::JSRuntimeOptions options; + get_js_runtime_options_v1(options, ctx.tx); + + // - Convert current options to JSON + auto j_options = nlohmann::json(options); + + // - Parse argument as JSON body + const auto arg_body = nlohmann::json::parse( + caller_identity.content.begin(), caller_identity.content.end()); + + // - Merge, to overwrite current options with anything from body. Note + // that nulls mean deletions, which results in resetting to a default + // value + j_options.merge_patch(arg_body); + + // - Parse patched options from JSON + options = j_options.get(); + + result = set_js_runtime_options_v1(ctx.tx, options); + if (result != ccf::ApiResult::OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to set options: {}", ccf::api_result_to_str(result))); + return; + } + + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + ctx.rpc_ctx->set_response_header( + http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON); + ctx.rpc_ctx->set_response_body(nlohmann::json(options).dump(2)); + }; + make_endpoint( + "/custom_endpoints/runtime_options", + HTTP_PATCH, + patch_runtime_options, + {ccf::user_cose_sign1_auth_policy}) + .install(); + + auto get_runtime_options = [this](ccf::endpoints::EndpointContext& ctx) { + ccf::JSRuntimeOptions options; + + auto result = get_js_runtime_options_v1(options, ctx.tx); + if (result != ccf::ApiResult::OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to get runtime options: {}", + ccf::api_result_to_str(result))); + return; + } + + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + ctx.rpc_ctx->set_response_header( + http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON); + ctx.rpc_ctx->set_response_body(nlohmann::json(options).dump(2)); + }; + make_endpoint( + "/custom_endpoints/runtime_options", + HTTP_GET, + get_runtime_options, + {ccf::empty_auth_policy}) + .set_auto_schema() + .install(); } }; } diff --git a/src/js/registry.cpp b/src/js/registry.cpp index 50a8c7ba97e2..f1de8acd787b 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -639,6 +639,37 @@ namespace ccf::js } } + ccf::ApiResult DynamicJSEndpointRegistry::set_js_runtime_options_v1( + kv::Tx& tx, const ccf::JSRuntimeOptions& options) + { + try + { + tx.wo(runtime_options_map)->put(options); + return ccf::ApiResult::OK; + } + catch (const std::exception& e) + { + return ccf::ApiResult::InternalError; + } + } + + ccf::ApiResult DynamicJSEndpointRegistry::get_js_runtime_options_v1( + ccf::JSRuntimeOptions& options, kv::ReadOnlyTx& tx) + { + try + { + options = tx.ro(runtime_options_map) + ->get() + .value_or(ccf::JSRuntimeOptions()); + + return ccf::ApiResult::OK; + } + catch (const std::exception& e) + { + return ccf::ApiResult::InternalError; + } + } + ccf::endpoints::EndpointDefinitionPtr DynamicJSEndpointRegistry:: find_endpoint(kv::Tx& tx, ccf::RpcContext& rpc_ctx) { diff --git a/src/node/rpc/member_frontend.h b/src/node/rpc/member_frontend.h index b3d087e87028..1df5f8eb3f6a 100644 --- a/src/node/rpc/member_frontend.h +++ b/src/node/rpc/member_frontend.h @@ -591,7 +591,7 @@ namespace ccf openapi_info.description = "This API is used to submit and query proposals which affect CCF's " "public governance tables."; - openapi_info.document_version = "4.1.6"; + openapi_info.document_version = "4.1.7"; } static std::optional get_caller_member_id( diff --git a/tests/infra/clients.py b/tests/infra/clients.py index 22d207a8ed72..2e89480575e2 100644 --- a/tests/infra/clients.py +++ b/tests/infra/clients.py @@ -1237,6 +1237,19 @@ def options(self, *args, **kwargs) -> Response: kwargs["http_verb"] = "OPTIONS" return self.call(*args, **kwargs) + def patch(self, *args, **kwargs) -> Response: + """ + Issue ``PATCH`` request. + See :py:meth:`infra.clients.CCFClient.call`. + + :return: :py:class:`infra.clients.Response` + """ + if "http_verb" in kwargs: + raise ValueError('"http_verb" should not be specified') + + kwargs["http_verb"] = "PATCH" + return self.call(*args, **kwargs) + def wait_for_commit( self, response: Response, timeout: int = DEFAULT_COMMIT_TIMEOUT_SEC ): diff --git a/tests/programmability.py b/tests/programmability.py index 02c2af0e8bb3..b2c9a018c8e5 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -165,6 +165,73 @@ def test_getters(c, expected_body): return network +def test_custom_endpoints_js_options(network, args): + primary, _ = network.find_primary() + + # Make user0 admin, so it can install custom endpoints + user = network.users[0] + network.consortium.set_user_data( + primary, user.service_id, user_data={"isAdmin": True} + ) + + def test_options_patch(c, **kwargs): + r = c.call( + "/app/custom_endpoints/runtime_options", {**kwargs}, http_verb="PATCH" + ) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + new_options = r.body.json() + + # Check get returns same updated options + get_r = c.get("/app/custom_endpoints/runtime_options") + assert get_r.status_code == http.HTTPStatus.OK.value, get_r.status_code + get_options = get_r.body.json() + + assert new_options == get_options, f"{new_options} != {get_options}" + return new_options + + with primary.client(None, None, user.local_id) as c: + r = c.get("/app/custom_endpoints/runtime_options") + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + + defaults = r.body.json() + + same = test_options_patch(c) + assert same == defaults + + reduced_heap = test_options_patch(c, max_heap_bytes=42) + assert reduced_heap == {**defaults, "max_heap_bytes": 42} + + multiple_changes = test_options_patch( + c, max_execution_time_ms=5000, max_cached_interpreters=15 + ) + assert multiple_changes == { + **defaults, + "max_heap_bytes": 42, + "max_execution_time_ms": 5000, + "max_cached_interpreters": 15, + } + + assign_and_reset = test_options_patch( + c, return_exception_details=True, max_execution_time_ms=None + ) + assert assign_and_reset == { + **defaults, + "max_heap_bytes": 42, + "max_cached_interpreters": 15, + "return_exception_details": True, + } + + reset_all = test_options_patch( + c, + max_cached_interpreters=None, + return_exception_details=None, + max_heap_bytes=None, + ) + assert reset_all == defaults + + return network + + def test_custom_role_definitions(network, args): primary, _ = network.find_primary() member = network.consortium.get_any_active_member()