From 6d9ce7014163a65ae96dfd13836c8df3f4f7a903 Mon Sep 17 00:00:00 2001 From: Derek D Date: Fri, 22 Dec 2023 07:26:49 +0000 Subject: [PATCH] [#38,#42] Handling for atomic metadata operations metadata_guard now properly accounts for atomic metadata operations. Added unit tests, which use PRC. --- CMakeLists.txt | 5 + ...ortium_continuous_integration_test_hook.py | 5 + ...ule_engine_plugin_metadata_guard_atomic.py | 147 +++++++++ packaging/postinst | 2 + src/main.cpp | 293 +++++++++++++----- 5 files changed, 374 insertions(+), 78 deletions(-) create mode 100644 packaging/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b22134..687a472 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,12 +77,17 @@ install(FILES ${CMAKE_SOURCE_DIR}/packaging/run_metadata_guard_test.py DESTINATION ${IRODS_HOME_DIRECTORY}/scripts PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ) +install(FILES ${CMAKE_SOURCE_DIR}/packaging/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py + DESTINATION ${IRODS_HOME_DIRECTORY}/scripts/irods_prc_tests + PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ) + set(PLUGIN_PACKAGE_NAME irods-rule-engine-plugin-metadata-guard) include(IrodsCPackCommon) list(APPEND CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "${CPACK_PACKAGING_INSTALL_PREFIX}${IRODS_HOME_DIRECTORY}") list(APPEND CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "${CPACK_PACKAGING_INSTALL_PREFIX}${IRODS_HOME_DIRECTORY}/scripts") +list(APPEND CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "${CPACK_PACKAGING_INSTALL_PREFIX}${IRODS_HOME_DIRECTORY}/scripts/irods_prc_tests") list(APPEND CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "${CPACK_PACKAGING_INSTALL_PREFIX}${IRODS_HOME_DIRECTORY}/scripts/irods") list(APPEND CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "${CPACK_PACKAGING_INSTALL_PREFIX}${IRODS_HOME_DIRECTORY}/scripts/irods/test") diff --git a/irods_consortium_continuous_integration_test_hook.py b/irods_consortium_continuous_integration_test_hook.py index 7c1a661..93ccc34 100644 --- a/irods_consortium_continuous_integration_test_hook.py +++ b/irods_consortium_continuous_integration_test_hook.py @@ -28,6 +28,8 @@ def main(): ) ) + irods_python_ci_utilities.subprocess_get_output(['python3', '-m', 'pip', 'install', 'python-irodsclient==1.1.9'], check_rc=True) + test = options.test or 'test_rule_engine_plugin_metadata_guard' try: @@ -35,6 +37,9 @@ def main(): irods_python_ci_utilities.subprocess_get_output(['sudo', 'su', '-', 'irods', '-c', f'python3 scripts/run_tests.py --xml_output --run_s {test} 2>&1 | tee {test_output_file}; exit $PIPESTATUS'], check_rc=True) + irods_python_ci_utilities.subprocess_get_output(['sudo', 'su', '-', 'irods', '-c', + f'python3 scripts/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py 2>&1 | tee {test_output_file}; exit $PIPESTATUS'], + check_rc=True) finally: output_root_directory = options.output_root_directory if output_root_directory: diff --git a/packaging/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py b/packaging/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py new file mode 100644 index 0000000..d7c7631 --- /dev/null +++ b/packaging/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py @@ -0,0 +1,147 @@ +from irods.access import iRODSAccess +from irods.session import iRODSSession +from irods.meta import iRODSMeta, AVUOperation + +import irods.exception + +import json +import os +import shutil +import tempfile +import unittest + +class test_metadata_guard(unittest.TestCase): + + IRODS_CONFIG_FILE_PATH = '/etc/irods/server_config.json' + TEST_DATA_OBJECT_PATH = '/tempZone/home/alice/test1' + + @classmethod + def backup_irods_config(cls): + cls.temp_path = os.path.join(tempfile.mkdtemp(), 'irods_config_backup') + shutil.copy2(cls.IRODS_CONFIG_FILE_PATH, cls.temp_path) + + @classmethod + def restore_irods_config(cls): + shutil.copy2(cls.temp_path,cls.IRODS_CONFIG_FILE_PATH) + + @classmethod + def setUpClass(cls): + with iRODSSession(host='localhost', port=1247, user='rods', password='rods', zone='tempZone') as local_admin: + # create unprivileged user + local_admin.users.create_with_password('alice', 'test') + + # unprivileged user creates test file + with iRODSSession(host='localhost', port=1247, user='alice', password='test', zone='tempZone') as unprivileged_user: + unprivileged_user.data_objects.create(cls.TEST_DATA_OBJECT_PATH) + unprivileged_user.acls.set( iRODSAccess('own', cls.TEST_DATA_OBJECT_PATH, 'rods', 'tempZone')) + + coll = local_admin.collections.get("/tempZone") + # add metadata_guard config AVU to collection + METADATA_GUARD_CONFIG = { + 'admin_only': True, + 'prefixes': ['irods::'] + } + if 'irods::metadata_guard' not in coll.metadata: + coll.metadata.add('irods::metadata_guard', json.dumps(METADATA_GUARD_CONFIG)) + + # create backup of irods config file + cls.backup_irods_config() + # insert metadata guard rule engine plugin to irods config file + with open(cls.IRODS_CONFIG_FILE_PATH, 'r+') as config_file: + METADATA_GUARD_PLUGIN_BLOCK = { + 'instance_name': 'irods_rule_engine_plugin-metadata_guard-instance', + 'plugin_name': 'irods_rule_engine_plugin-metadata_guard', + 'plugin_specific_configuration': {} + } + config_json = json.load(config_file) + config_json['plugin_configuration']['rule_engines'].insert(0, METADATA_GUARD_PLUGIN_BLOCK) + with open(cls.IRODS_CONFIG_FILE_PATH, 'wt') as config_file: + json.dump(config_json, config_file) + + @classmethod + def tearDownClass(cls): + cls.restore_irods_config() + + def test_guard_atomic_bad_config__issue_38(self): + # set metadata_guard config AVU on collection to nonsense + with iRODSSession(host='localhost', port=1247, user='rods', password='rods', zone='tempZone') as local_admin: + coll = local_admin.collections.get("/tempZone") + coll.metadata['irods::metadata_guard'] = iRODSMeta('irods::metadata_guard', '{"broken": ["irods::"], ') + + with iRODSSession(host='localhost', port=1247, user='alice', password='test', zone='tempZone') as unprivileged_user: + obj = unprivileged_user.data_objects.get(self.TEST_DATA_OBJECT_PATH) + obj.metadata.apply_atomic_operations( AVUOperation(operation='add', avu=iRODSMeta('irods::badconfig','badconfig'))) + + # bad config means operation should succeed + self.assertEqual(obj.metadata['irods::badconfig'], iRODSMeta('irods::badconfig', 'badconfig')) + obj.metadata.apply_atomic_operations( AVUOperation(operation='remove', avu=iRODSMeta('irods::badconfig','badconfig'))) + + def test_guard_atomic_operations_admin_only__issue_38(self): + # set metadata_guard config AVU on collection to admin_only and irods:: as protected prefix + with iRODSSession(host='localhost', port=1247, user='rods', password='rods', zone='tempZone') as local_admin: + coll = local_admin.collections.get("/tempZone") + METADATA_GUARD_CONFIG = { + 'admin_only': True, + 'prefixes': ['irods::'] + } + coll.metadata['irods::metadata_guard'] = iRODSMeta('irods::metadata_guard', json.dumps(METADATA_GUARD_CONFIG)) + + obj = local_admin.data_objects.get(self.TEST_DATA_OBJECT_PATH) + obj.metadata.apply_atomic_operations( AVUOperation(operation='add', avu=iRODSMeta('irods::adminonly','adminonly'))) + + # admin should still be allowed to add metadata + self.assertEqual(obj.metadata['irods::adminonly'], iRODSMeta('irods::adminonly', 'adminonly')) + + with iRODSSession(host='localhost', port=1247, user='alice', password='test', zone='tempZone') as unprivileged_user: + obj = unprivileged_user.data_objects.get(self.TEST_DATA_OBJECT_PATH) + + # "unguarded::" not protected, so operation should succeed + obj.metadata.apply_atomic_operations( AVUOperation(operation='add', avu=iRODSMeta('unguarded::atr1','val1'))) + self.assertEqual(obj.metadata['unguarded::atr1'], iRODSMeta('unguarded::atr1', 'val1')) + + # "irods::" protected, so metadata add should fail + self.assertRaises(irods.exception.CAT_INSUFFICIENT_PRIVILEGE_LEVEL, lambda: obj.metadata.apply_atomic_operations( AVUOperation(operation='add', avu=iRODSMeta('irods::atr','val')))) + + # "irods::" protected, so metadata delete should fail + self.assertRaises(irods.exception.CAT_INSUFFICIENT_PRIVILEGE_LEVEL, lambda: obj.metadata.apply_atomic_operations( AVUOperation(operation='remove', avu=iRODSMeta('irods::adminonly','adminonly')))) + + def test_guard_atomic_operations_editor_list__issue_38(self): + # set metadata_guard config AVU on collection to admin_only: false and irods:: as protected prefix + # also, add alice user as editor + with iRODSSession(host='localhost', port=1247, user='rods', password='rods', zone='tempZone') as local_admin: + coll = local_admin.collections.get("/tempZone") + METADATA_GUARD_CONFIG = { + 'admin_only': False, + 'editors': [{'name': 'rods', 'type': 'user'}, {'name': 'alice', 'type': 'user'}], + 'prefixes': ['irods::'] + } + coll.metadata['irods::metadata_guard'] = iRODSMeta('irods::metadata_guard', json.dumps(METADATA_GUARD_CONFIG)) + + with iRODSSession(host='localhost', port=1247, user='alice', password='test', zone='tempZone') as unprivileged_user: + obj = unprivileged_user.data_objects.get(self.TEST_DATA_OBJECT_PATH) + + # operation should succeed, as alice is set as an editor + obj.metadata.apply_atomic_operations( AVUOperation(operation='add', avu=iRODSMeta('irods::editorlist', 'editorlist'))) + self.assertEqual(obj.metadata['irods::editorlist'], iRODSMeta('irods::editorlist', 'editorlist')) + + # remove alice user from editor list + with iRODSSession(host='localhost', port=1247, user='rods', password='rods', zone='tempZone') as local_admin: + coll = local_admin.collections.get("/tempZone") + METADATA_GUARD_CONFIG = { + 'admin_only': False, + 'editors': [{'name': 'rods', 'type': 'user'}], + 'prefixes': ['irods::'] + } + coll.metadata['irods::metadata_guard'] = iRODSMeta('irods::metadata_guard', json.dumps(METADATA_GUARD_CONFIG)) + + with iRODSSession(host='localhost', port=1247, user='alice', password='test', zone='tempZone') as unprivileged_user: + obj = unprivileged_user.data_objects.get(self.TEST_DATA_OBJECT_PATH) + + # this was set previously, make sure it is still the case + self.assertEqual(obj.metadata['irods::editorlist'], iRODSMeta('irods::editorlist', 'editorlist')) + # operation should fail, as test user is no longer in editor list + self.assertRaises(irods.exception.CAT_INSUFFICIENT_PRIVILEGE_LEVEL, lambda: obj.metadata.apply_atomic_operations( AVUOperation(operation='remove', avu=iRODSMeta('irods::editorlist', 'editorlist')))) + + +if __name__ == '__main__': + unittest.main() diff --git a/packaging/postinst b/packaging/postinst index a5c7bf7..efd5d1c 100644 --- a/packaging/postinst +++ b/packaging/postinst @@ -9,6 +9,8 @@ chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /etc/irods chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /usr/lib/irods/plugins/rule_engines/libirods_rule_engine_plugin-metadata_guard.so chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts +chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts/irods_prc_tests +chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts/irods_prc_tests/test_rule_engine_plugin_metadata_guard_atomic.py chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts/irods chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts/irods/test chown $IRODS_SERVICE_ACCOUNT_NAME:$IRODS_SERVICE_GROUP_NAME /var/lib/irods/scripts/irods/test/test_rule_engine_plugin_metadata_guard.py diff --git a/src/main.cpp b/src/main.cpp index 7a02d8f..9997720 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,7 @@ #include #include #include +#include namespace { @@ -90,87 +91,223 @@ namespace auto rule_exists(irods::default_re_ctx&, const std::string& _rule_name, bool& _exists) -> irods::error { - _exists = (_rule_name == "pep_api_mod_avu_metadata_pre"); - return SUCCESS(); - } - - auto list_rules(irods::default_re_ctx&, std::vector& _rules) -> irods::error - { - _rules.push_back("pep_api_mod_avu_metadata_pre"); - return SUCCESS(); - } - - auto exec_rule(irods::default_re_ctx&, - const std::string& _rule_name, - std::list& _rule_arguments, - irods::callback _effect_handler) -> irods::error - { - try { - auto* input = boost::any_cast(*std::next(std::begin(_rule_arguments), 2)); - auto& rei = get_rei(_effect_handler); - const auto config = load_plugin_config(rei); - - if (!config) { - return CODE(RULE_ENGINE_CONTINUE); - } - - // JSON Configuration structure: - // { - // "prefixes": ["irods::"], - // "admin_only": true, - // "editors": [ - // {"type": "group", "name": "rodsadmin"}, - // {"type": "user", "name": "kory"}, - // {"type": "user", "name": "jane#otherZone"} - // ] - // } - for (auto&& prefix : config->at("prefixes")) { - // If the metadata attribute starts with the prefix, then verify that the user - // can modify the metadata attribute. - if (boost::starts_with(input->arg3, prefix.get_ref())) { - // The "admin_only" flag supersedes the "editors" configuration option. - if (config->count("admin_only") && config->at("admin_only").get()) { - return user_is_administrator(*rei.rsComm); - } - - namespace adm = irods::experimental::administration; - - const adm::user user{rei.uoic->userName, rei.uoic->rodsZone}; - - for (auto&& editor : config->at("editors")) { - if (const auto& type = editor.at("type").get_ref(); type == "group") { - const adm::group group{editor.at("name").get_ref()}; - - if (adm::server::user_is_member_of_group(*rei.rsComm, group, user)) { - return CODE(RULE_ENGINE_CONTINUE); - } - } - else if (type == "user") { - if (editor.at("name").get_ref() == adm::server::local_unique_name(*rei.rsComm, user)) { - return CODE(RULE_ENGINE_CONTINUE); - } - } - } - - // At this point, the user is not an administrator and they aren't a member of - // the editors list. Therefore, we return an error because the user is attempting to - // modify metadata in a guarded namespace. - return ERROR(CAT_INSUFFICIENT_PRIVILEGE_LEVEL, "User is not allowed to modify metadata"); - } - } - } - catch (const json::exception&) { - // clang-format off + _exists = (_rule_name == "pep_api_mod_avu_metadata_pre") || + (_rule_name == "pep_api_atomic_apply_metadata_operations_pre"); + return SUCCESS(); + } + + auto list_rules(irods::default_re_ctx&, std::vector& _rules) -> irods::error + { + _rules.push_back("pep_api_mod_avu_metadata_pre"); + _rules.push_back("pep_api_atomic_apply_metadata_operations_pre"); + return SUCCESS(); + } + + auto check_operation_for_violations(const std::string& _attribute, + ruleExecInfo_t& rei, + const json& config, + const json& prefixes, + const bool admin_only) -> irods::error + { + const auto prefix_matched = std::any_of(prefixes.cbegin(), prefixes.cend(), [&_attribute](const json& prefix) { + return boost::starts_with(_attribute, prefix.get_ref()); + }); + if (!prefix_matched) { + return CODE(RULE_ENGINE_CONTINUE); + } + + // If admin_only, success was already checked outside this call + // Therefore, always fail. + if (admin_only) { + return ERROR(CAT_INSUFFICIENT_PRIVILEGE_LEVEL, "User must be an admininstrator to modify metadata"); + } + + namespace adm = irods::experimental::administration; + const adm::user user{rei.uoic->userName, rei.uoic->rodsZone}; + const auto& editors = config.at("editors"); + const auto editor_matched = std::any_of(editors.cbegin(), editors.cend(), [&rei, &user](const json& editor) { + const auto& type = editor.at("type").get_ref(); + const auto& name{editor.at("name").get_ref()}; + if (type == "user") { + return adm::server::local_unique_name(*rei.rsComm, user) == name; + } + if (type == "group") { + const adm::group group{name}; + return adm::server::user_is_member_of_group(*rei.rsComm, group, user); + } + return false; + }); + if (editor_matched) { + return CODE(RULE_ENGINE_CONTINUE); + } + + return ERROR(CAT_INSUFFICIENT_PRIVILEGE_LEVEL, "User must be an authorized editor to modify metadata."); + } + + auto handle_pep_api_mod_avu_metadata_pre(std::list& _rule_arguments, irods::callback _effect_handler) + -> irods::error + { + try { + auto* input = boost::any_cast(*std::next(std::begin(_rule_arguments), 2)); + auto& rei = get_rei(_effect_handler); + const auto config = load_plugin_config(rei); + + if (!config) { + return CODE(RULE_ENGINE_CONTINUE); + } + + // JSON Configuration structure: + // { + // "prefixes": ["irods::"], + // "admin_only": true, + // "editors": [ + // {"type": "group", "name": "rodsadmin"}, + // {"type": "user", "name": "kory"}, + // {"type": "user", "name": "jane#otherZone"} + // ] + // } + for (auto&& prefix : config->at("prefixes")) { + // If the metadata attribute starts with the prefix, then verify that the user + // can modify the metadata attribute. + if (boost::starts_with(input->arg3, prefix.get_ref())) { + // The "admin_only" flag supersedes the "editors" configuration option. + const auto& admin_iter = config->find("admin_only"); + if (admin_iter != config->cend() && admin_iter->get()) { + return user_is_administrator(*rei.rsComm); + } + + namespace adm = irods::experimental::administration; + + const adm::user user{rei.uoic->userName, rei.uoic->rodsZone}; + + for (auto&& editor : config->at("editors")) { + if (const auto& type = editor.at("type").get_ref(); type == "group") { + const adm::group group{editor.at("name").get_ref()}; + + if (adm::server::user_is_member_of_group(*rei.rsComm, group, user)) { + return CODE(RULE_ENGINE_CONTINUE); + } + } + else if (type == "user") { + if (editor.at("name").get_ref() == + adm::server::local_unique_name(*rei.rsComm, user)) { + return CODE(RULE_ENGINE_CONTINUE); + } + } + } + + // At this point, the user is not an administrator and they aren't a member of + // the editors list. Therefore, we return an error because the user is attempting to + // modify metadata in a guarded namespace. + return ERROR(CAT_INSUFFICIENT_PRIVILEGE_LEVEL, "User is not allowed to modify metadata"); + } + } + } + catch (const json::exception&) { + // clang-format off log_re::error({{"log_message", "Unexpected JSON access or type error."}, {"rule_engine_plugin", "metadata_guard"}}); // clang-format on - } - catch (const std::exception& e) { - log_re::error({{"log_message", e.what()}, {"rule_engine_plugin", "metadata_guard"}}); - } - - return CODE(RULE_ENGINE_CONTINUE); - } + } + catch (const std::exception& e) { + log_re::error({{"log_message", e.what()}, {"rule_engine_plugin", "metadata_guard"}}); + } + + return CODE(RULE_ENGINE_CONTINUE); + } + + auto handle_pep_api_atomic_apply_metadata_operations_pre(std::list& _rule_arguments, + irods::callback _effect_handler) -> irods::error + { + auto is_input_valid = [](const bytesBuf_t* _input) -> std::tuple { + if (!_input) { + return {false, "Missing JSON input"}; + } + + if (_input->len <= 0) { + return {false, "Length of buffer must be greater than zero"}; + } + + if (!_input->buf) { + return {false, "Missing input buffer"}; + } + + return {true, ""}; + }; + + try { + auto* input_bb = boost::any_cast(*std::next(std::begin(_rule_arguments), 2)); + + if (const auto [valid, msg] = is_input_valid(input_bb); !valid) { + log_re::error(msg); + return ERROR(INPUT_ARG_NOT_WELL_FORMED_ERR, msg); + } + + const auto input_bb_casted = static_cast(input_bb->buf); + json input = json::parse(input_bb_casted, input_bb_casted + input_bb->len); + const auto& operations = input.at("operations"); + + auto& rei = get_rei(_effect_handler); + const auto config = load_plugin_config(rei); + if (config == std::nullopt) { + return CODE(RULE_ENGINE_CONTINUE); + } + const auto prefixes_iter = config->find("prefixes"); + if (prefixes_iter == config->cend()) { + log_re::error({{"log_message", "Required property \"prefixes\": [\"...\"] not found"}, + {"rule_engine_plugin", "metadata_guard"}}); + return CODE(RULE_ENGINE_CONTINUE); + } + + const auto admin_iter = config->find("admin_only"); + const bool admin_check = admin_iter != config->cend() && admin_iter->get(); + + if (admin_check && user_is_administrator(*rei.rsComm).ok()) { + return CODE(RULE_ENGINE_CONTINUE); + } + + for (auto&& op : operations) { + const auto err_code = check_operation_for_violations( + op.at("attribute").get_ref(), rei, *config, *prefixes_iter, admin_check); + if (!err_code.ok()) { + log_re::error({{"log_message", err_code.result()}, {"rule_engine_plugin", "metadata_guard"}}); + return err_code; + } + } + + return CODE(RULE_ENGINE_CONTINUE); + } + catch (const json::exception& e) { + log_re::error({{"log_message", "Failed to parse input into JSON"}, + {"error_message", e.what()}, + {"rule_engine_plugin", "metadata_guard"}}); + } + catch (const std::exception& e) { + log_re::error({{"log_message", e.what()}, {"rule_engine_plugin", "metadata_guard"}}); + } + + return CODE(RULE_ENGINE_CONTINUE); + } + + auto exec_rule(irods::default_re_ctx&, + const std::string& _rule_name, + std::list& _rule_arguments, + irods::callback _effect_handler) -> irods::error + { + static const std::unordered_map&, irods::callback)>> + lookup_table{ + {"pep_api_mod_avu_metadata_pre", handle_pep_api_mod_avu_metadata_pre}, + {"pep_api_atomic_apply_metadata_operations_pre", handle_pep_api_atomic_apply_metadata_operations_pre}}; + + static const auto rule_iterator = lookup_table.find(_rule_name); + + if (rule_iterator != lookup_table.end()) { + return std::get<1>(*rule_iterator)(_rule_arguments, _effect_handler); + } + + return CODE(RULE_ENGINE_CONTINUE); + } } // namespace (anonymous) //