From 3ba5e0133bd3f25a2889cfb26c2da0023b550bf2 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 7 Jun 2024 15:58:45 +0100 Subject: [PATCH] Set role definition sample (#6237) --- CMakeLists.txt | 40 +++-- .../roles/set_role_definition.js | 60 +++++++ tests/programmability.py | 155 ++++++++++++++++++ 3 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 samples/constitutions/roles/set_role_definition.js diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd0ab60c28f..4388baddd988 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1124,19 +1124,6 @@ if(BUILD_TESTS) add_picobench(merkle_bench SRCS src/node/test/merkle_bench.cpp) add_picobench(hash_bench SRCS src/ds/test/hash_bench.cpp) - set(CONSTITUTION_ARGS - --constitution - ${CCF_DIR}/samples/constitutions/default/actions.js - --constitution - ${CCF_DIR}/samples/constitutions/test/test_actions.js - --constitution - ${CCF_DIR}/samples/constitutions/default/validate.js - --constitution - ${CCF_DIR}/samples/constitutions/test/resolve.js - --constitution - ${CCF_DIR}/samples/constitutions/default/apply.js - ) - if(LONG_TESTS) set(ADDITIONAL_RECOVERY_ARGS --with-load) endif() @@ -1254,6 +1241,19 @@ if(BUILD_TESTS) ${CMAKE_SOURCE_DIR}/tests/js-launch-host-process ) + set(CONSTITUTION_ARGS + --constitution + ${CCF_DIR}/samples/constitutions/default/actions.js + --constitution + ${CCF_DIR}/samples/constitutions/test/test_actions.js + --constitution + ${CCF_DIR}/samples/constitutions/default/validate.js + --constitution + ${CCF_DIR}/samples/constitutions/test/resolve.js + --constitution + ${CCF_DIR}/samples/constitutions/default/apply.js + ) + add_e2e_test( NAME governance_test PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/governance.py @@ -1301,8 +1301,22 @@ if(BUILD_TESTS) ${CMAKE_SOURCE_DIR}/samples/apps/logging/js ) + set(RBAC_CONSTITUTION_ARGS + --constitution + ${CCF_DIR}/samples/constitutions/default/actions.js + --constitution + ${CCF_DIR}/samples/constitutions/roles/set_role_definition.js + --constitution + ${CCF_DIR}/samples/constitutions/default/validate.js + --constitution + ${CCF_DIR}/samples/constitutions/default/resolve.js + --constitution + ${CCF_DIR}/samples/constitutions/default/apply.js + ) + add_e2e_test( NAME programmability + CONSTITUTION ${RBAC_CONSTITUTION_ARGS} PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/programmability.py ) diff --git a/samples/constitutions/roles/set_role_definition.js b/samples/constitutions/roles/set_role_definition.js new file mode 100644 index 000000000000..2b178321d94c --- /dev/null +++ b/samples/constitutions/roles/set_role_definition.js @@ -0,0 +1,60 @@ +// A simple wrapper for set usage in the KV in the constitution +class KVSet { + #map; + + constructor(map) { + this.#map = map; + } + + has(key) { + return this.#map.has(key); + } + + add(key) { + this.#map.set(key, new ArrayBuffer(8)); + } + + delete(key) { + this.#map.delete(key); + } + + clear() { + this.#map.clear(); + } + + asSetOfStrings() { + let set = new Set(); + this.#map.forEach((_, key) => set.add(ccf.bufToStr(key))); + return set; + } +} + +actions.set( + "set_role_definition", + new Action( + function (args) { + checkType(args.role, "string", "role"); + checkType(args.actions, "array", "actions"); + for (const [i, action] of args.actions.entries()) { + checkType(action, "string", `actions[${i}]`); + } + }, + function (args) { + let roleDefinition = new KVSet( + ccf.kv[`public:ccf.gov.roles.${args.role}`], + ); + let oldValues = roleDefinition.asSetOfStrings(); + let newValues = new Set(args.actions); + for (const action of oldValues) { + if (!newValues.has(action)) { + roleDefinition.delete(ccf.strToBuf(action)); + } + } + for (const action of newValues) { + if (!oldValues.has(action)) { + roleDefinition.add(ccf.strToBuf(action)); + } + } + }, + ), +); diff --git a/tests/programmability.py b/tests/programmability.py index a25bc7c99639..88ea229b1f45 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -9,6 +9,7 @@ import os import json from infra.runner import ConcurrentRunner +from governance_js import action, proposal, ballot_yes import npm_tests @@ -25,6 +26,35 @@ } """ +TESTJS_ROLE = """ +export function content(request) { + let raw_id = ccf.strToBuf(request.caller.id); + let user_info = ccf.kv["public:ccf.gov.users.info"].get(raw_id); + if (user_info !== undefined) { + user_info = ccf.bufToJsonCompatible(user_info); + let roles = user_info?.user_data?.roles || []; + + for (const [_, role] of roles.entries()) { + let role_map = ccf.kv[`public:ccf.gov.roles.${role}`]; + let endpoint_name = request.url.split("/")[2]; + if (role_map?.has(ccf.strToBuf(`/${endpoint_name}/read`))) + { + return { + statusCode: 200, + body: { + payload: "Test content", + }, + }; + } + } + } + + return { + statusCode: 403 + }; +} +""" + def test_custom_endpoints(network, args): primary, _ = network.find_primary() @@ -86,6 +116,130 @@ def test_custom_endpoints(network, args): return network +def test_custom_role_definitions(network, args): + primary, _ = network.find_primary() + member = network.consortium.get_any_active_member() + + # Assign a role to user0 + user = network.users[0] + network.consortium.set_user_data( + primary, + user.service_id, + user_data={"isAdmin": True, "roles": ["ContentGetter"]}, + ) + + content_endpoint_def = { + "get": { + "js_module": "test.js", + "js_function": "content", + "forwarding_required": "never", + "redirection_strategy": "none", + "authn_policies": ["user_cert"], + "mode": "readonly", + "openapi": {}, + } + } + + bundle_with_auth = { + "metadata": {"endpoints": {"/content": content_endpoint_def}}, + "modules": [{"name": "test.js", "module": TESTJS_ROLE}], + } + + # Install app with auth/role support + with primary.client(None, None, user.local_id) as c: + r = c.put("/app/custom_endpoints", body=bundle_with_auth) + assert r.status_code == http.HTTPStatus.NO_CONTENT.value, r.status_code + + # Add role definition + prop = member.propose( + primary, + proposal( + action( + "set_role_definition", role="ContentGetter", actions=["/content/read"] + ) + ), + ) + member.vote(primary, prop, ballot_yes) + + # user0 has "ContentGetter" role, which has "/content/read" should be able to access "/content" + with primary.client("user0") as c: + r = c.get("/app/content") + assert r.status_code == http.HTTPStatus.OK, r.status_code + assert r.body.json()["payload"] == "Test content", r.body.json() + + # But user1 does not + with primary.client("user1") as c: + r = c.get("/app/content") + assert r.status_code == http.HTTPStatus.FORBIDDEN, r.status_code + + # And unauthenticated users definitely don't + with primary.client() as c: + r = c.get("/app/content") + assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code + + # Delete role definition + prop = member.propose( + primary, + proposal(action("set_role_definition", role="ContentGetter", actions=[])), + ) + member.vote(primary, prop, ballot_yes) + + # Now user0 can't access /content anymore + with primary.client("user0") as c: + r = c.get("/app/content") + assert r.status_code == http.HTTPStatus.FORBIDDEN, r.status_code + + # Multiple definitions + prop = member.propose( + primary, + proposal( + action( + "set_role_definition", role="ContentGetter", actions=["/content/read"] + ), + action( + "set_role_definition", + role="AllContentGetter", + actions=["/content/read", "/other_content/read"], + ), + ), + ) + member.vote(primary, prop, ballot_yes) + + bundle_with_auth_both = { + "metadata": { + "endpoints": { + "/content": content_endpoint_def, + "/other_content": content_endpoint_def, + } + }, + "modules": [{"name": "test.js", "module": TESTJS_ROLE}], + } + + # Install two endpoints with role auth + with primary.client(None, None, user.local_id) as c: + r = c.put("/app/custom_endpoints", body=bundle_with_auth_both) + assert r.status_code == http.HTTPStatus.NO_CONTENT.value, r.status_code + + # Assign the new role to user0 + user = network.users[0] + network.consortium.set_user_data( + primary, + user.service_id, + user_data={"isAdmin": True, "roles": ["ContentGetter", "AllContentGetter"]}, + ) + + # user0 has access both now + with primary.client("user0") as c: + r = c.get("/app/content") + assert r.status_code == http.HTTPStatus.OK, r.status_code + assert r.body.json()["payload"] == "Test content", r.body.json() + r = c.get("/app/other_content") + assert r.status_code == http.HTTPStatus.OK, r.status_code + assert r.body.json()["payload"] == "Test content", r.body.json() + + return network + + def deploy_npm_app_custom(network, args): primary, _ = network.find_nodes() @@ -123,6 +277,7 @@ def run(args): network.start_and_open(args) network = test_custom_endpoints(network, args) + network = test_custom_role_definitions(network, args) network = npm_tests.build_npm_app(network, args) network = deploy_npm_app_custom(network, args)