From cd575f36c0fd76b8e0928df02bd73b70fa4bb04c Mon Sep 17 00:00:00 2001 From: Maximilian Pohl Date: Mon, 30 Sep 2024 13:25:43 +0200 Subject: [PATCH 1/4] Add user management --- ...cd1e0bedc713e772b644dc275643a5bef8f8a.json | 88 ++++ ...3192e6f338d89cf9be41c51a339eb42b3a474.json | 14 + ...30b94e24b7388414df2cf77645b0e69805ad4.json | 14 + ...8c06dfe060616bcafa5ddc38468ed964e8abd.json | 14 + ...b1186846f42d49bc190f07ca94b6a5e558602.json | 16 + ...24d47b3a4d324e74cac449b48a3f9f612afef.json | 15 + ...699aa9346c8cea7ecea9088ec5dd1539bc70c.json | 15 + ...4808c5e7957b9f907053013aa8147bffc7a8b.json | 15 + ...c75e8e552b90a3261e782d16d3b8805e80b98.json | 28 + ...321f97c8961774c043dc5ab643b5e6357eb35.json | 23 + ...48c54455fb4fdf08c0783ef3e42071a6b9d76.json | 14 + ...95505b9f10392316312567099acacca9bfc2c.json | 14 + ...ae078dfaab4a5b7cd831a9f813386acb52a37.json | 16 + ...5527cf66f5230a73659f8430c47d0b183b00d.json | 14 + ...c088d214a7304149575343d4ebb4e65eae495.json | 23 - ...050b1d90608a5ce1497bc45bab258ed8efa55.json | 23 - ...3651cb5dcfb45368207cf11f16bd30bd06428.json | 14 + ...02b686b7c464f4aa2b276369b3727127608d4.json | 86 ++++ ...e09c45ecec073fd32461071f3687ccd9c7e91.json | 14 + Cargo.lock | 33 ++ Cargo.toml | 1 + fixtures/users.sql | 10 +- migrations/20240826084440_initial_scheme.sql | 25 +- openadr-vtn/Cargo.toml | 3 +- openadr-vtn/src/api/auth.rs | 7 +- openadr-vtn/src/api/mod.rs | 1 + openadr-vtn/src/api/user.rs | 117 +++++ openadr-vtn/src/data_source/mod.rs | 53 +- openadr-vtn/src/data_source/postgres/user.rs | 487 +++++++++++++++--- openadr-vtn/src/error.rs | 30 +- openadr-vtn/src/main.rs | 2 +- openadr-vtn/src/state.rs | 21 +- openadr-wire/src/lib.rs | 2 +- 33 files changed, 1115 insertions(+), 137 deletions(-) create mode 100644 .sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json create mode 100644 .sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json create mode 100644 .sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json create mode 100644 .sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json create mode 100644 .sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json create mode 100644 .sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json create mode 100644 .sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json create mode 100644 .sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json create mode 100644 .sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json create mode 100644 .sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json create mode 100644 .sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json create mode 100644 .sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json create mode 100644 .sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json create mode 100644 .sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json delete mode 100644 .sqlx/query-d9da3079248311a250712457f57c088d214a7304149575343d4ebb4e65eae495.json delete mode 100644 .sqlx/query-e75e4d1b55ae024c8ce9771ecc9050b1d90608a5ce1497bc45bab258ed8efa55.json create mode 100644 .sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json create mode 100644 .sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json create mode 100644 .sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json create mode 100644 openadr-vtn/src/api/user.rs diff --git a/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json b/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json new file mode 100644 index 0000000..8516828 --- /dev/null +++ b/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n json_arrayagg(c.client_id) AS \"client_ids!\",\n b.user_id IS NOT NULL AS \"is_business_user!\",\n json_arrayagg(b.business_id NULL ON NULL) AS business_ids,\n ven.user_id IS NOT NULL AS \"is_ven_user!\",\n json_arrayagg(ven.ven_id) AS ven_ids,\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n WHERE u.id = $1\n GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids!", + "type_info": "Json" + }, + { + "ordinal": 6, + "name": "is_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "business_ids", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "is_ven_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "ven_ids", + "type_info": "Json" + }, + { + "ordinal": 10, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a" +} diff --git a/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json b/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json new file mode 100644 index 0000000..b9dc7d2 --- /dev/null +++ b/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_manager WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474" +} diff --git a/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json b/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json new file mode 100644 index 0000000..5f29a95 --- /dev/null +++ b/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_business (user_id, business_id) VALUES ($1, NULL)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4" +} diff --git a/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json b/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json new file mode 100644 index 0000000..5cb5585 --- /dev/null +++ b/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_ven WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd" +} diff --git a/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json b/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json new file mode 100644 index 0000000..ec0b995 --- /dev/null +++ b/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \"user\" SET\n reference = $2,\n description = $3,\n modified = now()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602" +} diff --git a/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json b/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json new file mode 100644 index 0000000..b2d28d6 --- /dev/null +++ b/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_ven (user_id, ven_id) VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef" +} diff --git a/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json b/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json new file mode 100644 index 0000000..6c3a3f6 --- /dev/null +++ b/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_credentials WHERE user_id = $1 AND client_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c" +} diff --git a/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json b/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json new file mode 100644 index 0000000..b556b47 --- /dev/null +++ b/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_business (user_id, business_id) VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b" +} diff --git a/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json b/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json new file mode 100644 index 0000000..cab82dd --- /dev/null +++ b/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id,\n client_secret\n FROM \"user\"\n JOIN user_credentials ON user_id = id\n WHERE client_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "client_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98" +} diff --git a/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json b/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json new file mode 100644 index 0000000..7a161b7 --- /dev/null +++ b/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO \"user\" (id, reference, description, created, modified)\n VALUES (gen_random_uuid(), $1, $2, now(), now())\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35" +} diff --git a/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json b/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json new file mode 100644 index 0000000..44cb1f8 --- /dev/null +++ b/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_manager (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76" +} diff --git a/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json b/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json new file mode 100644 index 0000000..eb332b8 --- /dev/null +++ b/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM ven_manager WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c" +} diff --git a/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json b/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json new file mode 100644 index 0000000..63cae35 --- /dev/null +++ b/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_credentials \n (user_id, client_id, client_secret) \n VALUES \n ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37" +} diff --git a/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json b/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json new file mode 100644 index 0000000..6123775 --- /dev/null +++ b/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO ven_manager (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d" +} diff --git a/.sqlx/query-d9da3079248311a250712457f57c088d214a7304149575343d4ebb4e65eae495.json b/.sqlx/query-d9da3079248311a250712457f57c088d214a7304149575343d4ebb4e65eae495.json deleted file mode 100644 index d30aed0..0000000 --- a/.sqlx/query-d9da3079248311a250712457f57c088d214a7304149575343d4ebb4e65eae495.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT ven_id AS id\n FROM \"user\" u\n JOIN user_credentials c ON c.user_id = u.id\n JOIN user_ven v ON v.user_id = u.id \n WHERE client_id = $1\n AND client_secret = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "d9da3079248311a250712457f57c088d214a7304149575343d4ebb4e65eae495" -} diff --git a/.sqlx/query-e75e4d1b55ae024c8ce9771ecc9050b1d90608a5ce1497bc45bab258ed8efa55.json b/.sqlx/query-e75e4d1b55ae024c8ce9771ecc9050b1d90608a5ce1497bc45bab258ed8efa55.json deleted file mode 100644 index 729dda5..0000000 --- a/.sqlx/query-e75e4d1b55ae024c8ce9771ecc9050b1d90608a5ce1497bc45bab258ed8efa55.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT ub.business_id AS id \n FROM user_business ub\n JOIN \"user\" u ON u.id = ub.user_id\n JOIN user_credentials c ON c.user_id = u.id\n WHERE client_id = $1\n AND client_secret = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "e75e4d1b55ae024c8ce9771ecc9050b1d90608a5ce1497bc45bab258ed8efa55" -} diff --git a/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json b/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json new file mode 100644 index 0000000..c509720 --- /dev/null +++ b/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_business WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428" +} diff --git a/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json b/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json new file mode 100644 index 0000000..ae496bf --- /dev/null +++ b/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json @@ -0,0 +1,86 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n json_arrayagg(c.client_id) AS \"client_ids!\",\n b.user_id IS NOT NULL AS \"is_business_user!\",\n json_arrayagg(b.business_id NULL ON NULL) AS business_ids,\n ven.user_id IS NOT NULL AS \"is_ven_user!\",\n json_arrayagg(ven.ven_id) AS ven_ids,\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids!", + "type_info": "Json" + }, + { + "ordinal": 6, + "name": "is_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "business_ids", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "is_ven_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "ven_ids", + "type_info": "Json" + }, + { + "ordinal": 10, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4" +} diff --git a/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json b/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json new file mode 100644 index 0000000..d4b04f1 --- /dev/null +++ b/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM \"user\" WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91" +} diff --git a/Cargo.lock b/Cargo.lock index 207d9d2..150e1c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,18 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -224,6 +236,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1201,6 +1222,7 @@ dependencies = [ name = "openadr-vtn" version = "0.1.0" dependencies = [ + "argon2", "axum", "axum-extra", "chrono", @@ -1281,6 +1303,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index be51c5a..8b13b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,4 +55,5 @@ async-trait = "0.1.81" quickcheck = "1.0.3" sqlx = { version = "0.8.1", features = ["postgres", "runtime-tokio", "chrono", "migrate"] } +argon2 = "0.5.3" dotenvy = "0.15.7" \ No newline at end of file diff --git a/fixtures/users.sql b/fixtures/users.sql index 2b69ed0..8f803a3 100644 --- a/fixtures/users.sql +++ b/fixtures/users.sql @@ -1,10 +1,10 @@ -INSERT INTO "user" (id) -VALUES ('admin'); +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('admin', 'admin-ref', null, now(), now()); INSERT INTO user_business VALUES ('admin', NULL); INSERT INTO user_credentials (user_id, client_id, client_secret) -VALUES ('admin', 'admin', 'admin'); +VALUES ('admin', 'admin', '$argon2id$v=19$m=16,t=2,p=1$QmtwZnBPVnlIYkJTWUtHZg$lMxF0N+CeRa99UmzMaUKeg'); -- secret: admin -INSERT INTO "user" (id) -VALUES ('user-1'); \ No newline at end of file +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('user-1', 'user-1-ref', null, now(), now()); \ No newline at end of file diff --git a/migrations/20240826084440_initial_scheme.sql b/migrations/20240826084440_initial_scheme.sql index 66866f4..aa6dc58 100644 --- a/migrations/20240826084440_initial_scheme.sql +++ b/migrations/20240826084440_initial_scheme.sql @@ -75,18 +75,17 @@ create unique index report_report_name_uindex create table "user" ( - -- TODO maybe add a (human friendly) name or reference - id text not null - constraint user_pk - primary key + id text primary key, + reference text not null, + description text, + created timestamptz not null, + modified timestamptz not null ); create table user_credentials ( user_id text not null references "user" (id) on delete cascade, - client_id text not null - constraint user_credentials_pk - primary key, + client_id text primary key, client_secret text not null -- TODO maybe the credentials require their own role? ); @@ -146,4 +145,14 @@ create unique index null_test_user_business where business_id is null; create unique index uindex_user_business - on user_business (user_id, business_id); \ No newline at end of file + on user_business (user_id, business_id); + +create table ven_manager +( + user_id text primary key references "user" (id) on delete cascade +); + +create table user_manager +( + user_id text primary key references "user" (id) on delete cascade +); \ No newline at end of file diff --git a/openadr-vtn/Cargo.toml b/openadr-vtn/Cargo.toml index b1a5a90..157720d 100644 --- a/openadr-vtn/Cargo.toml +++ b/openadr-vtn/Cargo.toml @@ -38,6 +38,7 @@ chrono.workspace = true thiserror.workspace = true sqlx = {workspace = true, optional = true} +argon2 = {workspace = true, optional = true} dotenvy = {workspace = true, optional = true} [dev-dependencies] @@ -46,4 +47,4 @@ tokio = { workspace = true, features = ["full", "test-util"] } [features] default = ["postgres", "live-db-test"] live-db-test = ["postgres"] -postgres = ["sqlx/postgres", "dep:dotenvy"] \ No newline at end of file +postgres = ["sqlx/postgres", "dep:dotenvy", "dep:argon2"] \ No newline at end of file diff --git a/openadr-vtn/src/api/auth.rs b/openadr-vtn/src/api/auth.rs index 610ca5d..348c490 100644 --- a/openadr-vtn/src/api/auth.rs +++ b/openadr-vtn/src/api/auth.rs @@ -74,7 +74,7 @@ impl IntoResponse for AccessTokenResponse { } /// RFC 6749 client credentials grant flow -pub async fn token( +pub(crate) async fn token( State(auth_source): State>, State(jwt_manager): State>, authorization: Option>>, @@ -117,7 +117,10 @@ pub async fn token( }; // check that the client_id and client_secret are valid - let Some(user) = auth_source.get_user(client_id, client_secret).await else { + let Some(user) = auth_source + .check_credentials(client_id, client_secret) + .await + else { return Err(OAuthError::new(OAuthErrorType::InvalidClient) .with_description("Invalid client_id or client_secret".to_string()) .into()); diff --git a/openadr-vtn/src/api/mod.rs b/openadr-vtn/src/api/mod.rs index 124d8a7..b8b4e33 100644 --- a/openadr-vtn/src/api/mod.rs +++ b/openadr-vtn/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod event; pub mod program; pub mod report; pub mod resource; +pub mod user; pub mod ven; pub type AppResponse = Result, AppError>; diff --git a/openadr-vtn/src/api/user.rs b/openadr-vtn/src/api/user.rs new file mode 100644 index 0000000..bcee9bc --- /dev/null +++ b/openadr-vtn/src/api/user.rs @@ -0,0 +1,117 @@ +use crate::{ + api::AppResponse, + data_source::{AuthSource, UserDetails}, + jwt::{AuthRole, UserManagerUser}, +}; +use axum::{ + extract::{Path, State}, + Json, +}; +use serde_with::serde_derive::Deserialize; +use std::sync::Arc; +use tracing::{info, trace}; + +#[derive(Deserialize)] +pub struct NewUser { + reference: String, + description: Option, + roles: Vec, +} + +#[derive(Deserialize)] +pub struct NewCredential { + client_id: String, + client_secret: String, +} + +pub async fn get_all( + State(auth_source): State>, + UserManagerUser(_): UserManagerUser, +) -> AppResponse> { + let users = auth_source.get_all_users().await?; + + trace!("received {} users", users.len()); + Ok(Json(users)) +} + +pub async fn get( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.get_user(&id).await?; + trace!(user_id = user.id(), "received user"); + Ok(Json(user)) +} + +pub async fn add_user( + State(auth_source): State>, + UserManagerUser(_): UserManagerUser, + Json(new_user): Json, +) -> AppResponse { + let user = auth_source + .add_user( + &new_user.reference, + new_user.description.as_deref(), + &new_user.roles, + ) + .await?; + info!(user_id = user.id(), "created new user"); + Ok(Json(user)) +} + +pub async fn add_credential( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, + Json(new): Json, +) -> AppResponse { + let user = auth_source + .add_credentials(&id, &new.client_id, &new.client_secret) + .await?; + info!( + user_id = id, + client_id = new.client_id, + "created new credential for user" + ); + Ok(Json(user)) +} + +pub async fn edit( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, + Json(modified): Json, +) -> AppResponse { + let user = auth_source + .edit_user( + &id, + &modified.reference, + modified.description.as_deref(), + &modified.roles, + ) + .await?; + + info!(user_id = user.id(), "updated user"); + Ok(Json(user)) +} + +pub async fn delete_user( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.remove_user(&id).await?; + info!(user_id = user.id(), "deleted user"); + Ok(Json(user)) +} + +pub async fn delete_credential( + State(auth_source): State>, + Path((user_id, client_id)): Path<(String, String)>, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.remove_credentials(&user_id, &client_id).await?; + info!(user_id = user.id(), client_id, "deleted credential"); + Ok(Json(user)) +} diff --git a/openadr-vtn/src/data_source/mod.rs b/openadr-vtn/src/data_source/mod.rs index 33f2f9e..a28e9e6 100644 --- a/openadr-vtn/src/data_source/mod.rs +++ b/openadr-vtn/src/data_source/mod.rs @@ -2,6 +2,7 @@ mod postgres; use axum::async_trait; +use chrono::{DateTime, Utc}; use openadr_wire::{ event::{EventContent, EventId}, program::{ProgramContent, ProgramId}, @@ -10,10 +11,10 @@ use openadr_wire::{ ven::{Ven, VenContent, VenId}, Event, Program, Report, }; -use std::sync::Arc; - #[cfg(feature = "postgres")] pub use postgres::PostgresStorage; +use serde::Serialize; +use std::sync::Arc; use crate::{ error::AppError, @@ -189,9 +190,55 @@ pub trait ResourceCrud: { } +#[derive(Serialize)] +pub struct UserDetails { + id: String, + reference: String, + description: Option, + roles: Vec, + client_ids: Vec, + #[serde(with = "openadr_wire::serde_rfc3339")] + created: DateTime, + #[serde(with = "openadr_wire::serde_rfc3339")] + modified: DateTime, +} + +impl UserDetails { + pub fn id(&self) -> &str { + &self.id + } +} + #[async_trait] pub trait AuthSource: Send + Sync + 'static { - async fn get_user(&self, client_id: &str, client_secret: &str) -> Option; + async fn check_credentials(&self, client_id: &str, client_secret: &str) -> Option; + async fn get_user(&self, user_id: &str) -> Result; + async fn get_all_users(&self) -> Result, AppError>; + async fn add_user( + &self, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result; + async fn add_credentials( + &self, + user_id: &str, + client_id: &str, + client_secret: &str, + ) -> Result; + async fn remove_credentials( + &self, + user_id: &str, + client_id: &str, + ) -> Result; + async fn remove_user(&self, user_id: &str) -> Result; + async fn edit_user( + &self, + user_id: &str, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result; } pub trait DataSource: Send + Sync + 'static { diff --git a/openadr-vtn/src/data_source/postgres/user.rs b/openadr-vtn/src/data_source/postgres/user.rs index 493ae90..a4c4c30 100644 --- a/openadr-vtn/src/data_source/postgres/user.rs +++ b/openadr-vtn/src/data_source/postgres/user.rs @@ -1,10 +1,16 @@ use crate::{ - data_source::{postgres::PgId, AuthInfo, AuthSource}, + data_source::{postgres::PgId, AuthInfo, AuthSource, UserDetails}, + error::AppError, jwt::AuthRole, }; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; use axum::async_trait; -use openadr_wire::IdentifierError; -use sqlx::PgPool; +use chrono::{DateTime, Utc}; +use sqlx::{PgConnection, PgPool}; +use tracing::warn; pub struct PgAuthSource { db: PgPool, @@ -17,79 +23,438 @@ impl From for PgAuthSource { } #[derive(Debug)] -struct MaybePgId { - id: Option, +struct IntermediateUser { + id: String, + reference: String, + description: Option, + client_ids: serde_json::Value, + created: DateTime, + modified: DateTime, + is_business_user: bool, + business_ids: serde_json::Value, + is_ven_user: bool, + ven_ids: serde_json::Value, + is_user_manager: bool, + is_ven_manager: bool, +} + +impl TryFrom for UserDetails { + type Error = AppError; + + fn try_from(u: IntermediateUser) -> Result { + let mut roles = Vec::new(); + if u.is_business_user { + let business_ids: Vec> = serde_json::from_value(u.business_ids) + .map_err(|err| { + warn!( + user_id = u.id, + "failed to deserialize user associated businesses: {err}" + ); + AppError::SerdeJsonInternalServerError(err) + })?; + roles.append( + &mut business_ids + .into_iter() + .map(|id| match id { + None => Ok(AuthRole::AnyBusiness), + Some(id) => Ok(AuthRole::Business(id)), + }) + .collect::, AppError>>()?, + ) + } + + if u.is_ven_user { + let ven_ids: Vec = serde_json::from_value(u.ven_ids).map_err(|err| { + warn!( + user_id = u.id, + "failed to deserialize user associated vens: {err}" + ); + AppError::SerdeJsonInternalServerError(err) + })?; + roles.append( + &mut ven_ids + .into_iter() + .map(|id| Ok(AuthRole::VEN(id.parse()?))) + .collect::, AppError>>()?, + ) + } + + if u.is_user_manager { + roles.push(AuthRole::UserManager); + } + + if u.is_ven_manager { + roles.push(AuthRole::VenManager) + } + + let client_ids = serde_json::from_value(u.client_ids).map_err(|err| { + warn!( + user_id = u.id, + "failed to deserialize user client ids: {err}" + ); + AppError::SerdeJsonInternalServerError(err) + })?; + + Ok(Self { + id: u.id, + reference: u.reference, + description: u.description, + roles, + client_ids, + created: u.created, + modified: u.modified, + }) + } +} + +struct IdAndSecret { + id: String, + client_secret: String, } #[async_trait] impl AuthSource for PgAuthSource { - async fn get_user(&self, client_id: &str, client_secret: &str) -> Option { - let vens = sqlx::query_as!( - PgId, + async fn check_credentials(&self, client_id: &str, client_secret: &str) -> Option { + let mut tx = self + .db + .begin() + .await + .inspect_err(|err| warn!(client_id, "failed to open transaction: {err}")) + .ok()?; + + let db_entry = sqlx::query_as!( + IdAndSecret, r#" - SELECT ven_id AS id - FROM "user" u - JOIN user_credentials c ON c.user_id = u.id - JOIN user_ven v ON v.user_id = u.id + SELECT id, + client_secret + FROM "user" + JOIN user_credentials ON user_id = id WHERE client_id = $1 - AND client_secret = $2 "#, client_id, - client_secret ) - .fetch_all(&self.db) + .fetch_one(&mut *tx) .await - .ok(); + .ok()?; + + let parsed_hash = PasswordHash::new(&db_entry.client_secret) + .inspect_err(|err| warn!("Failed to parse client_secret_hash in DB: {}", err)) + .ok()?; + + Argon2::default() + .verify_password(client_secret.as_bytes(), &parsed_hash) + .ok()?; + + let user = Self::get_user(&mut tx, &db_entry.id) + .await + .inspect_err(|err| warn!(client_id, "error fetching user: {err}")) + .ok()?; + + Some(AuthInfo { + client_id: client_id.to_string(), + roles: user.roles, + }) + } - let businesses = sqlx::query_as!( - MaybePgId, + async fn get_user(&self, user_id: &str) -> Result { + let mut tx = self.db.begin().await?; + Self::get_user(&mut tx, user_id).await + } + + async fn get_all_users(&self) -> Result, AppError> { + sqlx::query_as!( + IntermediateUser, r#" - SELECT ub.business_id AS id - FROM user_business ub - JOIN "user" u ON u.id = ub.user_id - JOIN user_credentials c ON c.user_id = u.id - WHERE client_id = $1 - AND client_secret = $2 + SELECT u.*, + json_arrayagg(c.client_id) AS "client_ids!", + b.user_id IS NOT NULL AS "is_business_user!", + json_arrayagg(b.business_id NULL ON NULL) AS business_ids, + ven.user_id IS NOT NULL AS "is_ven_user!", + json_arrayagg(ven.ven_id) AS ven_ids, + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" + FROM "user" u + LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN user_business b ON u.id = b.user_id + LEFT JOIN user_manager um ON u.id = um.user_id + LEFT JOIN user_ven ven ON u.id = ven.user_id + LEFT JOIN ven_manager vm ON u.id = vm.user_id + GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id "#, - client_id, - client_secret ) .fetch_all(&self.db) - .await - .ok(); - - let mut ven_roles = vens - .and_then(|vens| { - vens.into_iter() - .map(|ven| Ok(AuthRole::VEN(ven.id.parse()?))) - .collect::, IdentifierError>>() - .ok() - }) - .unwrap_or_default(); - - let mut business_roles = businesses - .map(|vens| { - vens.into_iter() - .map(|ven| { - if let Some(id) = ven.id { - AuthRole::Business(id) - } else { - AuthRole::AnyBusiness - } - }) - .collect::>() - }) - .unwrap_or_default(); - - ven_roles.append(&mut business_roles); - - if ven_roles.is_empty() { - None - } else { - Some(AuthInfo { - client_id: client_id.to_string(), - roles: ven_roles, - }) + .await? + .into_iter() + .map(TryInto::try_into) + .collect() + } + + async fn add_user( + &self, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result { + let mut tx = self.db.begin().await?; + + let user = sqlx::query_as!( + PgId, + r#" + INSERT INTO "user" (id, reference, description, created, modified) + VALUES (gen_random_uuid(), $1, $2, now(), now()) + RETURNING id + "#, + reference, + description + ) + .fetch_one(&mut *tx) + .await?; + + for role in roles { + Self::add_role(&mut tx, &user.id, role) + .await + .inspect_err(|err| { + warn!( + "Failed to add role {:?} for new user {:?}: {}", + role, user, err + ) + })?; } + + let user = Self::get_user(&mut tx, &user.id) + .await + .inspect_err(|err| warn!("cannot find user just created: {}", err))?; + + tx.commit().await?; + Ok(user) + } + + async fn add_credentials( + &self, + user_id: &str, + client_id: &str, + client_secret: &str, + ) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(client_secret.as_bytes(), &salt)? + .to_string(); + + let mut tx = self.db.begin().await?; + + sqlx::query!( + r#" + INSERT INTO user_credentials + (user_id, client_id, client_secret) + VALUES + ($1, $2, $3) + "#, + user_id, + client_id, + &hash + ) + .execute(&mut *tx) + .await?; + let user = Self::get_user(&mut tx, user_id).await?; + tx.commit().await?; + + Ok(user) + } + + async fn remove_credentials( + &self, + user_id: &str, + client_id: &str, + ) -> Result { + let mut tx = self.db.begin().await?; + sqlx::query!( + r#" + DELETE FROM user_credentials WHERE user_id = $1 AND client_id = $2 + "#, + user_id, + client_id + ) + .execute(&mut *tx) + .await?; + let user = Self::get_user(&mut tx, user_id).await?; + tx.commit().await?; + Ok(user) + } + + async fn remove_user(&self, user_id: &str) -> Result { + let mut tx = self.db.begin().await?; + let user = Self::get_user(&mut tx, user_id).await?; + sqlx::query!( + r#" + DELETE FROM "user" WHERE id = $1 + "#, + user_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(user) + } + + async fn edit_user( + &self, + user_id: &str, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result { + let mut tx = self.db.begin().await?; + + sqlx::query!( + r#" + UPDATE "user" SET + reference = $2, + description = $3, + modified = now() + WHERE id = $1 + "#, + user_id, + reference, + description + ) + .execute(&mut *tx) + .await?; + + Self::delete_all_roles(&mut tx, user_id).await?; + + for role in roles { + Self::add_role(&mut tx, user_id, role) + .await + .inspect_err(|err| { + warn!( + "Failed to add role {:?} for updated user {:?}: {}", + role, user_id, err + ) + })?; + } + let user = Self::get_user(&mut tx, user_id) + .await + .inspect_err(|err| warn!("cannot find user just updated: {}", err))?; + + tx.commit().await?; + Ok(user) + } +} + +impl PgAuthSource { + async fn delete_all_roles(db: &mut PgConnection, user_id: &str) -> Result<(), AppError> { + sqlx::query!( + r#" + DELETE FROM user_ven WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM user_business WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM ven_manager WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM user_manager WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + Ok(()) + } + + async fn add_role( + tx: &mut PgConnection, + user_id: &str, + role: &AuthRole, + ) -> Result<(), AppError> { + match role { + AuthRole::Business(b_id) => sqlx::query!( + r#" + INSERT INTO user_business (user_id, business_id) VALUES ($1, $2) + "#, + user_id, + b_id + ), + AuthRole::AnyBusiness => sqlx::query!( + r#" + INSERT INTO user_business (user_id, business_id) VALUES ($1, NULL) + "#, + user_id + ), + AuthRole::VEN(v_id) => sqlx::query!( + r#" + INSERT INTO user_ven (user_id, ven_id) VALUES ($1, $2) + "#, + user_id, + v_id.as_str() + ), + AuthRole::VenManager => sqlx::query!( + r#" + INSERT INTO ven_manager (user_id) VALUES ($1) + "#, + user_id + ), + AuthRole::UserManager => sqlx::query!( + r#" + INSERT INTO user_manager (user_id) VALUES ($1) + "#, + user_id + ), + } + .execute(&mut *tx) + .await?; + + Ok(()) + } + + async fn get_user(tx: &mut PgConnection, user_id: &str) -> Result { + sqlx::query_as!( + IntermediateUser, + r#" + SELECT u.*, + json_arrayagg(c.client_id) AS "client_ids!", + b.user_id IS NOT NULL AS "is_business_user!", + json_arrayagg(b.business_id NULL ON NULL) AS business_ids, + ven.user_id IS NOT NULL AS "is_ven_user!", + json_arrayagg(ven.ven_id) AS ven_ids, + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" + FROM "user" u + LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN user_business b ON u.id = b.user_id + LEFT JOIN user_manager um ON u.id = um.user_id + LEFT JOIN user_ven ven ON u.id = ven.user_id + LEFT JOIN ven_manager vm ON u.id = vm.user_id + WHERE u.id = $1 + GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id + "#, + user_id + ) + .fetch_one(&mut *tx) + .await? + .try_into() } } diff --git a/openadr-vtn/src/error.rs b/openadr-vtn/src/error.rs index 5a83935..a74ba0c 100644 --- a/openadr-vtn/src/error.rs +++ b/openadr-vtn/src/error.rs @@ -1,3 +1,4 @@ +use argon2::password_hash; use axum::{ extract::rejection::JsonRejection, http::StatusCode, @@ -33,7 +34,7 @@ pub enum AppError { Conflict(String, Option>), #[cfg(feature = "sqlx")] #[error("Unprocessable Content: {0}")] - ForeignKeyConstrainstViolated(String, Option>), + ForeignKeyConstraintViolated(String, Option>), #[error("Authentication error: {0}")] Auth(String), #[cfg(feature = "sqlx")] @@ -49,6 +50,9 @@ pub enum AppError { Identifier(#[from] IdentifierError), #[error("Method not allowed")] MethodNotAllowed, + #[cfg(feature = "sqlx")] + #[error("Password Hash error: {0}")] + PasswordHashError(password_hash::Error), } #[cfg(feature = "sqlx")] @@ -60,7 +64,7 @@ impl From for AppError { Self::Conflict("Conflict".to_string(), Some(err)) } sqlx::Error::Database(err) if err.is_foreign_key_violation() => { - Self::ForeignKeyConstrainstViolated( + Self::ForeignKeyConstraintViolated( "A foreign key constraint is violated".to_string(), Some(err), ) @@ -70,6 +74,12 @@ impl From for AppError { } } +impl From for AppError { + fn from(hash_err: password_hash::Error) -> Self { + Self::PasswordHashError(hash_err) + } +} + impl AppError { fn into_problem(self) -> Problem { let reference = Uuid::new_v4(); @@ -202,7 +212,7 @@ impl AppError { r#type: Default::default(), title: Some(StatusCode::INTERNAL_SERVER_ERROR.to_string()), status: StatusCode::INTERNAL_SERVER_ERROR, - detail: Some(err.to_string()), + detail: None, instance: Some(reference.to_string()), } } @@ -231,7 +241,7 @@ impl AppError { } } #[cfg(feature = "sqlx")] - AppError::ForeignKeyConstrainstViolated(err, db_err) => { + AppError::ForeignKeyConstraintViolated(err, db_err) => { trace!(%reference, "Unprocessable Content: {}, DB details: {:?}", err, @@ -257,6 +267,18 @@ impl AppError { instance: Some(reference.to_string()), } } + AppError::PasswordHashError(err) => { + warn!(%reference, + "Password hash error: {}", + err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::INTERNAL_SERVER_ERROR.to_string()), + status: StatusCode::INTERNAL_SERVER_ERROR, + detail: Some("An internal error occurred".to_string()), + instance: Some(reference.to_string()), + } + } } } } diff --git a/openadr-vtn/src/main.rs b/openadr-vtn/src/main.rs index 7ae1b5d..8de0a3b 100644 --- a/openadr-vtn/src/main.rs +++ b/openadr-vtn/src/main.rs @@ -9,7 +9,7 @@ use openadr_vtn::{jwt::JwtManager, state::AppState}; #[tokio::main] async fn main() { tracing_subscriber::registry() - .with(fmt::layer()) + .with(fmt::layer().with_file(true).with_line_number(true)) .with(EnvFilter::from_default_env()) .init(); diff --git a/openadr-vtn/src/state.rs b/openadr-vtn/src/state.rs index 07cfad4..d8fef83 100644 --- a/openadr-vtn/src/state.rs +++ b/openadr-vtn/src/state.rs @@ -10,9 +10,13 @@ use axum::{ middleware, middleware::Next, response::IntoResponse, + routing::{delete, get, post}, }; use reqwest::StatusCode; use std::sync::Arc; +use tower_http::trace::TraceLayer; + +use crate::api::{auth, event, program, report, resource, user, ven}; #[derive(Clone, FromRef)] pub struct AppState { @@ -29,11 +33,6 @@ impl AppState { } fn router_without_state() -> axum::Router { - use axum::routing::{get, post}; - use tower_http::trace::TraceLayer; - - use crate::api::{auth, event, program, report, resource, ven}; - axum::Router::new() .route("/programs", get(program::get_all).post(program::add)) .route( @@ -67,6 +66,18 @@ impl AppState { ) .route("/auth/register", post(auth::register)) .route("/auth/token", post(auth::token)) + .route("/users", get(user::get_all).post(user::add_user)) + .route( + "/users/:id", + get(user::get) + .put(user::edit) + .delete(user::delete_user) + .post(user::add_credential), + ) + .route( + "/users/:user_id/:client_id", + delete(user::delete_credential), + ) .layer(middleware::from_fn(method_not_allowed)) .layer(TraceLayer::new_for_http()) } diff --git a/openadr-wire/src/lib.rs b/openadr-wire/src/lib.rs index 0508fb0..2e47127 100644 --- a/openadr-wire/src/lib.rs +++ b/openadr-wire/src/lib.rs @@ -23,7 +23,7 @@ pub mod target; pub mod values_map; pub mod ven; -mod serde_rfc3339 { +pub mod serde_rfc3339 { use super::*; use chrono::{DateTime, TimeZone, Utc}; From 0b8fb28f2bf59e460df3b00fc123b9ac56f7f280 Mon Sep 17 00:00:00 2001 From: Maximilian Pohl Date: Tue, 1 Oct 2024 10:54:56 +0200 Subject: [PATCH 2/4] Add tests for user management --- ...cd1e0bedc713e772b644dc275643a5bef8f8a.json | 88 ---- ...30b94e24b7388414df2cf77645b0e69805ad4.json | 14 - ...61a23cd37bae62eef2ad9cb70515fb1bf51d0.json | 80 ++++ ...b4d160553283d9c8d6339d64cc41d4b1b8920.json | 14 + ...df79aef9ca9f003c1c8b81878343fcfc05ce8.json | 14 + ...3e6a819d6ac15412b4c8bd52611aaa909335e.json | 82 ++++ ...02b686b7c464f4aa2b276369b3727127608d4.json | 86 ---- fixtures/resources.sql | 6 +- fixtures/users.sql | 16 +- migrations/20240826084440_initial_scheme.sql | 12 +- openadr-vtn/src/api/user.rs | 402 +++++++++++++++++- openadr-vtn/src/data_source/mod.rs | 20 +- openadr-vtn/src/data_source/postgres/user.rs | 100 +++-- openadr-vtn/src/error.rs | 14 +- openadr-vtn/src/jwt.rs | 1 + openadr-vtn/src/state.rs | 21 + openadr-wire/src/lib.rs | 8 +- openadr-wire/src/ven.rs | 2 +- 18 files changed, 711 insertions(+), 269 deletions(-) delete mode 100644 .sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json delete mode 100644 .sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json create mode 100644 .sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json create mode 100644 .sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json create mode 100644 .sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json create mode 100644 .sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json delete mode 100644 .sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json diff --git a/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json b/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json deleted file mode 100644 index 8516828..0000000 --- a/.sqlx/query-1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT u.*,\n json_arrayagg(c.client_id) AS \"client_ids!\",\n b.user_id IS NOT NULL AS \"is_business_user!\",\n json_arrayagg(b.business_id NULL ON NULL) AS business_ids,\n ven.user_id IS NOT NULL AS \"is_ven_user!\",\n json_arrayagg(ven.ven_id) AS ven_ids,\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n WHERE u.id = $1\n GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "reference", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "modified", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "client_ids!", - "type_info": "Json" - }, - { - "ordinal": 6, - "name": "is_business_user!", - "type_info": "Bool" - }, - { - "ordinal": 7, - "name": "business_ids", - "type_info": "Json" - }, - { - "ordinal": 8, - "name": "is_ven_user!", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "ven_ids", - "type_info": "Json" - }, - { - "ordinal": 10, - "name": "is_user_manager!", - "type_info": "Bool" - }, - { - "ordinal": 11, - "name": "is_ven_manager!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "1bdd2974acddd89b65e87a7ea39cd1e0bedc713e772b644dc275643a5bef8f8a" -} diff --git a/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json b/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json deleted file mode 100644 index 5f29a95..0000000 --- a/.sqlx/query-443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_business (user_id, business_id) VALUES ($1, NULL)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "443f908e67a420dab5adb4ce6cf30b94e24b7388414df2cf77645b0e69805ad4" -} diff --git a/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json b/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json new file mode 100644 index 0000000..b2ef418 --- /dev/null +++ b/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids,\n array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids,\n array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids,\n ab.user_id IS NOT NULL AS \"is_any_business_user!\",\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN any_business_user ab ON u.id = ab.user_id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n GROUP BY u.id,\n b.user_id,\n ab.user_id,\n um.user_id,\n ven.user_id,\n vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "business_ids", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "ven_ids", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "is_any_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0" +} diff --git a/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json b/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json new file mode 100644 index 0000000..74d4f34 --- /dev/null +++ b/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM any_business_user WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920" +} diff --git a/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json b/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json new file mode 100644 index 0000000..0148d42 --- /dev/null +++ b/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO any_business_user (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8" +} diff --git a/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json b/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json new file mode 100644 index 0000000..35026f9 --- /dev/null +++ b/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids,\n array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids,\n array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids,\n ab.user_id IS NOT NULL AS \"is_any_business_user!\",\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN any_business_user ab ON u.id = ab.user_id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n WHERE u.id = $1\n GROUP BY u.id,\n b.user_id,\n ab.user_id,\n um.user_id,\n ven.user_id,\n vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "business_ids", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "ven_ids", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "is_any_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e" +} diff --git a/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json b/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json deleted file mode 100644 index ae496bf..0000000 --- a/.sqlx/query-f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT u.*,\n json_arrayagg(c.client_id) AS \"client_ids!\",\n b.user_id IS NOT NULL AS \"is_business_user!\",\n json_arrayagg(b.business_id NULL ON NULL) AS business_ids,\n ven.user_id IS NOT NULL AS \"is_ven_user!\",\n json_arrayagg(ven.ven_id) AS ven_ids,\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "reference", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "modified", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "client_ids!", - "type_info": "Json" - }, - { - "ordinal": 6, - "name": "is_business_user!", - "type_info": "Bool" - }, - { - "ordinal": 7, - "name": "business_ids", - "type_info": "Json" - }, - { - "ordinal": 8, - "name": "is_ven_user!", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "ven_ids", - "type_info": "Json" - }, - { - "ordinal": 10, - "name": "is_user_manager!", - "type_info": "Bool" - }, - { - "ordinal": 11, - "name": "is_ven_manager!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - true, - false, - false, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "f901477ce3cd03de812359382af02b686b7c464f4aa2b276369b3727127608d4" -} diff --git a/fixtures/resources.sql b/fixtures/resources.sql index e160016..b529c20 100644 --- a/fixtures/resources.sql +++ b/fixtures/resources.sql @@ -1,8 +1,8 @@ -INSERT INTO resources (id, +INSERT INTO resource (id, created_date_time, modification_date_time, - resource_name - ven_name, + resource_name, + ven_id, attributes, targets) VALUES ('resource-1', diff --git a/fixtures/users.sql b/fixtures/users.sql index 8f803a3..8710b6e 100644 --- a/fixtures/users.sql +++ b/fixtures/users.sql @@ -1,10 +1,20 @@ INSERT INTO "user" (id, reference, description, created, modified) -VALUES ('admin', 'admin-ref', null, now(), now()); +VALUES ('admin', 'admin-ref', null, '2024-07-25 08:31:10.776000 +00:00', '2024-07-25 08:31:10.776000 +00:00'); -INSERT INTO user_business VALUES ('admin', NULL); +INSERT INTO any_business_user (user_id) +VALUES ('admin'); +INSERT INTO user_manager (user_id) +VALUES ('admin'); +INSERT INTO ven_manager (user_id) +VALUES ('admin'); INSERT INTO user_credentials (user_id, client_id, client_secret) VALUES ('admin', 'admin', '$argon2id$v=19$m=16,t=2,p=1$QmtwZnBPVnlIYkJTWUtHZg$lMxF0N+CeRa99UmzMaUKeg'); -- secret: admin INSERT INTO "user" (id, reference, description, created, modified) -VALUES ('user-1', 'user-1-ref', null, now(), now()); \ No newline at end of file +VALUES ('user-1', 'user-1-ref', 'desc', '2024-07-25 08:31:10.776000 +00:00', '2024-07-25 08:31:10.776000 +00:00'); + +INSERT INTO user_credentials (user_id, client_id, client_secret) +VALUES ('user-1', 'user-1-client-id', + '$argon2id$v=19$m=16,t=2,p=1$R04zbWxDNVhtVHB4aVJLag$mRpShTDhgZ9+bVNLa8GBgw'); -- secret: user-1 + diff --git a/migrations/20240826084440_initial_scheme.sql b/migrations/20240826084440_initial_scheme.sql index aa6dc58..216bef2 100644 --- a/migrations/20240826084440_initial_scheme.sql +++ b/migrations/20240826084440_initial_scheme.sql @@ -136,14 +136,9 @@ create table ven_program create table user_business ( user_id text not null references "user" (id) on delete cascade, - business_id text references business (id) on delete cascade + business_id text not null references business (id) on delete cascade ); --- allow at most one null entry per business, counting as `AnyBusiness` -create unique index null_test_user_business - on user_business (user_id, (business_id is null)) - where business_id is null; - create unique index uindex_user_business on user_business (user_id, business_id); @@ -153,6 +148,11 @@ create table ven_manager ); create table user_manager +( + user_id text primary key references "user" (id) on delete cascade +); + +create table any_business_user ( user_id text primary key references "user" (id) on delete cascade ); \ No newline at end of file diff --git a/openadr-vtn/src/api/user.rs b/openadr-vtn/src/api/user.rs index bcee9bc..6cd54e9 100644 --- a/openadr-vtn/src/api/user.rs +++ b/openadr-vtn/src/api/user.rs @@ -1,17 +1,22 @@ use crate::{ api::AppResponse, data_source::{AuthSource, UserDetails}, + error::AppError, jwt::{AuthRole, UserManagerUser}, }; use axum::{ extract::{Path, State}, Json, }; +use reqwest::StatusCode; +#[cfg(test)] +use serde::Serialize; use serde_with::serde_derive::Deserialize; use std::sync::Arc; use tracing::{info, trace}; -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] +#[cfg_attr(test, derive(Serialize))] pub struct NewUser { reference: String, description: Option, @@ -19,6 +24,7 @@ pub struct NewUser { } #[derive(Deserialize)] +#[cfg_attr(test, derive(Serialize, Default))] pub struct NewCredential { client_id: String, client_secret: String, @@ -48,7 +54,7 @@ pub async fn add_user( State(auth_source): State>, UserManagerUser(_): UserManagerUser, Json(new_user): Json, -) -> AppResponse { +) -> Result<(StatusCode, Json), AppError> { let user = auth_source .add_user( &new_user.reference, @@ -57,7 +63,7 @@ pub async fn add_user( ) .await?; info!(user_id = user.id(), "created new user"); - Ok(Json(user)) + Ok((StatusCode::CREATED, Json(user))) } pub async fn add_credential( @@ -67,7 +73,7 @@ pub async fn add_credential( Json(new): Json, ) -> AppResponse { let user = auth_source - .add_credentials(&id, &new.client_id, &new.client_secret) + .add_credential(&id, &new.client_id, &new.client_secret) .await?; info!( user_id = id, @@ -115,3 +121,391 @@ pub async fn delete_credential( info!(user_id = user.id(), client_id, "deleted credential"); Ok(Json(user)) } + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod test { + use super::*; + use crate::{ + api::test::jwt_test_token, data_source::PostgresStorage, jwt::JwtManager, state::AppState, + }; + use axum::{ + body::Body, + http, + http::{Request, Response, StatusCode}, + Router, + }; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + + async fn state(db: PgPool) -> AppState { + let store = PostgresStorage::new(db).unwrap(); + AppState::new(store, JwtManager::from_base64_secret("test").unwrap()) + } + + fn user_1() -> UserDetails { + UserDetails { + id: "user-1".to_string(), + reference: "user-1-ref".to_string(), + description: Some("desc".to_string()), + roles: vec![], + client_ids: vec!["user-1-client-id".to_string()], + created: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modified: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + } + } + + fn admin() -> UserDetails { + UserDetails { + id: "admin".to_string(), + reference: "admin-ref".to_string(), + description: None, + roles: vec![ + AuthRole::UserManager, + AuthRole::VenManager, + AuthRole::AnyBusiness, + ], + client_ids: vec!["admin".to_string()], + created: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modified: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + } + } + + fn new_user() -> NewUser { + NewUser { + reference: "new user reference".to_string(), + description: Some("Some description".to_string()), + roles: vec![ + AuthRole::UserManager, + AuthRole::VenManager, + AuthRole::AnyBusiness, + ], + } + } + + fn all_roles() -> Vec { + vec![ + AuthRole::VEN("ven-1".parse().unwrap()), + AuthRole::AnyBusiness, + AuthRole::Business("business-1".parse().unwrap()), + AuthRole::VenManager, + AuthRole::UserManager, + ] + } + + impl PartialEq for NewUser { + fn eq(&self, other: &UserDetails) -> bool { + let mut self_roles = self.roles.clone(); + self_roles.sort(); + + let mut other_roles = other.roles.clone(); + other_roles.sort(); + + self.reference == other.reference + && self.description == other.description + && self_roles == other_roles + } + } + + async fn help_get(app: &mut Router, token: &str, id: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/users/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_get_all(app: &mut Router, token: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/users") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_post( + app: &mut Router, + token: &str, + path: &str, + body: &T, + ) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::POST) + .uri(path) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_add_user(app: &mut Router, token: &str, user: &NewUser) -> Response { + help_post(app, token, "/users", user).await + } + + async fn help_edit_user( + app: &mut Router, + token: &str, + id: &str, + user: &NewUser, + ) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::PUT) + .uri(format!("/users/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap())) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_add_credential( + app: &mut Router, + token: &str, + user_id: &str, + credential: &NewCredential, + ) -> Response { + help_post(app, token, &format!("/users/{user_id}"), credential).await + } + + async fn help_delete(app: &mut Router, token: &str, path: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(path) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_login(app: &mut Router, client_id: &str, client_secret: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/auth/token") + .header( + http::header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::from(format!( + "client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials", + ))) + .unwrap(), + ) + .await + .unwrap() + } + + impl UserDetails { + async fn from(response: Response) -> Self { + let body = response.into_body().collect().await.unwrap().to_bytes(); + let mut user: UserDetails = serde_json::from_slice(&body).unwrap(); + user.roles.sort(); + user + } + } + + #[sqlx::test(fixtures("users"))] + async fn get(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_get(&mut app, &token, "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(user, admin()); + + let response = help_get(&mut app, &token, "user-1").await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(user, user_1()); + } + + #[sqlx::test(fixtures("users"))] + async fn all_routes_only_allowed_for_user_manager(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("123".parse().unwrap()), + AuthRole::AnyBusiness, + AuthRole::Business("1234".parse().unwrap()), + AuthRole::VenManager, + ], + ); + let mut app = state.into_router(); + let response = help_get(&mut app, &token, "admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_get_all(&mut app, &token).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_add_user(&mut app, &token, &new_user()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_edit_user(&mut app, &token, "admin", &new_user()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_add_credential(&mut app, &token, "admin", &Default::default()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_delete(&mut app, &token, "/users/admin/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_delete(&mut app, &token, "/users/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[sqlx::test(fixtures("users"))] + async fn get_all(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_get_all(&mut app, &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let mut users: Vec = serde_json::from_slice(&body).unwrap(); + users.iter_mut().for_each(|user| user.roles.sort()); + users.sort_by(|a, b| a.id.cmp(&b.id)); + + assert_eq!(users, vec![admin(), user_1()]); + } + + #[sqlx::test(fixtures("users", "vens", "business"))] + pub async fn add(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_user = NewUser { + roles: all_roles(), + ..new_user() + }; + + let response = help_add_user(&mut app, &token, &new_user).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let user = UserDetails::from(response).await; + assert_eq!(new_user, user); + + let response = help_get(&mut app, &token, user.id()).await; + assert_eq!(response.status(), StatusCode::OK); + + let user2 = UserDetails::from(response).await; + assert_eq!(user2, user); + } + + #[sqlx::test(fixtures("users", "vens", "business"))] + async fn edit(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_users = [ + NewUser { + roles: vec![], + ..new_user() + }, + NewUser { + roles: all_roles(), + ..new_user() + }, + ]; + for new_user in new_users { + let response = help_edit_user(&mut app, &token, "admin", &new_user).await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(new_user, user); + + let response = help_get(&mut app, &token, user.id()).await; + assert_eq!(response.status(), StatusCode::OK); + + let user2 = UserDetails::from(response).await; + assert_eq!(user2, user); + } + } + + #[sqlx::test(fixtures("users"))] + async fn add_credential(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_credential = NewCredential { + client_id: "test".to_string(), + client_secret: "test".to_string(), + }; + + let response = help_add_credential(&mut app, &token, "admin", &new_credential).await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert!(user.client_ids.contains(&"test".to_string())); + + let response = help_login( + &mut app, + &new_credential.client_id, + &new_credential.client_secret, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users"))] + async fn delete_credential(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_delete(&mut app, &token, "/users/admin/admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[sqlx::test(fixtures("users"))] + async fn delete_user(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_delete(&mut app, &token, "/users/admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/openadr-vtn/src/data_source/mod.rs b/openadr-vtn/src/data_source/mod.rs index a28e9e6..f2c13a3 100644 --- a/openadr-vtn/src/data_source/mod.rs +++ b/openadr-vtn/src/data_source/mod.rs @@ -13,7 +13,7 @@ use openadr_wire::{ }; #[cfg(feature = "postgres")] pub use postgres::PostgresStorage; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::{ @@ -190,17 +190,17 @@ pub trait ResourceCrud: { } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, PartialEq, Debug)] pub struct UserDetails { - id: String, - reference: String, - description: Option, - roles: Vec, - client_ids: Vec, + pub(crate) id: String, + pub(crate) reference: String, + pub(crate) description: Option, + pub(crate) roles: Vec, + pub(crate) client_ids: Vec, #[serde(with = "openadr_wire::serde_rfc3339")] - created: DateTime, + pub(crate) created: DateTime, #[serde(with = "openadr_wire::serde_rfc3339")] - modified: DateTime, + pub(crate) modified: DateTime, } impl UserDetails { @@ -220,7 +220,7 @@ pub trait AuthSource: Send + Sync + 'static { description: Option<&str>, roles: &[AuthRole], ) -> Result; - async fn add_credentials( + async fn add_credential( &self, user_id: &str, client_id: &str, diff --git a/openadr-vtn/src/data_source/postgres/user.rs b/openadr-vtn/src/data_source/postgres/user.rs index a4c4c30..4387581 100644 --- a/openadr-vtn/src/data_source/postgres/user.rs +++ b/openadr-vtn/src/data_source/postgres/user.rs @@ -27,13 +27,12 @@ struct IntermediateUser { id: String, reference: String, description: Option, - client_ids: serde_json::Value, + client_ids: Option>, created: DateTime, modified: DateTime, - is_business_user: bool, - business_ids: serde_json::Value, - is_ven_user: bool, - ven_ids: serde_json::Value, + business_ids: Option>, + ven_ids: Option>, + is_any_business_user: bool, is_user_manager: bool, is_ven_manager: bool, } @@ -43,34 +42,16 @@ impl TryFrom for UserDetails { fn try_from(u: IntermediateUser) -> Result { let mut roles = Vec::new(); - if u.is_business_user { - let business_ids: Vec> = serde_json::from_value(u.business_ids) - .map_err(|err| { - warn!( - user_id = u.id, - "failed to deserialize user associated businesses: {err}" - ); - AppError::SerdeJsonInternalServerError(err) - })?; + if let Some(business_ids) = u.business_ids { roles.append( &mut business_ids .into_iter() - .map(|id| match id { - None => Ok(AuthRole::AnyBusiness), - Some(id) => Ok(AuthRole::Business(id)), - }) + .map(|id| Ok(AuthRole::Business(id.to_string()))) .collect::, AppError>>()?, ) } - if u.is_ven_user { - let ven_ids: Vec = serde_json::from_value(u.ven_ids).map_err(|err| { - warn!( - user_id = u.id, - "failed to deserialize user associated vens: {err}" - ); - AppError::SerdeJsonInternalServerError(err) - })?; + if let Some(ven_ids) = u.ven_ids { roles.append( &mut ven_ids .into_iter() @@ -87,20 +68,16 @@ impl TryFrom for UserDetails { roles.push(AuthRole::VenManager) } - let client_ids = serde_json::from_value(u.client_ids).map_err(|err| { - warn!( - user_id = u.id, - "failed to deserialize user client ids: {err}" - ); - AppError::SerdeJsonInternalServerError(err) - })?; + if u.is_any_business_user { + roles.push(AuthRole::AnyBusiness) + } Ok(Self { id: u.id, reference: u.reference, description: u.description, roles, - client_ids, + client_ids: u.client_ids.unwrap_or_default(), created: u.created, modified: u.modified, }) @@ -166,20 +143,25 @@ impl AuthSource for PgAuthSource { IntermediateUser, r#" SELECT u.*, - json_arrayagg(c.client_id) AS "client_ids!", - b.user_id IS NOT NULL AS "is_business_user!", - json_arrayagg(b.business_id NULL ON NULL) AS business_ids, - ven.user_id IS NOT NULL AS "is_ven_user!", - json_arrayagg(ven.ven_id) AS ven_ids, - um.user_id IS NOT NULL AS "is_user_manager!", - vm.user_id IS NOT NULL AS "is_ven_manager!" + array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids, + array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids, + array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids, + ab.user_id IS NOT NULL AS "is_any_business_user!", + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" FROM "user" u LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN any_business_user ab ON u.id = ab.user_id LEFT JOIN user_business b ON u.id = b.user_id LEFT JOIN user_manager um ON u.id = um.user_id LEFT JOIN user_ven ven ON u.id = ven.user_id LEFT JOIN ven_manager vm ON u.id = vm.user_id - GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id + GROUP BY u.id, + b.user_id, + ab.user_id, + um.user_id, + ven.user_id, + vm.user_id "#, ) .fetch_all(&self.db) @@ -229,7 +211,7 @@ impl AuthSource for PgAuthSource { Ok(user) } - async fn add_credentials( + async fn add_credential( &self, user_id: &str, client_id: &str, @@ -364,6 +346,15 @@ impl PgAuthSource { .execute(&mut *db) .await?; + sqlx::query!( + r#" + DELETE FROM any_business_user WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + sqlx::query!( r#" DELETE FROM ven_manager WHERE user_id = $1 @@ -400,7 +391,7 @@ impl PgAuthSource { ), AuthRole::AnyBusiness => sqlx::query!( r#" - INSERT INTO user_business (user_id, business_id) VALUES ($1, NULL) + INSERT INTO any_business_user (user_id) VALUES ($1) "#, user_id ), @@ -435,21 +426,26 @@ impl PgAuthSource { IntermediateUser, r#" SELECT u.*, - json_arrayagg(c.client_id) AS "client_ids!", - b.user_id IS NOT NULL AS "is_business_user!", - json_arrayagg(b.business_id NULL ON NULL) AS business_ids, - ven.user_id IS NOT NULL AS "is_ven_user!", - json_arrayagg(ven.ven_id) AS ven_ids, - um.user_id IS NOT NULL AS "is_user_manager!", - vm.user_id IS NOT NULL AS "is_ven_manager!" + array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids, + array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids, + array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids, + ab.user_id IS NOT NULL AS "is_any_business_user!", + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" FROM "user" u LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN any_business_user ab ON u.id = ab.user_id LEFT JOIN user_business b ON u.id = b.user_id LEFT JOIN user_manager um ON u.id = um.user_id LEFT JOIN user_ven ven ON u.id = ven.user_id LEFT JOIN ven_manager vm ON u.id = vm.user_id WHERE u.id = $1 - GROUP BY u.id, b.user_id, um.user_id, ven.user_id, vm.user_id + GROUP BY u.id, + b.user_id, + ab.user_id, + um.user_id, + ven.user_id, + vm.user_id "#, user_id ) diff --git a/openadr-vtn/src/error.rs b/openadr-vtn/src/error.rs index a74ba0c..fc31e8e 100644 --- a/openadr-vtn/src/error.rs +++ b/openadr-vtn/src/error.rs @@ -10,7 +10,7 @@ use openadr_wire::{problem::Problem, IdentifierError}; use serde::{Deserialize, Serialize}; #[cfg(feature = "sqlx")] use sqlx::error::DatabaseError; -use tracing::{error, trace, warn}; +use tracing::{error, info, trace, warn}; use uuid::Uuid; #[derive(thiserror::Error, Debug)] @@ -53,6 +53,8 @@ pub enum AppError { #[cfg(feature = "sqlx")] #[error("Password Hash error: {0}")] PasswordHashError(password_hash::Error), + #[error("Unsupported Media Type: {0}")] + UnsupportedMediaType(String), } #[cfg(feature = "sqlx")] @@ -279,6 +281,16 @@ impl AppError { instance: Some(reference.to_string()), } } + AppError::UnsupportedMediaType(err) => { + info!(%reference, "Unsupported media type: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::UNSUPPORTED_MEDIA_TYPE.to_string()), + status: StatusCode::UNSUPPORTED_MEDIA_TYPE, + detail: Some(err), + instance: Some(reference.to_string()), + } + } } } } diff --git a/openadr-vtn/src/jwt.rs b/openadr-vtn/src/jwt.rs index 82a7676..7de54e6 100644 --- a/openadr-vtn/src/jwt.rs +++ b/openadr-vtn/src/jwt.rs @@ -21,6 +21,7 @@ pub struct JwtManager { } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(test, derive(PartialOrd, Ord))] #[serde(tag = "role", content = "id")] pub enum AuthRole { UserManager, diff --git a/openadr-vtn/src/state.rs b/openadr-vtn/src/state.rs index d8fef83..4c939bd 100644 --- a/openadr-vtn/src/state.rs +++ b/openadr-vtn/src/state.rs @@ -12,6 +12,7 @@ use axum::{ response::IntoResponse, routing::{delete, get, post}, }; +use http_body_util::BodyExt; use reqwest::StatusCode; use std::sync::Arc; use tower_http::trace::TraceLayer; @@ -79,6 +80,7 @@ impl AppState { delete(user::delete_credential), ) .layer(middleware::from_fn(method_not_allowed)) + .layer(middleware::from_fn(unsupported_media_type)) .layer(TraceLayer::new_for_http()) } @@ -96,6 +98,25 @@ pub async fn method_not_allowed(req: Request, next: Next) -> impl IntoResponse { } } +pub async fn unsupported_media_type(req: Request, next: Next) -> impl IntoResponse { + let resp = next.run(req).await; + let status = resp.status(); + match status { + StatusCode::UNSUPPORTED_MEDIA_TYPE => Err(AppError::UnsupportedMediaType( + String::from_utf8_lossy( + &resp + .into_body() + .collect() + .await + .unwrap_or_default() + .to_bytes(), + ) + .to_string(), + )), + _ => Ok(resp), + } +} + impl FromRef for Arc { fn from_ref(state: &AppState) -> Arc { state.storage.auth() diff --git a/openadr-wire/src/lib.rs b/openadr-wire/src/lib.rs index 2e47127..adef222 100644 --- a/openadr-wire/src/lib.rs +++ b/openadr-wire/src/lib.rs @@ -72,7 +72,7 @@ where } /// A string that matches `/^[a-zA-Z0-9_-]*$/` with length in 1..=128 -#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Identifier(#[serde(deserialize_with = "identifier")] String); impl<'de> Deserialize<'de> for Identifier { @@ -94,8 +94,12 @@ pub enum IdentifierError { InvalidLength(usize), #[error("identifier contains characters besides [a-zA-Z0-9_-]")] InvalidCharacter, + #[error("this identifier name is not allowed: {0}")] + ForbiddenName(String), } +const FORBIDDEN_NAMES: &[&str] = &["null"]; + impl std::str::FromStr for Identifier { type Err = IdentifierError; @@ -106,6 +110,8 @@ impl std::str::FromStr for Identifier { Err(IdentifierError::InvalidLength(s.len())) } else if !s.bytes().all(is_valid_character) { Err(IdentifierError::InvalidCharacter) + } else if FORBIDDEN_NAMES.contains(&s.to_ascii_lowercase().as_str()) { + Err(IdentifierError::ForbiddenName(s.to_string())) } else { Ok(Identifier(s.to_string())) } diff --git a/openadr-wire/src/ven.rs b/openadr-wire/src/ven.rs index e266549..9568cbc 100644 --- a/openadr-wire/src/ven.rs +++ b/openadr-wire/src/ven.rs @@ -50,7 +50,7 @@ pub enum ObjectType { Ven, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq, PartialOrd, Ord)] pub struct VenId(pub(crate) Identifier); impl Display for VenId { From d6b01052aba9a19b22aa38e03419a0b7dac91956 Mon Sep 17 00:00:00 2001 From: Maximilian Pohl Date: Tue, 1 Oct 2024 11:48:58 +0200 Subject: [PATCH 3/4] Improve error handling and testing --- openadr-vtn/src/api/auth.rs | 11 ++-- openadr-vtn/src/api/mod.rs | 111 +++++++++++++++++++++++++++++++-- openadr-vtn/src/api/program.rs | 1 - openadr-vtn/src/api/user.rs | 22 +++---- openadr-vtn/src/error.rs | 41 +++++++++++- openadr-vtn/src/state.rs | 21 ------- 6 files changed, 160 insertions(+), 47 deletions(-) diff --git a/openadr-vtn/src/api/auth.rs b/openadr-vtn/src/api/auth.rs index 348c490..7981f57 100644 --- a/openadr-vtn/src/api/auth.rs +++ b/openadr-vtn/src/api/auth.rs @@ -1,7 +1,8 @@ use std::sync::Arc; +use crate::{api::ValidatedForm, data_source::AuthSource, jwt::JwtManager, state::AppState}; use axum::{ - extract::{Form, State}, + extract::State, http::{Response, StatusCode}, response::IntoResponse, Json, @@ -12,10 +13,10 @@ use axum_extra::{ }; use openadr_wire::oauth::{OAuthError, OAuthErrorType}; use reqwest::header; +use serde::Deserialize; +use validator::Validate; -use crate::{data_source::AuthSource, jwt::JwtManager, state::AppState}; - -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize, Validate)] pub struct AccessTokenRequest { grant_type: String, // TODO: handle scope @@ -78,7 +79,7 @@ pub(crate) async fn token( State(auth_source): State>, State(jwt_manager): State>, authorization: Option>>, - Form(request): Form, + ValidatedForm(request): ValidatedForm, ) -> Result { if request.grant_type != "client_credentials" { return Err(OAuthError::new(OAuthErrorType::UnsupportedGrantType) diff --git a/openadr-vtn/src/api/mod.rs b/openadr-vtn/src/api/mod.rs index b8b4e33..641ba87 100644 --- a/openadr-vtn/src/api/mod.rs +++ b/openadr-vtn/src/api/mod.rs @@ -1,8 +1,11 @@ use crate::error::AppError; use axum::{ async_trait, - extract::{rejection::JsonRejection, FromRequest, FromRequestParts, Request}, - Json, + extract::{ + rejection::{FormRejection, JsonRejection}, + FromRequest, FromRequestParts, Request, + }, + Form, Json, }; use axum_extra::extract::{Query, QueryRejection}; use serde::de::DeserializeOwned; @@ -18,6 +21,9 @@ pub mod ven; pub type AppResponse = Result, AppError>; +#[derive(Debug, Clone)] +pub struct ValidatedForm(T); + #[derive(Debug, Clone)] pub struct ValidatedQuery(pub T); @@ -59,11 +65,41 @@ where } } +#[async_trait] +impl FromRequest for ValidatedForm +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Form: FromRequest, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Form(value) = Form::::from_request(req, state).await?; + value.validate()?; + Ok(ValidatedForm(value)) + } +} + #[cfg(test)] +#[cfg(feature = "live-db-test")] mod test { - use crate::{jwt::AuthRole, state::AppState}; + use crate::{ + data_source::PostgresStorage, + jwt::{AuthRole, JwtManager}, + state::AppState, + }; + use axum::{ + body::Body, + http, + http::{Request, StatusCode}, + response::Response, + }; + use http_body_util::BodyExt; + use openadr_wire::problem::Problem; + use sqlx::PgPool; + use tower::ServiceExt; - #[allow(dead_code)] pub(crate) fn jwt_test_token(state: &AppState, roles: Vec) -> String { state .jwt_manager @@ -74,4 +110,71 @@ mod test { ) .unwrap() } + + pub(crate) async fn state(db: PgPool) -> AppState { + let store = PostgresStorage::new(db).unwrap(); + AppState::new(store, JwtManager::from_base64_secret("test").unwrap()) + } + + async fn into_problem(response: Response) -> Problem { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + #[sqlx::test] + async fn unsupported_media_type(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness, AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = (&mut app) + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/programs") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + into_problem(response).await; + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/auth/token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + into_problem(response).await; + } + + #[sqlx::test] + async fn method_not_allowed(db: PgPool) { + let state = state(db).await; + let app = state.into_router(); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri("/programs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + + into_problem(response).await; + } } diff --git a/openadr-vtn/src/api/program.rs b/openadr-vtn/src/api/program.rs index f6ccec9..55e45c8 100644 --- a/openadr-vtn/src/api/program.rs +++ b/openadr-vtn/src/api/program.rs @@ -21,7 +21,6 @@ use crate::{ error::AppError, jwt::{BusinessUser, User}, }; - pub async fn get_all( State(program_source): State>, ValidatedQuery(query_params): ValidatedQuery, diff --git a/openadr-vtn/src/api/user.rs b/openadr-vtn/src/api/user.rs index 6cd54e9..528cb30 100644 --- a/openadr-vtn/src/api/user.rs +++ b/openadr-vtn/src/api/user.rs @@ -1,5 +1,5 @@ use crate::{ - api::AppResponse, + api::{AppResponse, ValidatedJson}, data_source::{AuthSource, UserDetails}, error::AppError, jwt::{AuthRole, UserManagerUser}, @@ -14,8 +14,9 @@ use serde::Serialize; use serde_with::serde_derive::Deserialize; use std::sync::Arc; use tracing::{info, trace}; +use validator::Validate; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Validate)] #[cfg_attr(test, derive(Serialize))] pub struct NewUser { reference: String, @@ -23,7 +24,7 @@ pub struct NewUser { roles: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, Validate)] #[cfg_attr(test, derive(Serialize, Default))] pub struct NewCredential { client_id: String, @@ -53,7 +54,7 @@ pub async fn get( pub async fn add_user( State(auth_source): State>, UserManagerUser(_): UserManagerUser, - Json(new_user): Json, + ValidatedJson(new_user): ValidatedJson, ) -> Result<(StatusCode, Json), AppError> { let user = auth_source .add_user( @@ -70,7 +71,7 @@ pub async fn add_credential( State(auth_source): State>, Path(id): Path, UserManagerUser(_): UserManagerUser, - Json(new): Json, + ValidatedJson(new): ValidatedJson, ) -> AppResponse { let user = auth_source .add_credential(&id, &new.client_id, &new.client_secret) @@ -87,7 +88,7 @@ pub async fn edit( State(auth_source): State>, Path(id): Path, UserManagerUser(_): UserManagerUser, - Json(modified): Json, + ValidatedJson(modified): ValidatedJson, ) -> AppResponse { let user = auth_source .edit_user( @@ -126,9 +127,7 @@ pub async fn delete_credential( #[cfg(feature = "live-db-test")] mod test { use super::*; - use crate::{ - api::test::jwt_test_token, data_source::PostgresStorage, jwt::JwtManager, state::AppState, - }; + use crate::api::test::{jwt_test_token, state}; use axum::{ body::Body, http, @@ -139,11 +138,6 @@ mod test { use sqlx::PgPool; use tower::ServiceExt; - async fn state(db: PgPool) -> AppState { - let store = PostgresStorage::new(db).unwrap(); - AppState::new(store, JwtManager::from_base64_secret("test").unwrap()) - } - fn user_1() -> UserDetails { UserDetails { id: "user-1".to_string(), diff --git a/openadr-vtn/src/error.rs b/openadr-vtn/src/error.rs index fc31e8e..70e7836 100644 --- a/openadr-vtn/src/error.rs +++ b/openadr-vtn/src/error.rs @@ -1,6 +1,6 @@ use argon2::password_hash; use axum::{ - extract::rejection::JsonRejection, + extract::rejection::{FormRejection, JsonRejection}, http::StatusCode, response::{IntoResponse, Response}, Json, @@ -18,7 +18,9 @@ pub enum AppError { #[error("Invalid request: {0}")] Validation(#[from] validator::ValidationErrors), #[error("Invalid request: {0}")] - Json(#[from] JsonRejection), + Json(JsonRejection), + #[error("Invalid request: {0}")] + Form(FormRejection), #[error("Invalid request: {0}")] QueryParams(#[from] QueryRejection), #[error("Object not found")] @@ -76,6 +78,28 @@ impl From for AppError { } } +impl From for AppError { + fn from(rejection: JsonRejection) -> Self { + match rejection { + JsonRejection::MissingJsonContentType(text) => { + AppError::UnsupportedMediaType(text.to_string()) + } + _ => AppError::Json(rejection), + } + } +} + +impl From for AppError { + fn from(rejection: FormRejection) -> Self { + match rejection { + FormRejection::InvalidFormContentType(text) => { + AppError::UnsupportedMediaType(text.to_string()) + } + _ => AppError::Form(rejection), + } + } +} + impl From for AppError { fn from(hash_err: password_hash::Error) -> Self { Self::PasswordHashError(hash_err) @@ -113,6 +137,19 @@ impl AppError { instance: Some(reference.to_string()), } } + AppError::Form(err) => { + trace!(%reference, + "Received invalid form data: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } AppError::QueryParams(err) => { trace!(%reference, "Received invalid query parameters: {}", diff --git a/openadr-vtn/src/state.rs b/openadr-vtn/src/state.rs index 4c939bd..d8fef83 100644 --- a/openadr-vtn/src/state.rs +++ b/openadr-vtn/src/state.rs @@ -12,7 +12,6 @@ use axum::{ response::IntoResponse, routing::{delete, get, post}, }; -use http_body_util::BodyExt; use reqwest::StatusCode; use std::sync::Arc; use tower_http::trace::TraceLayer; @@ -80,7 +79,6 @@ impl AppState { delete(user::delete_credential), ) .layer(middleware::from_fn(method_not_allowed)) - .layer(middleware::from_fn(unsupported_media_type)) .layer(TraceLayer::new_for_http()) } @@ -98,25 +96,6 @@ pub async fn method_not_allowed(req: Request, next: Next) -> impl IntoResponse { } } -pub async fn unsupported_media_type(req: Request, next: Next) -> impl IntoResponse { - let resp = next.run(req).await; - let status = resp.status(); - match status { - StatusCode::UNSUPPORTED_MEDIA_TYPE => Err(AppError::UnsupportedMediaType( - String::from_utf8_lossy( - &resp - .into_body() - .collect() - .await - .unwrap_or_default() - .to_bytes(), - ) - .to_string(), - )), - _ => Ok(resp), - } -} - impl FromRef for Arc { fn from_ref(state: &AppState) -> Arc { state.storage.auth() From 5a34cd5d8d0c700dfe1a46fd4cc03f80f6de41b5 Mon Sep 17 00:00:00 2001 From: Maximilian Pohl Date: Tue, 1 Oct 2024 12:02:31 +0200 Subject: [PATCH 4/4] Remove unused route --- openadr-vtn/src/api/auth.rs | 6 +----- openadr-vtn/src/state.rs | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/openadr-vtn/src/api/auth.rs b/openadr-vtn/src/api/auth.rs index 7981f57..643b91c 100644 --- a/openadr-vtn/src/api/auth.rs +++ b/openadr-vtn/src/api/auth.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::{api::ValidatedForm, data_source::AuthSource, jwt::JwtManager, state::AppState}; +use crate::{api::ValidatedForm, data_source::AuthSource, jwt::JwtManager}; use axum::{ extract::State, http::{Response, StatusCode}, @@ -137,7 +137,3 @@ pub(crate) async fn token( scope: None, }) } - -pub async fn register(State(_state): State) -> String { - todo!() -} diff --git a/openadr-vtn/src/state.rs b/openadr-vtn/src/state.rs index d8fef83..bcd546f 100644 --- a/openadr-vtn/src/state.rs +++ b/openadr-vtn/src/state.rs @@ -64,7 +64,6 @@ impl AppState { .put(resource::edit) .delete(resource::delete), ) - .route("/auth/register", post(auth::register)) .route("/auth/token", post(auth::token)) .route("/users", get(user::get_all).post(user::add_user)) .route(