diff --git a/doc/audit/read_write_restrictions.rst b/doc/audit/read_write_restrictions.rst index eb6479f2fdb6..7ba1b3f75b71 100644 --- a/doc/audit/read_write_restrictions.rst +++ b/doc/audit/read_write_restrictions.rst @@ -56,6 +56,11 @@ CCF ensures that governance audit is possible offline from a ledger, by consider An important exemption here is that application code may still `read` from governance tables. This allows authentication, authorization, and metadata to be configured and controlled by governance, but affect the execution of application endpoints. +.. + A link to this page is included in the CCF source code, and returned in error messages. + If this table is moved, make sure the source is updated in-sync. + (Ctrl+Shift+F: read_write_restrictions) + The possible access permissions are elaborated in the table below: .. table:: KV permissions in different execution contexts diff --git a/include/ccf/js/core/context.h b/include/ccf/js/core/context.h index 2ac142a567fe..47f3a166d7de 100644 --- a/include/ccf/js/core/context.h +++ b/include/ccf/js/core/context.h @@ -147,7 +147,7 @@ namespace ccf::js::core JSWrappedValue new_c_function( JSCFunction* func, const char* name, int length) const; JSWrappedValue new_getter_c_function( - JSCFunction* func, const char* name) const; + JSCFunction* func, const char* name, size_t arg_count = 0) const; JSWrappedValue duplicate_value(JSValueConst original) const; diff --git a/include/ccf/js/extensions/ccf/kv.h b/include/ccf/js/extensions/ccf/kv.h index ef912337afb3..51b3430190cf 100644 --- a/include/ccf/js/extensions/ccf/kv.h +++ b/include/ccf/js/extensions/ccf/kv.h @@ -3,10 +3,15 @@ #pragma once #include "ccf/js/extensions/extension_interface.h" -#include "ccf/tx.h" +#include "ccf/js/namespace_restrictions.h" #include +namespace kv +{ + class Tx; +} + namespace ccf::js::extensions { /** @@ -21,7 +26,9 @@ namespace ccf::js::extensions std::unique_ptr impl; - KvExtension(kv::Tx* t); + ccf::js::NamespaceRestriction namespace_restriction; + + KvExtension(kv::Tx* t, const ccf::js::NamespaceRestriction& nr = {}); ~KvExtension(); void install(js::core::Context& ctx); diff --git a/include/ccf/js/kv_access_permissions.h b/include/ccf/js/kv_access_permissions.h new file mode 100644 index 000000000000..180a762539d9 --- /dev/null +++ b/include/ccf/js/kv_access_permissions.h @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/js/core/context.h" + +namespace ccf::js +{ + enum class KVAccessPermissions + { + READ_WRITE, + READ_ONLY, + ILLEGAL + }; +} diff --git a/include/ccf/js/namespace_restrictions.h b/include/ccf/js/namespace_restrictions.h new file mode 100644 index 000000000000..7d3d2e9367f8 --- /dev/null +++ b/include/ccf/js/namespace_restrictions.h @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/js/kv_access_permissions.h" + +#include +#include + +namespace ccf::js +{ + // A function which calculates some access permission based on the given map + // name. Should also populate an explanation, which can be included in error + // messages if disallowed methods are accessed. + using NamespaceRestriction = std::function; +} \ No newline at end of file diff --git a/include/ccf/js/registry.h b/include/ccf/js/registry.h index 3617276e659c..38c4fc5067d1 100644 --- a/include/ccf/js/registry.h +++ b/include/ccf/js/registry.h @@ -7,6 +7,7 @@ #include "ccf/js/bundle.h" #include "ccf/js/core/context.h" #include "ccf/js/interpreter_cache_interface.h" +#include "ccf/js/namespace_restrictions.h" #include "ccf/tx.h" #include "ccf/tx_id.h" @@ -51,6 +52,8 @@ namespace ccf::js std::string modules_quickjs_bytecode_map; std::string runtime_options_map; + ccf::js::NamespaceRestriction namespace_restriction; + using PreExecutionHook = std::function; void do_execute_request( @@ -103,6 +106,12 @@ namespace ccf::js ccf::ApiResult get_custom_endpoint_module_v1( std::string& code, kv::ReadOnlyTx& tx, const std::string& module_name); + /** + * Pass a function to control which maps can be accessed by JS endpoints. + */ + void set_js_kv_namespace_restriction( + const ccf::js::NamespaceRestriction& restriction); + /** * Set options to control JS execution. Some hard limits may be applied to * bound any values specified here. diff --git a/samples/apps/basic/basic.cpp b/samples/apps/basic/basic.cpp index 47a8b79c743c..d6db5dfe5201 100644 --- a/samples/apps/basic/basic.cpp +++ b/samples/apps/basic/basic.cpp @@ -117,6 +117,36 @@ namespace basicapp make_endpoint("/records", HTTP_POST, post, {ccf::user_cert_auth_policy}) .install(); + // Restrict what KV maps the JS code can access. Here we make the + // PRIVATE_RECORDS map, written by the hardcoded C++ endpoints, + // read-only for JS code. Additionally, we reserve any map beginning + // with "basic." (public or private) as inaccessible for the JS code, in + // case we want to use it for the C++ app in future. + set_js_kv_namespace_restriction( + [](const std::string& map_name, std::string& explanation) + -> ccf::js::KVAccessPermissions { + if (map_name == PRIVATE_RECORDS) + { + explanation = fmt::format( + "The {} map is managed by C++ endpoints, so is read-only in " + "JS.", + PRIVATE_RECORDS); + return ccf::js::KVAccessPermissions::READ_ONLY; + } + + if ( + map_name.starts_with("public:basic.") || + map_name.starts_with("basic.")) + { + explanation = + "The 'basic.' prefix is reserved by the C++ endpoints for future " + "use."; + return ccf::js::KVAccessPermissions::ILLEGAL; + } + + return ccf::js::KVAccessPermissions::READ_WRITE; + }); + auto put_custom_endpoints = [this](ccf::endpoints::EndpointContext& ctx) { const auto& caller_identity = ctx.template get_caller(); diff --git a/src/js/core/context.cpp b/src/js/core/context.cpp index 86f93ec7de42..1003b14af981 100644 --- a/src/js/core/context.cpp +++ b/src/js/core/context.cpp @@ -392,10 +392,10 @@ namespace ccf::js::core } JSWrappedValue Context::new_getter_c_function( - JSCFunction* func, const char* name) const + JSCFunction* func, const char* name, size_t arg_count) const { return wrap(JS_NewCFunction2( - ctx, func, name, 0, JS_CFUNC_getter, JS_CFUNC_getter_magic)); + ctx, func, name, arg_count, JS_CFUNC_getter, JS_CFUNC_getter_magic)); } JSWrappedValue Context::duplicate_value(JSValueConst original) const diff --git a/src/js/extensions/ccf/historical.cpp b/src/js/extensions/ccf/historical.cpp index 03dbb64b8bac..406cdcdaed32 100644 --- a/src/js/extensions/ccf/historical.cpp +++ b/src/js/extensions/ccf/historical.cpp @@ -331,11 +331,23 @@ namespace ccf::js::extensions LOG_TRACE_FMT( "Looking for historical kv map '{}' at seqno {}", map_name, seqno); - // Ignore evaluated access permissions - all tables are read-only - const auto access_permission = MapAccessPermissions::READ_ONLY; + auto access_permission = + ccf::js::check_kv_map_access(jsctx.access, map_name); + std::string explanation = + ccf::js::explain_kv_map_access(access_permission, jsctx.access); + + // If it's illegal, it stays illegal in historical lookup + if (access_permission != KVAccessPermissions::ILLEGAL) + { + // But otherwise, ignore evaluated access permissions - all tables are + // read-only in historical KV + access_permission = KVAccessPermissions::READ_ONLY; + explanation = "All tables are read-only during historical transaction."; + } + auto handle_val = kvhelpers::create_kv_map_handle( - jsctx, map_name, access_permission); + jsctx, map_name, access_permission, explanation); if (JS_IsException(handle_val)) { return -1; diff --git a/src/js/extensions/ccf/kv.cpp b/src/js/extensions/ccf/kv.cpp index 7b231c700bbc..85f3d63a72fb 100644 --- a/src/js/extensions/ccf/kv.cpp +++ b/src/js/extensions/ccf/kv.cpp @@ -4,10 +4,11 @@ #include "ccf/js/extensions/ccf/kv.h" #include "ccf/js/core/context.h" +#include "ccf/tx.h" #include "js/checks.h" #include "js/extensions/ccf/kv_helpers.h" #include "js/global_class_ids.h" -#include "js/map_access_permissions.h" +#include "js/permissions_checks.h" #include #include @@ -85,11 +86,37 @@ namespace ccf::js::extensions const auto map_name = jsctx.to_str(property).value_or(""); LOG_TRACE_FMT("Looking for kv map '{}'", map_name); - const auto access_permission = + auto extension = jsctx.get_extension(); + if (extension == nullptr) + { + LOG_FAIL_FMT("No KV extension available"); + return -1; + } + + auto access_permission = ccf::js::check_kv_map_access(jsctx.access, map_name); + std::string explanation = + ccf::js::explain_kv_map_access(access_permission, jsctx.access); + + if (extension->namespace_restriction != nullptr) + { + std::string proposed_explanation; + const auto proposed_permission = + extension->namespace_restriction(map_name, proposed_explanation); + + // Name-based policy cannot grant more access (eg - cannot change + // Read-Only to Read-Write), can only make it more restricted + if (proposed_permission > access_permission) + { + access_permission = proposed_permission; + explanation = proposed_explanation; + } + } + auto handle_val = kvhelpers::create_kv_map_handle( - jsctx, map_name, access_permission); + jsctx, map_name, access_permission, explanation); + if (JS_IsException(handle_val)) { return -1; @@ -102,7 +129,8 @@ namespace ccf::js::extensions } } - KvExtension::KvExtension(kv::Tx* t) + KvExtension::KvExtension(kv::Tx* t, const ccf::js::NamespaceRestriction& nr) : + namespace_restriction(nr) { impl = std::make_unique(t); } diff --git a/src/js/extensions/ccf/kv_helpers.h b/src/js/extensions/ccf/kv_helpers.h index 234b2e1233f3..c81224e26faf 100644 --- a/src/js/extensions/ccf/kv_helpers.h +++ b/src/js/extensions/ccf/kv_helpers.h @@ -3,7 +3,7 @@ #pragma once #include "js/global_class_ids.h" -#include "js/map_access_permissions.h" +#include "js/permissions_checks.h" #include "kv/untyped_map.h" namespace ccf::js::extensions::kvhelpers @@ -15,9 +15,6 @@ namespace ccf::js::extensions::kvhelpers using RWHandleGetter = KVMap::Handle* (*)(js::core::Context& jsctx, JSValueConst this_val); - static constexpr char const* access_permissions_explanation_url = - "https://microsoft.github.io/CCF/main/audit/read_write_restrictions.html"; - #define JS_KV_PERMISSION_ERROR_HELPER(C_FUNC_NAME, JS_METHOD_NAME) \ static JSValue C_FUNC_NAME( \ JSContext* ctx, JSValueConst this_val, int, JSValueConst*) \ @@ -30,54 +27,29 @@ namespace ccf::js::extensions::kvhelpers { \ return JS_ThrowTypeError(ctx, "Internal: No map name stored on handle"); \ } \ - const auto permission = \ - ccf::js::check_kv_map_access(jsctx.access, table_name); \ - char const* table_kind = permission == MapAccessPermissions::READ_ONLY ? \ - "read-only" : \ - "inaccessible"; \ - char const* exec_context = "unknown"; \ - switch (jsctx.access) \ + auto func = jsctx.get_property(this_val, JS_METHOD_NAME); \ + std::string explanation; \ + auto error_msg = func["_error_msg"]; \ + if (!error_msg.is_undefined()) \ { \ - case (TxAccess::APP_RW): \ - { \ - exec_context = "application"; \ - break; \ - } \ - case (TxAccess::APP_RO): \ - { \ - exec_context = "read-only application"; \ - break; \ - } \ - case (TxAccess::GOV_RO): \ - { \ - exec_context = "read-only governance"; \ - break; \ - } \ - case (TxAccess::GOV_RW): \ - { \ - exec_context = "read-write governance"; \ - break; \ - } \ + explanation = jsctx.to_str(error_msg).value_or(""); \ } \ return JS_ThrowTypeError( \ ctx, \ - "Cannot call " #JS_METHOD_NAME \ - " on %s table named %s in %s execution context. See %s for more " \ - "detail.", \ - table_kind, \ + "Cannot call " #JS_METHOD_NAME " on table named %s. %s", \ table_name.c_str(), \ - exec_context, \ - access_permissions_explanation_url); \ + explanation.c_str()); \ } JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_has_denied, "has") JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_get_denied, "get") - JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_size_denied, "size") + JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_size_getter_denied, "size") JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_set_denied, "set") JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_delete_denied, "delete") JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_clear_denied, "clear") - JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_foreach_denied, "foreach") - JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_get_version_denied, "get_version") + JS_KV_PERMISSION_ERROR_HELPER(js_kv_map_foreach_denied, "forEach") + JS_KV_PERMISSION_ERROR_HELPER( + js_kv_get_version_of_previous_write_denied, "getVersionOfPreviousWrite") #undef JS_KV_PERMISSION_ERROR_HELPER #define JS_CHECK_HANDLE(h) \ @@ -349,7 +321,8 @@ namespace ccf::js::extensions::kvhelpers static JSValue create_kv_map_handle( js::core::Context& ctx, const std::string& map_name, - MapAccessPermissions access_permission) + KVAccessPermissions access_permission, + const std::string& permission_explanation) { // This follows the interface of Map: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map @@ -368,67 +341,74 @@ namespace ccf::js::extensions::kvhelpers // restrictions could vary between invocations, then this object's // properties would need to be updated as well. - auto has_fn = js_kv_map_has; - auto get_fn = js_kv_map_get; - auto size_fn = js_kv_map_size_getter; - auto set_fn = js_kv_map_set; - auto delete_fn = js_kv_map_delete; - auto clear_fn = js_kv_map_clear; - auto foreach_fn = js_kv_map_foreach; - auto get_version_fn = - js_kv_get_version_of_previous_write; - - if (access_permission == MapAccessPermissions::ILLEGAL) - { - has_fn = js_kv_map_has_denied; - get_fn = js_kv_map_get_denied; - size_fn = js_kv_map_size_denied; - set_fn = js_kv_map_set_denied; - delete_fn = js_kv_map_delete_denied; - clear_fn = js_kv_map_clear_denied; - foreach_fn = js_kv_map_foreach_denied; - get_version_fn = js_kv_map_get_version_denied; - } - else if (access_permission == MapAccessPermissions::READ_ONLY) - { - set_fn = js_kv_map_set_denied; - delete_fn = js_kv_map_delete_denied; - clear_fn = js_kv_map_clear_denied; - } - - auto has_fn_val = ctx.new_c_function(has_fn, "has", 1); - JS_CHECK_EXC(has_fn_val); - JS_CHECK_SET(view_val.set("has", std::move(has_fn_val))); - - auto get_fn_val = ctx.new_c_function(get_fn, "get", 1); - JS_CHECK_EXC(get_fn_val); - JS_CHECK_SET(view_val.set("get", std::move(get_fn_val))); - - auto get_size_fn_val = ctx.new_getter_c_function(size_fn, "size"); - JS_CHECK_EXC(get_size_fn_val); - JS_CHECK_SET(view_val.set_getter("size", std::move(get_size_fn_val))); - - auto set_fn_val = ctx.new_c_function(set_fn, "set", 2); - JS_CHECK_EXC(set_fn_val); - JS_CHECK_SET(view_val.set("set", std::move(set_fn_val))); - - auto delete_fn_val = ctx.new_c_function(delete_fn, "delete", 1); - JS_CHECK_EXC(delete_fn_val); - JS_CHECK_SET(view_val.set("delete", std::move(delete_fn_val))); - - auto clear_fn_val = ctx.new_c_function(clear_fn, "clear", 0); - JS_CHECK_EXC(clear_fn_val); - JS_CHECK_SET(view_val.set("clear", std::move(clear_fn_val))); - - auto foreach_fn_val = ctx.new_c_function(foreach_fn, "forEach", 1); - JS_CHECK_EXC(foreach_fn_val); - JS_CHECK_SET(view_val.set("forEach", std::move(foreach_fn_val))); +#define MAKE_FUNCTION( \ + C_FUNC_NAME, \ + JS_METHOD_NAME, \ + ARG_COUNT, \ + FUNC_FACTORY_METHOD, \ + SETTER_METHOD, \ + PERMISSION_ERROR_MIN, \ + HANDLE_GETTER) \ + do \ + { \ + auto fn_val = ctx.FUNC_FACTORY_METHOD( \ + access_permission >= PERMISSION_ERROR_MIN ? C_FUNC_NAME##_denied : \ + C_FUNC_NAME, \ + JS_METHOD_NAME, \ + ARG_COUNT); \ + JS_CHECK_EXC(fn_val); \ + if (access_permission >= PERMISSION_ERROR_MIN) \ + { \ + JS_CHECK_SET( \ + fn_val.set("_error_msg", ctx.new_string(permission_explanation))); \ + } \ + JS_CHECK_SET(view_val.SETTER_METHOD(JS_METHOD_NAME, std::move(fn_val))); \ + } while (0) - auto get_version_fn_val = - ctx.new_c_function(get_version_fn, "getVersionOfPreviousWrite", 1); - JS_CHECK_EXC(get_version_fn_val); - JS_CHECK_SET( - view_val.set("getVersionOfPreviousWrite", std::move(get_version_fn_val))); +#define MAKE_RO_FUNCTION(C_FUNC_NAME, JS_METHOD_NAME, ARG_COUNT) \ + MAKE_FUNCTION( \ + C_FUNC_NAME, \ + JS_METHOD_NAME, \ + ARG_COUNT, \ + new_c_function, \ + set, \ + KVAccessPermissions::ILLEGAL, \ + GetReadOnlyHandle) + +#define MAKE_RW_FUNCTION(C_FUNC_NAME, JS_METHOD_NAME, ARG_COUNT) \ + MAKE_FUNCTION( \ + C_FUNC_NAME, \ + JS_METHOD_NAME, \ + ARG_COUNT, \ + new_c_function, \ + set, \ + KVAccessPermissions::READ_ONLY, \ + GetWriteHandle) + + MAKE_RO_FUNCTION(js_kv_map_has, "has", 1); + MAKE_RO_FUNCTION(js_kv_map_get, "get", 1); + + MAKE_RO_FUNCTION(js_kv_map_foreach, "forEach", 1); + MAKE_RO_FUNCTION( + js_kv_get_version_of_previous_write, "getVersionOfPreviousWrite", 1); + + MAKE_RW_FUNCTION(js_kv_map_set, "set", 2); + MAKE_RW_FUNCTION(js_kv_map_delete, "delete", 1); + MAKE_RW_FUNCTION(js_kv_map_clear, "clear", 0); + + // This is a _getter_, subtly different from a read-only function + MAKE_FUNCTION( + js_kv_map_size_getter, + "size", + 0, + new_getter_c_function, + set_getter, + KVAccessPermissions::ILLEGAL, + GetReadOnlyHandle); + +#undef MAKE_RW_FUNCTION +#undef MAKE_RO_FUNCTION +#undef MAKE_FUNCTION return view_val.take(); } diff --git a/src/js/map_access_permissions.h b/src/js/permissions_checks.h similarity index 57% rename from src/js/map_access_permissions.h rename to src/js/permissions_checks.h index 689312370ea2..a9e39c4711a0 100644 --- a/src/js/map_access_permissions.h +++ b/src/js/permissions_checks.h @@ -2,19 +2,14 @@ // Licensed under the Apache 2.0 License. #pragma once +#include "ccf/js/kv_access_permissions.h" +#include "ccf/js/namespace_restrictions.h" #include "ccf/js/tx_access.h" #include "kv/kv_types.h" namespace ccf::js { - enum class MapAccessPermissions - { - READ_WRITE, - READ_ONLY, - ILLEGAL - }; - - static MapAccessPermissions check_kv_map_access( + static KVAccessPermissions check_kv_map_access( TxAccess execution_context, const std::string& table_name) { // Enforce the restrictions described in the read_write_restrictions page in @@ -36,17 +31,17 @@ namespace ccf::js execution_context == TxAccess::APP_RW && namespace_of_table == kv::AccessCategory::APPLICATION) { - return MapAccessPermissions::READ_WRITE; + return KVAccessPermissions::READ_WRITE; } else if ( execution_context == TxAccess::APP_RO && namespace_of_table == kv::AccessCategory::APPLICATION) { - return MapAccessPermissions::READ_ONLY; + return KVAccessPermissions::READ_ONLY; } else { - return MapAccessPermissions::ILLEGAL; + return KVAccessPermissions::ILLEGAL; } } @@ -56,18 +51,18 @@ namespace ccf::js { case kv::AccessCategory::INTERNAL: { - return MapAccessPermissions::READ_ONLY; + return KVAccessPermissions::READ_ONLY; } case kv::AccessCategory::GOVERNANCE: { if (execution_context == TxAccess::GOV_RW) { - return MapAccessPermissions::READ_WRITE; + return KVAccessPermissions::READ_WRITE; } else { - return MapAccessPermissions::READ_ONLY; + return KVAccessPermissions::READ_ONLY; } } @@ -77,15 +72,15 @@ namespace ccf::js { case (TxAccess::APP_RW): { - return MapAccessPermissions::READ_WRITE; + return KVAccessPermissions::READ_WRITE; } case (TxAccess::APP_RO): { - return MapAccessPermissions::READ_ONLY; + return KVAccessPermissions::READ_ONLY; } default: { - return MapAccessPermissions::ILLEGAL; + return KVAccessPermissions::ILLEGAL; } } } @@ -99,4 +94,47 @@ namespace ccf::js } } } + static std::string explain_kv_map_access( + ccf::js::KVAccessPermissions permission, ccf::js::TxAccess access) + { + char const* table_kind = permission == KVAccessPermissions::READ_ONLY ? + "read-only" : + "inaccessible"; + + char const* exec_context = "unknown"; + switch (access) + { + case (TxAccess::APP_RW): + { + exec_context = "application"; + break; + } + case (TxAccess::APP_RO): + { + exec_context = "read-only application"; + break; + } + case (TxAccess::GOV_RO): + { + exec_context = "read-only governance"; + break; + } + case (TxAccess::GOV_RW): + { + exec_context = "read-write governance"; + break; + } + } + + static constexpr char const* access_permissions_explanation_url = + "https://microsoft.github.io/CCF/main/audit/" + "read_write_restrictions.html"; + + return fmt::format( + "This table is {} in current ({}) execution context. See {} for more " + "detail.", + table_kind, + exec_context, + access_permissions_explanation_url); + } } diff --git a/src/js/registry.cpp b/src/js/registry.cpp index f1de8acd787b..ac89b9c119b6 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -110,7 +110,8 @@ namespace ccf::js // ccf.kv.* local_extensions.emplace_back( - std::make_shared(&endpoint_ctx.tx)); + std::make_shared( + &endpoint_ctx.tx, namespace_restriction)); // ccf.rpc.* local_extensions.emplace_back( @@ -639,6 +640,12 @@ namespace ccf::js } } + void DynamicJSEndpointRegistry::set_js_kv_namespace_restriction( + const ccf::js::NamespaceRestriction& nr) + { + namespace_restriction = nr; + } + ccf::ApiResult DynamicJSEndpointRegistry::set_js_runtime_options_v1( kv::Tx& tx, const ccf::JSRuntimeOptions& options) { diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index 33731ec324a5..b5c8e76bb081 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include "js/map_access_permissions.h" +#include "js/permissions_checks.h" +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include +#include TEST_CASE("Check KV Map access") { @@ -22,42 +23,42 @@ TEST_CASE("Check KV Map access") INFO("Public internal tables are read-only"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, public_internal_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } { INFO("Private tables in internal namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, private_internal_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Governance tables are read-only"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, public_gov_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } { INFO("Private tables in governance namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, private_gov_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Public application tables are read-write"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, public_app_table_name) == - MapAccessPermissions::READ_WRITE); + KVAccessPermissions::READ_WRITE); { INFO( "Unless the operation is read-only, in which case they're read-only"); REQUIRE( check_kv_map_access(TxAccess::APP_RO, public_app_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } } @@ -65,14 +66,14 @@ TEST_CASE("Check KV Map access") INFO("Private application tables are read-write"); REQUIRE( check_kv_map_access(TxAccess::APP_RW, private_app_table_name) == - MapAccessPermissions::READ_WRITE); + KVAccessPermissions::READ_WRITE); { INFO( "Unless the operation is read-only, in which case they're read-only"); REQUIRE( check_kv_map_access(TxAccess::APP_RO, private_app_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } } } @@ -83,42 +84,42 @@ TEST_CASE("Check KV Map access") INFO("Public internal tables are read-only"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, public_internal_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } { INFO("Private tables in internal namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, private_internal_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Governance tables are read-only"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, public_gov_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } { INFO("Private tables in governance namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, private_gov_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Public application cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, public_app_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Private application cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RO, private_app_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } } @@ -129,42 +130,42 @@ TEST_CASE("Check KV Map access") INFO("Public internal tables are read-only"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, public_internal_table_name) == - MapAccessPermissions::READ_ONLY); + KVAccessPermissions::READ_ONLY); } { INFO("Private tables in internal namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, private_internal_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Governance tables are read-write"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, public_gov_table_name) == - MapAccessPermissions::READ_WRITE); + KVAccessPermissions::READ_WRITE); } { INFO("Private tables in governance namespace cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, private_gov_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Public applications tables cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, public_app_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } { INFO("Private applications tables cannot even be read"); REQUIRE( check_kv_map_access(TxAccess::GOV_RW, private_app_table_name) == - MapAccessPermissions::ILLEGAL); + KVAccessPermissions::ILLEGAL); } } -} \ No newline at end of file +} diff --git a/tests/programmability.py b/tests/programmability.py index b2c9a018c8e5..f08507546f6c 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -64,25 +64,30 @@ """ +def endpoint_properties( + js_module, + js_function, + forwarding_required="never", + redirection_strategy="none", + mode="readonly", +): + return { + "js_module": js_module, + "js_function": js_function, + "forwarding_required": forwarding_required, + "redirection_strategy": redirection_strategy, + "authn_policies": ["no_auth"], + "mode": mode, + "openapi": {}, + } + + def test_custom_endpoints(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} - ) content_endpoint_def = { - "get": { - "js_module": "test.js", - "js_function": "content", - "forwarding_required": "never", - "redirection_strategy": "none", - "authn_policies": ["no_auth"], - "mode": "readonly", - "openapi": {}, - } + "get": endpoint_properties(js_module="test.js", js_function="content") } modules = [ @@ -165,6 +170,102 @@ def test_getters(c, expected_body): return network +def test_custom_endpoints_kv_restrictions(network, args): + primary, _ = network.find_primary() + user = network.users[0] + + module_name = "restrictions.js" + + endpoints = { + "/try_read": { + "post": endpoint_properties( + js_module=module_name, + js_function="try_read", + ) + }, + "/try_write": { + "post": endpoint_properties( + js_module=module_name, + js_function="try_write", + mode="readwrite", + ) + }, + } + + modules = [ + { + "name": module_name, + "module": open( + os.path.join( + os.path.dirname(__file__), "programmability", "restrictions.js" + ) + ).read(), + } + ] + + bundle_with_content = { + "metadata": {"endpoints": endpoints}, + "modules": modules, + } + + with primary.client(None, None, user.local_id) as c: + r = c.put("/app/custom_endpoints", body=bundle_with_content) + assert r.status_code == http.HTTPStatus.NO_CONTENT.value, r.status_code + + with primary.client() as c: + LOG.info("Custom table names can be read to and written from") + r = c.post("/app/try_read", {"table": "my_js_table"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + r = c.post("/app/try_write", {"table": "my_js_table"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + + r = c.post("/app/try_read", {"table": "public:my_js_table"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + r = c.post("/app/try_write", {"table": "public:my_js_table"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + + LOG.info("'records' is a read-only table") + r = c.post("/app/try_read", {"table": "records"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + r = c.post("/app/try_write", {"table": "records"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + LOG.info("'basic.' is a forbidden namespace") + r = c.post("/app/try_read", {"table": "basic.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + r = c.post("/app/try_write", {"table": "basic.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + r = c.post("/app/try_read", {"table": "public:basic.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + r = c.post("/app/try_write", {"table": "public:basic.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + LOG.info("Cannot grant access to gov/internal tables") + r = c.post("/app/try_read", {"table": "public:ccf.gov.foo"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + r = c.post("/app/try_write", {"table": "public:ccf.gov.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + r = c.post("/app/try_read", {"table": "public:ccf.internal.foo"}) + assert r.status_code == http.HTTPStatus.OK.value, r.status_code + r = c.post("/app/try_write", {"table": "public:ccf.internal.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + LOG.info("Cannot grant access to (hypothetical) private gov/internal tables") + r = c.post("/app/try_read", {"table": "ccf.gov.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + r = c.post("/app/try_write", {"table": "ccf.gov.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + r = c.post("/app/try_read", {"table": "ccf.internal.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + r = c.post("/app/try_write", {"table": "ccf.internal.foo"}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + + return network + + def test_custom_endpoints_js_options(network, args): primary, _ = network.find_primary() @@ -358,12 +459,7 @@ def test_custom_role_definitions(network, args): def deploy_npm_app_custom(network, args): primary, _ = network.find_nodes() - - # 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} - ) app_dir = os.path.join(npm_tests.THIS_DIR, "npm-app") @@ -392,7 +488,15 @@ def run(args): ) as network: network.start_and_open(args) + # Make user0 admin, so it can install custom endpoints + primary, _ = network.find_nodes() + user = network.users[0] + network.consortium.set_user_data( + primary, user.service_id, user_data={"isAdmin": True} + ) + network = test_custom_endpoints(network, args) + network = test_custom_endpoints_kv_restrictions(network, args) network = test_custom_role_definitions(network, args) network = npm_tests.build_npm_app(network, args) diff --git a/tests/programmability/restrictions.js b/tests/programmability/restrictions.js new file mode 100644 index 000000000000..2b9b91cb2462 --- /dev/null +++ b/tests/programmability/restrictions.js @@ -0,0 +1,56 @@ +const FIXED_KEY = ccf.strToBuf("hello"); +const FIXED_VALUE = ccf.strToBuf("world"); + +export function try_read(request) { + const table_name = request.body.json().table; + var handle; + try { + handle = ccf.kv[table_name]; + } catch (e) { + return { + statusCode: 400, + body: `Failed to get handle for table: ${table_name}\n${e}`, + }; + } + + try { + const v = handle.get(FIXED_KEY); + } catch (e) { + return { + statusCode: 400, + body: `Failed to read from handle for table: ${table_name}\n${e}`, + }; + } + + return { + statusCode: 200, + body: `Permitted to read from table: ${table_name}`, + }; +} + +export function try_write(request) { + const table_name = request.body.json().table; + var handle; + try { + handle = ccf.kv[table_name]; + } catch (e) { + return { + statusCode: 400, + body: `Failed to get handle for table: ${table_name}\n${e}`, + }; + } + + try { + handle.set(FIXED_KEY, FIXED_VALUE); + } catch (e) { + return { + statusCode: 400, + body: `Failed to write to handle for table: ${table_name}\n${e}`, + }; + } + + return { + statusCode: 200, + body: `Permitted to write to table: ${table_name}`, + }; +}