CCF
Loading...
Searching...
No Matches
proposals.h
Go to the documentation of this file.
1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the Apache 2.0 License.
3#pragma once
4
8#include "js/checks.h"
13
14namespace ccf::gov::endpoints
15{
16 namespace detail
17 {
19 {
20 enum class Status : uint8_t
21 {
24 TooOld
26
27 // May be empty, a colliding proposal ID, or a min_created_at value,
28 // depending on status
29 std::string info;
30 };
31
33 ccf::kv::Tx& tx,
34 const std::string& created_at,
35 const std::vector<uint8_t>& request_digest,
36 const ccf::ProposalId& proposal_id)
37 {
38 auto* cose_recent_proposals =
39 tx.rw<ccf::COSERecentProposals>(ccf::Tables::COSE_RECENT_PROPOSALS);
40 auto key = fmt::format("{}:{}", created_at, ds::to_hex(request_digest));
41
42 if (cose_recent_proposals->has(key))
43 {
44 auto colliding_proposal_id = cose_recent_proposals->get(key);
45 if (colliding_proposal_id.has_value())
46 {
47 return {
49 *colliding_proposal_id};
50 }
51 throw std::logic_error(fmt::format(
52 "Failed to get value for existing key in {}",
53 ccf::Tables::COSE_RECENT_PROPOSALS));
54 }
55
56 std::vector<std::string> replay_keys;
57 cose_recent_proposals->foreach_key(
58 [&replay_keys](const std::string& replay_key) {
59 replay_keys.push_back(replay_key);
60 return true;
61 });
62
63 std::sort(replay_keys.begin(), replay_keys.end());
64
65 // New proposal must be more recent than median proposal kept
66 if (!replay_keys.empty())
67 {
68 const auto [min_created_at, _] =
69 ccf::nonstd::split_1(replay_keys[replay_keys.size() / 2], ":");
70 auto [key_ts, __] = ccf::nonstd::split_1(key, ":");
71 if (key_ts < min_created_at)
72 {
73 return {
75 std::string(min_created_at)};
76 }
77 }
78
79 size_t window_size = ccf::default_recent_cose_proposals_window_size;
80 auto* config_handle =
81 tx.ro<ccf::Configuration>(ccf::Tables::CONFIGURATION);
82 auto config = config_handle->get();
83 if (
84 config.has_value() &&
85 config->recent_cose_proposals_window_size.has_value())
86 {
87 window_size = config->recent_cose_proposals_window_size.value();
88 }
89 cose_recent_proposals->put(key, proposal_id);
90 // Only keep the most recent window_size proposals, to avoid
91 // unbounded memory usage
92 if (replay_keys.size() >= (window_size - 1) /* We just added one */)
93 {
94 for (size_t i = 0; i < (replay_keys.size() - (window_size - 1)); i++)
95 {
96 cose_recent_proposals->remove(replay_keys[i]);
97 }
98 }
99 return {ProposalSubmissionResult::Status::Acceptable, {} /* reason */};
100 }
101
103 ccf::kv::Tx& tx,
104 const MemberId& caller_id,
105 const std::span<const uint8_t>& cose_sign1)
106 {
107 auto* cose_governance_history =
108 tx.wo<ccf::COSEGovernanceHistory>(ccf::Tables::COSE_GOV_HISTORY);
109 cose_governance_history->put(
110 caller_id, {cose_sign1.begin(), cose_sign1.end()});
111 }
112
114 ccf::kv::Tx& tx, const ProposalId& proposal_id)
115 {
116 auto* p = tx.rw<ccf::jsgov::ProposalMap>(jsgov::Tables::PROPOSALS);
117 auto* pi =
118 tx.rw<ccf::jsgov::ProposalInfoMap>(jsgov::Tables::PROPOSALS_INFO);
119 std::vector<ProposalId> to_be_removed;
120 pi->foreach(
121 [&to_be_removed, &proposal_id](
122 const ProposalId& pid, const ccf::jsgov::ProposalInfo& pinfo) {
123 if (pid != proposal_id && pinfo.state != ProposalState::OPEN)
124 {
125 to_be_removed.push_back(pid);
126 }
127 return true;
128 });
129 for (const auto& pr : to_be_removed)
130 {
131 p->remove(pr);
132 pi->remove(pr);
133 }
134 }
135
136 // Evaluate JS functions on this proposal. Result is presented in modified
137 // proposal_info argument, which is written back to the KV by this function
138 inline void resolve_proposal(
140 ccf::NetworkState& network,
141 ccf::kv::Tx& tx,
142 const ProposalId& proposal_id,
143 const std::span<const uint8_t>& proposal_bytes,
144 ccf::jsgov::ProposalInfo& proposal_info,
145 const std::string& constitution)
146 {
147 // Create some temporaries to store resolution progress. These are written
148 // to proposal_info, and the KV, when proposals leave the Open state.
149 ccf::jsgov::Votes votes = {};
150 ccf::jsgov::VoteFailures vote_failures = {};
151
152 const std::string_view proposal{
153 reinterpret_cast<const char*>(proposal_bytes.data()),
154 proposal_bytes.size()};
155
156 auto* proposal_info_handle = tx.template rw<ccf::jsgov::ProposalInfoMap>(
157 jsgov::Tables::PROPOSALS_INFO);
158
159 // Evaluate ballots
160 for (const auto& [mid, mb] : proposal_info.ballots)
161 {
163
164 auto ballot_func = js_context.get_exported_function(
165 mb,
166 "vote",
167 fmt::format(
168 "{}[{}].ballots[{}]",
169 ccf::jsgov::Tables::PROPOSALS_INFO,
170 proposal_id,
171 mid));
172
173 std::vector<js::core::JSWrappedValue> argv = {
174 js_context.new_string(proposal),
175 js_context.new_string(proposal_info.proposer_id.value())};
176
177 auto val = js_context.call_with_rt_options(
178 ballot_func,
179 argv,
180 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
182
183 if (!val.is_exception())
184 {
185 votes[mid] = val.is_true();
186 }
187 else
188 {
189 auto [reason, trace] = js_context.error_message();
190
191 if (js_context.interrupt_data.request_timed_out)
192 {
193 reason = "Operation took too long to complete.";
194 }
195 vote_failures[mid] = ccf::jsgov::Failure{reason, trace};
196 }
197 }
198
199 // Evaluate resolve function
200 // NB: Since the only change from the calls to `apply` is some tentative
201 // votes, there is no change to the proposal stored in the KV.
202 {
203 {
205
206 auto resolve_func = js_context.get_exported_function(
207 constitution,
208 "resolve",
209 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
210
211 std::vector<js::core::JSWrappedValue> argv;
212 argv.push_back(js_context.new_string(proposal));
213
214 argv.push_back(
215 js_context.new_string(proposal_info.proposer_id.value()));
216
217 auto vs = js_context.new_array();
218 size_t index = 0;
219 for (auto& [member_id, vote_result] : votes)
220 {
221 auto v = js_context.new_obj();
222
223 JS_CHECK_OR_THROW(v.set(
224 "member_id",
225 js_context.new_string_len(member_id.data(), member_id.size())));
226 JS_CHECK_OR_THROW(v.set_bool("vote", vote_result));
227
228 JS_CHECK_OR_THROW(vs.set_at_index(index++, std::move(v)));
229 }
230 argv.push_back(vs);
231
232 // Also pass the proposal_id as a string. This is useful for proposals
233 // that want to refer to themselves in the resolve function, for
234 // example to examine/distinguish themselves other pending proposals.
235 argv.push_back(js_context.new_string(proposal_id));
236
237 auto val = js_context.call_with_rt_options(
238 resolve_func,
239 argv,
240 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
242
243 if (val.is_exception())
244 {
245 proposal_info.state = ProposalState::FAILED;
246 auto [reason, trace] = js_context.error_message();
247 if (js_context.interrupt_data.request_timed_out)
248 {
249 reason = "Operation took too long to complete.";
250 }
251 proposal_info.failure = ccf::jsgov::Failure{
252 fmt::format("Failed to resolve(): {}", reason), trace};
253 }
254 else
255 {
256 auto status = js_context.to_str(val).value_or("");
257 // NB: It is not possible to produce every possible ProposalState
258 // here! WITHDRAWN and DROPPED are states that we transition to
259 // elsewhere, but not valid return values from resolve()
260 const std::unordered_map<std::string, ProposalState>
261 js_str_to_status = {
262 {"Open", ProposalState::OPEN},
263 {"Accepted", ProposalState::ACCEPTED},
264 {"Rejected", ProposalState::REJECTED}};
265 const auto it = js_str_to_status.find(status);
266 if (it != js_str_to_status.end())
267 {
268 proposal_info.state = it->second;
269 }
270 else
271 {
272 proposal_info.state = ProposalState::FAILED;
273 proposal_info.failure = ccf::jsgov::Failure{
274 fmt::format(
275 "resolve() returned invalid status value: \"{}\"", status),
276 std::nullopt // No trace
277 };
278 }
279 }
280
281 // Ensure resolved proposal_info is visible in the KV
282 proposal_info_handle->put(proposal_id, proposal_info);
283 }
284
285 if (proposal_info.state != ProposalState::OPEN)
286 {
288
289 // Write now-permanent values back to proposal_state, and into the
290 // KV
291 proposal_info.final_votes = votes;
292 proposal_info.vote_failures = vote_failures;
293 proposal_info_handle->put(proposal_id, proposal_info);
294
295 if (proposal_info.state == ProposalState::ACCEPTED)
296 {
297 // Evaluate apply function
298 auto gov_effects =
300 if (gov_effects == nullptr)
301 {
302 throw std::logic_error(
303 "Unexpected: Could not access GovEffects subsytem");
304 }
305
307
308 js_context.add_extension(
309 std::make_shared<ccf::js::extensions::NodeExtension>(
310 gov_effects.get(), &tx));
311 js_context.add_extension(
312 std::make_shared<ccf::js::extensions::NetworkExtension>(
313 &network, &tx));
314 js_context.add_extension(
315 std::make_shared<ccf::js::extensions::GovEffectsExtension>(&tx));
316
317 auto apply_func = js_context.get_exported_function(
318 constitution,
319 "apply",
320 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
321
322 std::vector<js::core::JSWrappedValue> argv = {
323 js_context.new_string(proposal),
324 js_context.new_string(proposal_id)};
325
326 auto val = js_context.call_with_rt_options(
327 apply_func,
328 argv,
329 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
331
332 if (val.is_exception())
333 {
334 proposal_info.state = ProposalState::FAILED;
335 auto [reason, trace] = js_context.error_message();
336 if (js_context.interrupt_data.request_timed_out)
337 {
338 reason = "Operation took too long to complete.";
339 }
340 proposal_info.failure = ccf::jsgov::Failure{
341 fmt::format("Failed to apply(): {}", reason), trace};
342
343 // Update final proposal_info (in KV) again, with failure info
344 proposal_info_handle->put(proposal_id, proposal_info);
345 }
346 }
347 }
348 }
349 }
350
351 inline nlohmann::json convert_proposal_to_api_format(
352 const ProposalId& proposal_id, const ccf::jsgov::ProposalInfo& summary)
353 {
354 auto response_body = nlohmann::json::object();
355
356 response_body["proposalId"] = proposal_id;
357 response_body["proposerId"] = summary.proposer_id;
358 response_body["proposalState"] = summary.state;
359 response_body["ballotCount"] = summary.ballots.size();
360
361 std::optional<ccf::jsgov::Votes> votes = summary.final_votes;
362
363 if (votes.has_value())
364 {
365 auto final_votes = nlohmann::json::object();
366 for (const auto& [voter_id, vote_result] : *votes)
367 {
368 final_votes[voter_id.value()] = vote_result;
369 }
370 response_body["finalVotes"] = final_votes;
371 }
372
373 if (summary.vote_failures.has_value())
374 {
375 auto vote_failures = nlohmann::json::object();
376 for (const auto& [failer_id, failure] : *summary.vote_failures)
377 {
378 vote_failures[failer_id.value()] = failure;
379 }
380 response_body["voteFailures"] = vote_failures;
381 }
382
383 if (summary.failure.has_value())
384 {
385 auto failure = nlohmann::json::object();
386 response_body["failure"] = *summary.failure;
387 }
388
389 return response_body;
390 }
391 }
392
395 NetworkState& network,
396 ccf::AbstractNodeContext& node_context)
397 {
399 auto create_proposal = [&](auto& ctx, ApiVersion api_version) {
400 switch (api_version)
401 {
403 case ApiVersion::v1:
404 default:
405 {
406 const auto& cose_ident =
407 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
408
409 std::span<const uint8_t> proposal_body = cose_ident.content;
410 ccf::jsgov::ProposalInfo proposal_info;
411 std::optional<std::string> constitution;
412
413 // Construct proposal_id, as digest of request and root
414 ProposalId proposal_id;
415 std::vector<uint8_t> request_digest;
416 {
417 auto root_at_read = ctx.tx.get_root_at_read_version();
418 if (!root_at_read.has_value())
419 {
420 detail::set_gov_error(
421 ctx.rpc_ctx,
422 HTTP_STATUS_INTERNAL_SERVER_ERROR,
423 ccf::errors::InternalError,
424 "Proposal failed to bind to state.");
425 return;
426 }
427
429 hasher->update_hash(root_at_read.value().h);
430
431 request_digest = ccf::crypto::sha256(
432 cose_ident.signature.data(), cose_ident.signature.size());
433
434 hasher->update_hash(request_digest);
435
436 const ccf::crypto::Sha256Hash proposal_hash = hasher->finalise();
437 proposal_id = proposal_hash.hex_str();
438 }
439
440 // Validate proposal, by calling into JS constitution
441 {
442 constitution =
443 ctx.tx.template ro<ccf::Constitution>(ccf::Tables::CONSTITUTION)
444 ->get();
445 if (!constitution.has_value())
446 {
447 detail::set_gov_error(
448 ctx.rpc_ctx,
449 HTTP_STATUS_INTERNAL_SERVER_ERROR,
450 ccf::errors::InternalError,
451 "No constitution is set - proposals cannot be evaluated");
452 return;
453 }
454
456
457 auto validate_func = context.get_exported_function(
458 constitution.value(),
459 "validate",
460 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
461
462 auto proposal_arg = context.new_string_len(cose_ident.content);
463 auto validate_result = context.call_with_rt_options(
464 validate_func,
465 {proposal_arg},
466 ctx.tx.template ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
468
469 // Handle error cases of validation
470 {
471 if (validate_result.is_exception())
472 {
473 auto [reason, trace] = context.error_message();
474 if (context.interrupt_data.request_timed_out)
475 {
476 reason = "Operation took too long to complete.";
477 }
478 detail::set_gov_error(
479 ctx.rpc_ctx,
480 HTTP_STATUS_INTERNAL_SERVER_ERROR,
481 ccf::errors::InternalError,
482 fmt::format(
483 "Failed to execute validation: {} {}",
484 reason,
485 trace.value_or("")));
486 return;
487 }
488
489 if (!validate_result.is_obj())
490 {
491 detail::set_gov_error(
492 ctx.rpc_ctx,
493 HTTP_STATUS_INTERNAL_SERVER_ERROR,
494 ccf::errors::InternalError,
495 "Validation failed to return an object");
496 return;
497 }
498
499 std::string description;
500 auto desc = validate_result["description"];
501 if (desc.is_str())
502 {
503 description = context.to_str(desc).value_or("");
504 }
505
506 auto valid = validate_result["valid"];
507 if (!valid.is_true())
508 {
509 detail::set_gov_error(
510 ctx.rpc_ctx,
511 HTTP_STATUS_BAD_REQUEST,
512 ccf::errors::ProposalFailedToValidate,
513 fmt::format("Proposal failed to validate: {}", description));
514 return;
515 }
516 }
517
518 // Write proposal to KV
519 {
520 auto proposals_handle =
521 ctx.tx.template rw<ccf::jsgov::ProposalMap>(
522 jsgov::Tables::PROPOSALS);
523 // Introduce a read dependency, so that if identical proposal
524 // creations are in-flight and reading at the same version, all
525 // except the first conflict and are re-executed. If we ever
526 // produce a proposal ID which already exists, we must have a
527 // hash collision.
528 if (proposals_handle->has(proposal_id))
529 {
530 detail::set_gov_error(
531 ctx.rpc_ctx,
532 HTTP_STATUS_INTERNAL_SERVER_ERROR,
533 ccf::errors::InternalError,
534 "Proposal ID collision.");
535 return;
536 }
537 proposals_handle->put(
538 proposal_id, {proposal_body.begin(), proposal_body.end()});
539
540 auto proposal_info_handle =
541 ctx.tx.template wo<ccf::jsgov::ProposalInfoMap>(
542 jsgov::Tables::PROPOSALS_INFO);
543
544 proposal_info.proposer_id = cose_ident.member_id;
545 proposal_info.state = ccf::ProposalState::OPEN;
546
547 proposal_info_handle->put(proposal_id, proposal_info);
548
550 ctx.tx, cose_ident.member_id, cose_ident.envelope);
551 }
552 }
553
554 // Validate proposal's created_at time
555 {
556 // created_at, submitted as a binary integer number of seconds
557 // since epoch in the COSE Sign1 envelope, is converted to a
558 // decimal representation in ASCII, stored as a string, and
559 // compared alphanumerically. This is partly to keep governance as
560 // text-based as possible, to faciliate audit, but also to be able
561 // to benefit from future planned ordering support in the KV. To
562 // compare correctly, the string representation needs to be padded
563 // with leading zeroes, and must therefore not exceed a fixed
564 // digit width. 10 digits is enough to last until November 2286,
565 // ie. long enough.
566 if (cose_ident.protected_header.gov_msg_created_at > 9'999'999'999)
567 {
568 detail::set_gov_error(
569 ctx.rpc_ctx,
570 HTTP_STATUS_BAD_REQUEST,
571 ccf::errors::InvalidCreatedAt,
572 "Header parameter created_at value is too large");
573 return;
574 }
575
576 const auto created_at_str = fmt::format(
577 "{:0>10}", cose_ident.protected_header.gov_msg_created_at);
578
579 const auto subtime_result =
581 ctx.tx, created_at_str, request_digest, proposal_id);
582 switch (subtime_result.status)
583 {
585 {
586 detail::set_gov_error(
587 ctx.rpc_ctx,
588 HTTP_STATUS_BAD_REQUEST,
589 ccf::errors::ProposalCreatedTooLongAgo,
590 fmt::format(
591 "Proposal created too long ago, created_at must be greater "
592 "than {}",
593 subtime_result.info));
594 return;
595 }
596
598 {
599 detail::set_gov_error(
600 ctx.rpc_ctx,
601 HTTP_STATUS_BAD_REQUEST,
602 ccf::errors::ProposalReplay,
603 fmt::format(
604 "Proposal submission replay, already exists as proposal {}",
605 subtime_result.info));
606 return;
607 }
608
610 {
611 break;
612 }
613
614 default:
615 {
616 throw std::runtime_error(
617 "Invalid ProposalSubmissionResult::Status value");
618 }
619 }
620 }
621
622 // Resolve proposal (may pass immediately)
623 {
625 node_context,
626 network,
627 ctx.tx,
628 proposal_id,
629 proposal_body,
630 proposal_info,
631 constitution.value());
632
633 if (proposal_info.state == ProposalState::FAILED)
634 {
635 // If the proposal failed to apply, we want to discard the tx and
636 // not apply its side-effects to the KV state, because it may have
637 // failed mid-execution (eg - thrown an exception), in which case
638 // we do not want to apply partial writes. Note this differs from
639 // a failure that happens after a vote, in that this proposal is
640 // not recorded in the KV at all.
641 detail::set_gov_error(
642 ctx.rpc_ctx,
643 HTTP_STATUS_INTERNAL_SERVER_ERROR,
644 ccf::errors::InternalError,
645 fmt::format("{}", proposal_info.failure));
646 return;
647 }
648
649 const auto response_body = detail::convert_proposal_to_api_format(
650 proposal_id, proposal_info);
651
652 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
653 return;
654 }
655 }
656 }
657 };
658 registry
660 "/members/proposals:create",
661 HTTP_POST,
662 api_version_adapter(create_proposal),
664 .set_openapi_hidden(true)
665 .install();
666
667 auto withdraw_proposal = [&](auto& ctx, ApiVersion api_version) {
668 switch (api_version)
669 {
671 case ApiVersion::v1:
672 default:
673 {
674 const auto& cose_ident =
675 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
676 ccf::ProposalId proposal_id;
677
679 cose_ident, ctx.rpc_ctx, proposal_id))
680 {
681 return;
682 }
683
684 auto proposal_info_handle =
685 ctx.tx.template rw<ccf::jsgov::ProposalInfoMap>(
686 jsgov::Tables::PROPOSALS_INFO);
687
688 // Check proposal exists
689 auto proposal_info = proposal_info_handle->get(proposal_id);
690 if (!proposal_info.has_value())
691 {
692 // If it doesn't, then withdrawal is idempotent - we don't know if
693 // this previously existed or not, was withdrawn or accepted, but
694 // return a 204
695 ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
696 return;
697 }
698
699 // Check caller is proposer
700 const auto member_id = cose_ident.member_id;
701 if (member_id != proposal_info->proposer_id)
702 {
703 detail::set_gov_error(
704 ctx.rpc_ctx,
705 HTTP_STATUS_FORBIDDEN,
706 ccf::errors::AuthorizationFailed,
707 fmt::format(
708 "Proposal {} can only be withdrawn by proposer {}, not caller "
709 "{}.",
710 proposal_id,
711 proposal_info->proposer_id,
712 member_id));
713 return;
714 }
715
716 // If proposal is still known, and state is neither OPEN nor
717 // WITHDRAWN, return an error - caller has done something wrong
718 if (
719 proposal_info->state != ProposalState::OPEN &&
720 proposal_info->state != ProposalState::WITHDRAWN)
721 {
722 detail::set_gov_error(
723 ctx.rpc_ctx,
724 HTTP_STATUS_BAD_REQUEST,
725 ccf::errors::ProposalNotOpen,
726 fmt::format(
727 "Proposal {} is currently in state {} and cannot be withdrawn.",
728 proposal_id,
729 proposal_info->state));
730 return;
731 }
732
733 // Check proposal is open - only write withdrawal if currently
734 // open
735 if (proposal_info->state == ProposalState::OPEN)
736 {
737 proposal_info->state = ProposalState::WITHDRAWN;
738 proposal_info_handle->put(proposal_id, proposal_info.value());
739
742 ctx.tx, cose_ident.member_id, cose_ident.envelope);
743 }
744
745 auto response_body = detail::convert_proposal_to_api_format(
746 proposal_id, proposal_info.value());
747
748 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
749 return;
750 }
751 }
752 };
753 registry
755 "/members/proposals/{proposalId}:withdraw",
756 HTTP_POST,
757 api_version_adapter(withdraw_proposal),
759 .set_openapi_hidden(true)
760 .install();
761
762 auto get_proposal = [&](auto& ctx, ApiVersion api_version) {
763 switch (api_version)
764 {
766 case ApiVersion::v1:
767 default:
768 {
769 ccf::ProposalId proposal_id;
770 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
771 {
772 return;
773 }
774
775 auto proposal_info_handle =
776 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
777 jsgov::Tables::PROPOSALS_INFO);
778 auto proposal_info = proposal_info_handle->get(proposal_id);
779 if (!proposal_info.has_value())
780 {
781 detail::set_gov_error(
782 ctx.rpc_ctx,
783 HTTP_STATUS_NOT_FOUND,
784 ccf::errors::ProposalNotFound,
785 fmt::format("Could not find proposal {}.", proposal_id));
786 return;
787 }
788
789 auto response_body = detail::convert_proposal_to_api_format(
790 proposal_id, proposal_info.value());
791
792 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
793 return;
794 }
795 }
796 };
797 registry
799 "/members/proposals/{proposalId}",
800 HTTP_GET,
801 api_version_adapter(get_proposal),
802 no_auth_required)
803 .set_openapi_hidden(true)
804 .install();
805
806 auto list_proposals = [&](auto& ctx, ApiVersion api_version) {
807 switch (api_version)
808 {
810 case ApiVersion::v1:
811 default:
812 {
813 auto proposal_info_handle =
814 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
815 jsgov::Tables::PROPOSALS_INFO);
816
817 auto proposal_list = nlohmann::json::array();
818 proposal_info_handle->foreach(
819 [&proposal_list](
820 const auto& proposal_id, const auto& proposal_info) {
821 auto api_proposal = detail::convert_proposal_to_api_format(
822 proposal_id, proposal_info);
823 proposal_list.push_back(api_proposal);
824 return true;
825 });
826
827 auto response_body = nlohmann::json::object();
828 response_body["value"] = proposal_list;
829
830 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
831 return;
832 }
833 }
834 };
835 registry
837 "/members/proposals",
838 HTTP_GET,
839 api_version_adapter(list_proposals),
840 no_auth_required)
841 .set_openapi_hidden(true)
842 .install();
843
844 auto get_actions = [&](auto& ctx, ApiVersion api_version) {
845 switch (api_version)
846 {
848 case ApiVersion::v1:
849 default:
850 {
851 ccf::ProposalId proposal_id;
852 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
853 {
854 return;
855 }
856
857 auto proposal_handle = ctx.tx.template ro<ccf::jsgov::ProposalMap>(
858 jsgov::Tables::PROPOSALS);
859
860 const auto proposal = proposal_handle->get(proposal_id);
861 if (!proposal.has_value())
862 {
863 detail::set_gov_error(
864 ctx.rpc_ctx,
865 HTTP_STATUS_NOT_FOUND,
866 ccf::errors::ProposalNotFound,
867 fmt::format("Could not find proposal {}.", proposal_id));
868 return;
869 }
870
871 ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
872 ctx.rpc_ctx->set_response_body(proposal.value());
873 return;
874 break;
875 }
876 }
877 };
878 registry
880 "/members/proposals/{proposalId}/actions",
881 HTTP_GET,
882 api_version_adapter(get_actions),
883 no_auth_required)
884 .set_openapi_hidden(true)
885 .install();
886
888 auto submit_ballot = [&](
890 ApiVersion api_version) {
891 switch (api_version)
892 {
894 case ApiVersion::v1:
895 default:
896 {
897 const auto& cose_ident =
898 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
899
900 ccf::ProposalId proposal_id;
902 cose_ident, ctx.rpc_ctx, proposal_id))
903 {
904 return;
905 }
906
907 ccf::MemberId member_id;
909 cose_ident, ctx.rpc_ctx, member_id))
910 {
911 return;
912 }
913
914 // Look up proposal info and check expected state
915 auto* proposal_info_handle =
916 ctx.tx.template rw<ccf::jsgov::ProposalInfoMap>(
917 jsgov::Tables::PROPOSALS_INFO);
918 auto proposal_info = proposal_info_handle->get(proposal_id);
919 if (!proposal_info.has_value())
920 {
921 detail::set_gov_error(
922 ctx.rpc_ctx,
923 HTTP_STATUS_NOT_FOUND,
924 ccf::errors::ProposalNotFound,
925 fmt::format("Could not find proposal {}.", proposal_id));
926 return;
927 }
928
929 if (proposal_info->state != ccf::ProposalState::OPEN)
930 {
931 detail::set_gov_error(
932 ctx.rpc_ctx,
933 HTTP_STATUS_BAD_REQUEST,
934 ccf::errors::ProposalNotOpen,
935 fmt::format(
936 "Proposal {} is currently in state {} - only {} proposals "
937 "can receive votes",
938 proposal_id,
939 proposal_info->state,
941 return;
942 }
943
944 // Look up proposal contents
945 auto* proposals_handle = ctx.tx.template ro<ccf::jsgov::ProposalMap>(
946 ccf::jsgov::Tables::PROPOSALS);
947 const auto proposal = proposals_handle->get(proposal_id);
948 if (!proposal.has_value())
949 {
950 detail::set_gov_error(
951 ctx.rpc_ctx,
952 HTTP_STATUS_NOT_FOUND,
953 ccf::errors::ProposalNotFound,
954 fmt::format("Could not find proposal {}.", proposal_id));
955 return;
956 }
957
958 // Parse and validate incoming ballot
959 const auto params = nlohmann::json::parse(cose_ident.content);
960 const auto ballot_it = params.find("ballot");
961 if (ballot_it == params.end() || !ballot_it.value().is_string())
962 {
963 detail::set_gov_error(
964 ctx.rpc_ctx,
965 HTTP_STATUS_BAD_REQUEST,
966 ccf::errors::InvalidInput,
967 "Signed request body is not a JSON object containing required "
968 "string field \"ballot\"");
969 return;
970 }
971
972 // Access constitution to evaluate ballots
973 const auto constitution =
974 ctx.tx.template ro<ccf::Constitution>(ccf::Tables::CONSTITUTION)
975 ->get();
976 if (!constitution.has_value())
977 {
978 detail::set_gov_error(
979 ctx.rpc_ctx,
980 HTTP_STATUS_INTERNAL_SERVER_ERROR,
981 ccf::errors::InternalError,
982 "No constitution is set - ballots cannot be evaluated");
983 return;
984 }
985
986 const auto ballot = ballot_it.value().get<std::string>();
987
988 const auto info_ballot_it = proposal_info->ballots.find(member_id);
989 if (info_ballot_it != proposal_info->ballots.end())
990 {
991 // If ballot matches previously submitted, aim for idempotent
992 // matching response
993 if (info_ballot_it->second == ballot)
994 {
995 const auto response_body = detail::convert_proposal_to_api_format(
996 proposal_id, proposal_info.value());
997
998 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
999 return;
1000 }
1001
1002 detail::set_gov_error(
1003 ctx.rpc_ctx,
1004 HTTP_STATUS_BAD_REQUEST,
1005 ccf::errors::VoteAlreadyExists,
1006 fmt::format(
1007 "Different ballot already submitted by {} for {}.",
1008 member_id,
1009 proposal_id));
1010 return;
1011 }
1012
1013 // Store newly provided ballot
1014 proposal_info->ballots.insert_or_assign(
1015 info_ballot_it, member_id, ballot_it.value().get<std::string>());
1016
1018 ctx.tx, cose_ident.member_id, cose_ident.envelope);
1019
1021 node_context,
1022 network,
1023 ctx.tx,
1024 proposal_id,
1025 proposal.value(),
1026 proposal_info.value(),
1027 constitution.value());
1028
1029 if (proposal_info->state == ProposalState::FAILED)
1030 {
1031 // If the proposal failed to apply, we want to discard the tx and
1032 // not apply its side-effects to the KV state, because it may have
1033 // failed mid-execution (eg - thrown an exception), in which case
1034 // we do not want to apply partial writes
1035 detail::set_gov_error(
1036 ctx.rpc_ctx,
1037 HTTP_STATUS_INTERNAL_SERVER_ERROR,
1038 ccf::errors::InternalError,
1039 fmt::format("{}", proposal_info->failure));
1040 return;
1041 }
1042
1043 const auto response_body = detail::convert_proposal_to_api_format(
1044 proposal_id, proposal_info.value());
1045
1046 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
1047 return;
1048 }
1049 }
1050 };
1051 registry
1053 "/members/proposals/{proposalId}/ballots/{memberId}:submit",
1054 HTTP_POST,
1055 api_version_adapter(submit_ballot),
1057 .set_openapi_hidden(true)
1058 .install();
1059
1060 auto get_ballot = [&](auto& ctx, ApiVersion api_version) {
1061 switch (api_version)
1062 {
1064 case ApiVersion::v1:
1065 default:
1066 {
1067 ccf::ProposalId proposal_id;
1068 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
1069 {
1070 return;
1071 }
1072
1073 ccf::MemberId member_id;
1074 if (!detail::try_parse_member_id(ctx.rpc_ctx, member_id))
1075 {
1076 return;
1077 }
1078
1079 // Look up proposal
1080 auto proposal_info_handle =
1081 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
1082 ccf::jsgov::Tables::PROPOSALS_INFO);
1083
1084 // NB: Logically constant (read-only), but non-const so we can
1085 // eventually move a field out
1086 auto proposal_info = proposal_info_handle->get(proposal_id);
1087 if (!proposal_info.has_value())
1088 {
1089 detail::set_gov_error(
1090 ctx.rpc_ctx,
1091 HTTP_STATUS_NOT_FOUND,
1092 ccf::errors::ProposalNotFound,
1093 fmt::format("Proposal {} does not exist.", proposal_id));
1094 return;
1095 }
1096
1097 // Look up ballot
1098 auto ballot_it = proposal_info->ballots.find(member_id);
1099 if (ballot_it == proposal_info->ballots.end())
1100 {
1101 detail::set_gov_error(
1102 ctx.rpc_ctx,
1103 HTTP_STATUS_NOT_FOUND,
1104 ccf::errors::VoteNotFound,
1105 fmt::format(
1106 "Member {} has not voted for proposal {}.",
1107 member_id,
1108 proposal_id));
1109 return;
1110 }
1111
1112 // Return the raw ballot, with appropriate content-type
1113 ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
1114 ctx.rpc_ctx->set_response_body(std::move(ballot_it->second));
1115 ctx.rpc_ctx->set_response_header(
1116 http::headers::CONTENT_TYPE,
1117 http::headervalues::contenttype::JAVASCRIPT);
1118 return;
1119 }
1120 }
1121 };
1122 registry
1124 "/members/proposals/{proposalId}/ballots/{memberId}",
1125 HTTP_GET,
1126 api_version_adapter(get_ballot),
1127 no_auth_required)
1128 .set_openapi_hidden(true)
1129 .install();
1130 }
1131}
#define JS_CHECK_OR_THROW(val)
Definition checks.h:23
Definition gov_effects_interface.h:12
Definition base_endpoint_registry.h:121
Definition sha256_hash.h:16
std::string hex_str() const
Definition sha256_hash.cpp:57
virtual Endpoint make_endpoint(const std::string &method, RESTVerb verb, const EndpointFunction &f, const AuthnPolicies &ap)
Definition endpoint_registry.cpp:204
virtual Endpoint make_read_only_endpoint(const std::string &method, RESTVerb verb, const ReadOnlyEndpointFunction &f, const AuthnPolicies &ap)
Definition endpoint_registry.cpp:235
Definition common_context.h:54
M::ReadOnlyHandle * ro(M &m)
Definition tx.h:168
Definition tx.h:200
M::Handle * rw(M &m)
Definition tx.h:211
M::WriteOnlyHandle * wo(M &m)
Definition tx.h:232
Definition map.h:30
Definition value.h:32
HashBytes sha256(const std::span< uint8_t const > &data)
Definition hash.cpp:24
std::shared_ptr< ISha256Hash > make_incremental_sha256()
Definition hash.cpp:46
bool try_parse_member_id(const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::MemberId &member_id)
Definition helpers.h:64
void record_cose_governance_history(ccf::kv::Tx &tx, const MemberId &caller_id, const std::span< const uint8_t > &cose_sign1)
Definition proposals.h:102
bool try_parse_proposal_id(const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::ProposalId &proposal_id)
Definition helpers.h:158
void remove_all_other_non_open_proposals(ccf::kv::Tx &tx, const ProposalId &proposal_id)
Definition proposals.h:113
nlohmann::json convert_proposal_to_api_format(const ProposalId &proposal_id, const ccf::jsgov::ProposalInfo &summary)
Definition proposals.h:351
void resolve_proposal(ccf::AbstractNodeContext &context, ccf::NetworkState &network, ccf::kv::Tx &tx, const ProposalId &proposal_id, const std::span< const uint8_t > &proposal_bytes, ccf::jsgov::ProposalInfo &proposal_info, const std::string &constitution)
Definition proposals.h:138
bool try_parse_signed_member_id(const ccf::MemberCOSESign1AuthnIdentity &cose_ident, const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::MemberId &member_id)
Definition helpers.h:100
bool try_parse_signed_proposal_id(const ccf::MemberCOSESign1AuthnIdentity &cose_ident, const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::ProposalId &proposal_id)
Definition helpers.h:198
ProposalSubmissionResult validate_proposal_submission_time(ccf::kv::Tx &tx, const std::string &created_at, const std::vector< uint8_t > &request_digest, const ccf::ProposalId &proposal_id)
Definition proposals.h:32
AuthnPolicies active_member_sig_only_policies(const std::string &gov_msg_type)
Definition helpers.h:16
Definition api_version.h:11
void init_proposals_handlers(ccf::BaseEndpointRegistry &registry, NetworkState &network, ccf::AbstractNodeContext &node_context)
Definition proposals.h:393
ApiVersion
Definition api_version.h:13
auto api_version_adapter(Fn &&f, ApiVersion min_accepted=ApiVersion::MIN)
Definition api_version.h:101
std::unordered_map< ccf::MemberId, bool > Votes
Definition gov.h:12
std::unordered_map< ccf::MemberId, Failure > VoteFailures
Definition gov.h:30
std::string ProposalId
Definition proposals.h:40
Definition node_context.h:12
std::shared_ptr< T > get_subsystem(const std::string &name) const
Definition node_context.h:37
Value & value()
Definition entity_id.h:67
Definition network_state.h:12
std::shared_ptr< ccf::RpcContext > rpc_ctx
Definition endpoint_context.h:31
Definition endpoint_context.h:55
ccf::kv::Tx & tx
Definition endpoint_context.h:61
Endpoint & set_openapi_hidden(bool hidden)
Definition endpoint.cpp:10
void install()
Definition endpoint.cpp:135
std::string info
Definition proposals.h:29
enum ccf::gov::endpoints::detail::ProposalSubmissionResult::Status status
Definition gov.h:15
Proposal metadata stored in the KV.
Definition gov.h:34
std::optional< Votes > final_votes
Definition gov.h:48
ccf::MemberId proposer_id
ID of the member who originally created/submitted this proposal.
Definition gov.h:36
std::optional< Failure > failure
Definition gov.h:56
std::optional< VoteFailures > vote_failures
Definition gov.h:52
Ballots ballots
Definition gov.h:43
ccf::ProposalState state
Current state of this proposal (eg - open, accepted, withdrawn)
Definition gov.h:38