diff --git a/.env_files/example.env b/.env_files/example.env index 416b639d..c46bee39 100644 --- a/.env_files/example.env +++ b/.env_files/example.env @@ -6,6 +6,8 @@ APP_NAME=ratings APP_PORT=8080 APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings +APP_ADMIN_USER=shadow +APP_ADMIN_PASSWORD=maria DOCKER_POSTGRES_USER=postgres DOCKER_POSTGRES_PASSWORD=@1234 diff --git a/.env_files/test-server.env b/.env_files/test-server.env index 9eac6810..7080e8e3 100644 --- a/.env_files/test-server.env +++ b/.env_files/test-server.env @@ -1,5 +1,5 @@ APP_ENV=dev -APP_HOST=0.0.0.0 +APP_HOST=127.0.0.1 APP_JWT_SECRET=deadbeef APP_LOG_LEVEL=error APP_NAME=ratings @@ -7,6 +7,9 @@ APP_PORT=8080 # Update this with some real PostgreSQL details APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings +APP_ADMIN_USER=shadow +APP_ADMIN_PASSWORD=maria + DOCKER_POSTGRES_USER=postgres DOCKER_POSTGRES_PASSWORD=@1234 diff --git a/.env_files/test.env b/.env_files/test.env index 0a385dbd..64bd8454 100644 --- a/.env_files/test.env +++ b/.env_files/test.env @@ -7,6 +7,8 @@ APP_PORT=8080 # Update this with some real PostgreSQL details APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings +APP_ADMIN_USER=shadow +APP_ADMIN_PASSWORD=maria DOCKER_POSTGRES_USER=postgres DOCKER_POSTGRES_PASSWORD=@1234 diff --git a/Cargo.lock b/Cargo.lock index 71691a14..eb0dd7cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "getrandom", @@ -45,11 +45,26 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b09b5178381e0874812a9b157f7fe84982617e48f71f4e3235482775e5b540" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" dependencies = [ "anstyle", "anstyle-parse", @@ -99,6 +114,18 @@ version = "1.0.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" +[[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-stream" version = "0.3.5" @@ -118,7 +145,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -129,7 +156,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -143,9 +170,9 @@ dependencies = [ [[package]] name = "atomic-write-file" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436" +checksum = "a8204db279bf648d64fe845bd8840f78b39c8132ed4d6a4194c3b10d4b4cfb0b" dependencies = [ "nix", "rand", @@ -168,7 +195,7 @@ dependencies = [ "bitflags 1.3.2", "bytes", "futures-util", - "http 0.2.11", + "http 0.2.12", "http-body 0.4.6", "hyper 0.14.28", "itoa", @@ -179,7 +206,11 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", @@ -194,7 +225,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 0.2.11", + "http 0.2.12", "http-body 0.4.6", "mime", "rustversion", @@ -223,6 +254,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" + [[package]] name = "base64ct" version = "1.6.0" @@ -244,6 +281,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" @@ -255,9 +301,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" dependencies = [ "memchr", "serde", @@ -265,9 +311,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.15.0" +version = "3.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32a994c2b3ca201d9b263612a374263f05e7adde37c4707f693dcd375076d1f" +checksum = "8ea184aa71bb362a1157c896979544cc23974e08fd265f29ea96b59f0b4a555b" [[package]] name = "bytecount" @@ -289,10 +335,11 @@ checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "cc" -version = "1.0.83" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" dependencies = [ + "jobserver", "libc", ] @@ -302,11 +349,30 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.52.4", +] + [[package]] name = "clap" -version = "4.5.1" +version = "4.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c918d541ef2913577a0f9566e9ce27cb35b6df072075769e0b26cb5a554520da" +checksum = "b230ab84b0ffdf890d5a10abdbc8b83ae1c4918275daea1ab8801f71536b2651" dependencies = [ "clap_builder", "clap_derive", @@ -314,9 +380,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.1" +version = "4.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3e7391dad68afb0c2ede1bf619f579a3dc9c2ec67f089baa397123a2f3d1eb" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" dependencies = [ "anstream", "anstyle", @@ -334,7 +400,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -499,7 +565,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.50", + "syn 2.0.52", "synthez", ] @@ -791,7 +857,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -858,7 +924,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.50", + "syn 2.0.52", "textwrap", "thiserror", "typed-builder", @@ -870,6 +936,19 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "git2" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b3ba52851e73b46a4c3df1d89343741112003f0f6f13beb0dfac9e457c3fdcd" +dependencies = [ + "bitflags 2.4.2", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "globset" version = "0.4.14" @@ -879,7 +958,7 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.5", + "regex-automata 0.4.6", "regex-syntax 0.8.2", ] @@ -905,8 +984,8 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http 0.2.11", - "indexmap 2.2.3", + "http 0.2.12", + "indexmap 2.2.5", "slab", "tokio", "tokio-util", @@ -949,9 +1028,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.6" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5256b483761cd23699d0da46cc6fd2ee3be420bbe6d020ae4a091e70b7e9fd" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hex" @@ -988,9 +1067,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", @@ -999,9 +1078,9 @@ dependencies = [ [[package]] name = "http" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -1015,7 +1094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http 0.2.11", + "http 0.2.12", "pin-project-lite", ] @@ -1026,7 +1105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" dependencies = [ "bytes", - "http 1.0.0", + "http 1.1.0", ] [[package]] @@ -1037,7 +1116,7 @@ checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840" dependencies = [ "bytes", "futures-util", - "http 1.0.0", + "http 1.1.0", "http-body 1.0.0", "pin-project-lite", ] @@ -1071,7 +1150,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 0.2.11", + "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", @@ -1086,18 +1165,19 @@ dependencies = [ [[package]] name = "hyper" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5aa53871fc917b1a9ed87b683a5d86db645e23acb32c2e0785a353e522fb75" +checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.0.0", + "http 1.1.0", "http-body 1.0.0", "httparse", "itoa", "pin-project-lite", + "smallvec", "tokio", "want", ] @@ -1127,6 +1207,29 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1147,7 +1250,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.5", + "regex-automata 0.4.6", "same-file", "walkdir", "winapi-util", @@ -1165,9 +1268,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.3" +version = "2.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" +checksum = "7b0b929d511467233429c45a44ac1dcaa21ba0f5ba11e4879e6ed28ddb4f9df4" dependencies = [ "equivalent", "hashbrown 0.14.3", @@ -1215,11 +1318,20 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "jobserver" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" -version = "0.3.68" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] @@ -1230,7 +1342,7 @@ version = "9.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" dependencies = [ - "base64", + "base64 0.21.7", "js-sys", "pem", "ring", @@ -1259,7 +1371,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -1277,6 +1389,18 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.8" @@ -1294,6 +1418,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037731f5d3aaa87a5675e895b63ddff1a87624bc29f77004ea829809654e48f6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1318,9 +1454,12 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +dependencies = [ + "serde", +] [[package]] name = "matchers" @@ -1376,9 +1515,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", @@ -1411,12 +1550,13 @@ dependencies = [ [[package]] name = "nix" -version = "0.27.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.4.2", "cfg-if", + "cfg_aliases", "libc", ] @@ -1563,7 +1703,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -1574,9 +1714,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.100" +version = "0.9.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae94056a791d0e1217d18b6cbdccb02c61e3054fc69893607f4067e3bb0b1fd1" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" dependencies = [ "cc", "libc", @@ -1613,6 +1753,17 @@ dependencies = [ "windows-targets 0.48.5", ] +[[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.14" @@ -1652,7 +1803,7 @@ version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" dependencies = [ - "base64", + "base64 0.21.7", "serde", ] @@ -1678,7 +1829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" dependencies = [ "fixedbitset", - "indexmap 2.2.3", + "indexmap 2.2.5", ] [[package]] @@ -1701,22 +1852,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -1764,7 +1915,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" dependencies = [ - "base64", + "base64 0.21.7", "byteorder", "bytes", "fallible-iterator", @@ -1806,7 +1957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -1845,7 +1996,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.50", + "syn 2.0.52", "tempfile", "which", ] @@ -1860,7 +2011,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -1915,19 +2066,30 @@ dependencies = [ name = "ratings" version = "0.0.3" dependencies = [ + "argon2", + "axum", + "base64 0.22.0", + "chrono", "cucumber", "dotenvy", "envy", "futures", - "http 1.0.0", + "git2", + "http 1.1.0", + "http-body 0.4.6", "hyper 0.14.28", "jsonwebtoken", + "lazy_static", + "log", "once_cell", "prost", "prost-types", "rand", + "regex", "reqwest", + "secrecy", "serde", + "serde_json", "sha2", "snapd", "sqlx", @@ -1961,7 +2123,7 @@ checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.5", + "regex-automata 0.4.6", "regex-syntax 0.8.2", ] @@ -1976,9 +2138,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", @@ -2009,13 +2171,13 @@ version = "0.11.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", - "http 0.2.11", + "http 0.2.12", "http-body 0.4.6", "hyper 0.14.28", "hyper-tls", @@ -2114,7 +2276,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -2182,7 +2344,17 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", ] [[package]] @@ -2225,7 +2397,7 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -2239,6 +2411,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2342,7 +2524,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -2358,9 +2540,9 @@ source = "git+https://github.com/ZoopOTheGoop/snapd-rs?branch=framework#f4b67567 dependencies = [ "async-trait", "deadpool", - "http 1.0.0", + "http 1.1.0", "http-body-util", - "hyper 1.1.0", + "hyper 1.2.0", "pin-project", "serde", "serde_json", @@ -2371,12 +2553,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2450,7 +2632,7 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap 2.2.3", + "indexmap 2.2.5", "log", "memchr", "once_cell", @@ -2519,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags 2.4.2", "byteorder", "bytes", @@ -2562,7 +2744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags 2.4.2", "byteorder", "crc", @@ -2655,7 +2837,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -2677,9 +2859,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.50" +version = "2.0.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" dependencies = [ "proc-macro2", "quote", @@ -2698,7 +2880,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" dependencies = [ - "syn 2.0.50", + "syn 2.0.52", "synthez-codegen", "synthez-core", ] @@ -2709,7 +2891,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" dependencies = [ - "syn 2.0.50", + "syn 2.0.52", "synthez-core", ] @@ -2722,7 +2904,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -2748,9 +2930,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", @@ -2796,14 +2978,14 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", @@ -2892,7 +3074,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -2965,10 +3147,10 @@ dependencies = [ "async-stream", "async-trait", "axum", - "base64", + "base64 0.21.7", "bytes", "h2", - "http 0.2.11", + "http 0.2.12", "http-body 0.4.6", "hyper 0.14.28", "hyper-timeout", @@ -2993,7 +3175,7 @@ dependencies = [ "proc-macro2", "prost-build", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -3061,7 +3243,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -3126,7 +3308,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] @@ -3229,9 +3411,9 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "walkdir" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", @@ -3252,11 +3434,17 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3264,24 +3452,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877b9c3f61ceea0e56331985743b13f3d25c406a7098d45180fb5f09bc19ed97" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" dependencies = [ "cfg-if", "js-sys", @@ -3291,9 +3479,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3301,28 +3489,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "web-sys" -version = "0.3.68" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96565907687f7aceb35bc5fc03770a8a0471d82e479f25832f54a0e3f4b28446" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" dependencies = [ "js-sys", "wasm-bindgen", @@ -3348,11 +3536,12 @@ dependencies = [ [[package]] name = "whoami" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" +checksum = "0fec781d48b41f8163426ed18e8fc2864c12937df9ce54c88ede7bd47270893e" dependencies = [ - "wasm-bindgen", + "redox_syscall", + "wasite", "web-sys", ] @@ -3387,6 +3576,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3402,7 +3600,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.4", ] [[package]] @@ -3422,17 +3620,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", ] [[package]] @@ -3443,9 +3641,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" [[package]] name = "windows_aarch64_msvc" @@ -3455,9 +3653,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" [[package]] name = "windows_i686_gnu" @@ -3467,9 +3665,9 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" [[package]] name = "windows_i686_msvc" @@ -3479,9 +3677,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" [[package]] name = "windows_x86_64_gnu" @@ -3491,9 +3689,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" [[package]] name = "windows_x86_64_gnullvm" @@ -3503,9 +3701,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" [[package]] name = "windows_x86_64_msvc" @@ -3515,9 +3713,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" [[package]] name = "winreg" @@ -3546,7 +3744,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.50", + "syn 2.0.52", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8d543723..44916994 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,17 +7,28 @@ version = "0.0.3" edition = "2021" [dependencies] -envy = "0.4" +argon2 = "0.5.3" +axum = "0.6.20" # this *must* be pinned because 0.7.x relies on hyper 1.x causing a ton of type conversion issues +base64 = "0.22.0" +chrono = { version = "0.4.34", default-features = false, features = [ + "std", + "clock", + "serde", +] } dotenvy = "0.15" +envy = "0.4" futures = "0.3" http = "1.0" +http-body = "0.4.6" hyper = { version = "0.14", features = ["full", "backports", "deprecated"] } jsonwebtoken = "9.2" +log = { version = "0.4.21", features = ["serde"] } once_cell = "1.19" prost = "0.12" prost-types = "0.12" rand = "0.8" reqwest = "0.11" +secrecy = { version = "0.8.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" snapd = { git = "https://github.com/ZoopOTheGoop/snapd-rs", branch = "framework" } @@ -36,12 +47,16 @@ tonic-reflection = "0.10" tower = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } - [build-dependencies] +git2 = { version = "0.18.2", default-features = false } tonic-build = { version = "0.11", features = ["prost"] } [dev-dependencies] cucumber = { version = "0.20.2", features = ["libtest", "tracing"] } +lazy_static = "1.4.0" +regex = "1.10.3" +serde_json = "1.0.114" + [[test]] name = "voting" @@ -54,3 +69,11 @@ harness = false [[test]] name = "chart" harness = false + +[[test]] +name = "log_level" +harness = false + +[[test]] +name = "api_info" +harness = false diff --git a/build.rs b/build.rs index b65a6ea5..e44a24c7 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,7 @@ +use git2::Repository; use std::path::Path; -fn main() -> Result<(), Box> { +fn init_proto() -> Result<(), Box> { // Define the path to the output directory within the `src` folder let out_dir = Path::new("proto"); std::fs::create_dir_all(out_dir)?; @@ -31,3 +32,26 @@ fn main() -> Result<(), Box> { Ok(()) } + +fn include_build_info() -> Result<(), Box> { + let repo = Repository::open(std::env::current_dir()?)?; + let head = repo.head()?; + let branch = head + .name() + .unwrap() + .strip_prefix("refs/heads/") + .unwrap_or("no-branch"); + println!("cargo:rustc-env=GIT_BRANCH={}", branch); + + let commit_sha = repo.head()?.target().unwrap(); + println!("cargo:rustc-env=GIT_HASH={}", commit_sha); + + Ok(()) +} + +fn main() -> Result<(), Box> { + init_proto()?; + include_build_info()?; + + Ok(()) +} diff --git a/src/app/context.rs b/src/app/context.rs index 1604708c..4d85c8de 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use crate::utils::{jwt::Claims, Config, Infrastructure}; +use crate::utils::{Config, Infrastructure}; /// An atomically reference counted app state. #[derive(Debug, Clone)] @@ -41,12 +41,3 @@ struct AppContextInner { /// App configuration settings. config: Config, } - -/// Contains the context for a given request -#[derive(Debug, Clone)] -pub struct RequestContext { - /// The URI this request is from. - pub uri: String, - /// If applicable, the associated JWT claims. - pub claims: Option, -} diff --git a/src/app/interfaces/authentication.rs b/src/app/interfaces/authentication.rs deleted file mode 100644 index 7f351a49..00000000 --- a/src/app/interfaces/authentication.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Contains the utilities for authorizing user requests - -use http::header; -use tonic::{Request, Status}; -use tracing::error; - -use crate::app::{context::AppContext, RequestContext}; - -/// Authorizes the user in the request specified by `req`. -pub fn authentication(req: Request<()>) -> Result, Status> { - let app_ctx = req.extensions().get::().unwrap().clone(); - - let req_ctx = req.extensions().get::().unwrap().clone(); - let uri = &req_ctx.uri; - - let public_paths = [ - "ratings.features.user.User/Register", - "ratings.features.user.User/Authenticate", - "grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo", - ]; - - if public_paths.iter().any(|&s| uri.contains(s)) { - return Ok(req); - } - - let Some(header) = req.metadata().get(header::AUTHORIZATION.as_str()) else { - let error = Err(Status::unauthenticated("missing authz header")); - error!("{error:?}"); - return error; - }; - - let raw: Vec<&str> = header.to_str().unwrap_or("").split_whitespace().collect(); - - if raw.len() != 2 { - let error = Err(Status::unauthenticated("invalid authz token")); - error!("{error:?}"); - return error; - } - - let token = raw[1]; - let infra = app_ctx.infrastructure(); - match infra.jwt.decode(token) { - Ok(claim) => { - let mut req = req; - req.extensions_mut().insert(claim); - Ok(req) - } - Err(error) => { - error!("{error:?}"); - Err(Status::unauthenticated("Failed to decode token.")) - } - } -} diff --git a/src/app/interfaces/authentication/admin.rs b/src/app/interfaces/authentication/admin.rs new file mode 100644 index 00000000..a1271f92 --- /dev/null +++ b/src/app/interfaces/authentication/admin.rs @@ -0,0 +1,156 @@ +//! An authenticator for our admin endpoints. Technically should work for any Basic auth +//! with some very minor modifications, but that's all we use it for now. +use std::convert::Infallible; + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, PasswordHash, PasswordVerifier, +}; +use axum::response::IntoResponse; +use base64::prelude::*; +use hyper::StatusCode; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use thiserror::Error; +use tracing::error; + +use super::CredentialVerifier; + +/// Errors that can occur while verifying authentication for the admin REST endpoints +#[derive(Error, Debug)] +#[allow(missing_docs)] +pub enum AdminAuthError { + #[error("basic auth: could not retrieve secret fron environment: {0}")] + EnvError(#[from] envy::Error), + #[error("basic auth: an error occurred while hashing the password")] + PasswordHashError, + #[error("basic auth: this expected basic authentication, but another type was used")] + WrongAuthType, + #[error("basic auth: the auth type was correct, but the header was malformed")] + MalformedAuth, + #[error("{0}")] + GenericMessage(String), +} + +impl IntoResponse for AdminAuthError { + fn into_response(self) -> axum::response::Response { + match self { + AdminAuthError::EnvError(_) | AdminAuthError::PasswordHashError => { + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() + } + AdminAuthError::WrongAuthType + | AdminAuthError::MalformedAuth + | Self::GenericMessage(_) => { + (StatusCode::UNAUTHORIZED, "Basic realm = \"admin\"").into_response() + } + } + } +} + +impl From for AdminAuthError { + fn from(_: argon2::password_hash::Error) -> Self { + Self::PasswordHashError + } +} + +#[derive(Clone)] +/// Authenticates the admin REST endpoints, works in principle for any Basic auth, +/// though you'd need to modify it to pass in the `realm` for the errors. +pub struct AdminAuthVerifier { + /// Our hashing algorithm + algo: Argon2<'static>, + /// The hashed, base64 auth password + hashed: SecretString, +} + +impl AdminAuthVerifier { + /// Creates a new [`AdminAuthVerifier`] from the secrets set in environment variables. + pub fn from_env() -> Result { + let config = AdminAuthConfig::from_env()?; + let encoded = config.into_base64(); + + let salt = SaltString::generate(&mut OsRng); + + let algo = Argon2::default(); + let hashed = SecretString::new( + algo.hash_password(encoded.expose_secret().as_bytes(), &salt) + .inspect_err(|e| error!("error hashing env password {e}"))? + .to_string(), + ); + + Ok(Self { algo, hashed }) + } +} + +impl CredentialVerifier for AdminAuthVerifier { + // We don't pass anything like a claim, so we just use an unconstructable type + // if `!` ever gets stabilized, switch to that. + type Extension = Infallible; + + type Error = AdminAuthError; + + fn verify( + &self, + credential: &axum::http::HeaderValue, + ) -> Result, Self::Error> { + let mut credential = credential.to_str().unwrap_or("").split_ascii_whitespace(); + + if credential.next().filter(|v| *v == "Basic").is_none() { + return Err(AdminAuthError::WrongAuthType); + } + + if let Some(credential) = credential.next() { + let hash = + PasswordHash::new(self.hashed.expose_secret()).expect("password hash is broken"); + self.algo.verify_password(credential.as_bytes(), &hash)?; + + Ok(None) + } else { + Err(AdminAuthError::MalformedAuth) + } + } + + fn unauthorized(&self, message: &str) -> Self::Error { + AdminAuthError::GenericMessage(message.to_owned()) + } +} + +/// A config that parses admin secrets from environment variables and then shreds them when done. +#[derive(Deserialize)] +pub struct AdminAuthConfig { + /// The admin's username + admin_user: SecretString, + /// The admin's password + admin_password: SecretString, +} + +impl AdminAuthConfig { + /// Loads the secret from the environment + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); + + envy::prefixed("APP_").from_env::() + } + + /// Converts this into a [`base64`] encoded secret. This *ideally* will + /// shred all intermediate data, but that can never be guaranteed. It tries its best, + /// though. + fn into_base64(self) -> SecretString { + let mut secret = String::with_capacity( + self.admin_password.expose_secret().len() + 1 + self.admin_user.expose_secret().len(), + ); + + secret.push_str(self.admin_user.expose_secret()); + secret.push(':'); + secret.push_str(self.admin_password.expose_secret()); + let secret = SecretString::new(secret); + + SecretString::new(BASE64_STANDARD.encode(secret.expose_secret())) + } + + /// Gets the inner values for other uses, mostly for tests + #[allow(dead_code)] + pub fn into_inner(self) -> (SecretString, SecretString) { + (self.admin_user, self.admin_password) + } +} diff --git a/src/app/interfaces/authentication/jwt.rs b/src/app/interfaces/authentication/jwt.rs new file mode 100644 index 00000000..433c15f7 --- /dev/null +++ b/src/app/interfaces/authentication/jwt.rs @@ -0,0 +1,127 @@ +//! JSON Web Tokens infrastructure and utlities. +use jsonwebtoken::{DecodingKey, Validation}; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use thiserror::Error; +use tracing::error; + +use crate::utils::{jwt::Claims, Config}; + +use super::CredentialVerifier; + +/// An error for things that can go wrong with JWT verification +#[derive(Error, Debug)] +#[allow(clippy::missing_docs_in_private_items, missing_docs)] +pub enum JwtVerifierError { + #[error("jwt: invalid shape")] + InvalidShape, + #[error("jwt: could not retrieve secret fron environment: {0}")] + EnvError(#[from] envy::Error), + #[error("jwt: error decoding secret: {0}")] + DecodeSecretError(#[from] jsonwebtoken::errors::Error), + #[error("jwt: invalid authz token")] + InvalidHeader, + #[error(transparent)] + GenericMessage(#[from] tonic::Status), +} + +impl From for tonic::Status { + fn from(value: JwtVerifierError) -> Self { + match value { + JwtVerifierError::InvalidShape | JwtVerifierError::EnvError(_) => { + tonic::Status::internal("Internal Server Error") + } + JwtVerifierError::DecodeSecretError(_) => { + tonic::Status::unauthenticated("invalid JWT token") + } + JwtVerifierError::InvalidHeader => { + tonic::Status::unauthenticated("invalid authz header") + } + JwtVerifierError::GenericMessage(status) => status, + } + } +} + +#[derive(Clone)] +/// A JWT verification agent that allows verifying assigned tokens are valid +pub struct JwtVerifier { + /// A decoding key for receipt + decoding_key: DecodingKey, +} + +impl JwtVerifier { + /// Attempts to create a new verifier from the invoker's environment. + pub fn from_env() -> Result { + let config = JwtConfig::from_env()?; + + Self::from_secret(&config.jwt_secret) + } + + /// Creates a new verifier from the given secret. + pub fn from_secret(secret: &SecretString) -> Result { + let decoding_key = DecodingKey::from_base64_secret(secret.expose_secret())?; + + Ok(Self { decoding_key }) + } + + /// Loads this verifier from the secret enclosed in [`Config`]. + #[allow(dead_code)] + pub fn from_config(cfg: &Config) -> Result { + Self::from_secret(&cfg.jwt_secret) + } + + /// Decodes a given token received + pub fn decode(&self, token: &str) -> Result { + jsonwebtoken::decode::(token, &self.decoding_key, &Validation::default()) + .map(|t| t.claims) + .map_err(|e| { + error!("{e:?}"); + JwtVerifierError::InvalidShape + }) + } +} + +impl CredentialVerifier for JwtVerifier { + type Extension = Claims; + + type Error = JwtVerifierError; + + fn verify( + &self, + credential: &hyper::header::HeaderValue, + ) -> Result, Self::Error> { + let raw: Vec<&str> = credential + .to_str() + .unwrap_or("") + .split_whitespace() + .collect(); + + if raw.len() != 2 { + return Err(JwtVerifierError::InvalidHeader); + } + + let token = raw[1]; + self.decode(token).map(Some) + } + + fn unauthorized(&self, message: &str) -> Self::Error { + JwtVerifierError::GenericMessage(tonic::Status::unauthenticated(message)) + } +} + +/// A configuration only containing a JWT secret, just used for fast +/// on-the-fly construction with `from_env`` +#[derive(Deserialize)] +#[allow(clippy::missing_docs_in_private_items)] +struct JwtConfig { + jwt_secret: SecretString, +} + +impl JwtConfig { + /// Loads the secret from the environment + fn from_env() -> Result { + dotenvy::dotenv().ok(); + + envy::prefixed("APP_").from_env::() + } +} diff --git a/src/app/interfaces/authentication/mod.rs b/src/app/interfaces/authentication/mod.rs new file mode 100644 index 00000000..774037b3 --- /dev/null +++ b/src/app/interfaces/authentication/mod.rs @@ -0,0 +1,143 @@ +//! Contains a generic authenticator implementation for use in different backends + +use hyper::header::{self, HeaderValue}; +use tracing::error; + +pub mod admin; +pub mod jwt; + +/// Verifies the given credentials, this is only really ever used by [`Authenticator`] +/// and unless you're adding a new auth method or endpoint, is probably useless to you. +pub trait CredentialVerifier { + /// Any extensions that will be added to the request's extensions field before being + /// passed down to the handler, should authentication succeed. + type Extension: Send + Sync; + /// Any errors that can be encountered during the verification procedure. These must + /// be convertable to [`tonic::Status`] values, especially so anything sensitive can be + /// erased before sending the error back to the client. + type Error: std::error::Error; + + /// Verifies the passed in header has the authentication values necessary. This does + /// NOT need to verify paths, nor that the header is actually an authorization header, + /// the [`Authenticator`] does that already. However, it should validate things like + /// header length, authentication type (Basic, etc) on its own. + fn verify(&self, credential: &HeaderValue) -> Result, Self::Error>; + + /// Returns this verifier's error but with the authenticator's message. This is mostly + /// a workaround for the awkward way multiplexing makes us use errors, it becomes hard + /// to commit to returning both valid GRPC/tonic and HTTP/axum responses from the [`Authenticator`] + /// when it detects a problem upfront otherwise. + fn unauthorized(&self, message: &str) -> Self::Error; +} + +/// A utility meant to verify requests. +#[derive(Debug, Clone)] +pub struct Authenticator { + /// The paths that can be accessed without verifying whether a user is authorized + public_paths: Vec, + /// A [`CredentialVerifier`] that will check if the auth header is valid for non-public paths. + verifier: V, +} + +impl> Authenticator +where + V: CredentialVerifier, + V::Extension: 'static, + A: AsRef, +{ + /// Attempts to authenticate the request's Authorization Header with the underlying [`CredentialVerifier`], + /// unless the URL path was designated as public during construction. + pub fn authenticate(&self, req: &mut hyper::Request) -> Result<(), V::Error> { + if self + .public_paths + .iter() + .any(|s| req.uri().path().ends_with(s.as_ref())) + { + return Ok(()); + } + + let Some(header) = req.headers().get(header::AUTHORIZATION.as_str()) else { + let error = self.verifier.unauthorized("missing authz header"); + error!("{error}"); + return Err(error); + }; + + let extension = self + .verifier + .verify(header) + .inspect_err(|err| error!("{err}"))?; + + if let Some(extension) = extension { + req.extensions_mut().insert(extension); + } + Ok(()) + } +} + +/// Builder pattern for [`Authenticator`], this is mostly used for iteratively adding +/// new public paths. +#[allow(clippy::missing_docs_in_private_items)] +pub struct AuthenticatorBuilder { + verifier: V, + public_paths: Vec, +} + +impl AuthenticatorBuilder { + /// Constructs a new in-progress authenticator from the given [`CredentialVerifier`]. + pub fn new(verifier: V) -> Self { + Self { + verifier, + public_paths: Vec::new(), + } + } + + /// Overrides an existing [`CredentialVerifier`] with a new one. + #[allow(dead_code)] + pub fn override_verifier( + self, + verifier: V2, + ) -> AuthenticatorBuilder { + AuthenticatorBuilder { + verifier, + public_paths: self.public_paths, + } + } +} + +impl> AuthenticatorBuilder { + /// Adds a new path that the constructed [`Authenticator`] will consider public, + /// and thus pass without attempting to authenticate the passed header. + pub fn with_public_path(&mut self, path: A) -> &Self { + self.public_paths.push(path); + self + } + + /// Like [AuthenticatorBuilder::with_public_path], but adds multiple at once. This + /// is slightly more efficient (and cleaner) than doing it yourself in a loop. + pub fn with_public_paths>(&mut self, paths: I) -> &Self { + self.public_paths.extend(paths); + self + } +} + +impl> AuthenticatorBuilder { + /// Renders this builder into an [`Authenticator`], ready to be used. + pub fn build(self) -> Authenticator { + Authenticator { + verifier: self.verifier, + public_paths: self.public_paths, + } + } +} + +impl Default for AuthenticatorBuilder +where + V: Default, +{ + fn default() -> Self { + Self { + verifier: Default::default(), + public_paths: Default::default(), + } + } +} diff --git a/src/app/interfaces/middleware.rs b/src/app/interfaces/middleware.rs index 1d526073..6f77165e 100644 --- a/src/app/interfaces/middleware.rs +++ b/src/app/interfaces/middleware.rs @@ -2,11 +2,13 @@ use std::pin::Pin; +use axum::body::Bytes; +use futures::ready; +use http_body::combinators::UnsyncBoxBody; use hyper::{service::Service, Body}; -use tonic::body::BoxBody; use tower::Layer; -use crate::app::context::{AppContext, RequestContext}; +use crate::app::context::AppContext; /// Passthrough [`Layer`] containing the [`AppContext`], this is mainly used to construct /// [`ContextMiddleware`]. @@ -25,7 +27,10 @@ impl ContextMiddlewareLayer { impl Layer for ContextMiddlewareLayer where - S: Service, Response = hyper::Response> + Clone + Send + 'static, + S: Service, Response = hyper::Response>> + + Clone + + Send + + 'static, S::Future: Send + 'static, { type Service = ContextMiddleware; @@ -34,6 +39,7 @@ where ContextMiddleware { app_ctx: self.app_ctx.clone(), inner: service, + ready: false, } } } @@ -45,12 +51,18 @@ where #[derive(Clone)] pub struct ContextMiddleware where - S: Service, Response = hyper::Response> + Clone + Send + 'static, + S: Service, Response = hyper::Response>> + + Clone + + Send + + 'static, S::Future: Send + 'static, { /// The current context of the app, as passed in from the [`ContextMiddlewareLayer`] app_ctx: AppContext, + /// Is the inner future ready to be used + ready: bool, + /// The inner [`Service`] containing the [`Future`]. /// /// [`Future`]: std::future::Future @@ -62,7 +74,10 @@ type BoxFuture<'a, T> = Pin + Send + 'a> impl Service> for ContextMiddleware where - S: Service, Response = hyper::Response> + Clone + Send + 'static, + S: Service, Response = hyper::Response>> + + Clone + + Send + + 'static, S::Future: Send + 'static, { type Response = S::Response; @@ -73,26 +88,27 @@ where &mut self, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - self.inner.poll_ready(cx) + loop { + if self.ready { + return std::task::Poll::Ready(Ok(())); + } else { + ready!(self.inner.poll_ready(cx))?; + self.ready = true; + } + } } - fn call(&mut self, req: hyper::Request) -> Self::Future { - let clone = self.inner.clone(); - let mut inner = std::mem::replace(&mut self.inner, clone); - let app_ctx = self.app_ctx.clone(); + fn call(&mut self, mut req: hyper::Request) -> Self::Future { + assert!(self.ready); + self.ready = false; - Box::pin(async move { - let req_ctx = RequestContext { - uri: req.uri().to_string(), - claims: None, - }; + req.extensions_mut().insert(self.app_ctx.clone()); - let mut req = req; - req.extensions_mut().insert(app_ctx); - req.extensions_mut().insert(req_ctx); + let future = self.inner.call(req); - let response = inner.call(req).await?; - Ok(response) + Box::pin(async move { + let res = future.await?; + Ok(res) }) } } diff --git a/src/app/interfaces/mod.rs b/src/app/interfaces/mod.rs index 89fec657..6b363780 100644 --- a/src/app/interfaces/mod.rs +++ b/src/app/interfaces/mod.rs @@ -2,6 +2,6 @@ pub mod authentication; pub mod middleware; -pub mod routes; +pub mod servers; mod errors; diff --git a/src/app/interfaces/routes.rs b/src/app/interfaces/routes.rs deleted file mode 100644 index a815cb02..00000000 --- a/src/app/interfaces/routes.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Contains definitions for service routers and related components. - -use tonic::transport::server::Router; -use tonic_reflection::server::{ServerReflection, ServerReflectionServer}; - -use crate::features::{chart, rating, user}; - -/// Creates a new default reflection server for this app -pub fn build_reflection_service() -> ServerReflectionServer { - let file_descriptor_set = tonic::include_file_descriptor_set!("ratings_descriptor"); - - tonic_reflection::server::Builder::configure() - .register_encoded_file_descriptor_set(file_descriptor_set) - .build() - .unwrap() -} - -/// Registers new services required to make the passed in [`Router`] work, -/// the [`Router`] won't be otherwise modified. -pub fn build_servers(router: Router) -> Router { - let user_service = user::service::build_service(); - let app_service = rating::service::build_service(); - let chart_service = chart::service::build_service(); - - router - .add_service(user_service) - .add_service(app_service) - .add_service(chart_service) -} diff --git a/src/app/interfaces/servers/grpc.rs b/src/app/interfaces/servers/grpc.rs new file mode 100644 index 00000000..18dac15c --- /dev/null +++ b/src/app/interfaces/servers/grpc.rs @@ -0,0 +1,180 @@ +//! Contains definitions for service routers and related components. + +use std::pin::Pin; + +use hyper::Body; +use thiserror::Error; +use tonic::{ + transport::server::{Routes, RoutesBuilder}, + Status, +}; +use tower::Service; + +use crate::{ + app::interfaces::authentication::{ + jwt::{JwtVerifier, JwtVerifierError}, + Authenticator, AuthenticatorBuilder, + }, + features::{chart::ChartService, rating::RatingService, user::UserService}, +}; + +/// An error deriving from the GRPC Endpoints +#[derive(Error, Debug)] +pub enum GrpcError { + /// The [`tonic`] API erases individual responses from our underlying routes, + /// so this collects those. + #[error("an error occurred in an underlying service: {0}")] + RoutesError(#[from] Box), + /// Errors hailing from our authentication interceptor + #[error("an error occurred during authentication: {0}")] + AuthError(#[from] tonic::Status), +} + +impl From for Status { + fn from(value: GrpcError) -> Self { + match value { + GrpcError::AuthError(status) => status, + GrpcError::RoutesError(err) => Status::internal(format!("{err}")), + } + } +} + +/// The file descriptors defining the [`tonic`] GRPC service +const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("ratings_descriptor"); + +/// The GRPC Service endpoint for the program, you probably want to build this with +/// [`GrpcServiceBuilder`] instead of using this directly. +#[derive(Clone)] +pub struct GrpcService { + /// The router that automatically sends requests to the proper underlying service + routes: Routes, + /// The authentication routine we use for validating input + authenticator: Authenticator, +} + +/// A type definition which is simply a future that's in a pinned location in the heap. +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +impl Service> for GrpcService { + type Response = hyper::Response; + + type Error = GrpcError; + + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.routes.poll_ready(cx).map_err(|e| e.into()) + } + + fn call(&mut self, mut req: hyper::Request) -> Self::Future { + let auth_result = self.authenticator.authenticate(&mut req); + + if let Err(err) = auth_result { + return Box::pin(async move { Err(GrpcError::AuthError(err.into())) }); + }; + + let future = self.routes.call(req); + Box::pin(async move { Ok(future.await?) }) + } +} + +/// The path of the reflection server, since we do this internally we don't +/// construct it in the same was as the servers under [`features`](crate::features). +const REFLECTION_SERVER_PATH: &str = + "grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo"; + +/// Errors that can occur while constructing our GRPC service +#[derive(Error, Debug)] +#[allow(clippy::missing_docs_in_private_items, missing_docs)] +pub enum GrpcServerBuildError { + #[error("grpc builder: error creating JWT authentication: {0}")] + JwtDecodeError(#[from] JwtVerifierError), +} + +/// A builder for the ratings GRPC backend +pub struct GrpcServiceBuilder { + /// The builder for the service's route dispatcher + builder: RoutesBuilder, + /// The authenticator we want to use + authenticator: AuthenticatorBuilder, +} + +impl GrpcServiceBuilder { + /// Creates a new builder for our GrpcService + pub fn from_env() -> Result { + Ok(GrpcServiceBuilder { + builder: RoutesBuilder::default(), + authenticator: AuthenticatorBuilder::new(JwtVerifier::from_env()?), + }) + } + + /// Creates a new builder with the given [`AuthenticatorBuilder`], should + /// it be constructed elsewhere. + #[allow(dead_code)] + pub fn from_authenticator_builder( + authenticator: AuthenticatorBuilder, + ) -> Self { + Self { + authenticator, + builder: Default::default(), + } + } + + /// Adds the [`ChartService`] to the [`GrpcService`] + pub fn with_charts(mut self) -> Self { + self.builder.add_service(ChartService.to_server()); + self.authenticator + .with_public_paths(ChartService::PUBLIC_PATHS.into_iter()); + self + } + + /// Adds the [`RatingService`] to the [`GrpcService`] + pub fn with_ratings(mut self) -> Self { + self.builder.add_service(RatingService.to_server()); + self.authenticator + .with_public_paths(RatingService::PUBLIC_PATHS.into_iter()); + self + } + + /// Adds the [`UserService`] to the [`GrpcService`] + pub fn with_user(mut self) -> Self { + self.builder.add_service(UserService.to_server()); + self.authenticator + .with_public_paths(UserService::PUBLIC_PATHS.into_iter()); + self + } + + /// Adds the tonic [`ServerReflectionServer`] to the [`GrpcService`] + /// + /// [`ServerReflectionServer`]: tonic_reflection::server::ServerReflectionServer + pub fn with_reflection(mut self) -> Self { + self.builder.add_service( + tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build() + .unwrap(), + ); + + self.authenticator.with_public_path(REFLECTION_SERVER_PATH); + self + } + + /// Constructs this with the default routes expected of our GRPC client. + pub fn with_default_routes(self) -> Self { + self.with_charts() + .with_ratings() + .with_user() + .with_reflection() + } + + /// Builds this into the GrpcService + pub fn build(self) -> GrpcService { + GrpcService { + routes: self.builder.routes(), + authenticator: self.authenticator.build(), + } + } +} diff --git a/src/app/interfaces/servers/mod.rs b/src/app/interfaces/servers/mod.rs new file mode 100644 index 00000000..7b3e2959 --- /dev/null +++ b/src/app/interfaces/servers/mod.rs @@ -0,0 +1,127 @@ +//! API endpoint definitions for different entry methods + +pub mod grpc; +pub mod rest; + +use std::{convert::Infallible, pin::Pin}; + +use axum::body::Bytes; +use axum::response::IntoResponse; +use futures::ready; +#[allow(unused_imports)] +pub use grpc::{GrpcService, GrpcServiceBuilder}; +use http_body::combinators::UnsyncBoxBody; +use hyper::{header::CONTENT_TYPE, Request}; +pub use rest::{RestService, RestServiceBuilder}; +use thiserror::Error; +use tower::Service; + +use self::grpc::GrpcError; + +/// Any error that can occur internally to our service +#[derive(Debug, Error)] +pub enum AppCenterRatingsError { + /// An error from the GRPC endpoints + #[error(transparent)] + GrpcError(#[from] GrpcError), + /// Technically, an error from the Rest endpoints, but they can't fail right now + /// since they immediately convert their errors into responses because that's what + /// [`axum`] recommends. + #[error(transparent)] + RestError(#[from] Infallible), +} + +/// The general service for our app, containing all our endpoints +#[derive(Clone)] +#[allow(clippy::missing_docs_in_private_items)] +pub struct AppCenterRatingsService { + grpc_service: GrpcService, + grpc_ready: bool, + rest_service: RestService, + rest_ready: bool, +} + +impl AppCenterRatingsService { + /// Constructs the service with all the default service endpoints for REST and GRPC + pub fn with_default_routes() -> AppCenterRatingsService { + Self { + grpc_service: GrpcServiceBuilder::from_env() + .expect("could not create GRPC service from environment") + .with_default_routes() + .build(), + grpc_ready: false, + rest_service: RestServiceBuilder::default() + .build() + .expect("could not create REST service from environment"), + rest_ready: false, + } + } +} + +/// A type definition which is simply a future that's in a pinned location in the heap. +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +impl Service> for AppCenterRatingsService { + type Response = hyper::Response>; + + type Error = AppCenterRatingsError; + + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + loop { + match (self.grpc_ready, self.rest_ready) { + (true, true) => return std::task::Poll::Ready(Ok(())), + (false, _) => { + ready!(self.grpc_service.poll_ready(cx))?; + self.grpc_ready = true + } + (_, false) => { + ready!(self.rest_service.poll_ready(cx)).unwrap(); + self.rest_ready = true + } + } + } + } + + fn call(&mut self, req: hyper::Request) -> Self::Future { + assert!( + self.grpc_ready, + "grpc service not ready. Did you forget to call `poll_ready`?" + ); + assert!( + self.rest_ready, + "rest service not ready. Did you forget to call `poll_ready`?" + ); + + // if we get a grpc request call the grpc service, otherwise call the rest service + // when calling a service it becomes not-ready so we have drive readiness again + if is_grpc_request(&req) { + self.grpc_ready = false; + let future = self.grpc_service.call(req); + Box::pin(async move { + let res = future.await?; + Ok(res.into_response()) + }) + } else { + self.rest_ready = false; + let future = self.rest_service.call(req); + Box::pin(async move { + let res = future.await?; + Ok(res.into_response()) + }) + } + } +} + +/// Checks to see if this request has a GRPC header (if not we assume REST) +fn is_grpc_request(req: &Request) -> bool { + req.headers() + .get(CONTENT_TYPE) + .map(|content_type| content_type.as_bytes()) + .filter(|content_type| content_type.starts_with(b"application/grpc")) + .is_some() +} diff --git a/src/app/interfaces/servers/rest.rs b/src/app/interfaces/servers/rest.rs new file mode 100644 index 00000000..267cdb03 --- /dev/null +++ b/src/app/interfaces/servers/rest.rs @@ -0,0 +1,134 @@ +//! The interface for serving on REST endpoints + +use std::{convert::Infallible, pin::Pin}; + +use axum::{body::Bytes, response::IntoResponse, Router}; +use http_body::combinators::UnsyncBoxBody; +use hyper::StatusCode; +use thiserror::Error; +use tower::Service; + +use crate::{ + app::interfaces::authentication::{ + admin::{AdminAuthError, AdminAuthVerifier}, + Authenticator, AuthenticatorBuilder, + }, + features::admin::{ + api_version::service::ApiVersionService, log_level::service::LogLevelService, + }, +}; + +/// The base path appended to all our internal endpoints +const BASE_ROUTE: &str = "/v1/"; + +/// Dispatches to our web endpoints +#[derive(Clone)] +pub struct RestService { + /// The axum router we use for dispatching to endpoints + router: Router, + /// Makes sure our admin endpoints aren't public without some kind of + /// username and password to access them. + authenticator: Authenticator, +} + +/// A type definition which is simply a future that's in a pinned location in the heap. +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +impl Service> for RestService { + type Response = hyper::Response>; + + type Error = Infallible; + + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.router + .poll_ready(cx) + .map_err(|_| unreachable!("error is infallible")) + } + + fn call(&mut self, mut req: hyper::Request) -> Self::Future { + let auth_result = self.authenticator.authenticate(&mut req); + + if let Err(err) = auth_result { + return Box::pin(async move { Ok(err.into_response()) }); + }; + + let future = self.router.call(req); + Box::pin(async move { + let resp = future + .await + .map_err(|_| unreachable!("error is infallible")) + .unwrap(); + + Ok(resp.into_response()) + }) + } +} + +/// Handles any missing paths +async fn handler_404() -> impl IntoResponse { + (StatusCode::NOT_FOUND, "no such API endpoint") +} + +/// Errors that can occur while constructing our REST service +#[derive(Error, Debug)] +#[allow(clippy::missing_docs_in_private_items, missing_docs)] +pub enum RestServerBuildError { + #[error("grpc builder: error creating admin authentication: {0}")] + JwtDecodeError(#[from] AdminAuthError), +} + +/// Builds the REST service +pub struct RestServiceBuilder { + /// The underlying axum router we're building up + router: Router, +} + +impl RestServiceBuilder { + /// Creates a new builder with an empty path, + /// you probably actually want [`RestServiceBuilder::default`], + /// since that seeds the default API endpoint paths. + pub fn new() -> Self { + Self { + router: Router::default(), + } + } + + /// Adds the log service + pub fn with_log_level(self) -> Self { + Self { + router: self + .router + .nest(BASE_ROUTE, LogLevelService.register_axum_route()), + } + } + + /// Adds the ability to get the API version from the REST endpoint + pub fn with_api_version(self) -> Self { + Self { + router: self + .router + .nest(BASE_ROUTE, ApiVersionService.register_axum_route()), + } + } + + /// Builds the REST service, applying all configured paths and + /// forcing the others to 404. + pub fn build(self) -> Result { + Ok(RestService { + router: self.router.fallback(handler_404), + // None of our paths are public right now, so + authenticator: AuthenticatorBuilder::new(AdminAuthVerifier::from_env()?).build(), + }) + } +} + +impl Default for RestServiceBuilder { + fn default() -> Self { + Self::new().with_log_level().with_api_version() + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 5c03fde4..83a5bb40 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,9 @@ //! Contains all definitions of the main application interface part of the program. -pub use context::{AppContext, RequestContext}; +pub use context::AppContext; pub use run::run; +pub mod interfaces; + mod context; -mod interfaces; mod run; diff --git a/src/app/run.rs b/src/app/run.rs index 1399c4af..a9fb2038 100644 --- a/src/app/run.rs +++ b/src/app/run.rs @@ -1,16 +1,13 @@ -//! Contains definitions for running the app context. +//! Contains definitions for runningi the app context. use std::{net::SocketAddr, time::Duration}; -use tonic::transport::Server; use tower::ServiceBuilder; use tracing::info; use crate::{ - app::context::AppContext, - app::interfaces::{ - authentication::authentication, - middleware::ContextMiddlewareLayer, - routes::{build_reflection_service, build_servers}, + app::{ + context::AppContext, + interfaces::{middleware::ContextMiddlewareLayer, servers::AppCenterRatingsService}, }, utils::{Config, Infrastructure, Migrator}, }; @@ -22,20 +19,20 @@ pub async fn run(config: Config) -> Result<(), Box> { let infra = Infrastructure::new(&config).await?; let app_ctx = AppContext::new(&config, infra); - let layer = ServiceBuilder::new() + info!("{} infrastructure initialized", config.name); + + let socket: SocketAddr = config.socket().parse()?; + // Shred the secrets in `config` + drop(config); + + let service = ServiceBuilder::new() .timeout(Duration::from_secs(30)) .layer(ContextMiddlewareLayer::new(app_ctx)) - .layer(tonic::service::interceptor(authentication)) - .into_inner(); + .service(AppCenterRatingsService::with_default_routes()); - let server = Server::builder() - .layer(layer) - .add_service(build_reflection_service()); - let server = build_servers(server); + let shared = tower::make::Shared::new(service); - let socket: SocketAddr = config.socket().parse()?; info!("Binding to {socket}"); - server.serve(socket).await?; - + hyper::Server::bind(&socket).serve(shared).await?; Ok(()) } diff --git a/src/features/admin/api_version/interface.rs b/src/features/admin/api_version/interface.rs new file mode 100644 index 00000000..d346b999 --- /dev/null +++ b/src/features/admin/api_version/interface.rs @@ -0,0 +1,16 @@ +//! The public interface of this endpoint + +use serde::{Deserialize, Serialize}; + +use super::ApiVersion; + +/// A response serialized as a JSON blob containing the entire branch state +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ApiVersionResponse(pub ApiVersion<'static>); + +impl From> for ApiVersionResponse { + fn from(value: ApiVersion<'static>) -> Self { + ApiVersionResponse(value) + } +} diff --git a/src/features/admin/api_version/mod.rs b/src/features/admin/api_version/mod.rs new file mode 100644 index 00000000..aa6d3a00 --- /dev/null +++ b/src/features/admin/api_version/mod.rs @@ -0,0 +1,36 @@ +//! Contains API endpoints for getting build target information for the currently running service + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +pub mod interface; +pub mod service; + +#[derive(Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Debug)] +/// The response returning the API Version information. +pub struct ApiVersion<'a> { + /// The current API Version + pub version: Cow<'a, str>, + /// The current commit sha + pub commit: Cow<'a, str>, + /// The current branch + pub branch: Cow<'a, str>, +} + +impl ApiVersion<'static> { + /// Retrieves the baked-in build info for the current branch state + pub const fn build_info() -> ApiVersion<'static> { + Self { + version: Cow::Borrowed(env!("CARGO_PKG_VERSION")), + commit: Cow::Borrowed(env!("GIT_HASH")), + branch: Cow::Borrowed(env!("GIT_BRANCH")), + } + } +} + +impl Default for ApiVersion<'static> { + fn default() -> Self { + Self::build_info() + } +} diff --git a/src/features/admin/api_version/service/mod.rs b/src/features/admin/api_version/service/mod.rs new file mode 100644 index 00000000..54ea247a --- /dev/null +++ b/src/features/admin/api_version/service/mod.rs @@ -0,0 +1,19 @@ +//! Contains the service definitions for the API version functionality + +use axum::{routing::get, Router}; + +mod rest; +use rest::get_api_version; + +/// The route we want to service +const ROUTE: &str = "/admin/api-version"; + +/// Essentially a builder for the API route registration +pub struct ApiVersionService; + +impl ApiVersionService { + /// Registers the route with axum + pub fn register_axum_route(self) -> Router { + Router::new().route(ROUTE, get(get_api_version)) + } +} diff --git a/src/features/admin/api_version/service/rest.rs b/src/features/admin/api_version/service/rest.rs new file mode 100644 index 00000000..1d3a9fe4 --- /dev/null +++ b/src/features/admin/api_version/service/rest.rs @@ -0,0 +1,12 @@ +//! The interface between the outside world + +use std::convert::Infallible; + +use super::super::{interface::ApiVersionResponse, ApiVersion}; + +use axum::extract; + +/// Converts the API version into the proper representation for use with business-logic methods +pub async fn get_api_version() -> Result, Infallible> { + Ok(ApiVersionResponse(ApiVersion::build_info()).into()) +} diff --git a/src/features/admin/log_level/interface.rs b/src/features/admin/log_level/interface.rs new file mode 100644 index 00000000..ddfa6c5b --- /dev/null +++ b/src/features/admin/log_level/interface.rs @@ -0,0 +1,25 @@ +//! The definitions for the interface between us and the outside world + +use log::Level; +use serde::{Deserialize, Serialize}; + +/// The request for setting the log level +#[derive(Copy, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SetLogLevelRequest { + /// The current log level, [`tracing`] doesn't implement [`serde`] traits so + // we convert between the two internally. + pub level: Level, +} + +/// The response for setting the log level, essentially nothing but an Ack +#[derive(Copy, Clone, Serialize)] +pub struct SetLogLevelResponse; + +/// Returns the log level to the caller +#[derive(Copy, Clone, Serialize, Deserialize)] +pub struct GetLogLevelResponse { + /// The current log level, [`tracing`] doesn't implement [`serde`] traits so + // we convert between the two internally. + pub level: Level, +} diff --git a/src/features/admin/log_level/mod.rs b/src/features/admin/log_level/mod.rs new file mode 100644 index 00000000..55aceb98 --- /dev/null +++ b/src/features/admin/log_level/mod.rs @@ -0,0 +1,26 @@ +//! Contains API endpoints for manipulating the log level + +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{reload::Handle, Registry}; + +pub mod interface; +pub mod service; + +/// Replaces the current app's global log level with the given level filter. +pub fn set_log_level(reload_handle: &Handle, level: LevelFilter) { + reload_handle + .modify(|layer| *layer = level) + .expect("setting global log level not working"); + + tracing::info!("log level changed to \"{}\"", level); +} + +/// Retrieves the current log level from the application +pub fn get_log_level(reload_handle: &Handle) -> LevelFilter { + let mut level = None; + reload_handle + .modify(|layer| level = Some(*layer)) + .expect("getting global log level not working"); + + level.unwrap() +} diff --git a/src/features/admin/log_level/service/mod.rs b/src/features/admin/log_level/service/mod.rs new file mode 100644 index 00000000..4a2c6cc8 --- /dev/null +++ b/src/features/admin/log_level/service/mod.rs @@ -0,0 +1,26 @@ +//! Contains the service definitions for the log level functionality + +use axum::{ + routing::{get, post}, + Router, +}; + +mod rest; +use rest::set_log_level; + +use self::rest::get_log_level; + +/// The route we want to service +const ROUTE: &str = "/admin/log-level"; + +/// Essentially a builder for registering the log level +pub struct LogLevelService; + +impl LogLevelService { + /// Registers the route with axum + pub fn register_axum_route(self) -> Router { + Router::new() + .route(ROUTE, post(set_log_level)) + .route(ROUTE, get(get_log_level)) + } +} diff --git a/src/features/admin/log_level/service/rest.rs b/src/features/admin/log_level/service/rest.rs new file mode 100644 index 00000000..a45237a8 --- /dev/null +++ b/src/features/admin/log_level/service/rest.rs @@ -0,0 +1,41 @@ +//! The interface between the outside world + +use std::{convert::Infallible, str::FromStr}; + +use crate::app::AppContext; + +use super::super::interface::{GetLogLevelResponse, SetLogLevelRequest, SetLogLevelResponse}; + +use axum::extract; +use log::Level; +use tracing::level_filters::LevelFilter; + +/// Converts the log level into the proper representation for use with business-logic methods +pub async fn set_log_level( + extract::Extension(app_context): extract::Extension, + extract::Json(req): extract::Json, +) -> Result, Infallible> { + let level = match req.level { + log::Level::Error => LevelFilter::ERROR, + log::Level::Warn => LevelFilter::WARN, + log::Level::Info => LevelFilter::INFO, + log::Level::Debug => LevelFilter::DEBUG, + log::Level::Trace => LevelFilter::TRACE, + }; + + super::super::set_log_level(app_context.infrastructure().log_reload_handle, level); + + Ok(SetLogLevelResponse.into()) +} + +/// Retrieves the log level, converting it into the proper response for [`axum`]. +pub async fn get_log_level( + extract::Extension(app_context): extract::Extension, +) -> Result, Infallible> { + let level = super::super::get_log_level(app_context.infrastructure().log_reload_handle); + + Ok(GetLogLevelResponse { + level: Level::from_str(level.into_level().unwrap().as_str()).unwrap(), + } + .into()) +} diff --git a/src/features/admin/mod.rs b/src/features/admin/mod.rs new file mode 100644 index 00000000..4f123fa9 --- /dev/null +++ b/src/features/admin/mod.rs @@ -0,0 +1,4 @@ +//! Contains API endpoints for administrative features + +pub mod api_version; +pub mod log_level; diff --git a/src/features/chart/mod.rs b/src/features/chart/mod.rs index 50f60b6a..a2be5964 100644 --- a/src/features/chart/mod.rs +++ b/src/features/chart/mod.rs @@ -1,9 +1,10 @@ //! Contains various feature implementations for charting snap ratings. pub mod entities; -pub mod interface; -pub mod service; + +pub use service::ChartService; mod errors; mod infrastructure; +mod service; mod use_cases; diff --git a/src/features/chart/service.rs b/src/features/chart/service.rs deleted file mode 100644 index e5aaac99..00000000 --- a/src/features/chart/service.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Definitions and utilities for building the [`ChartService`] for using the [`Chart`] feature. -//! -//! [`Chart`]: crate::features::chart::entities::Chart - -use crate::features::pb::chart::chart_server::ChartServer; - -/// An empty struct denoting that allows the building of a [`ChartServer`]. -#[derive(Debug, Default)] -pub struct ChartService; - -/// Creates a [`ChartServer`] with default parameters from a [`ChartService`]. -pub fn build_service() -> ChartServer { - let service = ChartService; - ChartServer::new(service) -} diff --git a/src/features/chart/interface.rs b/src/features/chart/service/grpc.rs similarity index 95% rename from src/features/chart/interface.rs rename to src/features/chart/service/grpc.rs index c34d9b07..dd2701bb 100644 --- a/src/features/chart/interface.rs +++ b/src/features/chart/service/grpc.rs @@ -1,4 +1,4 @@ -//! Contains trait implementations for the chart feature. +//! Contains GRPC definitions for the chart feature, which returns the top snaps in a given category. use crate::{ app::AppContext, features::{ diff --git a/src/features/chart/service/mod.rs b/src/features/chart/service/mod.rs new file mode 100644 index 00000000..70537b8a --- /dev/null +++ b/src/features/chart/service/mod.rs @@ -0,0 +1,27 @@ +//! Definitions and utilities for building the [`ChartService`] for using the [`Chart`] feature. +//! +//! [`Chart`]: crate::features::chart::entities::Chart + +use crate::features::pb::chart::chart_server::ChartServer; + +mod grpc; + +/// An empty struct denoting that allows the building of a [`ChartServer`]. +#[derive(Copy, Clone, Debug, Default)] +pub struct ChartService; + +impl ChartService { + /// The paths which are accessible without authentication, if any + pub const PUBLIC_PATHS: [&'static str; 0] = []; + + /// Converts this service into its corresponding server + pub fn to_server(self) -> ChartServer { + self.into() + } +} + +impl From for ChartServer { + fn from(value: ChartService) -> Self { + ChartServer::new(value) + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index ec0fc264..66469f94 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,5 +1,6 @@ //! Contains various feature implementations for the ratings backend. +pub mod admin; pub mod chart; pub mod common; pub mod pb; diff --git a/src/features/rating/mod.rs b/src/features/rating/mod.rs index 6bef6bc6..08efcea1 100644 --- a/src/features/rating/mod.rs +++ b/src/features/rating/mod.rs @@ -2,9 +2,9 @@ //! //! [`AppService`]: service::AppService -pub mod interface; -pub mod service; +pub use service::RatingService; mod errors; mod infrastructure; +mod service; mod use_cases; diff --git a/src/features/rating/service.rs b/src/features/rating/service.rs deleted file mode 100644 index 28f84991..00000000 --- a/src/features/rating/service.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Contains generation and definitions for the [`AppService`] -use crate::features::pb::app::app_server::AppServer; - -/// The general service governing retrieving ratings for the store app. -#[derive(Debug, Default)] -pub struct AppService; - -/// Builds a new [`AppServer`] using the given [`AppService`] with default parameters. -pub fn build_service() -> AppServer { - let service = AppService; - AppServer::new(service) -} diff --git a/src/features/rating/interface.rs b/src/features/rating/service/grpc.rs similarity index 87% rename from src/features/rating/interface.rs rename to src/features/rating/service/grpc.rs index 8c7bb1aa..3742c58e 100644 --- a/src/features/rating/interface.rs +++ b/src/features/rating/service/grpc.rs @@ -1,14 +1,14 @@ -//! Contains trait implementations for [`AppService`] and other app definitions. +//! Contains GRPC definitions for the ratings feature. use crate::app::AppContext; use crate::features::{ pb::app::{app_server::App, GetRatingRequest, GetRatingResponse}, - rating::{service::AppService, use_cases}, + rating::{service::RatingService, use_cases}, }; use tonic::{Request, Response, Status}; #[tonic::async_trait] -impl App for AppService { +impl App for RatingService { #[tracing::instrument(level = "debug")] async fn get_rating( &self, diff --git a/src/features/rating/service/mod.rs b/src/features/rating/service/mod.rs new file mode 100644 index 00000000..b815e308 --- /dev/null +++ b/src/features/rating/service/mod.rs @@ -0,0 +1,24 @@ +//! Contains generation and definitions for the [`AppService`] +use crate::features::pb::app::app_server::AppServer; + +mod grpc; + +/// The general service governing retrieving ratings for the store app. +#[derive(Copy, Clone, Debug, Default)] +pub struct RatingService; + +impl RatingService { + /// The paths which are accessible without authentication, if any + pub const PUBLIC_PATHS: [&'static str; 0] = []; + + /// Converts this service into its corresponding server + pub fn to_server(self) -> AppServer { + self.into() + } +} + +impl From for AppServer { + fn from(value: RatingService) -> Self { + AppServer::new(value) + } +} diff --git a/src/features/user/mod.rs b/src/features/user/mod.rs index 953e52a3..3096e3a8 100644 --- a/src/features/user/mod.rs +++ b/src/features/user/mod.rs @@ -1,9 +1,10 @@ //! Contains various feature implementations for autenticating users and registering their snap votes. pub mod entities; -pub mod interface; -pub mod service; + +pub use service::UserService; mod errors; mod infrastructure; +mod service; mod use_cases; diff --git a/src/features/user/service.rs b/src/features/user/service.rs deleted file mode 100644 index e04c4144..00000000 --- a/src/features/user/service.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Definitions and utilities for building the [`UserService`] for handling [`User`] data. -//! -//! [`User`]: crate::features::user::entities::User -use crate::features::pb::user::user_server::UserServer; - -/// An empty struct used to construct a [`UserServer`] -#[derive(Debug, Default)] -pub struct UserService; - -/// Builds a new [`UserServer`] with default parameters. -pub fn build_service() -> UserServer { - let service = UserService; - UserServer::new(service) -} diff --git a/src/features/user/interface.rs b/src/features/user/service/grpc.rs similarity index 97% rename from src/features/user/interface.rs rename to src/features/user/service/grpc.rs index 2ee95f8f..419681b0 100644 --- a/src/features/user/interface.rs +++ b/src/features/user/service/grpc.rs @@ -1,4 +1,4 @@ -//! Trait impls for a given [`User`] +//! Contains GRPC definitions for the user authentication and voting features. use time::OffsetDateTime; use tonic::{Request, Response, Status}; @@ -35,7 +35,7 @@ impl User for UserService { match use_cases::authenticate(&app_ctx, &id).await { Ok(user) => app_ctx .infrastructure() - .jwt + .jwt_encoder .encode(user.client_hash) .map(|token| AuthenticateResponse { token }) .map(Response::new) diff --git a/src/features/user/service/mod.rs b/src/features/user/service/mod.rs new file mode 100644 index 00000000..091c1885 --- /dev/null +++ b/src/features/user/service/mod.rs @@ -0,0 +1,29 @@ +//! Definitions and utilities for building the [`UserService`] for handling [`User`] data. +//! +//! [`User`]: crate::features::user::entities::User +use crate::features::pb::user::user_server::UserServer; + +mod grpc; + +/// An empty struct used to construct a [`UserServer`] +#[derive(Copy, Clone, Debug, Default)] +pub struct UserService; + +impl UserService { + /// The paths which are accessible without authentication, if any + pub const PUBLIC_PATHS: [&'static str; 2] = [ + "ratings.features.user.User/Register", + "ratings.features.user.User/Authenticate", + ]; + + /// Converts this service into its corresponding server + pub fn to_server(self) -> UserServer { + self.into() + } +} + +impl From for UserServer { + fn from(value: UserService) -> Self { + UserServer::new(value) + } +} diff --git a/src/main.rs b/src/main.rs index 8b836516..23c344de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -use tracing_subscriber::EnvFilter; - mod app; mod features; mod utils; @@ -8,18 +6,6 @@ mod utils; async fn main() -> Result<(), Box> { let config = utils::Config::load()?; - let app_name = config.name.as_str(); - let app_log_level = config.log_level.as_str(); - let app_logging_directive = format!("{app_name}={app_log_level}").parse()?; - let max_level = EnvFilter::from_default_env().add_directive(app_logging_directive); - - tracing_subscriber::fmt() - .with_env_filter(max_level) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) - .init(); - - let name = &config.name; - tracing::info!("Starting the {name} service"); app::run(config).await?; Ok(()) diff --git a/src/utils/config.rs b/src/utils/config.rs index b148d775..0345447a 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,5 +1,6 @@ //! Utility functions and definitions for configuring the service. use dotenvy::dotenv; +use secrecy::SecretString; use serde::Deserialize; /// Configuration for the general app center ratings backend service. @@ -10,7 +11,7 @@ pub struct Config { /// The host configuration pub host: String, /// The JWT secret value - pub jwt_secret: String, + pub jwt_secret: SecretString, /// Log level to use pub log_level: String, /// The service name diff --git a/src/utils/infrastructure.rs b/src/utils/infrastructure.rs index 73a38416..e76c7d46 100644 --- a/src/utils/infrastructure.rs +++ b/src/utils/infrastructure.rs @@ -7,8 +7,16 @@ use std::{ use snapd::SnapdClient; use sqlx::{pool::PoolConnection, postgres::PgPoolOptions, PgPool, Postgres}; +use tokio::sync::OnceCell; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{reload::Handle, Registry}; -use crate::utils::{config::Config, jwt::Jwt}; +use super::{jwt::JwtEncoder, log_util}; +use crate::utils::config::Config; + +/// The global reload handle, since [`tracing_subscriber`] is we have to be too because it panics +/// if you call init twice, which makes it so tests can't initialize [`Infrastructure`] more than once. +static RELOAD_HANDLE: tokio::sync::OnceCell> = OnceCell::const_new(); /// Resources important to the server, but are not necessarily in-memory #[derive(Clone)] @@ -17,8 +25,10 @@ pub struct Infrastructure { pub postgres: Arc, /// The client for making snapd requests pub snapd_client: SnapdClient, - /// The JWT instance - pub jwt: Arc, + /// The reload handle for the logger + pub log_reload_handle: &'static Handle, + /// The utility which lets us encode user tokens with our JWT credentials + pub jwt_encoder: Arc, } impl Infrastructure { @@ -30,13 +40,18 @@ impl Infrastructure { let postgres = PgPoolOptions::new().max_connections(5).connect(uri).await?; let postgres = Arc::new(postgres); - let jwt = Jwt::new(&config.jwt_secret)?; - let jwt = Arc::new(jwt); + let jwt_encoder = JwtEncoder::from_config(config)?; + let jwt_encoder = Arc::new(jwt_encoder); + + let reload_handle = RELOAD_HANDLE + .get_or_try_init(|| async move { log_util::init_logging(&config.log_level) }) + .await?; Ok(Infrastructure { postgres, - jwt, + jwt_encoder, snapd_client: Default::default(), + log_reload_handle: reload_handle, }) } diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index bdcc8a9e..cd816548 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -1,26 +1,17 @@ -//! JSON Web Tokens infrastructure and utlities. -use std::ops::Add; +//! Definitions meant to help with JWT handling throughout the app. -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{EncodingKey, Header}; +use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use thiserror::Error; use time::{Duration, OffsetDateTime}; use tracing::error; +use super::Config; + /// How many days until JWT info expires static JWT_EXPIRY_IN_DAYS: i64 = 1; -/// An error for things that can go wrong with JWT handling -#[derive(Error, Debug)] -pub enum JwtError { - /// The shape of the data is invalid - #[error("jwt: invalid shape")] - InvalidShape, - /// Anything else that can go wrong - #[error("jwt: unknown error")] - Unknown, -} - /// Information representating a claim on a specific subject at a specific time #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { @@ -33,50 +24,48 @@ pub struct Claims { impl Claims { /// Creates a new claim with the current datetime for the subject given by `sub`. pub fn new(sub: String) -> Self { - let exp = OffsetDateTime::now_utc().add(Duration::days(JWT_EXPIRY_IN_DAYS)); + let exp = OffsetDateTime::now_utc() + Duration::days(JWT_EXPIRY_IN_DAYS); let exp = exp.unix_timestamp() as usize; Self { sub, exp } } } -/// A JWT transaction representation -pub struct Jwt { +/// Errors that can happen while encoding and signing tokens with JWT. +#[derive(Error, Debug)] +#[allow(missing_docs)] +pub enum JwtEncoderError { + #[error("jwt: error decoding secret: {0}")] + DecodeSecretError(#[from] jsonwebtoken::errors::Error), + #[error("jwt: an error occurred, but the reason was erased for security reasons")] + Erased, +} + +/// An encoder which allows converting user hashes into valid JWT tokens +pub struct JwtEncoder { /// An encoding key for transfer encoding_key: EncodingKey, - /// A decoding key for receipt - decoding_key: DecodingKey, } -impl Jwt { - /// Attempts to create a new JWT representation from a given secret - pub fn new(secret: &str) -> Result { - let encoding_key = EncodingKey::from_base64_secret(secret)?; - let decoding_key = DecodingKey::from_base64_secret(secret)?; +impl JwtEncoder { + /// Loads the encoder from the given JWT secret + pub fn from_secret(secret: &SecretString) -> Result { + let encoding_key = EncodingKey::from_base64_secret(secret.expose_secret())?; + Ok(Self { encoding_key }) + } - Ok(Self { - encoding_key, - decoding_key, - }) + /// Loads our encoder from the secret enclosed in [`Config`] + pub fn from_config(config: &Config) -> Result { + Self::from_secret(&config.jwt_secret) } /// Encodes a token for use - pub fn encode(&self, sub: String) -> Result { + pub fn encode(&self, sub: String) -> Result { let claims = Claims::new(sub); jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| { - error!("{e:?}"); - JwtError::Unknown + error!("{e}"); + JwtEncoderError::Erased }) } - - /// Decodes a given token received - pub fn decode(&self, token: &str) -> Result { - jsonwebtoken::decode::(token, &self.decoding_key, &Validation::default()) - .map(|t| t.claims) - .map_err(|e| { - error!("{e:?}"); - JwtError::InvalidShape - }) - } } diff --git a/src/utils/log_util.rs b/src/utils/log_util.rs new file mode 100644 index 00000000..8b62d82e --- /dev/null +++ b/src/utils/log_util.rs @@ -0,0 +1,22 @@ +//! Allows us to initialize and manipulate the logging framework within our infrastructure + +use std::{error::Error, str::FromStr}; + +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{layer::SubscriberExt, prelude::*, reload::Handle, Registry}; + +/// Initializes logging app-wide, generating a reload handle for us to use later +pub fn init_logging(log_level: &str) -> Result, Box> { + let (filter, reload_handle) = + tracing_subscriber::reload::Layer::new(LevelFilter::from_str(log_level)?); + + tracing_subscriber::registry() + .with(filter) + .with( + tracing_subscriber::fmt::layer() + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE), + ) + .init(); + + Ok(reload_handle) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b6ca5d5b..6dd687aa 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -7,4 +7,5 @@ pub use migrator::Migrator; pub mod config; pub mod infrastructure; pub mod jwt; +pub mod log_util; pub mod migrator; diff --git a/tests/api_info.rs b/tests/api_info.rs new file mode 100644 index 00000000..d55cb5b7 --- /dev/null +++ b/tests/api_info.rs @@ -0,0 +1,73 @@ +use cucumber::{given, then, when, World}; +use lazy_static::lazy_static; +use ratings::{features::admin::api_version::ApiVersion, utils::Config}; +use regex::Regex; + +use helpers::client::*; + +mod helpers; + +#[derive(Clone, Debug, World)] +#[world(init = Self::new)] +struct LogWorld { + client: TestClient, + returned_info: Option>, +} + +impl LogWorld { + fn new() -> Self { + let config = Config::load().expect("could not load config"); + let client = TestClient::new(config.socket()); + Self { + client, + returned_info: None, + } + } +} + +#[given(expr = "Big doesn't know the API build info")] +fn unknown_level(world: &mut LogWorld) { + world.returned_info = None +} + +#[when(expr = "Big asks for the API info")] +async fn get_api_info(world: &mut LogWorld) { + world.returned_info = Some( + world + .client + .get_api_info() + .await + .expect("could not get API info") + .0, + ) +} + +lazy_static! { + static ref VALID_SHA: Regex = Regex::new(r"/^([a-f0-9]{64})$/").unwrap(); + static ref VALID_SEMVER: Regex = Regex::new(r"((\d+).?){0,3}").unwrap(); +} + +#[then(expr = "Big gets an answer")] +fn got_info(world: &mut LogWorld) { + assert!( + world.returned_info.is_some(), + "did not get a valid level from the endpoint" + ); + + let info = world.returned_info.as_ref().unwrap(); + VALID_SHA.is_match(&info.commit); + VALID_SEMVER.is_match(&info.version); + // The regex for a valid git branch is too absurd to even bother testing, + // and also may not even be present (e.g. build from a detached HEAD). +} + +#[tokio::main] +async fn main() { + dotenvy::from_filename(".env_files/test.env").ok(); + + LogWorld::cucumber() + .repeat_skipped() + .max_concurrent_scenarios(1) + .run_and_exit("tests/features/admin/api-info.feature") + .await +} diff --git a/tests/authentication.rs b/tests/authentication.rs index 8310ee79..c4a44d6e 100644 --- a/tests/authentication.rs +++ b/tests/authentication.rs @@ -63,10 +63,8 @@ fn verify_token(world: &mut AuthenticationWorld) { world.auth_error ); - let config = Config::load().expect("Could not load config"); - for token in world.tokens.iter() { - helpers::assert::assert_token_is_valid(token, &config.jwt_secret); + helpers::assert::assert_token_is_valid(token); } } @@ -98,7 +96,6 @@ async fn main() { AuthenticationWorld::cucumber() .repeat_skipped() - .init_tracing() .run_and_exit("tests/features/user/authentication.feature") .await } diff --git a/tests/chart.rs b/tests/chart.rs index df444395..3496cc02 100644 --- a/tests/chart.rs +++ b/tests/chart.rs @@ -245,7 +245,6 @@ async fn main() { ChartWorld::cucumber() .before(|_, _, _, _| clear_db().boxed_local()) .repeat_failed() - .init_tracing() .max_concurrent_scenarios(1) .run_and_exit("tests/features/chart.feature") .await diff --git a/tests/features/admin/api-info.feature b/tests/features/admin/api-info.feature new file mode 100644 index 00000000..28505428 --- /dev/null +++ b/tests/features/admin/api-info.feature @@ -0,0 +1,7 @@ +Feature: Can retrieve API version info via REST requests + + Scenario: Big wants to find out the build information for the service + Given Big doesn't know the API build info + When Big asks for the API info + Then Big gets an answer + diff --git a/tests/features/admin/log-level.feature b/tests/features/admin/log-level.feature new file mode 100644 index 00000000..7f5b597a --- /dev/null +++ b/tests/features/admin/log-level.feature @@ -0,0 +1,19 @@ +Feature: Can retrieve and set the internal log level via the REST endpoint + + Scenario: Espio wants to find out the current log level + Given Espio doesn't know the log level + When Espio asks for the log level + Then Espio gets an answer + + Scenario Outline: Espio wants to set the log level + Given the service's current log level + When Espio requests it changes to + Then the log level is set to + + Examples: + | level | + | error | + | info | + | debug | + | warn | + | trace | diff --git a/tests/helpers/assert.rs b/tests/helpers/assert.rs index 78fcc791..00d95db1 100644 --- a/tests/helpers/assert.rs +++ b/tests/helpers/assert.rs @@ -1,8 +1,8 @@ -use ratings::utils::jwt::Jwt; +use ratings::app::interfaces::authentication::jwt::JwtVerifier; #[allow(dead_code)] -pub fn assert_token_is_valid(value: &str, jwt_secret: &str) { - let jwt = Jwt::new(jwt_secret); +pub fn assert_token_is_valid(value: &str) { + let jwt = JwtVerifier::from_env(); assert!( jwt.unwrap().decode(value).is_ok(), "value should be a valid jwt" @@ -10,7 +10,7 @@ pub fn assert_token_is_valid(value: &str, jwt_secret: &str) { } #[allow(dead_code)] -pub fn assert_token_is_not_valid(value: &str, jwt_secret: &str) { - let jwt = Jwt::new(jwt_secret); +pub fn assert_token_is_not_valid(value: &str) { + let jwt = JwtVerifier::from_env(); assert!(jwt.unwrap().decode(value).is_err(), "expected invalid jwt"); } diff --git a/tests/helpers/client/api_info.rs b/tests/helpers/client/api_info.rs new file mode 100644 index 00000000..57c3280c --- /dev/null +++ b/tests/helpers/client/api_info.rs @@ -0,0 +1,40 @@ +use std::str::FromStr; + +use axum::async_trait; +use ratings::{ + app::interfaces::authentication::admin::AdminAuthConfig, + features::admin::api_version::interface::ApiVersionResponse, +}; +use reqwest::Url; +use secrecy::ExposeSecret; + +use super::Client; + +#[async_trait] +pub trait ApiInfoClient: Client { + fn rest_url(&self) -> Url { + Url::from_str(self.url()) + .unwrap() + .join("/v1/admin/api-version") + .unwrap() + } + + async fn get_api_info( + &self, + ) -> Result> { + let (un, pass) = AdminAuthConfig::from_env() + .expect("could not decode admin secrets from env") + .into_inner(); + + let text_response = reqwest::Client::new() + .get(self.rest_url()) + .basic_auth(un.expose_secret(), Some(pass.expose_secret())) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(serde_json::from_str(&text_response)?) + } +} diff --git a/tests/helpers/client/log_level.rs b/tests/helpers/client/log_level.rs new file mode 100644 index 00000000..3ad26e0d --- /dev/null +++ b/tests/helpers/client/log_level.rs @@ -0,0 +1,61 @@ +use std::str::FromStr; + +use axum::async_trait; +use log::Level; +use ratings::{ + app::interfaces::authentication::admin::AdminAuthConfig, + features::admin::log_level::interface::{GetLogLevelResponse, SetLogLevelRequest}, +}; +use reqwest::Url; +use secrecy::ExposeSecret; + +use super::Client; + +#[async_trait] +pub trait LogClient: Client { + fn rest_url(&self) -> Url { + Url::from_str(self.url()) + .unwrap() + .join("/v1/admin/log-level") + .unwrap() + } + + async fn get_log_level( + &self, + ) -> Result> { + let (un, pass) = AdminAuthConfig::from_env() + .expect("could not decode admin secrets from env") + .into_inner(); + + let text_response = reqwest::Client::new() + .get(self.rest_url()) + .header("Content-Type", "application/json") + .basic_auth(un.expose_secret(), Some(pass.expose_secret())) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(serde_json::from_str(&text_response)?) + } + + async fn set_log_level( + &self, + level: Level, + ) -> Result<(), Box> { + let (un, pass) = AdminAuthConfig::from_env() + .expect("could not decode admin secrets from env") + .into_inner(); + reqwest::Client::new() + .post(self.rest_url()) + .header("Content-Type", "application/json") + .basic_auth(un.expose_secret(), Some(pass.expose_secret())) + .body(serde_json::to_string(&SetLogLevelRequest { level }).unwrap()) + .send() + .await? + .error_for_status_ref()?; + + Ok(()) + } +} diff --git a/tests/helpers/client/mod.rs b/tests/helpers/client/mod.rs index bd9ee928..6d5dbe84 100644 --- a/tests/helpers/client/mod.rs +++ b/tests/helpers/client/mod.rs @@ -1,10 +1,15 @@ +pub mod api_info; pub mod app; pub mod chart; +pub mod log_level; pub mod user; use std::fmt::Display; -pub use self::{app::AppClient, chart::ChartClient, user::UserClient}; +pub use self::{ + api_info::ApiInfoClient, app::AppClient, chart::ChartClient, log_level::LogClient, + user::UserClient, +}; pub trait Client { fn url(&self) -> &str; @@ -33,3 +38,5 @@ impl Client for TestClient { impl AppClient for TestClient {} impl ChartClient for TestClient {} impl UserClient for TestClient {} +impl LogClient for TestClient {} +impl ApiInfoClient for TestClient {} diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 7db13580..54fe8144 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -1,4 +1,5 @@ #![allow(dead_code)] +#![cfg(test)] pub mod assert; pub mod client; diff --git a/tests/log_level.rs b/tests/log_level.rs new file mode 100644 index 00000000..0b8b9457 --- /dev/null +++ b/tests/log_level.rs @@ -0,0 +1,109 @@ +use std::str::FromStr; + +use cucumber::{given, then, when, Parameter, World}; + +use helpers::client::*; +use ratings::utils::Config; + +mod helpers; + +#[derive(Copy, Clone, Eq, PartialEq, Parameter, Debug)] +#[param(name = "level", regex = "info|warn|debug|trace|error")] +pub struct Level(log::Level); + +impl FromStr for Level { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Ok(Level(log::Level::from_str(s)?)) + } +} + +impl From for Level { + fn from(value: log::Level) -> Self { + Self(value) + } +} + +impl From for log::Level { + fn from(value: Level) -> Self { + value.0 + } +} + +#[derive(Clone, Debug, World)] +#[world(init = Self::new)] +struct LogWorld { + client: TestClient, + current_level: Option, +} + +impl LogWorld { + fn new() -> Self { + let config = Config::load().expect("could not load config"); + let client = TestClient::new(config.socket()); + Self { + client, + current_level: None, + } + } +} + +#[given(expr = "Espio doesn't know the log level")] +fn unknown_level(world: &mut LogWorld) { + world.current_level = None +} + +#[when(expr = "Espio asks for the log level")] +#[given(expr = "the service's current log level")] +async fn get_log_level(world: &mut LogWorld) { + world.current_level = Some( + world + .client + .get_log_level() + .await + .expect("could not get log level") + .level + .into(), + ) +} + +#[when(expr = "Espio requests it changes to {level}")] +async fn set_log_level(world: &mut LogWorld, level: Level) { + world + .client + .set_log_level(level.into()) + .await + .expect("problem setting log level"); +} + +#[then(expr = "Espio gets an answer")] +fn got_any_level(world: &mut LogWorld) { + assert!( + world.current_level.is_some(), + "did not get a valid level from the endpoint" + ); +} + +#[then(expr = "the log level is set to {level}")] +async fn got_expected_level(world: &mut LogWorld, level: Level) { + let post_set_level = world + .client + .get_log_level() + .await + .expect("could not get log level") + .level; + + assert_eq!(level.0, post_set_level) +} + +#[tokio::main] +async fn main() { + dotenvy::from_filename(".env_files/test.env").ok(); + + LogWorld::cucumber() + .repeat_skipped() + .max_concurrent_scenarios(1) + .run_and_exit("tests/features/admin/log-level.feature") + .await +} diff --git a/tests/voting.rs b/tests/voting.rs index 725cd2e0..a9c58d66 100644 --- a/tests/voting.rs +++ b/tests/voting.rs @@ -257,7 +257,6 @@ async fn main() { VotingWorld::cucumber() .repeat_skipped() - .init_tracing() .run_and_exit("tests/features/user/voting.feature") .await }