This commit is contained in:
Amaury Chamayou 2021-03-29 15:26:05 +01:00 коммит произвёл GitHub
Родитель 7af1179111
Коммит e3364b7200
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 1542 добавлений и 50 удалений

Просмотреть файл

@ -27,7 +27,7 @@ parameters:
perf:
cmake_args: '-DBUILD_UNIT_TESTS=OFF -DDISTRIBUTE_PERF_TESTS="`../.nodes.sh`"'
release:
cmake_args: "-DTLS_TEST=ON -DENABLE_BFT=OFF"
cmake_args: "-DTLS_TEST=ON -DENABLE_BFT=OFF -DENABLE_JS_GOV=OFF"
test:
NoSGX:

Просмотреть файл

@ -60,6 +60,10 @@ option(ZAP_TEST
"ZAP fuzz test using https://www.zaproxy.org/docs/docker/api-scan/" OFF
)
option(BUILD_SMALLBANK "Build SmallBank sample app and clients" ON)
option(ENABLE_JS_GOV "Enable JS Governance" ON)
if(ENABLE_JS_GOV)
add_definitions(-DENABLE_JS_GOV)
endif()
option(BUILD_TPCC "Build TPPC sample app and clients" ON)
# Build common library for CCF enclaves
@ -486,6 +490,15 @@ if(BUILD_TESTS)
add_picobench(hash_bench SRCS src/ds/test/hash_bench.cpp)
add_picobench(digest_bench SRCS src/crypto/test/digest_bench.cpp)
if(ENABLE_JS_GOV)
add_e2e_test(
NAME governance_js_test
PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/governance_js.py
CONSENSUS cft
ADDITIONAL_ARGS --oe-binary ${OE_BINDIR} --initial-operator-count 1
)
endif()
# Storing signed governance operations
add_e2e_test(
NAME governance_history_test

Просмотреть файл

@ -1,6 +1,38 @@
{
"components": {
"schemas": {
"Action": {
"properties": {
"args": {
"$ref": "#/components/schemas/json"
},
"name": {
"$ref": "#/components/schemas/string"
}
},
"required": [
"name",
"args"
],
"type": "object"
},
"Action_array": {
"items": {
"$ref": "#/components/schemas/Action"
},
"type": "array"
},
"Ballot": {
"properties": {
"ballot": {
"$ref": "#/components/schemas/string"
}
},
"required": [
"ballot"
],
"type": "object"
},
"CodeStatus": {
"enum": [
"AllowedToJoin"
@ -60,24 +92,6 @@
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"EntityId_to_Script": {
"items": {
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/EntityId"
},
{
"$ref": "#/components/schemas/Script"
}
]
},
"maxItems": 2,
"minItems": 2,
"type": "array"
},
"type": "array"
},
"GetCode__Out": {
"properties": {
"versions": {
@ -164,28 +178,12 @@
},
"Proposal": {
"properties": {
"parameter": {
"$ref": "#/components/schemas/json"
},
"proposer": {
"$ref": "#/components/schemas/EntityId"
},
"script": {
"$ref": "#/components/schemas/Script"
},
"state": {
"$ref": "#/components/schemas/ProposalState"
},
"votes": {
"$ref": "#/components/schemas/EntityId_to_Script"
"actions": {
"$ref": "#/components/schemas/Action_array"
}
},
"required": [
"script",
"parameter",
"proposer",
"state",
"votes"
"actions"
],
"type": "object"
},
@ -208,6 +206,29 @@
],
"type": "object"
},
"ProposalInfoSummary": {
"properties": {
"ballot_count": {
"$ref": "#/components/schemas/uint64"
},
"proposal_id": {
"$ref": "#/components/schemas/string"
},
"proposer_id": {
"$ref": "#/components/schemas/EntityId"
},
"state": {
"$ref": "#/components/schemas/ProposalState"
}
},
"required": [
"proposal_id",
"proposer_id",
"state",
"ballot_count"
],
"type": "object"
},
"ProposalState": {
"enum": [
"Open",
@ -539,6 +560,210 @@
]
}
},
"/proposals.js": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Proposal"
}
}
},
"description": "Auto-generated request body schema"
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalInfo"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
}
},
"/proposals.js/{proposal_id}": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalInfo"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
},
"parameters": [
{
"in": "path",
"name": "proposal_id",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"/proposals.js/{proposal_id}/actions": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Proposal"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
},
"parameters": [
{
"in": "path",
"name": "proposal_id",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"/proposals.js/{proposal_id}/ballots": {
"parameters": [
{
"in": "path",
"name": "proposal_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ballot"
}
}
},
"description": "Auto-generated request body schema"
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalInfoSummary"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
}
},
"/proposals.js/{proposal_id}/ballots/{member_id}": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ballot"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
},
"parameters": [
{
"in": "path",
"name": "proposal_id",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "member_id",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"/proposals.js/{proposal_id}/withdraw": {
"parameters": [
{
"in": "path",
"name": "proposal_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalInfo"
}
}
},
"description": "Default response description"
}
},
"security": [
{
"member_signature": []
}
]
}
},
"/proposals/{proposal_id}": {
"get": {
"responses": {

Просмотреть файл

@ -536,7 +536,6 @@ namespace ccfapp
network(network),
context(context)
{
js::register_class_ids();
metrics_tracker.install_endpoint(*this);
}

Просмотреть файл

@ -523,7 +523,7 @@ namespace js
}
// Historical queries
if (receipt)
if (receipt != nullptr)
{
auto state = JS_NewObject(ctx);

Просмотреть файл

@ -5,26 +5,69 @@
#include "ds/json.h"
#include "entities.h"
#include "kv/map.h"
#include "proposals.h"
namespace ccf
{
namespace jsgov
{
using ProposalId = std::string;
using Proposal = std::string;
struct ProposalInfo
{
ccf::MemberId proposer_id;
ccf::ProposalState state;
std::unordered_map<ccf::MemberId, std::string> ballots = {};
};
DECLARE_JSON_TYPE(ProposalInfo)
DECLARE_JSON_REQUIRED_FIELDS(ProposalInfo, proposer_id, ballots);
DECLARE_JSON_TYPE(ProposalInfo);
DECLARE_JSON_REQUIRED_FIELDS(ProposalInfo, proposer_id, state, ballots);
using ProposalMap = kv::RawCopySerialisedMap<ProposalId, Proposal>;
using ProposalInfoMap = kv::MapSerialisedWith<
ProposalId,
ProposalInfo,
kv::serialisers::BlitSerialiser,
kv::serialisers::JsonSerialiser>;
struct ProposalInfoSummary
{
ProposalId proposal_id;
ccf::MemberId proposer_id;
ccf::ProposalState state;
size_t ballot_count;
};
DECLARE_JSON_TYPE(ProposalInfoSummary);
DECLARE_JSON_REQUIRED_FIELDS(
ProposalInfoSummary, proposal_id, proposer_id, state, ballot_count);
struct ProposalInfoDetails
{
ProposalId proposal_id;
ccf::MemberId proposer_id;
ccf::ProposalState state;
std::unordered_map<ccf::MemberId, std::string> ballots = {};
};
DECLARE_JSON_TYPE(ProposalInfoDetails);
DECLARE_JSON_REQUIRED_FIELDS(
ProposalInfoDetails, proposal_id, proposer_id, state, ballots);
using ProposalMap =
kv::RawCopySerialisedMap<ProposalId, std::vector<uint8_t>>;
using ProposalInfoMap = ServiceMap<ProposalId, ProposalInfo>;
struct Action
{
std::string name;
nlohmann::json args;
};
DECLARE_JSON_TYPE(Action);
DECLARE_JSON_REQUIRED_FIELDS(Action, name, args);
struct Proposal
{
std::vector<Action> actions;
};
DECLARE_JSON_TYPE(Proposal);
DECLARE_JSON_REQUIRED_FIELDS(Proposal, actions);
struct Ballot
{
std::string ballot;
};
DECLARE_JSON_TYPE(Ballot);
DECLARE_JSON_REQUIRED_FIELDS(Ballot, ballot);
}
}

Просмотреть файл

@ -16,6 +16,7 @@
#include "genesis_gen.h"
#include "history.h"
#include "hooks.h"
#include "js/wrap.h"
#include "network_state.h"
#include "node/jwt_key_auto_refresh.h"
#include "node/progress_tracker.h"
@ -343,6 +344,7 @@ namespace ccf
config = std::move(config_);
js::register_class_ids();
open_frontend(ActorsType::nodes);
#ifdef GET_QUOTE

Просмотреть файл

@ -482,7 +482,12 @@ namespace ccf
void set_root_on_proposals(const enclave::RpcContext& ctx, kv::Tx& tx)
{
if (ctx.get_request_path() == "/gov/proposals")
if (
ctx.get_request_path() == "/gov/proposals"
#ifdef ENABLE_JS_GOV
|| ctx.get_request_path() == "/gov/proposals.js"
#endif
)
{
update_history();
if (history)

Просмотреть файл

@ -1152,6 +1152,152 @@ namespace ccf
return get_proposal_info(proposal_id, proposal);
}
#ifdef ENABLE_JS_GOV
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wc99-extensions"
ccf::jsgov::ProposalInfoSummary resolve_proposal(
kv::Tx& tx,
const ProposalId& proposal_id,
const std::vector<uint8_t>& proposal,
const std::string& constitution)
{
auto pi =
tx.rw<ccf::jsgov::ProposalInfoMap>("public:ccf.gov.proposals_info.js");
auto pi_ = pi->get(proposal_id);
std::vector<std::pair<MemberId, bool>> votes;
for (const auto& [mid, mb] : pi_->ballots)
{
std::string mbs = fmt::format(
"{}\n export default (proposal, proposer_id) => vote(proposal, "
"proposer_id);",
mb);
js::Runtime rt;
js::Context context(rt);
rt.add_ccf_classdefs();
js::populate_global_ccf(&tx, std::nullopt, nullptr, context);
auto ballot_func = context.function(
mbs, fmt::format("ballot from {} for {}", mid, proposal_id));
JSValue argv[2];
auto prop = JS_NewStringLen(
context, (const char*)proposal.data(), proposal.size());
argv[0] = prop;
auto pid = JS_NewStringLen(
context, pi_->proposer_id.data(), pi_->proposer_id.size());
argv[1] = pid;
auto val =
context(JS_Call(context, ballot_func, JS_UNDEFINED, 2, argv));
if (!JS_IsException(val))
{
votes.emplace_back(mid, JS_ToBool(context, val));
}
JS_FreeValue(context, ballot_func);
JS_FreeValue(context, prop);
JS_FreeValue(context, pid);
}
{
std::string mbs = fmt::format(
"{}\n export default (proposal, proposer_id, votes) => "
"resolve(proposal, proposer_id, votes);",
constitution);
js::Runtime rt;
js::Context context(rt);
js::populate_global_console(context);
rt.add_ccf_classdefs();
js::populate_global_ccf(&tx, std::nullopt, nullptr, context);
auto resolve_func =
context.function(mbs, fmt::format("resolve {}", proposal_id));
JSValue argv[3];
auto prop = JS_NewStringLen(
context, (const char*)proposal.data(), proposal.size());
argv[0] = prop;
auto prop_id = JS_NewStringLen(
context, pi_->proposer_id.data(), pi_->proposer_id.size());
argv[1] = prop_id;
auto vs = JS_NewArray(context);
size_t index = 0;
for (auto& [mid, vote] : votes)
{
auto v = JS_NewObject(context);
auto member_id = JS_NewStringLen(context, mid.data(), mid.size());
JS_DefinePropertyValueStr(
context, v, "member_id", member_id, JS_PROP_C_W_E);
auto vote_status = JS_NewBool(context, vote);
JS_DefinePropertyValueStr(
context, v, "vote", vote_status, JS_PROP_C_W_E);
JS_DefinePropertyValueUint32(context, vs, index++, v, JS_PROP_C_W_E);
}
argv[2] = vs;
auto val =
context(JS_Call(context, resolve_func, JS_UNDEFINED, 3, argv));
JS_FreeValue(context, resolve_func);
JS_FreeValue(context, prop);
JS_FreeValue(context, prop_id);
JS_FreeValue(context, vs);
if (JS_IsString(val))
{
auto s = JS_ToCString(context, val);
std::string status(s);
JS_FreeCString(context, s);
if (status == "Open")
{
pi_.value().state = ProposalState::OPEN;
}
else if (status == "Accepted")
{
pi_.value().state = ProposalState::ACCEPTED;
}
else if (status == "Withdrawn")
{
pi_.value().state = ProposalState::FAILED;
}
else if (status == "Rejected")
{
pi_.value().state = ProposalState::REJECTED;
}
else if (status == "Failed")
{
pi_.value().state = ProposalState::FAILED;
}
else
{
pi_.value().state = ProposalState::FAILED;
}
}
/* Apply actions
if (pi_.value().state != ProposalState::OPEN)
{
// Record votes and errors
if (pi_.value().state == ProposalState::ACCEPTED)
{
// Apply actions here
}
}
*/
return jsgov::ProposalInfoSummary{proposal_id,
pi_->proposer_id,
pi_.value().state,
pi_.value().ballots.size()};
}
}
# pragma clang diagnostic pop
#endif
bool check_member_active(kv::ReadOnlyTx& tx, const MemberId& id)
{
return check_member_status(tx, id, {MemberStatus::ACTIVE});
@ -2110,6 +2256,542 @@ namespace ccf
{std::make_shared<NodeCertAuthnPolicy>()})
.set_openapi_hidden(true)
.install();
// JavaScript governance
#ifdef ENABLE_JS_GOV
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wc99-extensions"
auto post_proposals_js = [this](ccf::endpoints::EndpointContext& ctx) {
const auto& caller_identity =
ctx.get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
return;
}
if (!consensus)
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"No consensus available.");
return;
}
ProposalId proposal_id;
if (consensus->type() == ConsensusType::CFT)
{
auto root_at_read = ctx.tx.get_root_at_read_version();
if (!root_at_read.has_value())
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"Proposal failed to bind to state.");
return;
}
// caller_identity.request_digest is set when getting the
// MemberSignatureAuthnIdentity identity. The proposal id is a
// digest of the root of the state tree at the read version and the
// request digest.
std::vector<uint8_t> acc(
root_at_read.value().h.begin(), root_at_read.value().h.end());
acc.insert(
acc.end(),
caller_identity.request_digest.begin(),
caller_identity.request_digest.end());
const crypto::Sha256Hash proposal_digest(acc);
proposal_id = proposal_digest.hex_str();
}
else
{
proposal_id = fmt::format(
"{:02x}", fmt::join(caller_identity.request_digest, ""));
}
js::Runtime rt;
js::Context context(rt);
auto constitution = ctx.tx.ro(network.constitution)->get(0);
if (!constitution.has_value())
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"No constitution is set - proposals cannot be evaluated");
return;
}
auto validate_script = fmt::format(
"{}\n export default (input) => validate(input);",
constitution.value());
auto validate_func = context.function(
validate_script, "public:ccf.gov.constitution[0].validate");
auto body =
reinterpret_cast<const char*>(ctx.rpc_ctx->get_request_body().data());
auto body_len = ctx.rpc_ctx->get_request_body().size();
auto proposal = JS_NewStringLen(context, body, body_len);
JSValueConst* argv = (JSValueConst*)&proposal;
auto val =
context(JS_Call(context, validate_func, JS_UNDEFINED, 1, argv));
JS_FreeValue(context, proposal);
JS_FreeValue(context, validate_func);
if (JS_IsException(val))
{
js::js_dump_error(context);
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"Failed to execute validation");
return;
}
if (!JS_IsObject(val))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"Validation failed to return an object");
return;
}
std::string description;
auto desc = context(JS_GetPropertyStr(context, val, "description"));
if (JS_IsString(desc))
{
auto cstr = JS_ToCString(context, desc);
description = std::string(cstr);
JS_FreeCString(context, cstr);
}
auto valid = context(JS_GetPropertyStr(context, val, "valid"));
if (!JS_ToBool(context, valid))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::ProposalFailedToValidate,
fmt::format("Proposal failed to validate: {}", description));
return;
}
auto pm =
ctx.tx.rw<ccf::jsgov::ProposalMap>("public:ccf.gov.proposals.js");
// Introduce a read dependency, so that if identical proposal
// creations are in-flight and reading at the same version, all except
// the first conflict and are re-executed. If we ever produce a
// proposal ID which already exists, we must have a hash collision.
if (pm->has(proposal_id))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"Proposal ID collision.");
return;
}
pm->put(proposal_id, ctx.rpc_ctx->get_request_body());
auto pi = ctx.tx.rw<ccf::jsgov::ProposalInfoMap>(
"public:ccf.gov.proposals_info.js");
pi->put(
proposal_id,
{caller_identity.member_id, ccf::ProposalState::OPEN, {}});
record_voting_history(
ctx.tx, caller_identity.member_id, caller_identity.signed_request);
auto rv = resolve_proposal(
ctx.tx,
proposal_id,
ctx.rpc_ctx->get_request_body(),
constitution.value());
pi->put(proposal_id, {caller_identity.member_id, rv.state, {}});
ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
ctx.rpc_ctx->set_response_header(
http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON);
ctx.rpc_ctx->set_response_body(nlohmann::json(rv).dump());
};
make_endpoint(
"proposals.js", HTTP_POST, post_proposals_js, member_sig_only)
.set_auto_schema<jsgov::Proposal, jsgov::ProposalInfo>()
.install();
auto get_proposal_js =
[this](endpoints::ReadOnlyEndpointContext& ctx, nlohmann::json&&) {
const auto& caller_identity =
ctx.get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
return make_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
}
// Take expand=ballots, return eg. "ballots": 3 if not set
// or "ballots": list of ballots in full if passed
ProposalId proposal_id;
std::string error;
if (!get_proposal_id_from_path(
ctx.rpc_ctx->get_request_path_params(), proposal_id, error))
{
return make_error(
HTTP_STATUS_BAD_REQUEST, ccf::errors::InvalidResourceName, error);
}
auto pm =
ctx.tx.ro<ccf::jsgov::ProposalMap>("public:ccf.gov.proposals.js");
auto p = pm->get(proposal_id);
if (!p)
{
return make_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::ProposalNotFound,
fmt::format("Proposal {} does not exist.", proposal_id));
}
auto pi = ctx.tx.ro<ccf::jsgov::ProposalInfoMap>(
"public:ccf.gov.proposals_info.js");
auto pi_ = pi->get(proposal_id);
if (!pi_)
{
return make_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
fmt::format(
"No proposal info associated with {} exists.", proposal_id));
}
return make_success(pi_.value());
};
make_read_only_endpoint(
"proposals.js/{proposal_id}",
HTTP_GET,
json_read_only_adapter(get_proposal_js),
member_cert_or_sig)
.set_auto_schema<void, jsgov::ProposalInfo>()
.install();
auto withdraw_js = [this](
endpoints::EndpointContext& ctx, nlohmann::json&&) {
const auto& caller_identity =
ctx.template get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
return make_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
}
ProposalId proposal_id;
std::string error;
if (!get_proposal_id_from_path(
ctx.rpc_ctx->get_request_path_params(), proposal_id, error))
{
return make_error(
HTTP_STATUS_BAD_REQUEST, ccf::errors::InvalidResourceName, error);
}
auto pi = ctx.tx.rw<ccf::jsgov::ProposalInfoMap>(
"public:ccf.gov.proposals_info.js");
auto pi_ = pi->get(proposal_id);
if (!pi_)
{
return make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::ProposalNotFound,
fmt::format("Proposal {} does not exist.", proposal_id));
}
if (caller_identity.member_id != pi_->proposer_id)
{
return make_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
fmt::format(
"Proposal {} can only be withdrawn by proposer {}, not caller "
"{}.",
proposal_id,
pi_->proposer_id,
caller_identity.member_id));
}
if (pi_->state != ProposalState::OPEN)
{
return make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::ProposalNotOpen,
fmt::format(
"Proposal {} is currently in state {} - only {} proposals can be "
"withdrawn.",
proposal_id,
pi_->state,
ProposalState::OPEN));
}
pi_->state = ProposalState::WITHDRAWN;
pi->put(proposal_id, pi_.value());
record_voting_history(
ctx.tx, caller_identity.member_id, caller_identity.signed_request);
return make_success(pi_.value());
};
make_endpoint(
"proposals.js/{proposal_id}/withdraw",
HTTP_POST,
json_adapter(withdraw_js),
member_cert_or_sig)
.set_auto_schema<void, jsgov::ProposalInfo>()
.install();
auto get_proposal_actions_js =
[this](ccf::endpoints::ReadOnlyEndpointContext& ctx) {
const auto& caller_identity =
ctx.get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
return;
}
ProposalId proposal_id;
std::string error;
if (!get_proposal_id_from_path(
ctx.rpc_ctx->get_request_path_params(), proposal_id, error))
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::InvalidResourceName,
std::move(error));
return;
}
auto pm =
ctx.tx.ro<ccf::jsgov::ProposalMap>("public:ccf.gov.proposals.js");
auto p = pm->get(proposal_id);
if (!p)
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::ProposalNotFound,
fmt::format("Proposal {} does not exist.", proposal_id));
return;
}
ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
ctx.rpc_ctx->set_response_header(
http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON);
ctx.rpc_ctx->set_response_body(std::move(p.value()));
};
make_read_only_endpoint(
"proposals.js/{proposal_id}/actions",
HTTP_GET,
get_proposal_actions_js,
member_cert_or_sig)
.set_auto_schema<void, jsgov::Proposal>()
.install();
auto vote_js = [this](
endpoints::EndpointContext& ctx,
nlohmann::json&& params) {
const auto& caller_identity =
ctx.get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
return make_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
}
ProposalId proposal_id;
std::string error;
if (!get_proposal_id_from_path(
ctx.rpc_ctx->get_request_path_params(), proposal_id, error))
{
return make_error(
HTTP_STATUS_BAD_REQUEST, ccf::errors::InvalidResourceName, error);
}
auto constitution = ctx.tx.ro(network.constitution)->get(0);
if (!constitution.has_value())
{
return make_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
"No constitution is set - proposals cannot be evaluated");
}
auto pi = ctx.tx.rw<ccf::jsgov::ProposalInfoMap>(
"public:ccf.gov.proposals_info.js");
auto pi_ = pi->get(proposal_id);
if (!pi_)
{
return make_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::ProposalNotFound,
fmt::format("Could not find proposal {}.", proposal_id));
}
if (pi_.value().state != ProposalState::OPEN)
{
return make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::ProposalNotOpen,
fmt::format(
"Proposal {} is currently in state {} - only {} proposals can "
"receive votes.",
proposal_id,
pi_.value().state,
ProposalState::OPEN));
}
auto pm =
ctx.tx.ro<ccf::jsgov::ProposalMap>("public:ccf.gov.proposals.js");
auto p = pm->get(proposal_id);
if (!p)
{
return make_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::ProposalNotFound,
fmt::format("Proposal {} does not exist.", proposal_id));
}
if (pi_->ballots.find(caller_identity.member_id) != pi_->ballots.end())
{
return make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::VoteAlreadyExists,
"Vote already submitted.");
}
// Validate vote
std::string ballot_script = fmt::format(
"{}\n export default (proposal, proposer_id, tx) => vote(proposal, "
"proposer_id, tx);",
params["ballot"]);
{
js::Runtime rt;
js::Context context(rt);
auto ballot_func =
context.function(ballot_script, "body[\"ballot\"]");
JS_FreeValue(context, ballot_func);
}
pi_->ballots[caller_identity.member_id] = params["ballot"];
pi->put(proposal_id, pi_.value());
// Do we still need to do this?
record_voting_history(
ctx.tx, caller_identity.member_id, caller_identity.signed_request);
auto rv = resolve_proposal(
ctx.tx, proposal_id, p.value(), constitution.value());
pi_.value().state = rv.state;
pi->put(proposal_id, pi_.value());
return make_success(rv);
};
make_endpoint(
"proposals.js/{proposal_id}/ballots",
HTTP_POST,
json_adapter(vote_js),
member_sig_only)
.set_auto_schema<jsgov::Ballot, jsgov::ProposalInfoSummary>()
.install();
auto get_vote_js =
[this](endpoints::ReadOnlyEndpointContext& ctx, nlohmann::json&&) {
const auto& caller_identity =
ctx.get_caller<ccf::MemberSignatureAuthnIdentity>();
if (!check_member_active(ctx.tx, caller_identity.member_id))
{
return make_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Member is not active.");
}
std::string error;
ProposalId proposal_id;
if (!get_proposal_id_from_path(
ctx.rpc_ctx->get_request_path_params(), proposal_id, error))
{
return make_error(
HTTP_STATUS_BAD_REQUEST, ccf::errors::InvalidResourceName, error);
}
MemberId vote_member_id;
if (!get_member_id_from_path(
ctx.rpc_ctx->get_request_path_params(), vote_member_id, error))
{
return make_error(
HTTP_STATUS_BAD_REQUEST, ccf::errors::InvalidResourceName, error);
}
auto pi = ctx.tx.ro<ccf::jsgov::ProposalInfoMap>(
"public:ccf.gov.proposals_info.js");
auto pi_ = pi->get(proposal_id);
if (!pi_)
{
return make_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::ProposalNotFound,
fmt::format("Proposal {} does not exist.", proposal_id));
}
const auto vote_it = pi_->ballots.find(vote_member_id);
if (vote_it == pi_->ballots.end())
{
return make_error(
HTTP_STATUS_NOT_FOUND,
ccf::errors::VoteNotFound,
fmt::format(
"Member {} has not voted for proposal {}.",
vote_member_id,
proposal_id));
}
return make_success(jsgov::Ballot{vote_it->second});
};
make_read_only_endpoint(
"proposals.js/{proposal_id}/ballots/{member_id}",
HTTP_GET,
json_read_only_adapter(get_vote_js),
member_cert_or_sig)
.set_auto_schema<void, jsgov::Ballot>()
.install();
# pragma clang diagnostic pop
#endif
}
};

Просмотреть файл

@ -0,0 +1,200 @@
class Action {
constructor(validate, apply) {
this.validate = validate;
this.apply = apply;
}
}
const actions = new Map([
[
"set_recovery_threshold",
new Action(
function (args) {
return (
Number.isInteger(args.threshold) &&
args.threshold > 0 &&
args.threshold < 255
);
},
function (args, tx) {
return true;
}
),
],
[
"always_accept_noop",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_reject_noop",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_accept_with_one_vote",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_reject_with_one_vote",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_accept_if_voted_by_operator",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_accept_if_proposed_by_operator",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_accept_with_two_votes",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
[
"always_reject_with_two_votes",
new Action(
function (args) {
return true;
},
function (args, tx) {
return true;
}
),
],
]);
function validate(input) {
let proposal = JSON.parse(input);
let errors = [];
let position = 0;
for (const action of proposal["actions"]) {
const definition = actions.get(action.name);
if (definition) {
if (!definition.validate(action.args)) {
errors.push(`${action.name} at position ${position} failed validation`);
}
} else {
errors.push(`${action.name}: no such action`);
}
position++;
}
return { valid: errors.length === 0, description: errors.join(", ") };
}
function resolve(proposal, proposer_id, votes) {
const actions = JSON.parse(proposal)["actions"];
if (actions.length === 1) {
if (actions[0].name === "always_accept_noop") {
return "Accepted";
}
if (actions[0].name === "always_reject_noop") {
return "Rejected";
}
if (
actions[0].name === "always_accept_with_one_vote" &&
votes.length === 1 &&
votes[0].vote === true
) {
return "Accepted";
}
if (
actions[0].name === "always_reject_with_one_vote" &&
votes.length === 1 &&
votes[0].vote === false
) {
return "Rejected";
}
if (actions[0].name === "always_accept_if_voted_by_operator") {
for (const vote of votes) {
const mi = ccf.kv["public:ccf.gov.members.info"].get(
ccf.strToBuf(vote.member_id)
);
if (mi && ccf.bufToJsonCompatible(mi).member_data.is_operator) {
return "Accepted";
}
}
}
if (actions[0].name === "always_accept_if_proposed_by_operator") {
const mi = ccf.kv["public:ccf.gov.members.info"].get(
ccf.strToBuf(proposer_id)
);
if (mi && ccf.bufToJsonCompatible(mi).member_data.is_operator) {
return "Accepted";
}
}
if (
actions[0].name === "always_accept_with_two_votes" &&
votes.length === 2 &&
votes[0].vote === true &&
votes[1].vote === true
) {
return "Accepted";
}
if (
actions[0].name === "always_reject_with_two_votes" &&
votes.length === 2 &&
votes[0].vote === false &&
votes[1].vote === false
) {
return "Rejected";
}
}
return "Open";
}
function apply(proposal) {
const proposed_actions = JSON.parse(proposal)["actions"];
for (proposed_action of proposed_actions) {
const definition = actions.get(proposed_action.name);
definition.apply(proposed_action.args);
}
}

323
tests/governance_js.py Normal file
Просмотреть файл

@ -0,0 +1,323 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the Apache 2.0 License.
import infra.network
import infra.path
import infra.proc
import infra.net
import infra.e2e_args
import suite.test_requirements as reqs
def action(name, **args):
return {"name": name, "args": args}
def proposal(*actions):
return {"actions": list(actions)}
def merge(*proposals):
return {"actions": sum((prop["actions"] for prop in proposals), [])}
valid_set_recovery_threshold = proposal(action("set_recovery_threshold", threshold=5))
valid_set_recovery_threshold_twice = merge(
valid_set_recovery_threshold, valid_set_recovery_threshold
)
no_args_set_recovery_threshold = proposal(action("set_recovery_threshold"))
bad_arg_set_recovery_threshold = proposal(
action("set_recovery_threshold", threshold=5000)
)
always_accept_noop = proposal(action("always_accept_noop"))
always_reject_noop = proposal(action("always_reject_noop"))
always_accept_with_one_vote = proposal(action("always_accept_with_one_vote"))
always_reject_with_one_vote = proposal(action("always_reject_with_one_vote"))
always_accept_if_voted_by_operator = proposal(
action("always_accept_if_voted_by_operator")
)
always_accept_if_proposed_by_operator = proposal(
action("always_accept_if_proposed_by_operator")
)
always_accept_with_two_votes = proposal(action("always_accept_with_two_votes"))
always_reject_with_two_votes = proposal(action("always_reject_with_two_votes"))
@reqs.description("Test proposal validation")
def test_proposal_validation(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
r = c.post("/gov/proposals.js", valid_set_recovery_threshold)
assert r.status_code == 200, r.body.text()
r = c.post("/gov/proposals.js", valid_set_recovery_threshold_twice)
assert r.status_code == 200, r.body.text()
r = c.post("/gov/proposals.js", no_args_set_recovery_threshold)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalFailedToValidate"
), r.body.text()
r = c.post(
"/gov/proposals.js",
merge(no_args_set_recovery_threshold, bad_arg_set_recovery_threshold),
)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalFailedToValidate"
), r.body.text()
return network
@reqs.description("Test proposal storage")
def test_proposal_storage(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
r = c.get("/gov/proposals.js/42")
assert r.status_code == 404, r.body.text()
r = c.get("/gov/proposals.js/42/actions")
assert r.status_code == 404, r.body.text()
for prop in (valid_set_recovery_threshold, valid_set_recovery_threshold_twice):
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposal_id"]
r = c.get(f"/gov/proposals.js/{proposal_id}")
assert r.status_code == 200, r.body.text()
expected = {
"proposer_id": network.consortium.get_member_by_local_id(
"member0"
).service_id,
"state": "Open",
"ballots": [],
}
assert r.body.json() == expected, r.body.json()
r = c.get(f"/gov/proposals.js/{proposal_id}/actions")
assert r.status_code == 200, r.body.text()
assert r.body.json() == prop, r.body.json()
return network
@reqs.description("Test proposal withdrawal")
def test_proposal_withdrawal(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
for prop in (valid_set_recovery_threshold, valid_set_recovery_threshold_twice):
r = c.post("/gov/proposals.js/42/withdraw")
assert r.status_code == 400, r.body.text()
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposal_id"]
with node.client(None, "member1") as oc:
r = oc.post(f"/gov/proposals.js/{proposal_id}/withdraw")
assert r.status_code == 403, r.body.text()
r = c.get(f"/gov/proposals.js/{proposal_id}")
assert r.status_code == 200, r.body.text()
expected = {
"proposer_id": network.consortium.get_member_by_local_id(
"member0"
).service_id,
"state": "Open",
"ballots": [],
}
assert r.body.json() == expected, r.body.json()
r = c.post(f"/gov/proposals.js/{proposal_id}/withdraw")
assert r.status_code == 200, r.body.text()
expected = {
"proposer_id": network.consortium.get_member_by_local_id(
"member0"
).service_id,
"state": "Withdrawn",
"ballots": [],
}
assert r.body.json() == expected, r.body.json()
r = c.post(f"/gov/proposals.js/{proposal_id}/withdraw")
assert r.status_code == 400, r.body.text()
return network
@reqs.description("Test ballot storage and validation")
def test_ballot_storage(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
r = c.post("/gov/proposals.js", valid_set_recovery_threshold)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposal_id"]
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", {})
assert r.status_code == 400, r.body.text()
ballot = {"ballot": "function vote (proposal, proposer_id) { return true }"}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.get(f"/gov/proposals.js/{proposal_id}/ballots/{member_id}")
assert r.status_code == 200, r.body.text()
assert r.body.json() == ballot, r.body.json()
with node.client(None, "member1") as c:
ballot = {"ballot": "function vote (proposal, proposer_id) { return false }"}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
member_id = network.consortium.get_member_by_local_id("member1").service_id
r = c.get(f"/gov/proposals.js/{proposal_id}/ballots/{member_id}")
assert r.status_code == 200, r.body.text()
assert r.body.json() == ballot
return network
@reqs.description("Test pure proposals")
def test_pure_proposals(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
for prop, state in [
(always_accept_noop, "Accepted"),
(always_reject_noop, "Rejected"),
]:
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == state, r.body.json()
proposal_id = r.body.json()["proposal_id"]
ballot = {"ballot": "function vote (proposal, proposer_id) { return true }"}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 400, r.body.text()
r = c.post(f"/gov/proposals.js/{proposal_id}/withdraw")
assert r.status_code == 400, r.body.text()
return network
def opposite(js_bool):
if js_bool == "true":
return "false"
elif js_bool == "false":
return "true"
else:
raise ValueError(f"{js_bool} is not a JavaScript boolean")
@reqs.description("Test vote proposals")
def test_proposals_with_votes(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
for prop, state, direction in [
(always_accept_with_one_vote, "Accepted", "true"),
(always_reject_with_one_vote, "Rejected", "false"),
]:
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Open", r.body.json()
proposal_id = r.body.json()["proposal_id"]
ballot = {
"ballot": f"function vote (proposal, proposer_id) {{ return {direction} }}"
}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == state, r.body.json()
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Open", r.body.json()
proposal_id = r.body.json()["proposal_id"]
member_id = network.consortium.get_member_by_local_id("member0").service_id
ballot = {
"ballot": f'function vote (proposal, proposer_id) {{ if (proposer_id == "{member_id}") {{ return {direction} }} else {{ return {opposite(direction) } }} }}'
}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == state, r.body.json()
with node.client(None, "member0") as c:
for prop, state, direction in [
(always_accept_with_two_votes, "Accepted", "true"),
(always_reject_with_two_votes, "Rejected", "false"),
]:
r = c.post("/gov/proposals.js", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Open", r.body.json()
proposal_id = r.body.json()["proposal_id"]
ballot = {
"ballot": f"function vote (proposal, proposer_id) {{ return {direction} }}"
}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Open", r.body.json()
with node.client(None, "member1") as oc:
ballot = {
"ballot": f"function vote (proposal, proposer_id) {{ return {direction} }}"
}
r = oc.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == state, r.body.json()
return network
@reqs.description("Test operator proposals and votes")
def test_operator_proposals_and_votes(network, args):
node = network.find_random_node()
with node.client(None, "member0") as c:
r = c.post("/gov/proposals.js", always_accept_if_voted_by_operator)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Open", r.body.json()
proposal_id = r.body.json()["proposal_id"]
ballot = {"ballot": "function vote (proposal, proposer_id) {{ return true }}"}
r = c.post(f"/gov/proposals.js/{proposal_id}/ballots", ballot)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Accepted", r.body.json()
with node.client(None, "member0") as c:
r = c.post("/gov/proposals.js", always_accept_if_proposed_by_operator)
assert r.status_code == 200, r.body.text()
assert r.body.json()["state"] == "Accepted", r.body.json()
proposal_id = r.body.json()["proposal_id"]
return network
def run(args):
with infra.network.network(
args.nodes, args.binary_dir, args.debug_nodes, args.perf_nodes, pdb=args.pdb
) as network:
network.start_and_join(args)
network = test_proposal_validation(network, args)
network = test_proposal_storage(network, args)
network = test_proposal_withdrawal(network, args)
network = test_ballot_storage(network, args)
network = test_pure_proposals(network, args)
network = test_proposals_with_votes(network, args)
network = test_operator_proposals_and_votes(network, args)
if __name__ == "__main__":
args = infra.e2e_args.cli_args()
args.package = "liblogging"
args.nodes = ["local://localhost"]
args.initial_user_count = 1
run(args)