diff --git a/Cargo.lock b/Cargo.lock index a297e69263d..b4720645fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3676,6 +3676,13 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "invocation-flags-test" +version = "0.0.0" +dependencies = [ + "spacetimedb", +] + [[package]] name = "ipnet" version = "2.11.0" diff --git a/Cargo.toml b/Cargo.toml index e679f9d8c4a..acbe2bd1eca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "modules/perf-test", "modules/module-test", "modules/environment-test", + "modules/invocation-flags-test", "templates/basic-rs/spacetimedb", "templates/chat-console-rs/spacetimedb", "modules/sdk-test", diff --git a/crates/bindings-cpp/README.md b/crates/bindings-cpp/README.md index 155fafedfda..3aba197cb05 100644 --- a/crates/bindings-cpp/README.md +++ b/crates/bindings-cpp/README.md @@ -2,6 +2,10 @@ The SpacetimeDB C++ Module Library provides a modern C++20 API for building SpacetimeDB modules that run inside the database as WebAssembly. +## Invocation authentication + +`ctx.sender_auth().is_internal()` captures host-verified invocation authority. JWT identity is the verified sender supplied by the host. Procedures preserve authentication in `with_tx` and `try_with_tx`. Internal authority is independent of connection and JWT presence. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. + ## Current State This library provides a production-ready C++ bindings for SpacetimeDB with complete type system support: diff --git a/crates/bindings-cpp/include/spacetimedb/abi/FFI.h b/crates/bindings-cpp/include/spacetimedb/abi/FFI.h index f3ad213452f..9133eed5863 100644 --- a/crates/bindings-cpp/include/spacetimedb/abi/FFI.h +++ b/crates/bindings-cpp/include/spacetimedb/abi/FFI.h @@ -74,6 +74,7 @@ using ::identity; // ===== JWT ===== using ::get_jwt; using ::env_get; +using ::get_call_auth_flags; // ===== Procedure Transactions ===== using ::procedure_start_mut_tx; diff --git a/crates/bindings-cpp/include/spacetimedb/abi/abi.h b/crates/bindings-cpp/include/spacetimedb/abi/abi.h index 502cd408be3..cf22ab5c8e2 100644 --- a/crates/bindings-cpp/include/spacetimedb/abi/abi.h +++ b/crates/bindings-cpp/include/spacetimedb/abi/abi.h @@ -41,6 +41,8 @@ #define STDB_IMPORT_10_6(name) \ __attribute__((import_module("spacetime_10.6"), import_name(#name))) extern +#define STDB_IMPORT_10_7(name) \ + __attribute__((import_module("spacetime_10.7"), import_name(#name))) extern // Import opaque types into global namespace for C compatibility using SpacetimeDB::Status; @@ -64,6 +66,9 @@ extern "C" { STDB_IMPORT_10_6(env_get) Status env_get(const uint8_t* key, uint32_t key_len, BytesSource* out); +// Verified invocation authority. Bit 0 is INTERNAL; JWT presence is independent. +STDB_IMPORT_10_7(get_call_auth_flags) +uint32_t get_call_auth_flags(); // ===== Table and Index Management ===== STDB_IMPORT(table_id_from_name) diff --git a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h index 3bc62f21997..a8ae7fc62c9 100644 --- a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h +++ b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h @@ -26,26 +26,27 @@ struct ConnectionId; * This class uses lazy loading - the JWT is only fetched and parsed when accessed. */ class AuthCtx { - friend struct HandlerContext; - private: bool is_internal_; + std::optional verified_sender_; mutable std::shared_ptr> jwt_; std::function()> jwt_loader_; // Private constructor used by factory methods - AuthCtx(bool is_internal, std::function()> loader); + AuthCtx(bool is_internal, std::function()> loader, + std::optional verified_sender = std::nullopt); + static AuthCtx from_connection_with_flags(ConnectionId connection_id, Identity sender, uint32_t flags); public: /** * @brief Creates an AuthCtx from an optional ConnectionId. * * If the connection_id is present, creates an AuthCtx that will load the JWT. - * If the connection_id is absent, creates an internal AuthCtx. + * Internal authority is captured from the host, independently of connection presence. * * @param connection_id Optional connection ID - * @param sender The identity of the caller (already derived from JWT claims by the host) - * @return An AuthCtx based on the connection_id + * @param sender The verified caller Identity supplied by the host + * @return An AuthCtx with captured invocation authority and lazy JWT loading */ static AuthCtx from_connection_id_opt(std::optional connection_id, Identity sender); @@ -65,11 +66,10 @@ class AuthCtx { * This is primarily used for testing purposes, allowing you to create * an AuthCtx with specific JWT claims without needing a real connection. * - * Note: The Identity must be computed by calling the host function, - * as we cannot compute Blake3 hashes in WASM. + * The Identity must be the verified sender supplied by the host. * * @param jwt_payload The raw JWT payload (JSON claims) - * @param identity The identity derived from the JWT's issuer and subject + * @param identity The verified sender Identity * @return An AuthCtx with the provided JWT */ static AuthCtx from_jwt_payload(std::string jwt_payload, Identity identity); @@ -78,11 +78,10 @@ class AuthCtx { * @brief Creates an AuthCtx that reads the JWT for the given connection ID. * * The JWT will be lazily loaded from the host when first accessed. - * The identity parameter is the sender's identity, already derived from - * JWT claims by the host (using Blake3 hashing). + * The identity parameter is the verified sender supplied by the host. * * @param connection_id The connection ID to load the JWT for - * @param sender The identity of the caller (already derived from JWT claims by the host) + * @param sender The verified sender Identity supplied by the host * @return An AuthCtx that will load the JWT on demand */ static AuthCtx from_connection_id(ConnectionId connection_id, Identity sender); @@ -95,9 +94,9 @@ class AuthCtx { bool is_internal() const { return is_internal_; } /** - * @brief Checks if there is a JWT without loading it. + * @brief Checks if there is a JWT, loading it lazily if necessary. * - * If is_internal() returns true, this will return false. + * Independent of is_internal(). Internal calls can also have a JWT. * * @return true if a JWT is available */ @@ -115,9 +114,8 @@ class AuthCtx { /** * @brief Gets the caller's identity. * - * For internal calls, this returns the database's identity. - * For external calls, this returns the identity derived from the JWT - * (based on the issuer and subject claims). + * Returns the verified sender captured when constructing the context, + * independently of JWT presence or token claims. * * @return The caller's Identity */ @@ -128,16 +126,16 @@ class AuthCtx { // INLINE IMPLEMENTATIONS // ============================================================================ -constexpr uint16_t ERROR_BUFFER_TOO_SMALL = 11; - -inline AuthCtx::AuthCtx(bool is_internal, std::function()> loader) - : is_internal_(is_internal), jwt_loader_(std::move(loader)) {} +inline AuthCtx::AuthCtx(bool is_internal, std::function()> loader, + std::optional verified_sender) + : is_internal_(is_internal), verified_sender_(std::move(verified_sender)), jwt_loader_(std::move(loader)) {} inline AuthCtx AuthCtx::from_connection_id_opt(std::optional connection_id, Identity sender) { + const auto flags = FFI::get_call_auth_flags(); if (connection_id.has_value()) { - return from_connection_id(*connection_id, std::move(sender)); + return from_connection_with_flags(*connection_id, std::move(sender), flags); } else { - return internal(); + return AuthCtx((flags & 1) != 0, []() -> std::optional { return std::nullopt; }, sender); } } @@ -146,13 +144,17 @@ inline AuthCtx AuthCtx::internal() { } inline AuthCtx AuthCtx::from_jwt_payload(std::string jwt_payload, Identity identity) { - return AuthCtx(false, [payload = std::move(jwt_payload), id = std::move(identity)]() mutable -> std::optional { + return AuthCtx(false, [payload = std::move(jwt_payload), id = identity]() mutable -> std::optional { return JwtClaims(std::move(payload), std::move(id)); - }); + }, identity); } inline AuthCtx AuthCtx::from_connection_id(ConnectionId connection_id, Identity sender) { - return AuthCtx(false, [connection_id, sender]() -> std::optional { + return from_connection_with_flags(connection_id, std::move(sender), FFI::get_call_auth_flags()); +} + +inline AuthCtx AuthCtx::from_connection_with_flags(ConnectionId connection_id, Identity sender, uint32_t flags) { + return AuthCtx((flags & 1) != 0, [connection_id, sender]() -> std::optional { // Call the host FFI to get the JWT BytesSource jwt_source; @@ -171,35 +173,24 @@ inline AuthCtx AuthCtx::from_connection_id(ConnectionId connection_id, Identity } // Read the JWT payload from the BytesSource - std::vector buffer; - buffer.resize(4096); // Start with 4KB buffer - - size_t buffer_len = buffer.size(); - int16_t result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); - - while (result == ERROR_BUFFER_TOO_SMALL) { - buffer.resize(buffer.size() * 2); - buffer_len = buffer.size(); - result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); - } - - if (result < 0) { - return std::nullopt; + std::array buffer; + std::string jwt_payload; + for (;;) { + size_t buffer_len = buffer.size(); + const auto result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); + if (result != 0 && result != -1) return std::nullopt; + jwt_payload.append(reinterpret_cast(buffer.data()), buffer_len); + // -1 is successful exhaustion and may include the final payload bytes. + if (result == -1) break; + if (buffer_len == 0) return std::nullopt; } - - // Convert bytes to string - std::string jwt_payload(buffer.begin(), buffer.begin() + buffer_len); - - // Use the provided sender identity (already computed by host from JWT claims) + if (jwt_payload.empty()) return std::nullopt; + // Token claims cannot override the verified sender, including hosted tokens. return JwtClaims(std::move(jwt_payload), sender); - }); + }, sender); } inline bool AuthCtx::has_jwt() const { - if (is_internal_) { - return false; - } - // Load the JWT if not already loaded, then check if it has a value // This ensures has_jwt() and get_jwt() are consistent return get_jwt().has_value(); @@ -213,6 +204,7 @@ inline const std::optional& AuthCtx::get_jwt() const { } inline Identity AuthCtx::get_caller_identity() const { + if (verified_sender_.has_value()) return *verified_sender_; if (is_internal_) { // Return database identity for internal calls std::array identity_bytes; diff --git a/crates/bindings-cpp/include/spacetimedb/handler_context.h b/crates/bindings-cpp/include/spacetimedb/handler_context.h index ec6fea1de47..ea86e511142 100644 --- a/crates/bindings-cpp/include/spacetimedb/handler_context.h +++ b/crates/bindings-cpp/include/spacetimedb/handler_context.h @@ -67,8 +67,7 @@ struct HandlerContext { return ReducerContext( Identity{}, std::nullopt, - tx_timestamp, - AuthCtx(false, [] { return std::nullopt; }) + tx_timestamp ); }; return Internal::with_tx(make_reducer_ctx, body); @@ -80,8 +79,7 @@ struct HandlerContext { return ReducerContext( Identity{}, std::nullopt, - tx_timestamp, - AuthCtx(false, [] { return std::nullopt; }) + tx_timestamp ); }; return Internal::try_with_tx(make_reducer_ctx, body); diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index 551fb3bc636..d69f83a0c89 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -31,5 +31,5 @@ namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector, std::vector, std::vector) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/src/internal/v10_builder.cpp b/crates/bindings-cpp/src/internal/v10_builder.cpp index be5bbd65035..e03917e8561 100644 --- a/crates/bindings-cpp/src/internal/v10_builder.cpp +++ b/crates/bindings-cpp/src/internal/v10_builder.cpp @@ -249,6 +249,9 @@ RawModuleDefV10 V10Builder::BuildModuleDef() const { RawModuleDefV10Section section_typespace; section_typespace.set<0>(typespace_); v10_module.sections.push_back(section_typespace); + RawModuleDefV10Section capabilities; + capabilities.set<16>(std::vector{"hosted_auth_v1"}); + v10_module.sections.push_back(std::move(capabilities)); if (!types.empty()) { RawModuleDefV10Section section_types; diff --git a/crates/bindings-cpp/tests/environment/main.cpp b/crates/bindings-cpp/tests/environment/main.cpp index e83d9be0483..040f81964c0 100644 --- a/crates/bindings-cpp/tests/environment/main.cpp +++ b/crates/bindings-cpp/tests/environment/main.cpp @@ -28,6 +28,12 @@ extern "C" int16_t bytes_source_read(BytesSource, uint8_t* out, size_t* size) { position += *size; return position == payload.size() ? -1 : 0; } +// The builder links the SDK module dispatcher; these unrelated host calls must +// never execute in this declaration-only fixture. +extern "C" uint32_t get_call_auth_flags() { std::abort(); } +extern "C" Status get_jwt(const uint8_t*, BytesSource*) { std::abort(); } +extern "C" int16_t bytes_source_remaining_length(BytesSource, uint32_t*) { std::abort(); } +extern "C" Status bytes_sink_write(BytesSink, const uint8_t*, size_t*) { std::abort(); } extern "C" void console_log(LogLevel, const uint8_t*, size_t, const uint8_t*, size_t, uint32_t, const uint8_t*, size_t) {} int main() { @@ -53,4 +59,16 @@ int main() { section.set<15>(entries); assert(section.get_tag() == 15); assert(section.get<15>() == entries); + const auto module = Internal::V10Builder{}.BuildModuleDef(); + bool saw_environment = false, saw_capabilities = false; + for (const auto& emitted : module.sections) { + if (emitted.get_tag() == 15) { + assert(!saw_environment && emitted.get<15>() == entries); + saw_environment = true; + } else if (emitted.get_tag() == 16) { + assert(!saw_capabilities && emitted.get<16>() == std::vector{"hosted_auth_v1"}); + saw_capabilities = true; + } + } + assert(saw_environment && saw_capabilities); } diff --git a/crates/bindings-cpp/tests/unit/CMakeLists.txt b/crates/bindings-cpp/tests/unit/CMakeLists.txt index da7b8705e1c..ddd46c6933e 100644 --- a/crates/bindings-cpp/tests/unit/CMakeLists.txt +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -14,21 +14,24 @@ add_executable(bindings_cpp_unit_tests environment_unit_tests.cpp ) -target_include_directories(bindings_cpp_unit_tests PRIVATE +add_executable(hosted_auth_unit_tests main.cpp hosted_auth_unit_tests.cpp) + +foreach(test_target bindings_cpp_unit_tests hosted_auth_unit_tests) +target_include_directories(${test_target} PRIVATE ../../include ) -target_compile_definitions(bindings_cpp_unit_tests PRIVATE +target_compile_definitions(${test_target} PRIVATE SPACETIMEDB_UNSTABLE_FEATURES ) if(MSVC) - target_compile_options(bindings_cpp_unit_tests PRIVATE /W4) + target_compile_options(${test_target} PRIVATE /W4) else() - target_compile_options(bindings_cpp_unit_tests PRIVATE -Wall -Wextra) + target_compile_options(${test_target} PRIVATE -Wall -Wextra) endif() -target_link_options(bindings_cpp_unit_tests PRIVATE +target_link_options(${test_target} PRIVATE "SHELL:-sWASM=1" "SHELL:-sENVIRONMENT=node" "SHELL:-sEXIT_RUNTIME=1" @@ -36,4 +39,5 @@ target_link_options(bindings_cpp_unit_tests PRIVATE "SHELL:-O2" ) -set_target_properties(bindings_cpp_unit_tests PROPERTIES SUFFIX ".cjs") +set_target_properties(${test_target} PROPERTIES SUFFIX ".cjs") +endforeach() diff --git a/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp b/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp index c6bd0f58cd8..37a271a05e9 100644 --- a/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp +++ b/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp @@ -1,4 +1,5 @@ #include "test_harness.h" +#include "spacetimedb.h" #include "spacetimedb/environment.h" #include "spacetimedb/bsatn/reader.h" #include @@ -18,6 +19,14 @@ extern "C" Status env_get(const uint8_t* key, uint32_t key_len, BytesSource* out return Status{0}; } +extern "C" uint32_t get_call_auth_flags() { return 0; } + +extern "C" Status get_jwt(const uint8_t*, BytesSource* out) { + payload_offset = 0; + *out = BytesSource{payload.empty() ? 0u : 1u}; + return Status{0}; +} + extern "C" int16_t bytes_source_read(BytesSource, uint8_t* out, size_t* len) { *len = std::min(*len, payload.size() - payload_offset); std::memcpy(out, payload.data() + payload_offset, *len); @@ -48,3 +57,19 @@ TEST_CASE(optional_reader_matches_canonical_bsatn_tags_and_preserves_following_b ASSERT_EQ(std::string("a\0b", 3), bsatn::deserialize>(reader).value()); ASSERT_EQ(uint8_t{42}, reader.read_u8()); } + +TEST_CASE(jwt_source_reads_all_chunks_including_final_exhausted_bytes) { + payload = "{\"padding\":\"" + std::string(8192, 'x') + "\",\"sub\":\"last\"}"; + auto ctx = AuthCtx::from_connection_id(ConnectionId(5), Identity{}); + ASSERT_TRUE(ctx.has_jwt()); + ASSERT_EQ(std::string("last"), ctx.get_jwt()->subject()); + ASSERT_EQ(payload.size(), payload_offset); +} + +TEST_CASE(jwt_source_keeps_final_bytes_from_a_single_read) { + payload = R"({"sub":"short"})"; + auto ctx = AuthCtx::from_connection_id(ConnectionId(5), Identity{}); + ASSERT_TRUE(ctx.has_jwt()); + ASSERT_EQ(std::string("short"), ctx.get_jwt()->subject()); + ASSERT_EQ(payload.size(), payload_offset); +} diff --git a/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp b/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp new file mode 100644 index 00000000000..692487046f0 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp @@ -0,0 +1,96 @@ +#include "test_harness.h" +#include "spacetimedb/procedure_context.h" + +#include +#include + +using namespace SpacetimeDB; + +namespace { +uint32_t auth_flags; +size_t flag_reads; +size_t jwt_reads; +size_t payload_offset; +std::string jwt_payload; + +Identity verified_sender() { + std::array bytes{}; + bytes[0] = 42; + return Identity(bytes); +} + +void reset_host(uint32_t flags, std::string payload = {}) { + auth_flags = flags; + flag_reads = jwt_reads = payload_offset = 0; + jwt_payload = std::move(payload); +} +} + +extern "C" uint32_t get_call_auth_flags() { + ++flag_reads; + return auth_flags; +} + +extern "C" Status get_jwt(const uint8_t* connection, BytesSource* out) { + ++jwt_reads; + payload_offset = 0; + const bool no_connection = std::all_of(connection, connection + 16, [](uint8_t b) { return b == 0; }); + *out = BytesSource{jwt_payload.empty() || no_connection ? 0u : 1u}; + return Status{0}; +} + +extern "C" int16_t bytes_source_read(BytesSource, uint8_t* out, size_t* len) { + *len = std::min(*len, jwt_payload.size() - payload_offset); + std::memcpy(out, jwt_payload.data() + payload_offset, *len); + payload_offset += *len; + // Successful exhaustion can return the last bytes together with -1. + return payload_offset == jwt_payload.size() ? -1 : 0; +} + +extern "C" void identity(uint8_t* out) { std::memset(out, 0, 32); } +extern "C" Status procedure_start_mut_tx(int64_t* out) { *out = 0; return Status{0}; } +extern "C" Status procedure_commit_mut_tx() { return Status{0}; } +extern "C" Status procedure_abort_mut_tx() { return Status{0}; } +extern "C" void console_log(LogLevel, const uint8_t*, size_t, const uint8_t*, size_t, + uint32_t, const uint8_t*, size_t) {} + +TEST_CASE(authority_without_connection_is_captured_from_host) { + for (uint32_t flags : {0u, 1u}) { + reset_host(flags); + auto ctx = AuthCtx::from_connection_id_opt(std::nullopt, verified_sender()); + auth_flags = flags ^ 1; + ASSERT_EQ(size_t{1}, flag_reads); + ASSERT_EQ(flags == 1, ctx.is_internal()); + ASSERT_TRUE(!ctx.has_jwt()); + ASSERT_EQ(verified_sender(), ctx.get_caller_identity()); + ASSERT_EQ(size_t{0}, jwt_reads); + } +} + +TEST_CASE(internal_call_retains_lazy_jwt_and_verified_identity) { + reset_host(1, R"({"iss":"other","sub":"other","identity":"untrusted"})"); + auto ctx = AuthCtx::from_connection_id(ConnectionId(5), verified_sender()); + auth_flags = 0; + ASSERT_TRUE(ctx.is_internal()); + ASSERT_EQ(size_t{0}, jwt_reads); + ASSERT_TRUE(ctx.has_jwt()); + ASSERT_EQ(verified_sender(), ctx.get_jwt()->get_identity()); + ASSERT_EQ(std::string("other"), ctx.get_jwt()->subject()); + ASSERT_EQ(size_t{1}, jwt_reads); +} + +TEST_CASE(procedure_transactions_preserve_authority_connection_and_sender) { + for (uint64_t connection : {0u, 5u}) { + reset_host(1, R"({"sub":"worker"})"); + ProcedureContext ctx(verified_sender(), Timestamp::from_micros_since_epoch(0), ConnectionId(connection)); + ctx.with_tx([&](TxContext& tx) { + ASSERT_TRUE(tx.sender_auth().is_internal()); + ASSERT_EQ(verified_sender(), tx.sender()); + ASSERT_TRUE(tx.connection_id.has_value()); + ASSERT_EQ(ConnectionId(connection), tx.connection_id.value()); + ASSERT_EQ(connection != 0, tx.sender_auth().has_jwt()); + if (connection) ASSERT_EQ(verified_sender(), tx.sender_auth().get_jwt()->get_identity()); + }); + ASSERT_EQ(size_t{1}, flag_reads); + } +} diff --git a/crates/bindings-cpp/tests/unit/run-unit-tests.sh b/crates/bindings-cpp/tests/unit/run-unit-tests.sh index 7de9aba68b8..71c6e994611 100644 --- a/crates/bindings-cpp/tests/unit/run-unit-tests.sh +++ b/crates/bindings-cpp/tests/unit/run-unit-tests.sh @@ -38,13 +38,13 @@ echo "==> Configuring unit tests" echo echo "==> Building unit tests" -cmake --build "$BUILD_DIR" --target bindings_cpp_unit_tests +cmake --build "$BUILD_DIR" echo echo "==> Running unit tests" -LAUNCHER="$BUILD_DIR/bindings_cpp_unit_tests.cjs" +for LAUNCHER in "$BUILD_DIR"/*_unit_tests.cjs; do if [[ ! -f "$LAUNCHER" ]]; then - echo "Could not find built bindings_cpp_unit_tests.cjs launcher" >&2 + echo "Could not find built unit test launcher" >&2 exit 1 fi @@ -53,3 +53,4 @@ if [[ $VERBOSE -eq 1 ]]; then else node "$LAUNCHER" fi +done diff --git a/crates/bindings-csharp/README.md b/crates/bindings-csharp/README.md index 947f57c9a08..f7fd4a65ac2 100644 --- a/crates/bindings-csharp/README.md +++ b/crates/bindings-csharp/README.md @@ -21,6 +21,10 @@ The [`Codegen`](./Codegen/) and [`Runtime`](./Runtime/) libraries are used: They provide all of the functionality needed to write SpacetimeDB modules in C#. See their READMEs for more information. +### Invocation authentication + +`ctx.SenderAuth.IsInternal` captures host-verified invocation authority. JWT identity is the verified sender supplied by the host. Internal authority is independent of connection and JWT presence. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. + ### Declared environment A module may declare one `[SpacetimeDB.Env]` struct. `string` is required and diff --git a/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs b/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs new file mode 100644 index 00000000000..ada932fb631 --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs @@ -0,0 +1,43 @@ +namespace Runtime.Tests; + +using SpacetimeDB; + +public class HostedAuthTests +{ + [Theory] + [InlineData(0u, false)] + [InlineData(1u, true)] + public void NoJwtCallsPreserveVerifiedInternalFlag(uint flags, bool expectedInternal) + { + var auth = AuthCtx.FromVerifiedCall(flags, () => null); + Assert.Equal(expectedInternal, auth.IsInternal); + Assert.False(auth.HasJwt); + Assert.Null(auth.Jwt); + } + + [Fact] + public void InternalCallCanRetainJwtAndVerifiedSenderIdentity() + { + var sender = Identity.FromHexString(new string('a', 64)); + var reads = 0; + var flags = 1u; + var auth = AuthCtx.FromVerifiedCall( + flags, + () => + { + reads++; + return new JwtClaims( + "{\"iss\":\"different-issuer\",\"sub\":\"different-subject\",\"identity\":\"untrusted\"}", + sender + ); + } + ); + flags = 0; + Assert.True(auth.IsInternal); + Assert.Equal(0, reads); + Assert.True(auth.HasJwt); + Assert.Equal(sender, auth.Jwt!.Identity); + Assert.Equal("different-subject", auth.Jwt.Subject); + Assert.Equal(1, reads); + } +} diff --git a/crates/bindings-csharp/Runtime/AuthCtx.cs b/crates/bindings-csharp/Runtime/AuthCtx.cs index 679dff924b5..68b14d01aff 100644 --- a/crates/bindings-csharp/Runtime/AuthCtx.cs +++ b/crates/bindings-csharp/Runtime/AuthCtx.cs @@ -18,12 +18,10 @@ private AuthCtx(bool isInternal, Func jwtFactory) internal static AuthCtx Anonymous() => new(isInternal: false, jwtFactory: () => null); /// - /// Create an AuthCtx for an internal call, with no JWT. + /// Capture verified invocation authority independently from lazy JWT loading. /// - private static AuthCtx Internal() - { - return new AuthCtx(isInternal: true, jwtFactory: () => null); - } + internal static AuthCtx FromVerifiedCall(uint callAuthFlags, Func jwtFactory) => + new(isInternal: (callAuthFlags & 1) != 0, jwtFactory); /// /// Create an AuthCtx by looking up the credentials for a connection id in system tables. @@ -33,20 +31,27 @@ private static AuthCtx Internal() /// public static AuthCtx BuildFromSystemTables(ConnectionId? connectionId, Identity identity) { + // Read synchronously while this invocation is active. Neither connection + // presence nor token claims determine internal authority. + var callAuthFlags = SpacetimeDB.Internal.FFI.get_call_auth_flags(); if (connectionId == null) { - return Internal(); + return FromVerifiedCall(callAuthFlags, () => null); } - return FromConnectionId(connectionId.Value, identity); + return FromConnectionId(connectionId.Value, identity, callAuthFlags); } /// /// Create an AuthCtx that reads JWT for a given connection ID. /// - private static AuthCtx FromConnectionId(ConnectionId connectionId, Identity identity) + private static AuthCtx FromConnectionId( + ConnectionId connectionId, + Identity identity, + uint callAuthFlags + ) { - return new AuthCtx( - isInternal: false, + return FromVerifiedCall( + callAuthFlags, jwtFactory: () => { var result = SpacetimeDB.Internal.FFI.get_jwt(ref connectionId, out var source); @@ -67,23 +72,18 @@ private static AuthCtx FromConnectionId(ConnectionId connectionId, Identity iden } /// - /// True if this reducer was spawned from inside the database. + /// True if the host verified internal authority for this invocation. /// public bool IsInternal => _isInternal; /// /// Check if there is a JWT present. - /// If IsInternal is true, this will be false. + /// Independent of IsInternal. An internal call may also have a JWT. /// public bool HasJwt { get { - if (_isInternal) - { - return false; - } - // At this point we do load the bytes. return _jwtLazy.Value != null; } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 52f750e3903..3cbd9ac2683 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -24,6 +24,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List HttpRoutes, System.Collections.Generic.List ViewPrimaryKeys, System.Collections.Generic.List Submodules, - System.Collections.Generic.List Environment + System.Collections.Generic.List Environment, + System.Collections.Generic.List Capabilities )>; } diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 8957759d8eb..c0e30ffe7f7 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -109,6 +109,14 @@ internal static partial class FFI #endif ; + const string StdbNamespace10_7 = +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER + "spacetime_10.7" +#else + "bindings" +#endif + ; + const string StdbNamespace10_6 = #if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.6" @@ -125,6 +133,10 @@ public static unsafe partial CheckedStatus env_get( out BytesSource source ); + [WasmImportLinkage] + [LibraryImport(StdbNamespace10_7)] + public static partial uint get_call_auth_flags(); + [NativeMarshalling(typeof(Marshaller))] public struct CheckedStatus { diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 438c3439146..260a0ec3265 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -167,6 +167,7 @@ internal RawModuleDefV10 BuildModuleDefinition() { new RawModuleDefV10Section.Typespace(typespace), new RawModuleDefV10Section.Environment(environment), + new RawModuleDefV10Section.Capabilities(["hosted_auth_v1"]), }; if (typeDefs.Count > 0) diff --git a/crates/bindings-csharp/Runtime/JwtClaims.cs b/crates/bindings-csharp/Runtime/JwtClaims.cs index 3ca3e11e029..bbd53cdfd25 100644 --- a/crates/bindings-csharp/Runtime/JwtClaims.cs +++ b/crates/bindings-csharp/Runtime/JwtClaims.cs @@ -16,8 +16,8 @@ public sealed class JwtClaims /// /// Create a JwtClaims from a raw JWT payload (JSON claims) and its associated Identity. /// - /// This only takes an Identity because the Blake3 hash package on nuget wraps rust code. - /// We should not expose this constructor publicly, but it is needed for AuthCtx. + /// Identity is the verified sender provided by the host. Claims cannot + /// override it, including for hosted database credentials. /// internal JwtClaims(string jwt, Identity identity) { diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index b877876b37f..8a0f12e7a30 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -134,6 +134,10 @@ IMPORT(Status, datastore_clear, (TableId table_id, uint64_t* count), (table_id, count)); #undef SPACETIME_MODULE_VERSION + +#define SPACETIME_MODULE_VERSION "spacetime_10.7" +IMPORT(uint32_t, get_call_auth_flags, (void), ()); +#undef SPACETIME_MODULE_VERSION #define SPACETIME_MODULE_VERSION "spacetime_10.6" IMPORT(Status, env_get, (const uint8_t* key, uint32_t key_len, BytesSource* source), (key, key_len, source)); diff --git a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets index 45d4b570d58..10bec84a2ef 100644 --- a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets +++ b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets @@ -66,6 +66,7 @@ + diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index c683d4c8a59..1470fd4e1f2 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -883,6 +883,14 @@ pub mod raw { pub fn datastore_clear(table_id: TableId, out: *mut u64) -> u16; } + #[link(wasm_import_module = "spacetime_10.7")] + unsafe extern "C" { + /// Authentication flags for the active invocation. Bit 0 is INTERNAL. + /// Read at context construction; neither a missing connection ID nor JWT + /// claims imply internal authority. Unknown bits must be ignored. + pub fn get_call_auth_flags() -> u32; + } + #[link(wasm_import_module = "spacetime_10.6")] unsafe extern "C" { /// Read a UTF-8 environment value. Writes INVALID for a missing key; @@ -1675,3 +1683,10 @@ pub mod procedure { } } } + +/// Read host-verified authentication flags for the active invocation. +/// Bit 0 is INTERNAL; all other bits are reserved. +pub fn get_call_auth_flags() -> u32 { + // SAFETY: no pointers or guest-provided values are passed to the host. + unsafe { raw::get_call_auth_flags() } +} diff --git a/crates/bindings-typescript/README.md b/crates/bindings-typescript/README.md index a006b8489fd..6ac1d49dcf5 100644 --- a/crates/bindings-typescript/README.md +++ b/crates/bindings-typescript/README.md @@ -18,6 +18,10 @@ You can use the package in the browser, using a bundler like vite/parcel/rsbuild ### Usage +#### Module invocation authentication + +`ctx.senderAuth.isInternal` captures host-verified invocation authority independently of connection and JWT presence. `ctx.senderAuth.jwt.identity` is the verified sender. Procedure transactions preserve authentication. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. + In order to connect to a database you have to generate module bindings for your database. ```ts diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index ef51c0682ed..6e47663aa70 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -396,6 +396,7 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get Environment() { return __t.array(EnvironmentDeclaration); }, + Capabilities: __t.array(__t.string()), }); export type RawModuleDefV10Section = __Infer; diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index f2d470152ec..485c3ee5f0f 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -61,7 +61,7 @@ export type Reducer = ( * Authentication information for the caller of a reducer. */ export type AuthCtx = Readonly<{ - /** Whether the caller is an internal system process. */ + /** Whether the host verified internal invocation authority. Independent of JWT presence. */ isInternal: boolean; /** Whether the caller has authenticated with a JWT token. */ hasJWT: boolean; @@ -93,7 +93,7 @@ export interface JwtClaims { readonly issuer: string; /** The audience of the JWT token ('aud') */ readonly audience: readonly string[]; - /** The identity associated with the JWT token, which is based on the sub and iss */ + /** The verified sender Identity provided by the host, including hosted credentials. */ readonly identity: Identity; /** The full payload as a JsonObject */ readonly fullPayload: JsonObject; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index 67b263d8397..5260b0a58d0 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -200,6 +200,7 @@ export class ModuleContext { lifeCycleReducers: [], httpHandlers: [], httpRoutes: [], + capabilities: ['hosted_auth_v1'], caseConversionPolicy: { tag: 'SnakeCase' }, explicitNames: { entries: [], @@ -222,6 +223,7 @@ export class ModuleContext { const module = this.#moduleDef; push(module.typespace && { tag: 'Typespace', value: module.typespace }); + push({ tag: 'Capabilities', value: module.capabilities }); push(module.types && { tag: 'Types', value: module.types }); push(module.tables && { tag: 'Tables', value: module.tables }); push(module.reducers && { tag: 'Reducers', value: module.reducers }); diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 70c6eb81401..524f8b5796b 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -1,6 +1,7 @@ import { environment, type EnvironmentFor } from './environment'; import * as _syscalls2_0 from 'spacetime:sys@2.0'; import * as _syscalls2_1 from 'spacetime:sys@2.1'; +import * as _syscalls2_3 from 'spacetime:sys@2.3'; import type { ModuleHooks, u128, u16, u256, u32 } from 'spacetime:sys@2.0'; import { @@ -80,7 +81,7 @@ import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; -export const sys = { ..._syscalls2_0, ..._syscalls2_1 }; +export const sys = { ..._syscalls2_0, ..._syscalls2_1, ..._syscalls2_3 }; function requestFromWire(request: HttpRequest, body: Uint8Array): Request { return Request[makeRequest](body, { @@ -125,7 +126,8 @@ class JwtClaimsImpl implements JwtClaims { /** * Creates a new JwtClaims instance. * @param rawPayload The JWT payload as a raw JSON string. - * @param identity The identity for this JWT. We are only taking this because we don't have a blake3 implementation (which we need to compute it). + * @param identity The verified sender Identity supplied by the host. Claims + * cannot override it, including for hosted database credentials. */ constructor( public readonly rawPayload: string, @@ -199,29 +201,21 @@ class AuthCtxImpl implements AuthCtx { return this._jwtClaims!; } - /** Create a context representing internal (non-user) requests. */ - static internal(): AuthCtx { - return new AuthCtxImpl({ - isInternal: true, - jwtSource: () => null, - senderIdentity: Identity.zero(), - }); - } - /** If there is a connection id, look up the JWT payload from the system tables. */ static fromSystemTables( connectionId: ConnectionId | null, sender: Identity ): AuthCtx { + const callAuthFlags = sys.get_call_auth_flags(); if (connectionId === null) { return new AuthCtxImpl({ - isInternal: false, + isInternal: (callAuthFlags & 1) !== 0, jwtSource: () => null, senderIdentity: sender, }); } return new AuthCtxImpl({ - isInternal: false, + isInternal: (callAuthFlags & 1) !== 0, jwtSource: () => { const payloadBuf = sys.get_jwt_payload(connectionId.__connection_id__); if (payloadBuf.length === 0) return null; diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 77a9dacaf4e..f9712fcc99a 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -124,6 +124,11 @@ declare module 'spacetime:sys@2.1' { export function datastore_clear(table_id: u32): u64; } +declare module 'spacetime:sys@2.3' { + /** Verified invocation flags. Bit 0 is INTERNAL; JWT presence is independent. */ + export function get_call_auth_flags(): number; +} + declare module 'spacetime:sys@2.2' { /** Null means missing; an empty string is a present value. */ export function env_get(key: string): string | null; diff --git a/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts b/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts new file mode 100644 index 00000000000..940221f4710 --- /dev/null +++ b/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts @@ -0,0 +1,2 @@ +// Ordinary host calls are external unless a test supplies trusted flags. +export const get_call_auth_flags = (): number => 0; diff --git a/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts b/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts new file mode 100644 index 00000000000..5202167f3f7 --- /dev/null +++ b/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts @@ -0,0 +1,2 @@ +// Tests have no environment unless a fixture supplies one. +export const env_get = (_name: string): string | null => null; diff --git a/crates/bindings-typescript/tests/environment.test.ts b/crates/bindings-typescript/tests/environment.test.ts index fe2c821b4f3..c6334c625e7 100644 --- a/crates/bindings-typescript/tests/environment.test.ts +++ b/crates/bindings-typescript/tests/environment.test.ts @@ -43,9 +43,14 @@ describe('declared database environment', () => { { name: 'get', ty: { tag: 'String' }, optional: true }, ]); const defined = schema({}, { env: declarations }); - const section = defined - .buildRawModuleDefV10({}) - .sections.find(section => section.tag === 'Environment'); + const moduleDef = defined.buildRawModuleDefV10({}); + expect(moduleDef.sections).toContainEqual({ + tag: 'Capabilities', + value: ['hosted_auth_v1'], + }); + const section = moduleDef.sections.find( + section => section.tag === 'Environment' + ); expect(section).toEqual({ tag: 'Environment', value: environmentDeclarations(declarations), diff --git a/crates/bindings-typescript/tests/hosted_auth.test.ts b/crates/bindings-typescript/tests/hosted_auth.test.ts new file mode 100644 index 00000000000..5b6ff5fba19 --- /dev/null +++ b/crates/bindings-typescript/tests/hosted_auth.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const host = vi.hoisted(() => ({ + flags: 0, + payload: '', + jwtReads: 0, + flagReads: 0, + conflicts: 0, +})); +vi.mock('spacetime:sys@2.0', () => ({ + moduleHooks: Symbol('moduleHooks'), + identity: () => 1n, + row_iter_bsatn_close: () => {}, + procedure_start_mut_tx: () => 0n, + procedure_commit_mut_tx: () => { + if (host.conflicts-- > 0) throw new Error('transaction conflict'); + }, + procedure_abort_mut_tx: () => {}, + get_jwt_payload: () => { + host.jwtReads++; + return new TextEncoder().encode(host.payload); + }, +})); +vi.mock('spacetime:sys@2.3', () => ({ + get_call_auth_flags: () => { + host.flagReads++; + return host.flags; + }, +})); + +// Load procedures first so its existing runtime import cycle initializes in order. +import { callProcedure } from '../src/server/procedures'; +import { ReducerCtxImpl } from '../src/server/runtime'; +import { ConnectionId } from '../src/lib/connection_id'; +import { Identity } from '../src/lib/identity'; +import { Timestamp } from '../src/lib/timestamp'; +import { schema, exportContext, registerExport } from '../src/server/schema'; +import { t } from '../src/lib/type_builders'; + +beforeEach(() => { + Object.assign(host, { + flags: 0, + payload: '', + jwtReads: 0, + flagReads: 0, + conflicts: 0, + }); +}); + +describe('verified invocation authentication', () => { + it.each([0, 1])( + 'preserves flag %s for calls without a connection or JWT', + flags => { + host.flags = flags; + const ctx = new ReducerCtxImpl( + new Identity(1n), + Timestamp.UNIX_EPOCH, + null, + {} + ); + expect(host.flagReads).toBe(0); + expect(ctx.senderAuth.isInternal).toBe(Boolean(flags)); + expect(host.flagReads).toBe(1); + expect(ctx.senderAuth.hasJWT).toBe(false); + expect(ctx.senderAuth.jwt).toBeNull(); + expect(host.jwtReads).toBe(0); + } + ); + + it('retains an internal connection and JWT independently, using the verified sender Identity', () => { + host.flags = 1; + host.payload = JSON.stringify({ + iss: 'unrelated-issuer', + sub: 'unrelated-subject', + identity: 'untrusted-claim', + }); + const sender = new Identity(123n); + const connection = new ConnectionId(7n); + const ctx = new ReducerCtxImpl( + sender, + Timestamp.UNIX_EPOCH, + connection, + {} + ); + expect(ctx.connectionId).toBe(connection); + expect(ctx.senderAuth.isInternal).toBe(true); + expect(host.jwtReads).toBe(0); + expect(ctx.senderAuth.hasJWT).toBe(true); + expect(ctx.senderAuth.jwt?.identity).toBe(sender); + expect(ctx.senderAuth.jwt?.subject).toBe('unrelated-subject'); + expect(host.jwtReads).toBe(1); + }); + + it('refreshes captured flags and sender when a cached reducer context is reused', () => { + host.flags = 1; + const ctx = new ReducerCtxImpl( + new Identity(1n), + Timestamp.UNIX_EPOCH, + null, + {} + ); + const firstAuth = ctx.senderAuth; + host.flags = 0; + ReducerCtxImpl.reset( + ctx, + new Identity(2n), + Timestamp.UNIX_EPOCH, + new ConnectionId(8n) + ); + expect(firstAuth.isInternal).toBe(true); + expect(ctx.senderAuth.isInternal).toBe(false); + expect(ctx.senderAuth.hasJWT).toBe(false); + }); + + it.each([0, 1])( + 'reads invocation flag %s in every procedure transaction retry', + flags => { + host.flags = flags; + host.conflicts = 1; + const sender = new Identity(9n); + const connection = new ConnectionId(8n); + let attempts = 0; + const module = schema({}); + const proc = module.procedure(t.unit(), ctx => { + ctx.withTx(tx => { + attempts++; + expect(tx.senderAuth.isInternal).toBe(Boolean(flags)); + expect(tx.sender).toBe(sender); + expect(tx.connectionId).toBe(connection); + }); + return {}; + }); + const inner = proc[exportContext]!; + proc[registerExport](inner, 'procedure_auth'); + callProcedure( + inner.procedures, + 0, + sender, + connection, + Timestamp.UNIX_EPOCH, + new Uint8Array(), + () => ({}) + ); + expect(attempts).toBe(2); + expect(host.flagReads).toBe(2); + } + ); +}); + +describe('hosted authentication capability', () => { + it('advertises the updated bindings without changing function visibility', () => { + const module = schema({}); + const run = module.reducer(() => {}); + const inner = run[exportContext]!; + run[registerExport](inner, 'run'); + expect(inner.moduleDef.capabilities).toContain('hosted_auth_v1'); + expect(inner.moduleDef.reducers[0].visibility.tag).toBe('ClientCallable'); + }); +}); diff --git a/crates/bindings-typescript/vitest.config.ts b/crates/bindings-typescript/vitest.config.ts index f532d437b74..a1c3f9cf8dd 100644 --- a/crates/bindings-typescript/vitest.config.ts +++ b/crates/bindings-typescript/vitest.config.ts @@ -14,7 +14,18 @@ export default defineConfig({ alias: [ { find: 'spacetime:sys@2.0', replacement: sysMock }, { find: 'spacetime:sys@2.1', replacement: sysMock }, - { find: 'spacetime:sys@2.2', replacement: sysMock }, + { + find: 'spacetime:sys@2.3', + replacement: fileURLToPath( + new URL('./tests/__mocks__/spacetime-auth.ts', import.meta.url) + ), + }, + { + find: 'spacetime:sys@2.2', + replacement: fileURLToPath( + new URL('./tests/__mocks__/spacetime-environment.ts', import.meta.url) + ), + }, ], }, test: { diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 94fe822db20..4dfcc43604d 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -143,12 +143,12 @@ impl HandlerContext { /// Acquire a mutable transaction and execute `body` with read-write access. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, Identity::ZERO, None, true) + with_tx(body, Identity::ZERO, None) } /// Acquire a mutable transaction and execute `body` with read-write access. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, Identity::ZERO, None, true) + try_with_tx(body, Identity::ZERO, None) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. @@ -829,6 +829,11 @@ mod tests { static COMMIT_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + #[unsafe(no_mangle)] + extern "C" fn get_call_auth_flags() -> u32 { + 0 // HTTP handlers are external invocations. + } + #[unsafe(no_mangle)] unsafe extern "C" fn procedure_start_mut_tx(out: *mut i64) -> u16 { unsafe { out.write(0) }; diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 47cdd9e07cf..15c7b3e7880 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1139,7 +1139,7 @@ impl ReducerContext { sender, timestamp, connection_id, - sender_auth: AuthCtx::from_connection_id_opt(connection_id), + sender_auth: AuthCtx::from_connection_id_opt(connection_id, sender), #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), #[cfg(feature = "rand08")] @@ -1272,7 +1272,6 @@ fn try_with_tx( body: impl Fn(&TxContext) -> Result, identity: Identity, connection_id: Option, - is_http_handler: bool, ) -> Result { let abort = || { crate::sys::procedure::procedure_abort_mut_tx() @@ -1284,11 +1283,7 @@ fn try_with_tx( .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - let mut tx = ReducerContext::new(crate::Local {}, identity, connection_id, timestamp); - if is_http_handler { - // HTTP requests have no connection ID, but are not host-originated calls. - tx.sender_auth = AuthCtx::new(false, || None); - } + let tx = ReducerContext::new(crate::Local {}, identity, connection_id, timestamp); let tx = TxContext(tx); struct DoOnDrop(F); @@ -1321,14 +1316,9 @@ fn try_with_tx( res } -fn with_tx( - body: impl Fn(&TxContext) -> T, - identity: Identity, - connection_id: Option, - is_http_handler: bool, -) -> T { +fn with_tx(body: impl Fn(&TxContext) -> T, identity: Identity, connection_id: Option) -> T { use core::convert::Infallible; - match try_with_tx::(|tx| Ok(body(tx)), identity, connection_id, is_http_handler) { + match try_with_tx::(|tx| Ok(body(tx)), identity, connection_id) { Ok(v) => v, Err(e) => match e {}, } @@ -1469,7 +1459,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, self.sender(), self.connection_id(), false) + with_tx(body, self.sender(), self.connection_id()) } /// Acquire a mutable transaction @@ -1502,7 +1492,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, self.sender(), self.connection_id(), false) + try_with_tx(body, self.sender(), self.connection_id()) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. @@ -1935,6 +1925,7 @@ impl CtxWithHttp for ProcedureContext { /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token #[non_exhaustive] pub struct JwtClaims { + identity: Option, payload: String, parsed: OnceCell, audience: OnceCell>, @@ -1950,10 +1941,16 @@ pub struct AuthCtx { } impl AuthCtx { - /// Creates an [`AuthCtx`] both for cases where there's a [`ConnectionId`] - /// and for when there isn't. - fn from_connection_id_opt(conn_id: Option) -> Self { - conn_id.map(Self::from_connection_id).unwrap_or_else(Self::internal) + /// Capture host authority immediately. JWT loading remains independent and lazy. + fn from_connection_id_opt(connection_id: Option, sender: Identity) -> Self { + let flags = spacetimedb_bindings_sys::get_call_auth_flags(); + Self::from_host_auth(sender, flags, move || connection_id.and_then(rt::get_jwt)) + } + + fn from_host_auth(sender: Identity, flags: u32, jwt_fn: impl FnOnce() -> Option + 'static) -> Self { + Self::new(flags & 1 != 0, move || { + jwt_fn().map(|payload| JwtClaims::new(payload, Some(sender))) + }) } fn new(is_internal: bool, jwt_fn: impl FnOnce() -> Option + 'static) -> Self { @@ -1976,14 +1973,7 @@ impl AuthCtx { /// /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token pub fn from_jwt_payload(jwt_payload: String) -> AuthCtx { - Self::new(false, move || Some(JwtClaims::new(jwt_payload))) - } - - /// Creates an [`AuthCtx`] that reads the [JWT] for the given connection id. - /// - /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token - fn from_connection_id(connection_id: ConnectionId) -> AuthCtx { - Self::new(false, move || rt::get_jwt(connection_id).map(JwtClaims::new)) + Self::new(false, move || Some(JwtClaims::new(jwt_payload, None))) } /// Returns whether this reducer was spawned from inside the database. @@ -1991,8 +1981,8 @@ impl AuthCtx { self.is_internal } - /// Checks if there is a [JWT] without loading it. - /// If [`AuthCtx::is_internal`] returns true, this will return false. + /// Returns whether this invocation has a [JWT]. Internal invocations may + /// also carry a JWT; internal authority and credential presence are independent. /// /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token pub fn has_jwt(&self) -> bool { @@ -2008,8 +1998,9 @@ impl AuthCtx { } impl JwtClaims { - fn new(jwt: String) -> Self { + fn new(jwt: String, identity: Option) -> Self { Self { + identity, payload: jwt, parsed: OnceCell::new(), audience: OnceCell::new(), @@ -2051,10 +2042,11 @@ impl JwtClaims { self.audience.get_or_init(|| self.extract_audience()) } - /// Returns the identity for these credentials, which is - /// based on the iss and sub claims. + /// The effective sender verified by the host for this invocation. + /// Hosted database credentials need not derive this Identity from iss/sub. pub fn identity(&self) -> Identity { - Identity::from_claims(self.issuer(), self.subject()) + self.identity + .unwrap_or_else(|| Identity::from_claims(self.issuer(), self.subject())) } /// Get the whole JWT payload as a json string. @@ -2207,3 +2199,48 @@ mod tests { assert_eq!(audience, &["my-project-id".to_string()]); } } + +#[cfg(test)] +mod hosted_auth_tests { + use super::*; + + #[test] + fn host_authority_is_independent_of_jwt_presence() { + for internal in [false, true] { + for has_jwt in [false, true] { + let flags = u32::from(internal) | (1 << 31); + let auth = AuthCtx::from_host_auth(Identity::ONE, flags, move || { + has_jwt.then(|| r#"{"iss":"hosted","sub":"generation","hex_identity":"untrusted"}"#.to_string()) + }); + assert_eq!(auth.is_internal(), internal); + assert_eq!(auth.has_jwt(), has_jwt); + if let Some(jwt) = auth.jwt() { + assert_eq!(jwt.identity(), Identity::ONE); + assert_ne!(jwt.identity(), Identity::from_claims(jwt.issuer(), jwt.subject())); + } + } + } + } + + #[test] + fn verified_sender_wins_over_signed_payload_identity_claims() { + let payload = format!( + r#"{{"iss":"hosted","sub":"generation","hex_identity":"{}"}}"#, + Identity::ZERO.to_hex() + ); + let auth = AuthCtx::from_host_auth(Identity::ONE, 1, move || Some(payload)); + assert_eq!(auth.jwt().unwrap().identity(), Identity::ONE); + assert!(auth.is_internal()); + assert!(auth.has_jwt()); + } + + #[test] + fn test_payload_remains_lazy_and_can_omit_identity_claims() { + let auth = AuthCtx::from_jwt_payload("not JSON".into()); + assert_eq!(auth.jwt().unwrap().raw_payload(), "not JSON"); + let auth = AuthCtx::from_jwt_payload(r#"{"aud":"test"}"#.into()); + assert_eq!(auth.jwt().unwrap().audience(), &["test"]); + let auth = AuthCtx::from_jwt_payload(r#"{"iss":"test","sub":"user"}"#.into()); + assert_eq!(auth.jwt().unwrap().identity(), Identity::from_claims("test", "user")); + } +} diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 6caf1c4ee04..89e29ae5891 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -1066,6 +1066,9 @@ extern "C" fn __describe_module__(description: BytesSink) { describer(&mut module) } + // These bindings capture host flags and preserve the verified sender in JWT claims. + module.inner.add_capability("hosted_auth_v1"); + // Serialize the module to bsatn. module.inner.ensure_environment(); let module_def = module.inner.finish(); diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index 26fedc29250..f7b659cf9b9 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -55,6 +55,9 @@ use tokio::sync::{watch, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as use tokio::time::error::Elapsed; use tokio::time::{interval_at, timeout, Instant}; +#[cfg(test)] +mod invocation_flags_tests; + // TODO: // // - [db::Config] should be per-[Database] diff --git a/crates/core/src/host/host_controller/invocation_flags_tests.rs b/crates/core/src/host/host_controller/invocation_flags_tests.rs new file mode 100644 index 00000000000..a140cf4d7d2 --- /dev/null +++ b/crates/core/src/host/host_controller/invocation_flags_tests.rs @@ -0,0 +1,140 @@ +//! Run actual V8 hosts without a network or external service. +use super::*; +use crate::db::persistence::LocalPersistenceProvider; +use crate::host::module_host::CallProcedureParams; +use crate::host::{ArgsTuple, FunctionArgs}; +use spacetimedb_lib::db::raw_def::{v10::FunctionVisibility, v10::RawModuleDefV10Builder, v9::Lifecycle}; +use spacetimedb_paths::FromPathUnchecked; +use spacetimedb_primitives::ProcedureId; +use spacetimedb_sats::{AlgebraicType, ProductType}; + +fn program() -> Program { + let mut schema = RawModuleDefV10Builder::new(); + schema.add_lifecycle_reducer(Lifecycle::Init, "init", ProductType::unit()); + schema.add_reducer("external", ProductType::unit()); + schema.add_reducer("private", ProductType::unit()); + schema.add_procedure("external_procedure", ProductType::unit(), AlgebraicType::U8); + schema.add_procedure("system_procedure", ProductType::unit(), AlgebraicType::U8); + let mut schema = schema.finish(); + for section in &mut schema.sections { + if let spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Section::Reducers(reducers) = section { + reducers + .iter_mut() + .find(|r| &*r.source_name == "private") + .unwrap() + .visibility = FunctionVisibility::Private; + } + } + let schema = spacetimedb_lib::bsatn::to_vec(&spacetimedb_lib::RawModuleDef::V10(schema)).unwrap(); + Program::from_bytes( + ModuleKind::JS, + format!( + r#" + import {{ register_hooks }} from "spacetime:sys@1.0"; + import {{ register_hooks as register_procedures }} from "spacetime:sys@1.2"; + import {{ get_call_auth_flags }} from "spacetime:sys@2.3"; + register_hooks({{ + __describe_module__: function() {{ return new Uint8Array({schema:?}); }}, + __call_reducer__: function(id) {{ + const expected = id === 0 ? 1 : 0; + if (get_call_auth_flags() !== expected) {{ throw new Error("incorrect invocation flags"); }} + return {{ tag: "ok" }}; + }}, + }}); + register_procedures({{ __call_procedure__: function() {{ + return new Uint8Array([get_call_auth_flags()]); + }} }}); + "# + ) + .into_bytes(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn invocation_flags_are_host_owned_and_lifecycle_calls_remain_restricted() { + let directory = tempfile::tempdir().unwrap(); + let data = Arc::new(ServerDataDir::from_path_unchecked(directory.path().to_owned())); + let program = program(); + let initial = program.clone(); + let storage = move |hash| { + let program = initial.clone(); + async move { Ok((program.hash == hash).then_some(program.bytes)) } + }; + let controller = HostController::new( + data.clone(), + db::Config { + storage: db::Storage::Memory, + page_pool_max_size: None, + }, + HostRuntimeConfig::default(), + Arc::new(storage), + Arc::new(NullEnergyMonitor), + Arc::new(()), + Arc::new(LocalPersistenceProvider::new(data)), + JobCores::without_pinned_cores(), + ); + let database = Database { + id: 0xab10, + database_identity: Identity::from_u256(0xab10u64.into()), + owner_identity: Identity::ONE, + host_type: HostType::Js, + initial_program: program.hash, + bootstrap_generation: 0, + }; + // The init reducer itself asserts flags=1, so successful construction also + // verifies the real host-to-JS syscall path for a trusted lifecycle call. + let module = controller + .get_or_launch_module_host(database.clone(), database.id) + .await + .unwrap(); + for sender in [database.owner_identity, database.database_identity, Identity::ZERO] { + module + .call_reducer(sender, None, None, None, None, "external", FunctionArgs::Nullary) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + for name in ["init"] { + assert!(module + .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) + .await + .is_err()); + } + let result = module + .call_procedure(sender, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(0)); + assert_eq!( + module + .call_reducer(sender, None, None, None, None, "private", FunctionArgs::Nullary) + .await + .is_ok(), + sender == database.owner_identity, + ); + } + // Trusted host work uses its explicit constructor. An ordinary later call + // observes zero again even if the procedure instance is reused. + let result = module + .call_procedure_with_params( + "system_procedure", + CallProcedureParams::from_system( + Timestamp::now(), + database.database_identity, + ProcedureId(1), + ArgsTuple::nullary(), + ), + ) + .await + .unwrap(); + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(1)); + let result = module + .call_procedure(Identity::ONE, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(0)); + drop(module); + controller + .exit_module_host(database.id, Duration::from_secs(5)) + .await + .unwrap(); +} diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 97609d60bb3..ba556ff08a8 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -57,6 +57,7 @@ pub struct InstanceEnv { in_anon_tx: bool, /// A procedure's last known transaction offset. procedure_last_tx_offset: Option, + call_auth_flags: u32, } /// `InstanceEnv` needs to be `Send` because it is created on the host thread @@ -243,6 +244,7 @@ impl InstanceEnv { environment_call_active: false, in_anon_tx: false, procedure_last_tx_offset: None, + call_auth_flags: 0, } } @@ -258,6 +260,15 @@ impl InstanceEnv { self.func_type = func_type; self.func_name = Some(name); self.environment_call_active = true; + self.call_auth_flags = 0; + } + + pub(crate) fn set_call_auth_flags(&mut self, flags: u32) { + self.call_auth_flags = flags; + } + + pub(crate) fn get_call_auth_flags(&self) -> u32 { + self.call_auth_flags } /// Returns the name of the most recent reducer to be run in this environment, diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index 01e4dee6309..7aa76a15189 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -193,6 +193,7 @@ pub enum AbiCall { JwtLength, GetJwt, EnvGet, + GetCallAuthFlags, VolatileNonatomicScheduleImmediate, diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 1e9b27af2e8..a4238ac1ecf 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -821,6 +821,7 @@ pub struct CallReducerParams { pub timestamp: Timestamp, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub(crate) call_auth_flags: u32, pub client: Option>, pub request_id: Option, pub timer: Option, @@ -841,6 +842,7 @@ impl CallReducerParams { timestamp, caller_identity, caller_connection_id: ConnectionId::ZERO, + call_auth_flags: 1, client: None, request_id: None, timer: None, @@ -1249,6 +1251,7 @@ pub struct CallProcedureParams { pub timestamp: Timestamp, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub(crate) call_auth_flags: u32, pub timer: Option, pub procedure_id: ProcedureId, pub args: ArgsTuple, @@ -1267,6 +1270,7 @@ impl CallProcedureParams { timestamp, caller_identity, caller_connection_id: ConnectionId::ZERO, + call_auth_flags: 1, timer: None, procedure_id, args, @@ -2337,6 +2341,7 @@ impl ModuleHost { timestamp: Timestamp::now(), caller_identity, caller_connection_id, + call_auth_flags: 0, client, request_id, timer, @@ -2904,6 +2909,7 @@ impl ModuleHost { timestamp: Timestamp::now(), caller_identity, caller_connection_id, + call_auth_flags: 0, timer, procedure_id, args, diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 90a83691fd8..207a4a08ec6 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -2007,6 +2007,7 @@ where // Start the timer. // We'd like this tightly around `call`. env.start_funcall(op.name().clone(), op.timestamp(), op.call_type()); + env.instance_env.set_call_auth_flags(op.call_auth_flags()); // Wrap the call in `TryCatch`. // @@ -2132,6 +2133,7 @@ mod test { name: &ReducerName::for_test("foobar"), caller_identity: &Identity::ONE, caller_connection_id: &ConnectionId::ZERO, + call_auth_flags: 0, timestamp: Timestamp::from_micros_since_unix_epoch(24), args: &ArgsTuple::nullary(), }; diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index e00cf40d1f0..03421af0437 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -44,6 +44,7 @@ pub fn call_call_procedure( name: _, caller_identity: sender, caller_connection_id: connection_id, + call_auth_flags: _, timestamp, arg_bytes: procedure_args, } = op; diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 467e7f26562..bb942a47f2d 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -63,6 +63,7 @@ fn resolve_sys_module_inner<'scope>( (2, 0) => Ok(v2::sys_v2_0(scope)), (2, 1) => Ok(v2::sys_v2_1(scope)), (2, 2) => Ok(v2::sys_v2_2(scope)), + (2, 3) => Ok(v2::sys_v2_3(scope)), _ => Err(TypeError(format!( "Could not import {spec:?}, likely because this module was built for a newer version of SpacetimeDB.\n\ It requires sys module v{major}.{minor}, but that version is not supported by the database." diff --git a/crates/core/src/host/v8/syscall/v1.rs b/crates/core/src/host/v8/syscall/v1.rs index f3aea9f6c52..4d6783465df 100644 --- a/crates/core/src/host/v8/syscall/v1.rs +++ b/crates/core/src/host/v8/syscall/v1.rs @@ -495,6 +495,7 @@ pub(super) fn call_call_reducer( name: _, caller_identity: sender, caller_connection_id: conn_id, + call_auth_flags: _, timestamp, args: reducer_args, } = op; diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index 3e25145fc74..bb441a2a89c 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -169,6 +169,18 @@ pub(super) fn sys_v2_1<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope ) } +pub(super) fn sys_v2_3<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { + create_synthetic_module!( + scope, + "spacetime:sys@2.3", + (with_sys_result, AbiCall::GetCallAuthFlags, get_call_auth_flags), + ) +} + +fn get_call_auth_flags(scope: &mut PinScope<'_, '_>, _args: FunctionCallbackArguments<'_>) -> SysCallResult { + Ok(get_env(scope)?.instance_env.get_call_auth_flags()) +} + pub(super) fn sys_v2_2<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { create_synthetic_module!(scope, "spacetime:sys@2.2", (with_sys_result, AbiCall::EnvGet, env_get),) } @@ -467,6 +479,7 @@ pub(super) fn call_call_reducer<'scope>( name: _, caller_identity: sender, caller_connection_id: conn_id, + call_auth_flags: _, timestamp, args: reducer_args, } = op; diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index f5ab623fec3..0967d222870 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -445,6 +445,7 @@ macro_rules! abi_funcs { "spacetime_10.5"::datastore_clear, "spacetime_10.6"::env_get, + "spacetime_10.7"::get_call_auth_flags, } $link_async! { diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 5ec1fea3047..bf176615395 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -1063,6 +1063,7 @@ impl InstanceCommon { timestamp, caller_identity, caller_connection_id, + call_auth_flags, client, request_id, reducer_id, @@ -1086,6 +1087,7 @@ impl InstanceCommon { name: reducer_name, caller_identity: &caller_identity, caller_connection_id: &caller_connection_id, + call_auth_flags, timestamp, args: &args, }; @@ -1963,6 +1965,9 @@ pub trait InstanceOp { fn name(&self) -> &NamespacedIdentifier; fn timestamp(&self) -> Timestamp; fn call_type(&self) -> FuncCallType; + fn call_auth_flags(&self) -> u32 { + 0 + } } /// Describes a view call in a cheaply shareable way. @@ -2023,6 +2028,7 @@ pub struct ReducerOp<'a> { pub name: &'a ReducerName, pub caller_identity: &'a Identity, pub caller_connection_id: &'a ConnectionId, + pub call_auth_flags: u32, pub timestamp: Timestamp, /// The arguments passed to the reducer. pub args: &'a ArgsTuple, @@ -2038,6 +2044,9 @@ impl InstanceOp for ReducerOp<'_> { fn call_type(&self) -> FuncCallType { FuncCallType::Reducer } + fn call_auth_flags(&self) -> u32 { + self.call_auth_flags + } } impl From> for execution_context::ReducerContext { @@ -2047,6 +2056,7 @@ impl From> for execution_context::ReducerContext { name, caller_identity, caller_connection_id, + call_auth_flags: _, timestamp, args, }: ReducerOp<'_>, @@ -2068,6 +2078,7 @@ pub struct ProcedureOp { pub name: NamespacedIdentifier, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub call_auth_flags: u32, pub timestamp: Timestamp, pub arg_bytes: Bytes, } @@ -2084,6 +2095,7 @@ impl ProcedureOp { name, caller_identity: params.caller_identity, caller_connection_id: params.caller_connection_id, + call_auth_flags: params.call_auth_flags, timestamp: params.timestamp, arg_bytes: params.args.get_bsatn().clone(), }, @@ -2103,6 +2115,9 @@ impl InstanceOp for ProcedureOp { fn call_type(&self) -> FuncCallType { FuncCallType::Procedure } + fn call_auth_flags(&self) -> u32 { + self.call_auth_flags + } } /// Describes an HTTP handler call in a cheaply shareable way. @@ -2345,7 +2360,7 @@ mod tests { .into_iter() .enumerate() { - let params = CallProcedureParams::from_system( + let mut params = CallProcedureParams::from_system( Timestamp::UNIX_EPOCH, Identity::ZERO, ProcedureId::from(index), @@ -2355,6 +2370,19 @@ mod tests { assert_eq!(&**op.name(), expected_name); assert_eq!(op.name().is_namespaced(), index != 0); assert_eq!(op.id, params.procedure_id); + assert_eq!( + op.call_auth_flags(), + 1, + "system authority survives canonical resolution" + ); + params.call_auth_flags = 0; + let (external_op, _, _) = ProcedureOp::for_module(&module, ¶ms).unwrap(); + assert_eq!(external_op.name(), op.name()); + assert_eq!( + external_op.call_auth_flags(), + 0, + "namespaces cannot grant internal authority" + ); assert_eq!(&*def.name, "read_env", "declaration names remain local"); if index == 2 { let expected = AlgebraicValue::Product(ProductValue::from_iter([AlgebraicValue::Bool(true)])); diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 0c22ee54694..9770a311407 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -340,6 +340,14 @@ impl WasmInstanceEnv { self.bytes_sinks.remove(&sink).unwrap_or_default() } + pub fn get_call_auth_flags(caller: Caller<'_, Self>) -> u32 { + caller.data().instance_env.get_call_auth_flags() + } + + pub(crate) fn set_call_auth_flags(&mut self, flags: u32) { + self.instance_env.set_call_auth_flags(flags); + } + /// Signal to this `WasmInstanceEnv` that a reducer or procedure call is beginning. /// /// Returns the handle used by reducers and procedures to read from `args` diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index be8dd719582..13c71b89343 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -55,7 +55,7 @@ impl WasmtimeModule { WasmtimeModule { module } } - pub const IMPLEMENTED_ABI: abi::VersionTuple = abi::VersionTuple::new(10, 6); + pub const IMPLEMENTED_ABI: abi::VersionTuple = abi::VersionTuple::new(10, 7); pub(super) fn link_imports(linker: &mut Linker) -> anyhow::Result<()> { link_imports(linker, AsyncImportMode::SyncStub) @@ -647,6 +647,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store .data_mut() .start_funcall(reducer_name, args_bytes, op.timestamp, op.call_type()); + store.data_mut().set_call_auth_flags(op.call_auth_flags); let call_result = call_sync_typed_func( &self.call_reducer, @@ -770,6 +771,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store .data_mut() .start_funcall(op.name().clone(), op.arg_bytes, op.timestamp, FuncCallType::Procedure); + store.data_mut().set_call_auth_flags(op.call_auth_flags); let Some(call_procedure) = self.call_procedure.as_ref() else { let res = module_host_actor::ProcedureExecuteResult { diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 9aec59ffaa8..c011551ff34 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -104,6 +104,10 @@ pub enum RawModuleDefV10Section { /// Declared publish-only configuration. Even an empty section requires ENV support. Environment(Vec), + + /// Module bindings capabilities, independent of function visibility. + /// Older hosts reject this section instead of silently ignoring its requirements. + Capabilities(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -1225,6 +1229,22 @@ impl RawModuleDefV10Builder { }); } + /// Declare a module bindings capability. + pub fn add_capability(&mut self, capability: impl Into) { + if let Some(RawModuleDefV10Section::Capabilities(names)) = self + .module + .sections + .iter_mut() + .find(|section| matches!(section, RawModuleDefV10Section::Capabilities(_))) + { + names.push(capability.into()); + } else { + self.module + .sections + .push(RawModuleDefV10Section::Capabilities(vec![capability.into()])); + } + } + /// Add a row-level security policy to the module. /// /// The `sql` expression should be a valid SQL expression that will be used to filter rows. @@ -1507,3 +1527,120 @@ impl RawTableDefBuilderV10<'_> { .map(|i| ColId(i as u16)) } } + +#[cfg(test)] +mod compatibility_tests { + use super::*; + use crate::{bsatn, RawModuleDef}; + + // Frozen pre-extension wire types. Do not replace the visibility, function, + // or section definitions below with their current counterparts. + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacyVisibility { + Private, + ClientCallable, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyReducer { + source_name: RawIdentifier, + params: ProductType, + visibility: LegacyVisibility, + ok_return_type: AlgebraicType, + err_return_type: AlgebraicType, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyProcedure { + source_name: RawIdentifier, + params: ProductType, + return_type: AlgebraicType, + visibility: LegacyVisibility, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacySection { + Typespace(Typespace), + Types(Vec), + Tables(Vec), + Reducers(Vec), + Procedures(Vec), + Views(Vec), + Schedules(Vec), + LifeCycleReducers(Vec), + RowLevelSecurity(Vec), + CaseConversionPolicy(CaseConversionPolicy), + ExplicitNames(ExplicitNames), + HttpHandlers(Vec), + HttpRoutes(Vec), + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyV10 { + sections: Vec, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacyModule { + V8BackCompat(crate::RawModuleDefV8), + V9(super::super::v9::RawModuleDefV9), + V10(LegacyV10), + } + + #[test] + fn existing_v10_wire_tags_and_function_products_are_unchanged() { + for (visibility, expected) in [ + (FunctionVisibility::Private, 0), + (FunctionVisibility::ClientCallable, 1), + ] { + assert_eq!(bsatn::to_vec(&visibility).unwrap(), [expected]); + } + let legacy = LegacyModule::V10(LegacyV10 { + sections: vec![ + LegacySection::Reducers(vec![LegacyReducer { + source_name: "run".into(), + params: ProductType::unit(), + visibility: LegacyVisibility::ClientCallable, + ok_return_type: reducer_default_ok_return_type(), + err_return_type: reducer_default_err_return_type(), + }]), + LegacySection::Procedures(vec![LegacyProcedure { + source_name: "read".into(), + params: ProductType::unit(), + return_type: AlgebraicType::U64, + visibility: LegacyVisibility::Private, + }]), + ], + }); + let bytes = bsatn::to_vec(&legacy).unwrap(); + assert_eq!(bytes[0], 2); + let current: RawModuleDef = bsatn::from_slice(&bytes).unwrap(); + assert_eq!(bsatn::to_vec(¤t).unwrap(), bytes); + let frozen: LegacyModule = bsatn::from_slice(&bsatn::to_vec(¤t).unwrap()).unwrap(); + assert_eq!(bsatn::to_vec(&frozen).unwrap(), bytes); + + assert_eq!( + bsatn::to_vec(&RawModuleDefV10Section::HttpRoutes(vec![])).unwrap(), + [12, 0, 0, 0, 0] + ); + assert_eq!( + bsatn::to_vec(&RawModuleDefV10Section::Capabilities(vec![])).unwrap(), + [16, 0, 0, 0, 0] + ); + } + + #[test] + fn older_hosts_reject_new_capabilities() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_capability("hosted_auth_v1"); + let bytes = bsatn::to_vec(&RawModuleDef::V10(builder.finish())).unwrap(); + assert!(bsatn::from_slice::(&bytes).is_err()); + assert!(bsatn::from_slice::(&bytes).is_ok()); + } +} diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 0d5ce696ac2..3c507da8e1a 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -15,7 +15,7 @@ //! After validation, a `ModuleDef` can be converted to the `*Schema` types in `crate::schema` for use in the database. //! (Eventually, we may unify these types...) -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Debug, Write}; use std::hash::Hash; use std::sync::LazyLock; @@ -184,6 +184,9 @@ pub struct ModuleDef { /// `None` means undeclared; an explicitly empty declaration is `Some(empty)`. environment: Option, + + /// Validated module bindings capabilities. Legacy modules have none. + capabilities: BTreeSet, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -195,6 +198,14 @@ pub enum RawModuleDefVersion { } impl ModuleDef { + /// Whether the bindings use host-verified invocation authority and sender identity. + /// Container Hosting's admission and publication checks require this marker to + /// reject legacy modules on new hosts. Those callers are introduced in the + /// Container Hosting PR; this prerequisite only defines and emits the marker. + pub fn supports_hosted_auth_v1(&self) -> bool { + self.capabilities.contains(&RawIdentifier::new("hosted_auth_v1")) + } + /// The validated root environment schema. Legacy modules have an empty schema. pub fn environment(&self) -> &EnvironmentSchema { static EMPTY: LazyLock = LazyLock::new(EnvironmentSchema::default); @@ -1018,6 +1029,7 @@ impl From for RawModuleDefV9 { raw_module_def_version: _, submodules: _, environment: _, + capabilities: _, } = val; // Extract column defaults from tables before consuming tables @@ -1079,6 +1091,7 @@ impl From for RawModuleDefV10 { raw_module_def_version: _, submodules, environment, + capabilities, } = val; let mut sections = Vec::new(); @@ -1246,6 +1259,9 @@ impl From for RawModuleDefV10 { sections.push(RawModuleDefV10Section::Submodules(submodules)); } + if !capabilities.is_empty() { + sections.push(RawModuleDefV10Section::Capabilities(capabilities.into_iter().collect())); + } RawModuleDefV10 { sections } } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 11fbcf4e3d4..4293413bdaa 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -78,6 +78,33 @@ impl From for ValidationCase { /// Validate a `RawModuleDefV10` and convert it into a `ModuleDef`, /// or return a stream of errors if the definition is invalid. pub fn validate(def: RawModuleDefV10) -> Result { + let mut seen_capabilities = false; + let mut capabilities = std::collections::BTreeSet::new(); + for section in &def.sections { + if let RawModuleDefV10Section::Capabilities(names) = section { + if seen_capabilities { + return Err(ValidationError::DuplicateModuleSection { + section: "Capabilities".into(), + } + .into()); + } + seen_capabilities = true; + if names.len() > 32 { + return Err(ValidationError::InvalidModuleCapabilities.into()); + } + for name in names { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + || !capabilities.insert(name.clone()) + { + return Err(ValidationError::InvalidModuleCapabilities.into()); + } + } + } + } let environment = validate_environment(&def); let mut typespace = def.typespace().cloned().unwrap_or_else(|| Typespace::EMPTY.clone()); let known_type_definitions = def.types().into_iter().flatten().map(|def| def.ty); @@ -321,6 +348,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { procedures, http_handlers, http_routes, + capabilities, raw_module_def_version: RawModuleDefVersion::V10, submodules, environment, @@ -2820,6 +2848,45 @@ mod tests { } } +#[cfg(test)] +mod capability_tests { + use super::*; + use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; + #[test] + fn v9_schema_export_remains_available_for_capable_v10_modules() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_capability("hosted_auth_v1"); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let _: spacetimedb_lib::db::raw_def::v9::RawModuleDefV9 = module.into(); + } + + #[test] + fn capabilities_are_explicit_bounded_and_preserved() { + let bare: ModuleDef = RawModuleDefV10Builder::new().finish().try_into().unwrap(); + assert!(!bare.supports_hosted_auth_v1()); + let mut builder = RawModuleDefV10Builder::new(); + builder.add_capability("hosted_auth_v1"); + let module: ModuleDef = builder.finish().try_into().unwrap(); + assert!(module.supports_hosted_auth_v1()); + let reloaded: ModuleDef = RawModuleDefV10::from(module).try_into().unwrap(); + assert!(reloaded.supports_hosted_auth_v1()); + for names in [ + vec!["".to_string()], + vec!["Uppercase".to_string()], + vec!["with-dash".to_string()], + vec!["a".repeat(65)], + vec!["duplicate".to_string(); 2], + (0..33).map(|i| format!("cap_{i}")).collect(), + ] { + let mut builder = RawModuleDefV10Builder::new(); + for name in names { + builder.add_capability(name); + } + assert!(ModuleDef::try_from(builder.finish()).is_err()); + } + } +} + #[cfg(test)] mod environment_tests { use super::*; @@ -2861,6 +2928,26 @@ mod environment_tests { ); } + #[test] + fn environment_and_capabilities_have_distinct_sections_and_survive_validation() { + let mut raw = declared("SECRET"); + raw.sections + .push(RawModuleDefV10Section::Capabilities(vec!["hosted_auth_v1".into()])); + let encoded = spacetimedb_lib::bsatn::to_vec(&raw).unwrap(); + let decoded = spacetimedb_lib::bsatn::from_slice(&encoded).unwrap(); + let module = validate(decoded).unwrap(); + assert!(module.environment().get("SECRET").is_some()); + assert!(module.supports_hosted_auth_v1()); + let roundtrip = validate(module.into()).unwrap(); + assert!(roundtrip.environment_declared()); + assert!(roundtrip.environment().get("SECRET").is_some()); + assert!(roundtrip.supports_hosted_auth_v1()); + assert_eq!( + spacetimedb_lib::bsatn::to_vec(&RawModuleDefV10Section::Capabilities(vec![])).unwrap(), + vec![16, 0, 0, 0, 0] + ); + } + #[test] fn environment_rejects_ambiguous_sections_and_nested_declarations() { let mut duplicate = declared("A"); diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index c6eef4f74f9..8a8d12a4520 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -169,6 +169,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { procedures, http_handlers: IndexMap::new(), http_routes: Vec::new(), + capabilities: Default::default(), raw_module_def_version: RawModuleDefVersion::V9OrEarlier, submodules: IndexMap::new(), environment: None, diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 691fef4a3f5..ff49dc4b6b1 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -22,6 +22,10 @@ pub type ValidationErrors = ErrorStream; #[derive(thiserror::Error, Debug, PartialOrd, Ord, PartialEq, Eq)] #[non_exhaustive] pub enum ValidationError { + #[error("invalid module capabilities: at most 32 unique names of 1..64 lowercase ASCII letters, digits or underscores are allowed")] + InvalidModuleCapabilities, + #[error("module contains repeated V10 section `{section}`")] + DuplicateModuleSection { section: String }, #[error("module has repeated environment declarations")] RepeatedEnvironmentDeclaration, #[error("invalid environment declaration: {error}")] diff --git a/crates/testing/tests/invocation_flags.rs b/crates/testing/tests/invocation_flags.rs new file mode 100644 index 00000000000..44d098efa5c --- /dev/null +++ b/crates/testing/tests/invocation_flags.rs @@ -0,0 +1,64 @@ +//! Exercise real Rust Wasm bindings and host admission without a server endpoint. +use serial_test::serial; +use spacetimedb::host::FunctionArgs; +use spacetimedb_lib::{AlgebraicValue, Identity}; +use spacetimedb_testing::modules::{CompilationMode, CompiledModule, DEFAULT_CONFIG}; +use std::time::Duration; + +#[test] +#[serial] +fn wasm_invocation_flags_do_not_infer_authority_from_identity_or_connection_absence() { + CompiledModule::compile("invocation-flags-test", CompilationMode::Debug).with_module_async( + DEFAULT_CONFIG, + |handle| async move { + let module = handle.client.module(); + for sender in [Identity::ZERO, Identity::ONE, handle.db_identity] { + module + .call_reducer(sender, None, None, None, None, "external", FunctionArgs::Nullary) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + for name in ["init"] { + assert!(module + .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) + .await + .is_err()); + } + let result = module + .call_procedure(sender, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::Bool(true)); + } + module + .call_reducer( + Identity::ZERO, + None, + None, + None, + None, + "schedule", + FunctionArgs::Nullary, + ) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let result = module + .call_procedure(Identity::ZERO, None, None, "scheduled_finished", FunctionArgs::Nullary) + .await; + if result.result.unwrap().return_val == AlgebraicValue::Bool(true) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the real scheduled reducer did not observe trusted internal authority"); + }, + ); +} diff --git a/modules/invocation-flags-test/Cargo.toml b/modules/invocation-flags-test/Cargo.toml new file mode 100644 index 00000000000..8b34d4125a9 --- /dev/null +++ b/modules/invocation-flags-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "invocation-flags-test" +version = "0.0.0" +edition.workspace = true +license-file = "../../LICENSE.txt" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies.spacetimedb] +workspace = true +features = ["unstable"] diff --git a/modules/invocation-flags-test/src/lib.rs b/modules/invocation-flags-test/src/lib.rs new file mode 100644 index 00000000000..fd813c39706 --- /dev/null +++ b/modules/invocation-flags-test/src/lib.rs @@ -0,0 +1,63 @@ +//! Real Wasm fixture for generic host invocation authority. +use spacetimedb::{ProcedureContext, ReducerContext, Table}; + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + assert!(ctx.sender_auth().is_internal()); + assert!(!ctx.sender_auth().has_jwt()); +} + +#[spacetimedb::reducer] +pub fn external(ctx: &ReducerContext) { + assert!(!ctx.sender_auth().is_internal()); + assert_eq!(ctx.connection_id(), None); + assert!(!ctx.sender_auth().has_jwt()); +} + +#[spacetimedb::procedure] +pub fn external_procedure(ctx: &mut ProcedureContext) -> bool { + let sender = ctx.sender(); + let connection = ctx.connection_id(); + ctx.with_tx(|tx| { + assert!(!tx.sender_auth().is_internal()); + assert_eq!(tx.sender(), sender); + assert_eq!(tx.connection_id(), connection); + }); + true +} + +#[spacetimedb::table(accessor = jobs, scheduled(scheduled))] +pub struct Job { + #[primary_key] + #[auto_inc] + id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} + +#[spacetimedb::table(accessor = finished)] +pub struct Finished { + #[primary_key] + id: u64, +} + +#[spacetimedb::reducer] +pub fn schedule(ctx: &ReducerContext) { + ctx.db.jobs().insert(Job { + id: 0, + scheduled_at: ctx.timestamp.into(), + }); +} + +#[spacetimedb::reducer] +pub fn scheduled(ctx: &ReducerContext, job: Job) { + assert!(ctx.sender_auth().is_internal()); + assert_eq!(ctx.sender(), ctx.database_identity()); + assert_eq!(ctx.connection_id(), None); + assert!(!ctx.sender_auth().has_jwt()); + ctx.db.finished().insert(Finished { id: job.id }); +} + +#[spacetimedb::procedure] +pub fn scheduled_finished(ctx: &mut ProcedureContext) -> bool { + ctx.with_tx(|tx| tx.db.finished().iter().next().is_some()) +}