diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3dbbcf3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/.github/workflows/bypass-rust-tests.yml b/.github/workflows/bypass-rust-tests.yml index 6afd205..cd4821b 100644 --- a/.github/workflows/bypass-rust-tests.yml +++ b/.github/workflows/bypass-rust-tests.yml @@ -9,6 +9,9 @@ on: - Cargo.toml - Cargo.lock - .github/workflows/run-rust-tests.yml + branches: + - production + - staging workflow_dispatch: jobs: diff --git a/.github/workflows/run-rust-tests.yml b/.github/workflows/run-rust-tests.yml index 33386f7..63a1917 100644 --- a/.github/workflows/run-rust-tests.yml +++ b/.github/workflows/run-rust-tests.yml @@ -6,12 +6,18 @@ on: - Cargo.toml - Cargo.lock - .github/workflows/run-rust-tests.yml + branches: + - production + - staging pull_request: paths: - src/** - Cargo.toml - Cargo.lock - .github/workflows/run-rust-tests.yml + branches: + - production + - staging workflow_dispatch: jobs: @@ -22,4 +28,10 @@ jobs: steps: - uses: actions/checkout@v6.0.0 - uses: actions-rust-lang/setup-rust-toolchain@v1 - - run: RUSTFLAGS="-Awarnings" cargo test --all-features \ No newline at end of file + - run: mkdir secrets + - run: openssl genrsa -out secrets/jwt-private-key.pem 2048 + - run: openssl rsa -in secrets/jwt-private-key.pem -pubout -out ./secrets/jwt-public-key.pem + - run: RUSTFLAGS="-Awarnings" cargo test --all-features + env: + JWT_PUBLIC_KEY_PATH: ./secrets/jwt-public-key.pem + JWT_PRIVATE_KEY_PATH: ./secrets/jwt-private-key.pem \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 1728046..8a0cdc6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { - "rust-analyzer.linkedProjects": [ - "./Cargo.toml", - ], - "editor.tabSize": 2 + "rust-analyzer.linkedProjects": [ + "./Cargo.toml", + ], + "editor.tabSize": 2 } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e16af5f..9bbc0ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -139,6 +140,74 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "axum-test" +version = "18.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -151,6 +220,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bitflags" version = "2.10.0" @@ -261,6 +336,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.47" @@ -284,8 +365,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -298,6 +381,33 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -323,6 +433,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -333,6 +455,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "darling" version = "0.21.3" @@ -368,6 +517,54 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "serde", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.16", + "serde", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -378,6 +575,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -385,6 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -423,18 +627,106 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -456,12 +748,62 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "expect-json" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "fallible-iterator" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.26" @@ -486,6 +828,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -592,6 +949,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -601,8 +959,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -617,6 +977,17 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.12" @@ -654,12 +1025,27 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -791,12 +1177,29 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -804,12 +1207,16 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -982,6 +1389,31 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1007,12 +1439,50 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.10" @@ -1102,6 +1572,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "neli" version = "0.6.5" @@ -1127,6 +1614,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ntest" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb183f0a1da7a937f672e5ee7b7edb727bf52b8a52d531374ba8ebb9345c0330" +dependencies = [ + "ntest_test_cases", + "ntest_timeout", +] + +[[package]] +name = "ntest_test_cases" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d0d3f2a488592e5368ebbe996e7f1d44aa13156efad201f5b4d84e150eaa93" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ntest_timeout" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc7c92f190c97f79b4a332f5e81dcf68c8420af2045c936c9be0bc9de6f63b5" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num" version = "0.4.3" @@ -1151,6 +1671,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1204,6 +1740,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -1212,12 +1759,74 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1266,6 +1875,25 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1374,6 +2002,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1431,6 +2086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef4605b7c057056dd35baeb6ac0c0338e4975b1f2bef0f65da953285eb007095" dependencies = [ "bytes", + "chrono", "fallible-iterator", "postgres-derive", "postgres-protocol", @@ -1461,6 +2117,34 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1523,6 +2207,8 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1532,10 +2218,20 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.3", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1551,6 +2247,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "rand_core" @@ -1619,6 +2318,65 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1633,12 +2391,56 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror", +] + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1676,7 +2478,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -1759,6 +2561,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -1766,7 +2595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1782,6 +2611,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1916,6 +2751,28 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -1932,20 +2789,31 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" name = "slashstep_server" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "axum-extra", + "axum-macros", + "axum-test", + "chrono", "colored", + "deadpool-postgres", "dotenvy", + "jsonwebtoken", "local-ip-address", + "ntest", "pg_escape", "postgres", "postgres-types", "regex", + "reqwest", "serde", "serde_json", "strum", "testcontainers", "testcontainers-modules", + "thiserror", "tokio", + "tower", "uuid", ] @@ -1965,6 +2833,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2065,6 +2949,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2077,6 +2964,40 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "testcontainers" version = "0.25.2" @@ -2219,6 +3140,16 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.15" @@ -2279,6 +3210,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + [[package]] name = "tonic" version = "0.14.2" @@ -2338,6 +3299,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2388,12 +3367,42 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "ulid" version = "1.2.1" @@ -2497,9 +3506,16 @@ checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.4", "js-sys", + "serde", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2549,6 +3565,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.105" @@ -2684,6 +3713,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2867,6 +3907,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -2889,6 +3938,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 2a5071c..61c805c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,20 +11,31 @@ keywords = ["project management", "problem management", "issue tracking"] publish = false [dependencies] -axum = "0.8.7" +axum = { version = "0.8.7", features = ["macros"] } +chrono = "0.4.42" colored = "3.0.0" +deadpool-postgres = { version = "0.14.1", features = ["serde"] } dotenvy = "0.15.7" local-ip-address = "0.6.5" pg_escape = "0.1.1" -postgres = { version = "0.19.12", features = ["with-uuid-1"] } +postgres = { version = "0.19.12", features = ["with-uuid-1", "with-chrono-0_4"] } postgres-types = { version = "0.2.11", features = ["derive"] } regex = "1.12.2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" strum = { version = "0.27.2", features = ["derive"] } +thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["full"] } -uuid = { version = "1.18.1", features = ["v7"] } +uuid = { version = "1.18.1", features = ["v7", "serde"] } +reqwest = { version = "0.12.24", features = ["json"] } +tower = "0.5.2" +ntest = "0.9.3" +axum-extra = { version = "0.12.2", features = ["cookie"] } +jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } +anyhow = "1.0.100" +axum-macros = "0.5.0" [dev-dependencies] testcontainers = { version = "0.25.2", features = ["blocking"] } testcontainers-modules = { version = "0.13.0", features = ["postgres"] } +axum-test = "18.3.0" diff --git a/Containerfile b/Containerfile index 52119fb..525c5cb 100644 --- a/Containerfile +++ b/Containerfile @@ -5,5 +5,10 @@ WORKDIR /usr/src/app COPY ./ ./ RUN cargo build --release +## Expose the port. +ARG APP_PORT="3001" +ENV APP_PORT=${APP_PORT} +EXPOSE ${APP_PORT} + # Set the entrypoint ENTRYPOINT ["/usr/src/app/target/release/slashstep_server"] \ No newline at end of file diff --git a/compose.debug.yaml b/compose.debug.yaml new file mode 100644 index 0000000..e9646db --- /dev/null +++ b/compose.debug.yaml @@ -0,0 +1,6 @@ +services: + slashstepserver: + image: slashstepserver + build: + context: . + dockerfile: ./Containerfile diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e9646db --- /dev/null +++ b/compose.yaml @@ -0,0 +1,6 @@ +services: + slashstepserver: + image: slashstepserver + build: + context: . + dockerfile: ./Containerfile diff --git a/src/errors.rs b/src/errors.rs index 887740c..0936a74 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,4 +1,2 @@ -pub mod resource_already_exists_error; pub mod resource_not_found_error; -pub mod slashstepql_invalid_limit_error; -pub mod not_found_error; \ No newline at end of file +pub mod slashstepql_invalid_limit_error; \ No newline at end of file diff --git a/src/errors/not_found_error.rs b/src/errors/not_found_error.rs deleted file mode 100644 index 5a60554..0000000 --- a/src/errors/not_found_error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use serde::Serialize; - -#[derive(Serialize)] -pub struct NotFoundError { - pub message: String -} - -impl NotFoundError { - - pub fn new(message: Option) -> Self { - - let message = match message { - Some(message) => message, - None => "Not found".to_string() - }; - - NotFoundError { - message - } - } - -} \ No newline at end of file diff --git a/src/errors/resource_already_exists_error.rs b/src/errors/resource_already_exists_error.rs deleted file mode 100644 index d9c651f..0000000 --- a/src/errors/resource_already_exists_error.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::fmt; -use std::error::Error; - -/// An error that occurs when a resource already exists. -#[derive(Debug, Eq, PartialEq)] -pub struct ResourceAlreadyExistsError { - - /// The type of the resource. - pub resource_type: String, - -} - -impl Error for ResourceAlreadyExistsError {} - -impl fmt::Display for ResourceAlreadyExistsError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "That {} already exists.", self.resource_type.to_lowercase()) - } -} \ No newline at end of file diff --git a/src/errors/resource_not_found_error.rs b/src/errors/resource_not_found_error.rs deleted file mode 100644 index 1d865ab..0000000 --- a/src/errors/resource_not_found_error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::fmt; -use std::error::Error; - -/// An error that occurs when a resource does not exist. -#[derive(Debug)] -pub struct ResourceNotFoundError<'a> { - - /// The type of the resource. - pub resource_type: &'a str, - - /// The ID of the resource. - pub resource_id: &'a str - -} - -impl Error for ResourceNotFoundError<'_> {} - -impl fmt::Display for ResourceNotFoundError<'_> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "A resource with the ID \"{}\" does not exist.", self.resource_id) - } -} \ No newline at end of file diff --git a/src/errors/slashstepql_invalid_limit_error.rs b/src/errors/slashstepql_invalid_limit_error.rs deleted file mode 100644 index e167d14..0000000 --- a/src/errors/slashstepql_invalid_limit_error.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::fmt; -use std::error::Error; - -/// An error that occurs when a resource does not exist. -#[derive(Debug)] -pub struct SlashstepQLInvalidLimitError { - - pub limit_string: String, - - pub maximum_limit: Option - -} - -impl Error for SlashstepQLInvalidLimitError {} - -impl fmt::Display for SlashstepQLInvalidLimitError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "Invalid limit \"{}\" in filter query. It must be a non-negative integer{}.", self.limit_string, if let Some(maximum_limit) = self.maximum_limit { format!(" and must be less than or equal to {}", maximum_limit) } else { "".to_string() }) - } -} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 82d0170..4334bd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,29 @@ #![warn(clippy::unwrap_used)] pub mod resources; -pub mod errors; pub mod utilities; +pub mod middleware; mod routes; +mod pre_definitions; +#[cfg(test)] +mod tests; + +use std::{fmt, sync::Arc}; +use axum::{Json, body::Body, response::{IntoResponse, Response}}; +use deadpool_postgres::{Pool, tokio_postgres}; use local_ip_address::local_ip; +use postgres::NoTls; +use reqwest::{StatusCode}; use tokio::net::TcpListener; use colored::Colorize; +use uuid::Uuid; +use thiserror::Error; + +use crate::{pre_definitions::initialize_pre_defined_actions, pre_definitions::initialize_pre_defined_roles, resources::{access_policy::{AccessPolicy, AccessPolicyError}, action::{Action, ActionError}, app::{App, AppError}, app_authorization::{AppAuthorization, AppAuthorizationError}, app_authorization_credential::{AppAuthorizationCredential, AppAuthorizationCredentialError}, app_credential::{AppCredential, AppCredentialError}, group::{Group, GroupError}, http_transaction::{HTTPTransaction, HTTPTransactionError}, item::{Item, ItemError}, milestone::{Milestone, MilestoneError}, project::{Project, ProjectError}, role::{Role, RoleError}, role_memberships::{RoleMembership, RoleMembershipError}, server_log_entry::{ServerLogEntry, ServerLogEntryError}, session::{Session, SessionError}, user::{User, UserError}, workspace::{Workspace, WorkspaceError}}}; const DEFAULT_APP_PORT: i16 = 8080; +const DEFAULT_MAXIMUM_POSTGRES_CONNECTION_COUNT: u32 = 5; fn print_shutdown_message() { @@ -63,28 +77,227 @@ fn get_app_port_string() -> String { } -#[derive(Debug)] -enum AppError { - STDIOError(std::io::Error), - LocalIPAddressError(local_ip_address::Error) +#[derive(Debug, Error)] +pub enum SlashstepServerError { + #[error("Please set a value for the environment variable \"{0}\".")] + EnvironmentVariableNotSet(String), + + #[error(transparent)] + HTTPTransactionError(#[from] HTTPTransactionError), + + #[error(transparent)] + UserError(#[from] UserError), + + #[error(transparent)] + SessionError(#[from] SessionError), + + #[error(transparent)] + GroupError(#[from] GroupError), + + #[error(transparent)] + AppError(#[from] AppError), + + #[error(transparent)] + WorkspaceError(#[from] WorkspaceError), + + #[error(transparent)] + ProjectError(#[from] ProjectError), + + #[error(transparent)] + RoleError(#[from] RoleError), + + #[error(transparent)] + ItemError(#[from] ItemError), + + #[error(transparent)] + ActionError(#[from] ActionError), + + #[error(transparent)] + AppAuthorizationError(#[from] AppAuthorizationError), + + #[error(transparent)] + AppAuthorizationCredentialError(#[from] AppAuthorizationCredentialError), + + #[error(transparent)] + AppCredentialError(#[from] AppCredentialError), + + #[error(transparent)] + MilestoneError(#[from] MilestoneError), + + #[error(transparent)] + AccessPolicyError(#[from] AccessPolicyError), + + #[error(transparent)] + RoleMembershipError(#[from] RoleMembershipError), + + #[error(transparent)] + PostgresError(#[from] postgres::Error), + + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), + + #[error(transparent)] + DeadpoolBuildError(#[from] deadpool_postgres::BuildError), + + #[error(transparent)] + DeadpoolPoolError(#[from] deadpool_postgres::PoolError), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + LocalIPAddressError(#[from] local_ip_address::Error), + + #[error(transparent)] + AnyhowError(#[from] anyhow::Error) + } -impl From for AppError { - fn from(error: std::io::Error) -> Self { - AppError::STDIOError(error) +pub async fn initialize_required_tables(postgres_client: &mut deadpool_postgres::Client) -> Result<(), SlashstepServerError> { + + // Because the access_policies table depends on other tables, we need to initialize them in a specific order. + HTTPTransaction::initialize_http_transactions_table(postgres_client).await?; + User::initialize_users_table(postgres_client).await?; + Session::initialize_sessions_table(postgres_client).await?; + Group::initialize_groups_table(postgres_client).await?; + App::initialize_apps_table(postgres_client).await?; + Workspace::initialize_workspaces_table(postgres_client).await?; + Project::initialize_projects_table(postgres_client).await?; + Role::initialize_roles_table(postgres_client).await?; + Item::initialize_items_table(postgres_client).await?; + Action::initialize_actions_table(postgres_client).await?; + AppCredential::initialize_app_credentials_table(postgres_client).await?; + AppAuthorization::initialize_app_authorizations_table(postgres_client).await?; + AppAuthorizationCredential::initialize_app_authorization_credentials_table(postgres_client).await?; + Milestone::initialize_milestones_table(postgres_client).await?; + AccessPolicy::initialize_access_policies_table(postgres_client).await?; + RoleMembership::initialize_role_memberships_table(postgres_client).await?; + + return Ok(()); + +} + +#[derive(Debug, Clone)] +pub enum HTTPError { + GoneError(Option), + ForbiddenError(Option), + NotFoundError(Option), + ConflictError(Option), + BadRequestError(Option), + InternalServerError(Option), + UnauthorizedError(Option) +} + +impl fmt::Display for HTTPError { + + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HTTPError::NotFoundError(message) => write!(f, "{}", message.to_owned().unwrap_or("Not found.".to_string())), + HTTPError::ConflictError(message) => write!(f, "{}", message.to_owned().unwrap_or("Conflict.".to_string())), + HTTPError::ForbiddenError(message) => write!(f, "{}", message.to_owned().unwrap_or("Forbidden.".to_string())), + HTTPError::GoneError(message) => write!(f, "{}", message.to_owned().unwrap_or("Gone.".to_string())), + HTTPError::BadRequestError(message) => write!(f, "{}", message.to_owned().unwrap_or("Bad request.".to_string())), + HTTPError::InternalServerError(message) => write!(f, "{}", message.to_owned().unwrap_or("Internal server error.".to_string())), + HTTPError::UnauthorizedError(message) => write!(f, "{}", message.to_owned().unwrap_or("Unauthorized.".to_string())) + } } + } -impl From for AppError { - fn from(error: local_ip_address::Error) -> Self { - AppError::LocalIPAddressError(error) +impl IntoResponse for HTTPError { + fn into_response(self) -> Response { + let (status_code, error_message) = match self { + + HTTPError::GoneError(message) => (StatusCode::GONE, message.unwrap_or("Gone.".to_string())), + + HTTPError::NotFoundError(message) => (StatusCode::NOT_FOUND, message.unwrap_or("Not found.".to_string())), + + HTTPError::ForbiddenError(message) => (StatusCode::FORBIDDEN, message.unwrap_or("Forbidden.".to_string())), + + HTTPError::BadRequestError(message) => (StatusCode::BAD_REQUEST, message.unwrap_or("Bad request.".to_string())), + + HTTPError::ConflictError(message) => (StatusCode::CONFLICT, message.unwrap_or("Conflict.".to_string())), + + HTTPError::UnauthorizedError(message) => (StatusCode::UNAUTHORIZED, message.unwrap_or("Unauthorized.".to_string())), + + HTTPError::InternalServerError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Something bad happened on our side. Please try again later.".to_string()) + + }; + + return (status_code, Json(serde_json::json!({"message": error_message}))).into_response(); + } } -#[tokio::main] -async fn main() -> Result<(), AppError> { +impl HTTPError { - println!("Slashstep Server v{}", env!("CARGO_PKG_VERSION")); + pub async fn print_and_save(&self, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result, ()> { + + let server_log_entry = ServerLogEntry::from_http_error(self, http_request_id, postgres_client).await; + return Ok(server_log_entry); + + } + +} + +#[derive(Debug, Clone)] +pub struct AppState { + pub database_pool: Arc, +} + +pub fn handle_pool_error(error: deadpool_postgres::PoolError) -> Response { + + eprintln!("{}", format!("Failed to get database connection, so the log cannot be saved. Printing to the console: {}", error).red()); + let http_error = HTTPError::InternalServerError(Some(error.to_string())); + return http_error.into_response(); + +} + +fn get_environment_variable(variable_name: &str) -> Result { + + let variable_value = match std::env::var(variable_name) { + Ok(variable_value) => variable_value, + Err(_) => return Err(SlashstepServerError::EnvironmentVariableNotSet(variable_name.to_string())) + }; + + return Ok(variable_value); + +} + +async fn create_database_pool() -> Result { + + let host = get_environment_variable("POSTGRESQL_HOST")?; + let username = get_environment_variable("POSTGRESQL_USERNAME")?; + let database_name = get_environment_variable("POSTGRESQL_DATABASE_NAME")?; + + let mut postgres_config = tokio_postgres::Config::new(); + postgres_config.host(host); + postgres_config.user(username); + postgres_config.dbname(database_name); + let manager_config = deadpool_postgres::ManagerConfig { + recycling_method: deadpool_postgres::RecyclingMethod::Fast + }; + let manager = deadpool_postgres::Manager::from_config(postgres_config, NoTls, manager_config); + + let maximum_postgres_connection_count_string = match get_environment_variable("MAXIMUM_POSTGRES_CONNECTION_COUNT") { + + Ok(maximum_postgres_connection_count) => maximum_postgres_connection_count, + Err(_) => { + + println!("{}", format!("Please set a MAXIMUM_POSTGRES_CONNECTION_COUNT environment variable. Defaulting to {}.", DEFAULT_MAXIMUM_POSTGRES_CONNECTION_COUNT).yellow()); + DEFAULT_MAXIMUM_POSTGRES_CONNECTION_COUNT.to_string() + + } + + }; + let maximum_postgres_connection_count = maximum_postgres_connection_count_string.parse::()?; + + let pool = Pool::builder(manager).max_size(maximum_postgres_connection_count).build()?; + return Ok(pool); + +} + +pub fn import_env_file() { if dotenvy::dotenv().is_ok() { @@ -92,8 +305,24 @@ async fn main() -> Result<(), AppError> { } +} + +#[tokio::main] +async fn main() -> Result<(), SlashstepServerError> { + + println!("Slashstep Server v{}", env!("CARGO_PKG_VERSION")); + + import_env_file(); + let pool = create_database_pool().await?; + let state = AppState { + database_pool: Arc::new(pool), + }; + + let _ = initialize_pre_defined_actions(&mut state.database_pool.get().await?).await?; + let _ = initialize_pre_defined_roles(&mut state.database_pool.get().await?).await?; + let app_port = get_app_port_string(); - let router = routes::get_router(); + let router = routes::get_router(state.clone()).with_state(state); let listener = TcpListener::bind(format!("0.0.0.0:{}", app_port)).await?; let app_ip = local_ip()?; println!("{}", format!("Slashstep Server is now listening on port {}. You can access it on your machine at http://localhost:{}, or your local network at http://{}:{}.", app_port, app_port, app_ip, app_port).green()); diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..0838d81 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,2 @@ +pub mod authentication_middleware; +pub mod http_request_middleware; \ No newline at end of file diff --git a/src/middleware/authentication_middleware.rs b/src/middleware/authentication_middleware.rs new file mode 100644 index 0000000..37da4ef --- /dev/null +++ b/src/middleware/authentication_middleware.rs @@ -0,0 +1,319 @@ +use std::sync::Arc; + +use axum::{Extension, body::Body, extract::{Request, State}, middleware::Next, response::{IntoResponse, Response}}; +use axum_extra::extract::CookieJar; +use uuid::Uuid; +use crate::{AppState, HTTPError, handle_pool_error, resources::{http_transaction::HTTPTransaction, role::Role, role_memberships::{InitialRoleMembershipProperties, RoleMembership}, server_log_entry::ServerLogEntry, session::{Session, SessionError, SessionTokenClaims}, user::{InitialUserProperties, User, UserError}}}; + +async fn get_jwt_public_key(http_transaction_id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let jwt_public_key = match Session::get_json_web_token_public_key().await { + + Ok(jwt_public_key) => jwt_public_key, + + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("{:?}", error))); + let _ = http_error.print_and_save(Some(http_transaction_id), postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + + return Ok(jwt_public_key); + +} + +async fn get_decoding_key(http_transaction_id: &Uuid, postgres_client: &mut deadpool_postgres::Client, jwt_public_key: &str) -> Result { + + let decoding_key = match jsonwebtoken::DecodingKey::from_rsa_pem(&jwt_public_key.as_bytes()) { + Ok(decoding_key) => decoding_key, + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to decode JWT public key: {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction_id), postgres_client).await; + return Err(http_error.into_response()); + + } + }; + + return Ok(decoding_key); + +} + +async fn get_decoded_claims(http_transaction_id: &Uuid, postgres_client: &mut deadpool_postgres::Client, session_token: &str, decoding_key: &jsonwebtoken::DecodingKey, validation: &jsonwebtoken::Validation) -> Result, Response> { + + let decoded_claims = match jsonwebtoken::decode::(&session_token, &decoding_key, &validation) { + Ok(decoded_claims) => decoded_claims, + Err(error) => { + + let http_error = match &error.kind() { + + jsonwebtoken::errors::ErrorKind::InvalidToken => HTTPError::UnauthorizedError(Some("Please provide a valid session token.".to_string())), + + jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claims) => { + + let _ = ServerLogEntry::warning(&format!("Missing required claim \"{}\" in session token.", claims), Some(&http_transaction_id), postgres_client).await; + HTTPError::UnauthorizedError(Some("Please provide a valid session token.".to_string())) + + }, + + _ => HTTPError::InternalServerError(Some(format!("Failed to decode session token: {:?}", error))) + + }; + + let _ = http_error.print_and_save(Some(&http_transaction_id), postgres_client).await; + return Err(http_error.into_response()); + + } + }; + + return Ok(decoded_claims); + +} + +async fn get_user_by_id(http_transaction_id: &Uuid, postgres_client: &mut deadpool_postgres::Client, user_id: &Uuid) -> Result { + + let user = match User::get_by_id(&user_id, postgres_client).await { + Ok(user) => user, + Err(error) => { + + let http_error = match error { + + // For this middleware, signalling that the token is invalid is a higher priority than the user not existing. + UserError::NotFoundError(_, _) => HTTPError::UnauthorizedError(Some("Please provide a valid session token.".to_string())), + _ => HTTPError::InternalServerError(Some(error.to_string())) + + }; + + let _ = http_error.print_and_save(Some(&http_transaction_id), postgres_client).await; + + return Err(http_error.into_response()); + + } + }; + + return Ok(user); + +} + +async fn get_session_by_id(http_transaction_id: &Uuid, postgres_client: &mut deadpool_postgres::Client, session_id: &Uuid) -> Result { + + let session = match Session::get_by_id(&session_id, postgres_client).await { + Ok(session) => session, + Err(error) => { + + let http_error = match error { + SessionError::NotFoundError(_) => HTTPError::UnauthorizedError(Some(format!("Session with ID {} not found.", session_id))), + SessionError::PostgresError(error) => match error.as_db_error() { + + Some(db_error) => HTTPError::InternalServerError(Some(format!("{:?}", db_error))), + + None => HTTPError::InternalServerError(Some(format!("{:?}", error))) + + }, + _ => HTTPError::InternalServerError(Some(error.to_string())) + }; + + let _ = ServerLogEntry::from_http_error(&http_error, Some(&http_transaction_id), postgres_client).await; + + return Err(http_error.into_response()); + + } + }; + + return Ok(session); + +} + +#[axum_macros::debug_middleware] +pub async fn authenticate_user( + State(state): State, + Extension(http_transaction): Extension>, + cookie_jar: CookieJar, + mut request: Request, + next: Next +) -> Result { + + // Get the cookie from the request. + let mut postgres_client = state.database_pool.get().await.map_err(handle_pool_error)?; + + let Some(session_token) = cookie_jar.get("sessionToken") else { + + // Use an anonymous user. + let _ = ServerLogEntry::trace("No user token found in request. Checking for existing anonymous user...", Some(&http_transaction.id), &mut postgres_client).await; + + let ip_user = match User::get_by_ip_address(&http_transaction.ip_address, &mut postgres_client).await { + + Ok(ip_user) => Arc::new(ip_user), + + Err(error) => { + + match error { + + UserError::NotFoundError(_, _) => { + + let _ = ServerLogEntry::trace("No existing anonymous user found. Creating a new one...", Some(&http_transaction.id), &mut postgres_client).await; + let anonymous_user = match User::create(&InitialUserProperties { + username: None, + display_name: None, + hashed_password: None, + is_anonymous: true, + ip_address: Some(http_transaction.ip_address) + }, &mut postgres_client).await { + + Ok(anonymous_user) => Arc::new(anonymous_user), + + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to create anonymous user: {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + + anonymous_user + + }, + + _ => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to get anonymous user: {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + } + + } + + }; + + let _ = ServerLogEntry::trace("Getting anonymous-users role...", Some(&http_transaction.id), &mut postgres_client).await; + let anonymous_users_role = match Role::get_by_name("anonymous-users", &mut postgres_client).await { + + Ok(anonymous_users_role) => anonymous_users_role, + + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to get anonymous-users role: {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + let _ = ServerLogEntry::trace(&format!("Checking if user {} has the anonymous-users role...", ip_user.id), Some(&http_transaction.id), &mut postgres_client).await; + let role_memberships = match RoleMembership::list(&format!("role_id = \"{}\" and principal_type = \"User\" and principal_user_id = \"{}\"", anonymous_users_role.id, ip_user.id), &mut postgres_client).await { + + Ok(role_memberships) => role_memberships, + + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to get role memberships: {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + + if role_memberships.len() == 0 { + + let _ = ServerLogEntry::trace("User does not have the anonymous-users role. Creating a new role membership...", Some(&http_transaction.id), &mut postgres_client).await; + let _ = RoleMembership::create(&InitialRoleMembershipProperties { + role_id: &anonymous_users_role.id, + principal_type: &crate::resources::role_memberships::RoleMembershipPrincipalType::User, + principal_user_id: Some(&ip_user.id), + principal_app_id: None, + principal_group_id: None + }, &mut postgres_client).await; + + } + + let _ = ServerLogEntry::trace(&format!("Adding user {} to request extensions...", ip_user.id), Some(&http_transaction.id), &mut postgres_client).await; + + request.extensions_mut().insert(Some(ip_user.clone())); + + let _ = ServerLogEntry::info(&format!("Authenticated as anonymous user {}.", ip_user.id), Some(&http_transaction.id), &mut postgres_client).await; + + return Ok(next.run(request).await); + + }; + + if !session_token.value().starts_with("Bearer ") { + + let http_error = HTTPError::UnauthorizedError(Some("Please provide a valid session token.".to_string())); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + let session_token = session_token.value().to_string().replace("Bearer ", ""); + + // Make sure the user token is valid. + let _ = ServerLogEntry::trace("Decoding session token...", Some(&http_transaction.id), &mut postgres_client).await; + + let jwt_public_key = get_jwt_public_key(&http_transaction.id, &mut postgres_client).await?; + let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + let decoding_key = get_decoding_key(&http_transaction.id, &mut postgres_client, &jwt_public_key).await?; + let decoded_claims = get_decoded_claims(&http_transaction.id, &mut postgres_client, &session_token, &decoding_key, &validation).await?; + + // Set the user and session in the request extensions. + let session_id = match Uuid::parse_str(&decoded_claims.claims.jti) { + + Ok(user_id) => user_id, + Err(_) => { + + let http_error = HTTPError::BadRequestError(Some("You must provide a valid UUID for the user ID.".to_string())); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + let user_id = match Uuid::parse_str(&decoded_claims.claims.sub) { + + Ok(user_id) => user_id, + Err(_) => { + + let http_error = HTTPError::BadRequestError(Some("You must provide a valid UUID for the user ID.".to_string())); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + let _ = ServerLogEntry::trace("Getting session...", Some(&http_transaction.id), &mut postgres_client).await; + let session = get_session_by_id(&http_transaction.id, &mut postgres_client, &session_id).await?; + let _ = ServerLogEntry::trace("Getting user from session...", Some(&http_transaction.id), &mut postgres_client).await; + let user = get_user_by_id(&http_transaction.id, &mut postgres_client, &user_id).await?; + let _ = ServerLogEntry::trace("Adding user and session to request extensions...", Some(&http_transaction.id), &mut postgres_client).await; + request.extensions_mut().insert(Some(Arc::new(user.clone()))); + request.extensions_mut().insert(Some(Arc::new(session.clone()))); + + let _ = ServerLogEntry::info(&format!("Successfully authenticated as user {}.", user_id), Some(&http_transaction.id), &mut postgres_client).await; + + let response = next.run(request).await; + + return Ok(response); + +} + +pub async fn authenticate_app(request: Request, next: Next) -> Result { + // Perform actions before the handler + + println!("Request received: {}", request.uri()); + + // Call the next service in the stack (the handler or next middleware) + let response = next.run(request).await; + + // Perform actions after the handler + println!("Response status: {}", response.status()); + + return Ok(response); +} \ No newline at end of file diff --git a/src/middleware/http_request_middleware.rs b/src/middleware/http_request_middleware.rs new file mode 100644 index 0000000..6614fea --- /dev/null +++ b/src/middleware/http_request_middleware.rs @@ -0,0 +1,66 @@ +use std::{net::SocketAddr, sync::Arc}; +use axum::{body::Body, extract::{ConnectInfo, Request, State}, middleware::Next, response::{IntoResponse, Response}}; +use chrono::{Duration, Utc}; +use crate::{AppState, HTTPError, handle_pool_error, resources::{http_transaction::{HTTPTransaction, HTTPTransactionError, InitialHTTPTransactionProperties}, server_log_entry::ServerLogEntry}}; + +pub async fn create_http_request( + ConnectInfo(address): ConnectInfo, + State(state): State, + mut request: Request, + next: Next +) -> Result { + + // Remove sensitive headers from the stored request. + let client_ip = address.ip(); + let method = request.method().to_string(); + let url = request.uri().to_string(); + let mut safe_headers = request.headers().clone(); + safe_headers.remove("authorization"); + safe_headers.remove("cookie"); + let headers_json_string: serde_json::Value = format!("{:?}", safe_headers).into(); + + // Create the HTTP request and add it to the request extension. + let mut postgres_client = state.database_pool.get().await.map_err(handle_pool_error)?; + + let http_request = match HTTPTransaction::create(&InitialHTTPTransactionProperties { + method, + url, + ip_address: client_ip, + headers: headers_json_string.to_string(), + status_code: None, + expiration_date: Some(Utc::now() + Duration::days(30)) + }, &mut postgres_client).await { + + Ok(http_request) => Arc::new(http_request), + + Err(error) => { + + let http_error = match error { + + HTTPTransactionError::PostgresError(postgres_error) => { + + match postgres_error.as_db_error() { + + Some(db_error) => HTTPError::InternalServerError(Some(format!("{:?}", db_error))), + + None => HTTPError::InternalServerError(Some(format!("{:?}", postgres_error))) + + } + + } + + }; + let _ = ServerLogEntry::from_http_error(&http_error, None, &mut postgres_client).await; + return Err(http_error.into_response()); + + } + + }; + + request.extensions_mut().insert(http_request.clone()); + + let _ = ServerLogEntry::info(&format!("HTTP request handling started."), Some(&http_request.id), &mut postgres_client).await; + let response = next.run(request).await; + return Ok(response); + +} \ No newline at end of file diff --git a/src/pre_definitions.rs b/src/pre_definitions.rs new file mode 100644 index 0000000..d7fa377 --- /dev/null +++ b/src/pre_definitions.rs @@ -0,0 +1,147 @@ +use crate::resources::{action::{Action, ActionError, InitialActionProperties}, role::{InitialRoleProperties, Role, RoleError}}; +use colored::Colorize; + +pub async fn initialize_pre_defined_actions(postgres_client: &mut deadpool_postgres::Client) -> Result, ActionError> { + + println!("{}", "Initializing pre-defined actions...".dimmed()); + + let pre_defined_actions: Vec = vec![ + InitialActionProperties { + name: "slashstep.accessPolicies.get".to_string(), + display_name: "Get access policies".to_string(), + description: "Get a specific access policy on a particular scope.".to_string(), + app_id: None + }, + InitialActionProperties { + name: "slashstep.accessPolicies.list".to_string(), + display_name: "List access policies".to_string(), + description: "List all access policies on a particular scope.".to_string(), + app_id: None + }, + InitialActionProperties { + name: "slashstep.accessPolicies.create".to_string(), + display_name: "Create access policies".to_string(), + description: "Create new access policy on a particular scope.".to_string(), + app_id: None + }, + InitialActionProperties { + name: "slashstep.accessPolicies.update".to_string(), + display_name: "Update access policies".to_string(), + description: "Update access policies on a particular scope.".to_string(), + app_id: None + }, + InitialActionProperties { + name: "slashstep.accessPolicies.delete".to_string(), + display_name: "Delete access policy".to_string(), + description: "Delete access policies on a particular scope.".to_string(), + app_id: None + } + ]; + + let mut actions: Vec = Vec::new(); + + for pre_defined_action in pre_defined_actions { + + // Make sure we didn't go through this action already. + let mut should_continue = false; + for action in actions.iter() { + + if action.name == pre_defined_action.name { + + println!("{}", format!("Skipping pre-defined action \"{}\" because it already exists.", pre_defined_action.name).yellow()); + should_continue = true; + + } + + } + + if should_continue { + + continue; + + } + + // Create the action, but if it already exists, add it to the list of actions. + let action = match Action::create(&pre_defined_action, postgres_client).await { + + Ok(action) => action, + + Err(error) => { + + match error { + + ActionError::ConflictError(_) => { + + let action = Action::get_by_name(&pre_defined_action.name, postgres_client).await?; + + action + + }, + + _ => return Err(error) + + } + + } + + }; + actions.push(action); + + } + + println!("{}", format!("Successfully initialized {} pre-defined actions.", actions.len()).blue()); + + return Ok(actions); + +} + +pub async fn initialize_pre_defined_roles(postgres_client: &mut deadpool_postgres::Client) -> Result, RoleError> { + + println!("{}", "Initializing pre-defined roles...".dimmed()); + + let pre_defined_roles: Vec = vec![ + InitialRoleProperties { + name: "anonymous-users".to_string(), + display_name: "Anonymous Users".to_string(), + description: Some("Users who have not logged in. Registered users should not be assigned this role.".to_string()), + parent_resource_type: crate::resources::role::RoleParentResourceType::Instance, + parent_workspace_id: None, + parent_project_id: None, + parent_group_id: None + } + ]; + + let mut roles: Vec = Vec::new(); + + for pre_defined_role in pre_defined_roles { + + // Make sure we didn't go through this role already. + let mut should_continue = false; + for role in roles.iter() { + + if role.name == pre_defined_role.name { + + println!("{}", format!("Skipping pre-defined role \"{}\" because it already exists.", pre_defined_role.name).yellow()); + should_continue = true; + + } + + } + + if should_continue { + + continue; + + } + + // Create the role, but if it already exists, add it to the list of roles. + let role = Role::create(&pre_defined_role, postgres_client).await?; + roles.push(role); + + } + + println!("{}", format!("Successfully initialized {} pre-defined roles.", roles.len()).blue()); + + return Ok(roles); + +} diff --git a/src/queries/access-policies/insert-access-policy-row.sql b/src/queries/access-policies/insert-access-policy-row.sql index 5565236..644c359 100644 --- a/src/queries/access-policies/insert-access-policy-row.sql +++ b/src/queries/access-policies/insert-access-policy-row.sql @@ -7,6 +7,7 @@ insert into access_policies ( scoped_resource_type, scoped_action_id, scoped_app_id, + scoped_app_credential_id, scoped_group_id, scoped_item_id, scoped_milestone_id, @@ -17,4 +18,4 @@ insert into access_policies ( permission_level, inheritance_level, action_id -) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) returning *; \ No newline at end of file +) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) returning *; \ No newline at end of file diff --git a/src/queries/app-credentials/get-app-credential-row.sql b/src/queries/app-credentials/get-app-credential-row-by-id.sql similarity index 100% rename from src/queries/app-credentials/get-app-credential-row.sql rename to src/queries/app-credentials/get-app-credential-row-by-id.sql diff --git a/src/queries/apps/delete-app-row.sql b/src/queries/apps/delete-app-row-by-id.sql similarity index 100% rename from src/queries/apps/delete-app-row.sql rename to src/queries/apps/delete-app-row-by-id.sql diff --git a/src/queries/apps/get-app-row.sql b/src/queries/apps/get-app-row-by-id.sql similarity index 100% rename from src/queries/apps/get-app-row.sql rename to src/queries/apps/get-app-row-by-id.sql diff --git a/src/queries/http-requests/create-http-requests-table.sql b/src/queries/http-requests/create-http-requests-table.sql new file mode 100644 index 0000000..1c9c6f5 --- /dev/null +++ b/src/queries/http-requests/create-http-requests-table.sql @@ -0,0 +1,9 @@ +create table if not exists http_requests ( + id UUID default uuidv7() primary key, + method text not null, + url text not null, + ip_address inet not null, + headers text not null, + status_code integer, + expiration_date timestamptz not null default now() + interval '14 days' +); \ No newline at end of file diff --git a/src/queries/http-requests/create-hydrated-http-requests-view.sql b/src/queries/http-requests/create-hydrated-http-requests-view.sql new file mode 100644 index 0000000..eb014a9 --- /dev/null +++ b/src/queries/http-requests/create-hydrated-http-requests-view.sql @@ -0,0 +1,5 @@ +create or replace view hydrated_http_requests as + select + http_requests.* + from + http_requests \ No newline at end of file diff --git a/src/queries/http-requests/insert-http-request-row.sql b/src/queries/http-requests/insert-http-request-row.sql new file mode 100644 index 0000000..8c3a2c5 --- /dev/null +++ b/src/queries/http-requests/insert-http-request-row.sql @@ -0,0 +1,15 @@ +insert into http_requests ( + method, + url, + ip_address, + headers, + status_code, + expiration_date +) values ( + $1, + $2, + $3, + $4, + $5, + $6 +) returning *; \ No newline at end of file diff --git a/src/queries/items/get-item-row-by-id.sql b/src/queries/items/get-item-row-by-id.sql new file mode 100644 index 0000000..4c4cf1a --- /dev/null +++ b/src/queries/items/get-item-row-by-id.sql @@ -0,0 +1 @@ +select * from items where id = $1 limit 1; \ No newline at end of file diff --git a/src/queries/milestones/get-milestone-row-by-id.sql b/src/queries/milestones/get-milestone-row-by-id.sql new file mode 100644 index 0000000..afe6560 --- /dev/null +++ b/src/queries/milestones/get-milestone-row-by-id.sql @@ -0,0 +1 @@ +select * from hydrated_milestones where id = $1 limit 1; \ No newline at end of file diff --git a/src/queries/projects/get-project-row-by-id.sql b/src/queries/projects/get-project-row-by-id.sql new file mode 100644 index 0000000..604fa3f --- /dev/null +++ b/src/queries/projects/get-project-row-by-id.sql @@ -0,0 +1 @@ +select * from projects where id = $1; \ No newline at end of file diff --git a/src/queries/role-memberships/get-role-membership-row-by-id.sql b/src/queries/role-memberships/get-role-membership-row-by-id.sql new file mode 100644 index 0000000..ffce740 --- /dev/null +++ b/src/queries/role-memberships/get-role-membership-row-by-id.sql @@ -0,0 +1 @@ +select * from hydrated_role_memberships where id = $1 limit 1; \ No newline at end of file diff --git a/src/queries/role-memberships/initialize-hydrated-role-memberships-view.sql b/src/queries/role-memberships/initialize-hydrated-role-memberships-view.sql new file mode 100644 index 0000000..d29d0f5 --- /dev/null +++ b/src/queries/role-memberships/initialize-hydrated-role-memberships-view.sql @@ -0,0 +1,5 @@ +create or replace view hydrated_role_memberships as + select + role_memberships.* + from + role_memberships \ No newline at end of file diff --git a/src/queries/role-memberships/initialize-role-memberships-table.sql b/src/queries/role-memberships/initialize-role-memberships-table.sql new file mode 100644 index 0000000..40e6618 --- /dev/null +++ b/src/queries/role-memberships/initialize-role-memberships-table.sql @@ -0,0 +1,20 @@ +do $$ +begin + if not exists (select 1 from pg_type where typname = 'role_membership_principal_type') then + create type role_membership_principal_type as enum ( + 'User', + 'Group', + 'App' + ); + end if; + + create table if not exists role_memberships ( + id UUID default uuidv7() primary key, + role_id UUID references roles(id) on delete cascade, + principal_type role_membership_principal_type not null, + principal_user_id UUID references users(id) on delete cascade, + principal_group_id UUID references groups(id) on delete cascade, + principal_app_id UUID references apps(id) on delete cascade + ); +end +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/src/queries/role-memberships/insert-role-membership-row.sql b/src/queries/role-memberships/insert-role-membership-row.sql new file mode 100644 index 0000000..5d0b2a1 --- /dev/null +++ b/src/queries/role-memberships/insert-role-membership-row.sql @@ -0,0 +1 @@ +insert into role_memberships (role_id, principal_type, principal_user_id, principal_group_id, principal_app_id) values ($1, $2, $3, $4, $5) returning *; \ No newline at end of file diff --git a/src/queries/roles/initialize-roles-table.sql b/src/queries/roles/initialize-roles-table.sql index 084f513..98e7eac 100644 --- a/src/queries/roles/initialize-roles-table.sql +++ b/src/queries/roles/initialize-roles-table.sql @@ -19,7 +19,7 @@ BEGIN parent_group_id UUID REFERENCES groups(id) ON DELETE CASCADE, parent_workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, parent_project_id UUID REFERENCES projects(id) ON DELETE CASCADE, - is_predefined boolean not null default false, + is_pre_defined boolean not null default false, /* Constraints */ CONSTRAINT one_parent_type CHECK ( diff --git a/src/queries/server-log-entries/create-hydrated-server-log-entries-view.sql b/src/queries/server-log-entries/create-hydrated-server-log-entries-view.sql new file mode 100644 index 0000000..4fb0843 --- /dev/null +++ b/src/queries/server-log-entries/create-hydrated-server-log-entries-view.sql @@ -0,0 +1,5 @@ +create or replace view hydrated_server_log_entries as + select + server_log_entries.* + from + server_log_entries \ No newline at end of file diff --git a/src/queries/server-log-entries/create-server-log-entries-table.sql b/src/queries/server-log-entries/create-server-log-entries-table.sql new file mode 100644 index 0000000..1d6ea3f --- /dev/null +++ b/src/queries/server-log-entries/create-server-log-entries-table.sql @@ -0,0 +1,21 @@ +do $$ +begin + if not exists (select 1 from pg_type where typname = 'server_log_entry_level') then + create type server_log_entry_level as enum ( + 'Success', + 'Trace', + 'Info', + 'Warning', + 'Error', + 'Critical' + ); + end if; +end +$$ LANGUAGE plpgsql; + +create table if not exists server_log_entries ( + id UUID default uuidv7() primary key, + message text not null, + http_request_id UUID references http_requests(id) on delete cascade, + level server_log_entry_level not null +); \ No newline at end of file diff --git a/src/queries/server-log-entries/insert-server-log-entry-row.sql b/src/queries/server-log-entries/insert-server-log-entry-row.sql new file mode 100644 index 0000000..b41c819 --- /dev/null +++ b/src/queries/server-log-entries/insert-server-log-entry-row.sql @@ -0,0 +1,9 @@ +insert into server_log_entries ( + message, + http_request_id, + level +) values ( + $1, + $2, + $3 +) returning *; \ No newline at end of file diff --git a/src/queries/sessions/delete-session-row.sql b/src/queries/sessions/delete-session-row.sql new file mode 100644 index 0000000..ddcfc7d --- /dev/null +++ b/src/queries/sessions/delete-session-row.sql @@ -0,0 +1,2 @@ + +delete from sessions where id = $1; \ No newline at end of file diff --git a/src/queries/sessions/get-session-row-by-id.sql b/src/queries/sessions/get-session-row-by-id.sql new file mode 100644 index 0000000..861a9bb --- /dev/null +++ b/src/queries/sessions/get-session-row-by-id.sql @@ -0,0 +1 @@ +select * from hydrated_sessions where id = $1; \ No newline at end of file diff --git a/src/queries/sessions/initialize-hydrated-sessions-view.sql b/src/queries/sessions/initialize-hydrated-sessions-view.sql new file mode 100644 index 0000000..bf2dc9f --- /dev/null +++ b/src/queries/sessions/initialize-hydrated-sessions-view.sql @@ -0,0 +1,9 @@ +create or replace view hydrated_sessions as + select + sessions.*, + users.username as user_username, + users.display_name as user_display_name + from + sessions + inner join + users on users.id = sessions.user_id; \ No newline at end of file diff --git a/src/queries/sessions/initialize-sessions-table.sql b/src/queries/sessions/initialize-sessions-table.sql new file mode 100644 index 0000000..2840b40 --- /dev/null +++ b/src/queries/sessions/initialize-sessions-table.sql @@ -0,0 +1,6 @@ +create table if not exists sessions ( + id UUID default uuidv7() primary key, + user_id UUID references users(id) on delete cascade, + expiration_date timestamptz not null, + creation_ip_address inet not null +); \ No newline at end of file diff --git a/src/queries/sessions/insert-session-row.sql b/src/queries/sessions/insert-session-row.sql new file mode 100644 index 0000000..53b0547 --- /dev/null +++ b/src/queries/sessions/insert-session-row.sql @@ -0,0 +1 @@ +insert into sessions (user_id, expiration_date, creation_ip_address) values ($1, $2, $3) returning *; \ No newline at end of file diff --git a/src/resources.rs b/src/resources.rs index 15bef6d..d978238 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -10,4 +10,8 @@ pub mod milestone; pub mod project; pub mod role; pub mod user; -pub mod workspace; \ No newline at end of file +pub mod workspace; +pub mod server_log_entry; +pub mod http_transaction; +pub mod session; +pub mod role_memberships; \ No newline at end of file diff --git a/src/resources/access_policy.rs b/src/resources/access_policy.rs deleted file mode 100644 index 737ce67..0000000 --- a/src/resources/access_policy.rs +++ /dev/null @@ -1,808 +0,0 @@ -/** - * - * This module defines the implementation and types of an access policy. - * - * Programmers: - * - Christian Toney (https://christiantoney.com) - * - * © 2025 Beastslash LLC - * - */ - -use core::{fmt}; -use std::str::FromStr; -use pg_escape::quote_literal; -use postgres::{error::SqlState, types::ToSql}; -use postgres_types::FromSql; -use uuid::Uuid; -use crate::{ - errors::resource_already_exists_error::ResourceAlreadyExistsError, - utilities::slashstepql::{ - SlashstepQLFilterSanitizer, - SlashstepQLParameterType, - SlashstepQLSanitizeError, - SlashstepQLSanitizeFunctionOptions - } -}; - -pub const ALLOWED_QUERY_KEYS: &[&str] = &[ - "id", - "action_id", - "principal_type", - "principal_user_id", - "principal_group_id", - "principal_role_id", - "principal_app_id", - "scoped_resource_type", - "scoped_action_id", - "scoped_app_id", - "scoped_group_id", - "scoped_item_id", - "scoped_milestone_id", - "scoped_project_id", - "scoped_role_id", - "scoped_user_id", - "scoped_workspace_id" -]; - -pub const UUID_QUERY_KEYS: &[&str] = &[ - "id", - "action_id", - "principal_user_id", - "principal_group_id", - "principal_role_id", - "principal_app_id", - "scoped_action_id", - "scoped_app_id", - "scoped_group_id", - "scoped_item_id", - "scoped_milestone_id", - "scoped_project_id", - "scoped_role_id", - "scoped_user_id", - "scoped_workspace_id" -]; - -pub const DEFAULT_ACCESS_POLICY_LIST_LIMIT: i64 = 1000; - -#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone, Copy)] -#[postgres(name = "permission_level")] -pub enum AccessPolicyPermissionLevel { - None, - User, - Editor, - Admin -} - -impl fmt::Display for AccessPolicyPermissionLevel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessPolicyPermissionLevel::None => write!(f, "None"), - AccessPolicyPermissionLevel::User => write!(f, "User"), - AccessPolicyPermissionLevel::Editor => write!(f, "Editor"), - AccessPolicyPermissionLevel::Admin => write!(f, "Admin") - } - } -} - -impl FromStr for AccessPolicyPermissionLevel { - - type Err = String; - - fn from_str(string: &str) -> Result { - - match string { - "None" => Ok(AccessPolicyPermissionLevel::None), - "User" => Ok(AccessPolicyPermissionLevel::User), - "Editor" => Ok(AccessPolicyPermissionLevel::Editor), - "Admin" => Ok(AccessPolicyPermissionLevel::Admin), - _ => Err(format!("Invalid permission level: {}", string)) - } - - } - -} - -#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone, Copy)] -#[postgres(name = "inheritance_level")] -pub enum AccessPolicyInheritanceLevel { - Disabled, - Enabled, - Required -} - -impl fmt::Display for AccessPolicyInheritanceLevel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessPolicyInheritanceLevel::Disabled => write!(f, "Disabled"), - AccessPolicyInheritanceLevel::Enabled => write!(f, "Enabled"), - AccessPolicyInheritanceLevel::Required => write!(f, "Required") - } - } -} - -impl FromStr for AccessPolicyInheritanceLevel { - - type Err = String; - - fn from_str(string: &str) -> Result { - - match string { - "Disabled" => Ok(AccessPolicyInheritanceLevel::Disabled), - "Enabled" => Ok(AccessPolicyInheritanceLevel::Enabled), - "Required" => Ok(AccessPolicyInheritanceLevel::Required), - _ => Err(format!("Invalid inheritance level: {}", string)) - } - - } - -} - -#[derive(Debug, PartialEq, Eq, ToSql, FromSql)] -#[postgres(name = "scoped_resource_type")] -pub enum AccessPolicyScopedResourceType { - Instance, - Workspace, - Project, - Item, - Action, - User, - Role, - Group, - App, - AppCredential, - Milestone, -} - -impl fmt::Display for AccessPolicyScopedResourceType { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessPolicyScopedResourceType::Workspace => write!(formatter, "Workspace"), - AccessPolicyScopedResourceType::Project => write!(formatter, "Project"), - AccessPolicyScopedResourceType::Milestone => write!(formatter, "Milestone"), - AccessPolicyScopedResourceType::Item => write!(formatter, "Item"), - AccessPolicyScopedResourceType::Action => write!(formatter, "Action"), - AccessPolicyScopedResourceType::Role => write!(formatter, "Role"), - AccessPolicyScopedResourceType::Group => write!(formatter, "Group"), - AccessPolicyScopedResourceType::User => write!(formatter, "User"), - AccessPolicyScopedResourceType::App => write!(formatter, "App"), - AccessPolicyScopedResourceType::AppCredential => write!(formatter, "AppCredential"), - AccessPolicyScopedResourceType::Instance => write!(formatter, "Instance") - } - } -} - -impl FromStr for AccessPolicyScopedResourceType { - - type Err = String; - - fn from_str(string: &str) -> Result { - - match string { - "Instance" => Ok(AccessPolicyScopedResourceType::Instance), - "Workspace" => Ok(AccessPolicyScopedResourceType::Workspace), - "Project" => Ok(AccessPolicyScopedResourceType::Project), - "Milestone" => Ok(AccessPolicyScopedResourceType::Milestone), - "Item" => Ok(AccessPolicyScopedResourceType::Item), - "Action" => Ok(AccessPolicyScopedResourceType::Action), - "Role" => Ok(AccessPolicyScopedResourceType::Role), - "Group" => Ok(AccessPolicyScopedResourceType::Group), - "User" => Ok(AccessPolicyScopedResourceType::User), - "App" => Ok(AccessPolicyScopedResourceType::App), - _ => Err(format!("Invalid scoped resource type: {}", string)) - } - - } - -} - -#[derive(Debug, PartialEq, Eq, ToSql, FromSql)] -#[postgres(name = "principal_type")] -pub enum AccessPolicyPrincipalType { - - /// A resource that identifies a user. - User, - - /// A resource that identifies multiple users, apps, and other groups. - Group, - - /// A resource that identifies a role. - Role, - - /// A resource that identifies an app. - App - -} - -impl fmt::Display for AccessPolicyPrincipalType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessPolicyPrincipalType::App => write!(f, "App"), - AccessPolicyPrincipalType::Group => write!(f, "Group"), - AccessPolicyPrincipalType::Role => write!(f, "Role"), - AccessPolicyPrincipalType::User => write!(f, "User") - } - } -} - -impl FromStr for AccessPolicyPrincipalType { - - type Err = String; - - fn from_str(string: &str) -> Result { - - match string { - "App" => Ok(AccessPolicyPrincipalType::App), - "Group" => Ok(AccessPolicyPrincipalType::Group), - "Role" => Ok(AccessPolicyPrincipalType::Role), - "User" => Ok(AccessPolicyPrincipalType::User), - _ => Err(format!("Invalid principal type: {}", string)) - } - - } - -} - -#[derive(Debug)] -pub struct InitialAccessPolicyProperties { - - pub action_id: Uuid, - - pub permission_level: AccessPolicyPermissionLevel, - - pub inheritance_level: AccessPolicyInheritanceLevel, - - pub principal_type: AccessPolicyPrincipalType, - - pub principal_user_id: Option, - - pub principal_group_id: Option, - - pub principal_role_id: Option, - - pub principal_app_id: Option, - - pub scoped_resource_type: AccessPolicyScopedResourceType, - - pub scoped_action_id: Option, - - pub scoped_app_id: Option, - - pub scoped_group_id: Option, - - pub scoped_item_id: Option, - - pub scoped_milestone_id: Option, - - pub scoped_project_id: Option, - - pub scoped_role_id: Option, - - pub scoped_user_id: Option, - - pub scoped_workspace_id: Option - -} - -#[derive(Debug)] -pub enum AccessPolicyCreationError { - ResourceAlreadyExistsError(ResourceAlreadyExistsError), - String(String), - PostgresError(postgres::Error) -} - -#[derive(Debug)] -pub enum AccessPolicyListError { - PostgresError(postgres::Error), - SlashstepQLSanitizeError(SlashstepQLSanitizeError), - SlashstepQLParseError(SlashstepQLParseError) -} - -impl From for AccessPolicyListError { - fn from(error: postgres::Error) -> Self { - AccessPolicyListError::PostgresError(error) - } -} - -impl From for AccessPolicyListError { - fn from(error: SlashstepQLSanitizeError) -> Self { - AccessPolicyListError::SlashstepQLSanitizeError(error) - } -} - -impl From for AccessPolicyListError { - fn from(error: SlashstepQLParseError) -> Self { - AccessPolicyListError::SlashstepQLParseError(error) - } -} - -#[derive(Debug)] -pub enum SlashstepQLParseError { - PostgresError(postgres::Error), - SlashstepQLSanitizeError(SlashstepQLSanitizeError), - UUIDError(uuid::Error), - String(String) -} - -impl From for SlashstepQLParseError { - fn from(error: postgres::Error) -> Self { - SlashstepQLParseError::PostgresError(error) - } -} - -impl From for SlashstepQLParseError { - fn from(error: SlashstepQLSanitizeError) -> Self { - SlashstepQLParseError::SlashstepQLSanitizeError(error) - } -} - -impl From for SlashstepQLParseError { - fn from(error: uuid::Error) -> Self { - SlashstepQLParseError::UUIDError(error) - } -} - -impl From for SlashstepQLParseError { - fn from(error: String) -> Self { - SlashstepQLParseError::String(error) - } -} - -#[derive(Debug)] -pub enum AccessPolicyCountError { - PostgresError(postgres::Error), - CountNotFoundError(()), - SlashstepQLSanitizeError(SlashstepQLSanitizeError) -} - -impl From for AccessPolicyCountError { - fn from(error: postgres::Error) -> Self { - AccessPolicyCountError::PostgresError(error) - } -} - -impl From for AccessPolicyCountError { - fn from(error: SlashstepQLSanitizeError) -> Self { - AccessPolicyCountError::SlashstepQLSanitizeError(error) - } -} - -impl From for AccessPolicyCreationError { - fn from(error: ResourceAlreadyExistsError) -> Self { - AccessPolicyCreationError::ResourceAlreadyExistsError(error) - } -} - -impl From for AccessPolicyCreationError { - fn from(error: String) -> Self { - AccessPolicyCreationError::String(error) - } -} - -pub struct EditableAccessPolicyProperties { - - permission_level: Option, - - inheritance_level: Option, - -} - -pub type ResourceHierarchy<'a> = Vec<(&'a AccessPolicyScopedResourceType, Option<&'a Uuid>)>; - -/// A piece of information that defines the level of access and inheritance for a principal to perform an action. -pub struct AccessPolicy { - - /// The access policy's ID. - pub id: Uuid, - - /// The action ID that this access policy refers to. - pub action_id: Uuid, - - pub permission_level: AccessPolicyPermissionLevel, - - pub inheritance_level: AccessPolicyInheritanceLevel, - - pub principal_type: AccessPolicyPrincipalType, - - pub principal_user_id: Option, - - pub principal_group_id: Option, - - pub principal_role_id: Option, - - pub principal_app_id: Option, - - pub scoped_resource_type: AccessPolicyScopedResourceType, - - pub scoped_action_id: Option, - - pub scoped_app_id: Option, - - pub scoped_group_id: Option, - - pub scoped_item_id: Option, - - pub scoped_milestone_id: Option, - - pub scoped_project_id: Option, - - pub scoped_role_id: Option, - - pub scoped_user_id: Option, - - pub scoped_workspace_id: Option - -} - -impl AccessPolicy { - - /* Static methods */ - /// Counts the number of access policies based on a query. - pub fn count(query: &str, postgres_client: &mut postgres::Client) -> Result { - - // Prepare the query. - let sanitizer_options = SlashstepQLSanitizeFunctionOptions { - filter: query.to_string(), - allowed_fields: ALLOWED_QUERY_KEYS.into_iter().map(|string| string.to_string()).collect(), - default_limit: None, - maximum_limit: None, - should_ignore_limit: true, - should_ignore_offset: true - }; - let sanitized_filter = SlashstepQLFilterSanitizer::sanitize(&sanitizer_options)?; - let where_clause = sanitized_filter.where_clause.and_then(|string| Some(format!(" where {}", string))).unwrap_or("".to_string()); - let query = format!("select count(*) from hydrated_access_policies{}", where_clause); - - // Execute the query and return the count. - let rows = postgres_client.query_one(&query, &[])?; - let count = rows.get(0); - return Ok(count); - - } - - /// Creates a new access policy. - pub fn create(initial_properties: &InitialAccessPolicyProperties, postgres_client: &mut postgres::Client) -> Result { - - // Insert the access policy into the database. - let query = include_str!("../queries/access-policies/insert-access-policy-row.sql"); - let parameters: &[&(dyn ToSql + Sync)] = &[ - &initial_properties.principal_type, - &initial_properties.principal_user_id, - &initial_properties.principal_group_id, - &initial_properties.principal_role_id, - &initial_properties.principal_app_id, - &initial_properties.scoped_resource_type, - &initial_properties.scoped_action_id, - &initial_properties.scoped_app_id, - &initial_properties.scoped_group_id, - &initial_properties.scoped_item_id, - &initial_properties.scoped_milestone_id, - &initial_properties.scoped_project_id, - &initial_properties.scoped_role_id, - &initial_properties.scoped_user_id, - &initial_properties.scoped_workspace_id, - &initial_properties.permission_level, - &initial_properties.inheritance_level, - &initial_properties.action_id - ]; - let row_result = postgres_client.query_one(query, parameters); - - // Return the access policy. - match row_result { - - Ok(row) => { - - let access_policy = AccessPolicy { - id: row.get("id"), - action_id: row.get("action_id"), - permission_level: row.get("permission_level"), - inheritance_level: row.get("inheritance_level"), - principal_type: row.get("principal_type"), - principal_user_id: row.get("principal_user_id"), - principal_group_id: row.get("principal_group_id"), - principal_role_id: row.get("principal_role_id"), - principal_app_id: row.get("principal_app_id"), - scoped_resource_type: row.get("scoped_resource_type"), - scoped_action_id: row.get("scoped_action_id"), - scoped_app_id: row.get("scoped_app_id"), - scoped_group_id: row.get("scoped_group_id"), - scoped_item_id: row.get("scoped_item_id"), - scoped_milestone_id: row.get("scoped_milestone_id"), - scoped_project_id: row.get("scoped_project_id"), - scoped_role_id: row.get("scoped_role_id"), - scoped_user_id: row.get("scoped_user_id"), - scoped_workspace_id: row.get("scoped_workspace_id") - }; - - return Ok(access_policy); - - }, - - Err(error) => { - - match error.as_db_error() { - - Some(db_error) => { - - match db_error.code() { - - &SqlState::UNIQUE_VIOLATION => { - - let resource_already_exists_error = ResourceAlreadyExistsError { - resource_type: "Action".to_string() - }; - - return Err(AccessPolicyCreationError::ResourceAlreadyExistsError(resource_already_exists_error)) - - }, - - _ => { - return Err(AccessPolicyCreationError::PostgresError(error)) - } - - } - - }, - - None => return Err(AccessPolicyCreationError::PostgresError(error)) - - } - - } - - }; - - } - - /// Gets an access policy by its ID. - pub fn get_by_id(id: &Uuid, postgres_client: &mut postgres::Client) -> Result { - - let query = include_str!("../queries/access-policies/get-access-policy-row-by-id.sql"); - let parameters: &[&(dyn ToSql + Sync)] = &[&id]; - let row = postgres_client.query_one(query, parameters)?; - let access_policy = AccessPolicy::convert_from_row(&row); - return Ok(access_policy); - - } - - fn convert_from_row(row: &postgres::Row) -> Self { - - return AccessPolicy { - id: row.get("id"), - action_id: row.get("action_id"), - permission_level: row.get("permission_level"), - inheritance_level: row.get("inheritance_level"), - principal_type: row.get("principal_type"), - principal_user_id: row.get("principal_user_id"), - principal_group_id: row.get("principal_group_id"), - principal_role_id: row.get("principal_role_id"), - principal_app_id: row.get("principal_app_id"), - scoped_resource_type: row.get("scoped_resource_type"), - scoped_action_id: row.get("scoped_action_id"), - scoped_app_id: row.get("scoped_app_id"), - scoped_group_id: row.get("scoped_group_id"), - scoped_item_id: row.get("scoped_item_id"), - scoped_milestone_id: row.get("scoped_milestone_id"), - scoped_project_id: row.get("scoped_project_id"), - scoped_role_id: row.get("scoped_role_id"), - scoped_user_id: row.get("scoped_user_id"), - scoped_workspace_id: row.get("scoped_workspace_id") - }; - - } - - /// Initializes the access policies table. - pub fn initialize_access_policies_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let table_query = include_str!("../queries/access-policies/initialize-access-policies-table.sql"); - postgres_client.execute(table_query, &[])?; - - let view_query = include_str!("../queries/access-policies/initialize-hydrated-access-policies-view.sql"); - postgres_client.execute(view_query, &[])?; - return Ok(()); - - } - - fn parse_slashstepql_parameters(slashstepql_parameters: &Vec<(String, SlashstepQLParameterType)>) -> Result>, SlashstepQLParseError> { - - let mut parameters: Vec> = Vec::new(); - - for (key, value) in slashstepql_parameters { - - match value { - - SlashstepQLParameterType::String(string_value) => { - - if UUID_QUERY_KEYS.contains(&key.as_str()) { - - let uuid = Uuid::parse_str(string_value)?; - parameters.push(Box::new(uuid)); - - } else { - - match key.as_str() { - - "scoped_resource_type" => { - - let scoped_resource_type = AccessPolicyScopedResourceType::from_str(string_value)?; - parameters.push(Box::new(scoped_resource_type)); - - }, - - "principal_type" => { - - let principal_type = AccessPolicyPrincipalType::from_str(string_value)?; - parameters.push(Box::new(principal_type)); - - }, - - "inheritance_level" => { - - let inheritance_level = AccessPolicyInheritanceLevel::from_str(string_value)?; - parameters.push(Box::new(inheritance_level)); - - }, - - "permission_level" => { - - let permission_level = AccessPolicyPermissionLevel::from_str(string_value)?; - parameters.push(Box::new(permission_level)); - - }, - - _ => { - - parameters.push(Box::new(string_value)); - - } - - } - - } - - }, - - SlashstepQLParameterType::Number(number_value) => { - - parameters.push(Box::new(number_value)); - - }, - - SlashstepQLParameterType::Boolean(boolean_value) => { - - parameters.push(Box::new(boolean_value)); - - } - - } - - } - - return Ok(parameters); - - } - - /// Returns a list of access policies based on a query. - pub fn list(query: &str, postgres_client: &mut postgres::Client) -> Result, AccessPolicyListError> { - - // Prepare the query. - let sanitizer_options = SlashstepQLSanitizeFunctionOptions { - filter: query.to_string(), - allowed_fields: ALLOWED_QUERY_KEYS.into_iter().map(|string| string.to_string()).collect(), - default_limit: Some(DEFAULT_ACCESS_POLICY_LIST_LIMIT), - maximum_limit: None, - should_ignore_limit: false, - should_ignore_offset: false - }; - let sanitized_filter = SlashstepQLFilterSanitizer::sanitize(&sanitizer_options)?; - let where_clause = sanitized_filter.where_clause.and_then(|string| Some(format!(" where {}", string))).unwrap_or("".to_string()); - let limit_clause = sanitized_filter.limit.and_then(|limit| Some(format!(" limit {}", limit))).unwrap_or("".to_string()); - let offset_clause = sanitized_filter.offset.and_then(|offset| Some(format!(" offset {}", offset))).unwrap_or("".to_string()); - let query = format!("select * from hydrated_access_policies{}{}{}", where_clause, limit_clause, offset_clause); - - // Execute the query. - let parsed_parameters = Self::parse_slashstepql_parameters(&sanitized_filter.parameters)?; - let parameters = parsed_parameters.iter().map(|parameter| parameter.as_ref()).collect::>(); - let rows = postgres_client.query(&query, ¶meters)?; - let access_policies = rows.iter().map(AccessPolicy::convert_from_row).collect(); - return Ok(access_policies); - - } - - /// Returns a list of access policies based on a hierarchy. - pub fn list_by_hierarchy(resource_hierarchy: &ResourceHierarchy, action_id: &Uuid, postgres_client: &mut postgres::Client) -> Result, AccessPolicyListError> { - - let mut query_clauses: Vec = Vec::new(); - - for (resource_type, resource_id) in resource_hierarchy { - - match resource_type { - - AccessPolicyScopedResourceType::Instance => query_clauses.push(format!("scoped_resource_type = 'Instance'")), - AccessPolicyScopedResourceType::Workspace => query_clauses.push(format!("scoped_workspace_id = {}", resource_id.expect("A workspace ID must be provided."))), - AccessPolicyScopedResourceType::Project => query_clauses.push(format!("scoped_project_id = {}", resource_id.expect("A project ID must be provided."))), - AccessPolicyScopedResourceType::Milestone => query_clauses.push(format!("scoped_milestone_id = {}", resource_id.expect("A milestone ID must be provided."))), - AccessPolicyScopedResourceType::Item => query_clauses.push(format!("scoped_item_id = {}", resource_id.expect("An item ID must be provided."))), - AccessPolicyScopedResourceType::Action => query_clauses.push(format!("scoped_action_id = {}", resource_id.expect("An action ID must be provided."))), - AccessPolicyScopedResourceType::User => query_clauses.push(format!("scoped_user_id = {}", resource_id.expect("A user ID must be provided."))), - AccessPolicyScopedResourceType::Role => query_clauses.push(format!("scoped_role_id = {}", resource_id.expect("A role ID must be provided."))), - AccessPolicyScopedResourceType::Group => query_clauses.push(format!("scoped_group_id = {}", resource_id.expect("A group ID must be provided."))), - AccessPolicyScopedResourceType::App => query_clauses.push(format!("scoped_app_id = {}", resource_id.expect("An app ID must be provided."))), - AccessPolicyScopedResourceType::AppCredential => query_clauses.push(format!("scoped_app_credential_id = {}", resource_id.expect("An app credential ID must be provided."))) - - } - - } - - // This will turn the query into something like: - // action_id = $1 and (scoped_resource_type = 'Instance' or scoped_workspace_id = $2 or scoped_project_id = $3 or scoped_milestone_id = $4 or scoped_item_id = $5) - let mut query_filter = String::new(); - query_filter.push_str(format!("action_id = {} and (", quote_literal(&action_id.to_string())).as_str()); - for i in 0..query_clauses.len() { - - if i > 0 { - - query_filter.push_str(" or "); - - } - - query_filter.push_str(&query_clauses[i]); - - } - query_filter.push_str(")"); - - let access_policies: Vec = AccessPolicy::list(&query_filter, postgres_client)?; - - return Ok(access_policies); - - } - - /* Instance methods */ - /// Deletes this access policy. - pub fn delete(&self, postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/access-policies/delete-access-policy-row.sql"); - postgres_client.execute(query, &[&self.id])?; - return Ok(()); - - } - - fn add_parameter(mut parameter_boxes: Vec>, mut query: String, key: &str, parameter_value: &Option) -> (Vec>, String) { - - if let Some(parameter_value) = parameter_value.clone() { - - query.push_str(format!("{}{} = ${}", if parameter_boxes.len() > 0 { ", " } else { "" }, key, parameter_boxes.len() + 1).as_str()); - parameter_boxes.push(Box::new(parameter_value)); - - } - - return (parameter_boxes, query); - - } - - /// Updates this access policy and returns a new instance of the access policy. - pub fn update(&self, properties: &EditableAccessPolicyProperties, postgres_client: &mut postgres::Client) -> Result { - - let query = String::from("update access_policies set "); - let parameter_boxes: Vec> = Vec::new(); - - postgres_client.query("begin;", &[])?; - let (parameter_boxes, query) = Self::add_parameter(parameter_boxes, query, "permission_level", &properties.permission_level); - let (mut parameter_boxes, mut query) = Self::add_parameter(parameter_boxes, query, "inheritance_level", &properties.inheritance_level); - - query.push_str(format!(" where id = ${} returning *;", parameter_boxes.len() + 1).as_str()); - parameter_boxes.push(Box::new(&self.id)); - let parameters = parameter_boxes.iter().map(|parameter| parameter.as_ref()).collect::>(); - let row = postgres_client.query_one(&query, ¶meters)?; - postgres_client.query("commit;", &[])?; - - let access_policy = AccessPolicy::convert_from_row(&row); - return Ok(access_policy); - - } - -} - -/// To reduce line count, tests are in a separate module. -#[cfg(test)] -mod tests; \ No newline at end of file diff --git a/src/resources/access_policy/mod.rs b/src/resources/access_policy/mod.rs new file mode 100644 index 0000000..7e3eed0 --- /dev/null +++ b/src/resources/access_policy/mod.rs @@ -0,0 +1,1182 @@ + +/** + * + * This module defines the implementation and types of an access policy. + * + * Programmers: + * - Christian Toney (https://christiantoney.com) + * + * © 2025 Beastslash LLC + * + */ + +use core::{fmt}; +use std::str::FromStr; +use pg_escape::quote_literal; +use postgres::{ + error::SqlState, + types::ToSql +}; +use postgres_types::FromSql; +use serde::Serialize; +use thiserror::Error; +use uuid::Uuid; +use crate::{ + resources::{action::{Action, ActionError}, app::{App, AppError, AppParentResourceType}, app_credential::{AppCredential, AppCredentialError}, item::{Item, ItemError}, milestone::{Milestone, MilestoneError, MilestoneParentResourceType}, project::{Project, ProjectError}, role::{Role, RoleError, RoleParentResourceType}}, utilities::slashstepql::{ + SlashstepQLError, + SlashstepQLFilterSanitizer, + SlashstepQLParameterType, + SlashstepQLSanitizeFunctionOptions + } +}; + +pub const ALLOWED_QUERY_KEYS: &[&str] = &[ + "id", + "action_id", + "principal_type", + "principal_user_id", + "principal_group_id", + "principal_role_id", + "principal_app_id", + "scoped_resource_type", + "scoped_action_id", + "scoped_app_id", + "scoped_app_credential_id", + "scoped_group_id", + "scoped_item_id", + "scoped_milestone_id", + "scoped_project_id", + "scoped_role_id", + "scoped_user_id", + "scoped_workspace_id" +]; + +pub const UUID_QUERY_KEYS: &[&str] = &[ + "id", + "action_id", + "principal_user_id", + "principal_group_id", + "principal_role_id", + "principal_app_id", + "scoped_action_id", + "scoped_app_id", + "scoped_group_id", + "scoped_app_credential_id", + "scoped_item_id", + "scoped_milestone_id", + "scoped_project_id", + "scoped_role_id", + "scoped_user_id", + "scoped_workspace_id" +]; + +pub const DEFAULT_ACCESS_POLICY_LIST_LIMIT: i64 = 1000; + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone, Copy, Serialize, Default)] +#[postgres(name = "permission_level")] +pub enum AccessPolicyPermissionLevel { + #[default] + None, + User, + Editor, + Admin +} + +impl fmt::Display for AccessPolicyPermissionLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessPolicyPermissionLevel::None => write!(f, "None"), + AccessPolicyPermissionLevel::User => write!(f, "User"), + AccessPolicyPermissionLevel::Editor => write!(f, "Editor"), + AccessPolicyPermissionLevel::Admin => write!(f, "Admin") + } + } +} + +#[derive(Debug, Error)] +pub enum AccessPolicyError { + #[error("Invalid permission level: {0}")] + InvalidPermissionLevel(String), + + #[error("Invalid inheritance level: {0}")] + InvalidInheritanceLevel(String), + + #[error("Invalid scoped resource type: {0}")] + InvalidScopedResourceType(String), + + #[error("Invalid principal type: {0}")] + InvalidPrincipalType(String), + + #[error("A scoped resource ID is required for the {0} resource type.")] + ScopedResourceIDMissingError(AccessPolicyScopedResourceType), + + #[error("An ancestor resource of type {0} is required for this access policy.")] + OrphanedResourceError(AccessPolicyScopedResourceType), + + #[error("An access policy for action {0} already exists.")] + ConflictError(Uuid), + + #[error(transparent)] + UUIDError(#[from] uuid::Error), + + #[error("Couldn't find an access policy with ID \"{0}\".")] + NotFoundError(Uuid), + + #[error(transparent)] + SlashstepQLError(#[from] SlashstepQLError), + + #[error(transparent)] + PostgresError(#[from] postgres::Error), + + #[error(transparent)] + ProjectError(#[from] ProjectError), + + #[error(transparent)] + ItemError(#[from] ItemError), + + #[error(transparent)] + ActionError(#[from] ActionError), + + #[error(transparent)] + AppError(#[from] AppError), + + #[error(transparent)] + AppCredentialError(#[from] AppCredentialError), + + #[error(transparent)] + RoleError(#[from] RoleError), + + #[error(transparent)] + MilestoneError(#[from] MilestoneError) +} + +impl FromStr for AccessPolicyPermissionLevel { + + type Err = AccessPolicyError; + + fn from_str(string: &str) -> Result { + + match string { + "None" => Ok(AccessPolicyPermissionLevel::None), + "User" => Ok(AccessPolicyPermissionLevel::User), + "Editor" => Ok(AccessPolicyPermissionLevel::Editor), + "Admin" => Ok(AccessPolicyPermissionLevel::Admin), + _ => Err(AccessPolicyError::InvalidPermissionLevel(string.to_string())) + } + + } + +} + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone, Copy, Serialize, Default)] +#[postgres(name = "inheritance_level")] +pub enum AccessPolicyInheritanceLevel { + #[default] + Disabled, + Enabled, + Required +} + +impl fmt::Display for AccessPolicyInheritanceLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessPolicyInheritanceLevel::Disabled => write!(f, "Disabled"), + AccessPolicyInheritanceLevel::Enabled => write!(f, "Enabled"), + AccessPolicyInheritanceLevel::Required => write!(f, "Required") + } + } +} + +impl FromStr for AccessPolicyInheritanceLevel { + + type Err = AccessPolicyError; + + fn from_str(string: &str) -> Result { + + match string { + "Disabled" => Ok(AccessPolicyInheritanceLevel::Disabled), + "Enabled" => Ok(AccessPolicyInheritanceLevel::Enabled), + "Required" => Ok(AccessPolicyInheritanceLevel::Required), + _ => Err(AccessPolicyError::InvalidInheritanceLevel(string.to_string())) + } + + } + +} + +#[derive(Debug, Clone, PartialEq, Eq, ToSql, FromSql, Serialize, Default)] +#[postgres(name = "scoped_resource_type")] +pub enum AccessPolicyScopedResourceType { + #[default] + Instance, + Workspace, + Project, + Item, + Action, + User, + Role, + Group, + App, + AppCredential, + Milestone, +} + +impl fmt::Display for AccessPolicyScopedResourceType { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessPolicyScopedResourceType::Workspace => write!(formatter, "Workspace"), + AccessPolicyScopedResourceType::Project => write!(formatter, "Project"), + AccessPolicyScopedResourceType::Milestone => write!(formatter, "Milestone"), + AccessPolicyScopedResourceType::Item => write!(formatter, "Item"), + AccessPolicyScopedResourceType::Action => write!(formatter, "Action"), + AccessPolicyScopedResourceType::Role => write!(formatter, "Role"), + AccessPolicyScopedResourceType::Group => write!(formatter, "Group"), + AccessPolicyScopedResourceType::User => write!(formatter, "User"), + AccessPolicyScopedResourceType::App => write!(formatter, "App"), + AccessPolicyScopedResourceType::AppCredential => write!(formatter, "AppCredential"), + AccessPolicyScopedResourceType::Instance => write!(formatter, "Instance") + } + } +} + +impl FromStr for AccessPolicyScopedResourceType { + + type Err = AccessPolicyError; + + fn from_str(string: &str) -> Result { + + match string { + "Instance" => Ok(AccessPolicyScopedResourceType::Instance), + "Workspace" => Ok(AccessPolicyScopedResourceType::Workspace), + "Project" => Ok(AccessPolicyScopedResourceType::Project), + "Milestone" => Ok(AccessPolicyScopedResourceType::Milestone), + "Item" => Ok(AccessPolicyScopedResourceType::Item), + "Action" => Ok(AccessPolicyScopedResourceType::Action), + "Role" => Ok(AccessPolicyScopedResourceType::Role), + "Group" => Ok(AccessPolicyScopedResourceType::Group), + "User" => Ok(AccessPolicyScopedResourceType::User), + "App" => Ok(AccessPolicyScopedResourceType::App), + _ => Err(AccessPolicyError::InvalidScopedResourceType(string.to_string())) + } + + } + +} + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Serialize, Default)] +#[postgres(name = "principal_type")] +pub enum AccessPolicyPrincipalType { + + /// A resource that identifies a user. + #[default] + User, + + /// A resource that identifies multiple users, apps, and other groups. + Group, + + /// A resource that identifies a role. + Role, + + /// A resource that identifies an app. + App + +} + +impl fmt::Display for AccessPolicyPrincipalType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessPolicyPrincipalType::App => write!(f, "App"), + AccessPolicyPrincipalType::Group => write!(f, "Group"), + AccessPolicyPrincipalType::Role => write!(f, "Role"), + AccessPolicyPrincipalType::User => write!(f, "User") + } + } +} + +impl FromStr for AccessPolicyPrincipalType { + + type Err = AccessPolicyError; + + fn from_str(string: &str) -> Result { + + match string { + "App" => Ok(AccessPolicyPrincipalType::App), + "Group" => Ok(AccessPolicyPrincipalType::Group), + "Role" => Ok(AccessPolicyPrincipalType::Role), + "User" => Ok(AccessPolicyPrincipalType::User), + _ => Err(AccessPolicyError::InvalidPrincipalType(string.to_string())) + } + + } + +} + +#[derive(Debug, Default)] +pub struct InitialAccessPolicyProperties { + + pub action_id: Uuid, + + pub permission_level: AccessPolicyPermissionLevel, + + pub inheritance_level: AccessPolicyInheritanceLevel, + + pub principal_type: AccessPolicyPrincipalType, + + pub principal_user_id: Option, + + pub principal_group_id: Option, + + pub principal_role_id: Option, + + pub principal_app_id: Option, + + pub scoped_resource_type: AccessPolicyScopedResourceType, + + pub scoped_action_id: Option, + + pub scoped_app_id: Option, + + pub scoped_app_credential_id: Option, + + pub scoped_group_id: Option, + + pub scoped_item_id: Option, + + pub scoped_milestone_id: Option, + + pub scoped_project_id: Option, + + pub scoped_role_id: Option, + + pub scoped_user_id: Option, + + pub scoped_workspace_id: Option + +} + +pub struct EditableAccessPolicyProperties { + + permission_level: Option, + + inheritance_level: Option, + +} + +pub type ResourceHierarchy = Vec<(AccessPolicyScopedResourceType, Option)>; + +/// A piece of information that defines the level of access and inheritance for a principal to perform an action. +#[derive(Debug, Serialize)] +pub struct AccessPolicy { + + /// The access policy's ID. + pub id: Uuid, + + /// The action ID that this access policy refers to. + pub action_id: Uuid, + + pub permission_level: AccessPolicyPermissionLevel, + + pub inheritance_level: AccessPolicyInheritanceLevel, + + pub principal_type: AccessPolicyPrincipalType, + + pub principal_user_id: Option, + + pub principal_group_id: Option, + + pub principal_role_id: Option, + + pub principal_app_id: Option, + + pub scoped_resource_type: AccessPolicyScopedResourceType, + + pub scoped_action_id: Option, + + pub scoped_app_id: Option, + + pub scoped_app_credential_id: Option, + + pub scoped_group_id: Option, + + pub scoped_item_id: Option, + + pub scoped_milestone_id: Option, + + pub scoped_project_id: Option, + + pub scoped_role_id: Option, + + pub scoped_user_id: Option, + + pub scoped_workspace_id: Option + +} + +impl AccessPolicy { + + /* Static methods */ + /// Counts the number of access policies based on a query. + pub async fn count(query: &str, postgres_client: &mut deadpool_postgres::Client) -> Result { + + // Prepare the query. + let sanitizer_options = SlashstepQLSanitizeFunctionOptions { + filter: query.to_string(), + allowed_fields: ALLOWED_QUERY_KEYS.into_iter().map(|string| string.to_string()).collect(), + default_limit: None, + maximum_limit: None, + should_ignore_limit: true, + should_ignore_offset: true + }; + let sanitized_filter = SlashstepQLFilterSanitizer::sanitize(&sanitizer_options)?; + let where_clause = sanitized_filter.where_clause.and_then(|string| Some(format!(" where {}", string))).unwrap_or("".to_string()); + let query = format!("select count(*) from hydrated_access_policies{}", where_clause); + + // Execute the query and return the count. + let rows = postgres_client.query_one(&query, &[]).await?; + let count = rows.get(0); + return Ok(count); + + } + + /// Creates a new access policy. + pub async fn create(initial_properties: &InitialAccessPolicyProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + // Insert the access policy into the database. + let query = include_str!("../../queries/access-policies/insert-access-policy-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.principal_type, + &initial_properties.principal_user_id, + &initial_properties.principal_group_id, + &initial_properties.principal_role_id, + &initial_properties.principal_app_id, + &initial_properties.scoped_resource_type, + &initial_properties.scoped_action_id, + &initial_properties.scoped_app_id, + &initial_properties.scoped_app_credential_id, + &initial_properties.scoped_group_id, + &initial_properties.scoped_item_id, + &initial_properties.scoped_milestone_id, + &initial_properties.scoped_project_id, + &initial_properties.scoped_role_id, + &initial_properties.scoped_user_id, + &initial_properties.scoped_workspace_id, + &initial_properties.permission_level, + &initial_properties.inheritance_level, + &initial_properties.action_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => { + + match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => AccessPolicyError::ConflictError(initial_properties.action_id), + + _ => AccessPolicyError::PostgresError(error) + + } + + }, + + None => AccessPolicyError::PostgresError(error) + + })?; + + let access_policy = AccessPolicy { + id: row.get("id"), + action_id: row.get("action_id"), + permission_level: row.get("permission_level"), + inheritance_level: row.get("inheritance_level"), + principal_type: row.get("principal_type"), + principal_user_id: row.get("principal_user_id"), + principal_group_id: row.get("principal_group_id"), + principal_role_id: row.get("principal_role_id"), + principal_app_id: row.get("principal_app_id"), + scoped_resource_type: row.get("scoped_resource_type"), + scoped_action_id: row.get("scoped_action_id"), + scoped_app_id: row.get("scoped_app_id"), + scoped_app_credential_id: row.get("scoped_app_credential_id"), + scoped_group_id: row.get("scoped_group_id"), + scoped_item_id: row.get("scoped_item_id"), + scoped_milestone_id: row.get("scoped_milestone_id"), + scoped_project_id: row.get("scoped_project_id"), + scoped_role_id: row.get("scoped_role_id"), + scoped_user_id: row.get("scoped_user_id"), + scoped_workspace_id: row.get("scoped_workspace_id") + }; + + return Ok(access_policy); + + } + + /// Gets an access policy by its ID. + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/access-policies/get-access-policy-row-by-id.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[&id]; + let row = match postgres_client.query_opt(query, parameters).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(AccessPolicyError::NotFoundError(id.clone())) + + }, + + Err(error) => return Err(AccessPolicyError::PostgresError(error)) + + }; + + let access_policy = AccessPolicy::convert_from_row(&row); + + return Ok(access_policy); + + } + + fn convert_from_row(row: &postgres::Row) -> Self { + + return AccessPolicy { + id: row.get("id"), + action_id: row.get("action_id"), + permission_level: row.get("permission_level"), + inheritance_level: row.get("inheritance_level"), + principal_type: row.get("principal_type"), + principal_user_id: row.get("principal_user_id"), + principal_group_id: row.get("principal_group_id"), + principal_role_id: row.get("principal_role_id"), + principal_app_id: row.get("principal_app_id"), + scoped_resource_type: row.get("scoped_resource_type"), + scoped_action_id: row.get("scoped_action_id"), + scoped_app_id: row.get("scoped_app_id"), + scoped_app_credential_id: row.get("scoped_app_credential_id"), + scoped_group_id: row.get("scoped_group_id"), + scoped_item_id: row.get("scoped_item_id"), + scoped_milestone_id: row.get("scoped_milestone_id"), + scoped_project_id: row.get("scoped_project_id"), + scoped_role_id: row.get("scoped_role_id"), + scoped_user_id: row.get("scoped_user_id"), + scoped_workspace_id: row.get("scoped_workspace_id") + }; + + } + + /// Initializes the access policies table. + pub async fn initialize_access_policies_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), AccessPolicyError> { + + let table_query = include_str!("../../queries/access-policies/initialize-access-policies-table.sql"); + postgres_client.execute(table_query, &[]).await?; + + let view_query = include_str!("../../queries/access-policies/initialize-hydrated-access-policies-view.sql"); + postgres_client.execute(view_query, &[]).await?; + return Ok(()); + + } + + fn parse_slashstepql_parameters(slashstepql_parameters: &Vec<(String, SlashstepQLParameterType)>) -> Result>, AccessPolicyError> { + + let mut parameters: Vec> = Vec::new(); + + for (key, value) in slashstepql_parameters { + + match value { + + SlashstepQLParameterType::String(string_value) => { + + if UUID_QUERY_KEYS.contains(&key.as_str()) { + + let uuid = Uuid::parse_str(string_value)?; + parameters.push(Box::new(uuid)); + + } else { + + match key.as_str() { + + "scoped_resource_type" => { + + let scoped_resource_type = AccessPolicyScopedResourceType::from_str(string_value)?; + parameters.push(Box::new(scoped_resource_type)); + + }, + + "principal_type" => { + + let principal_type = AccessPolicyPrincipalType::from_str(string_value)?; + parameters.push(Box::new(principal_type)); + + }, + + "inheritance_level" => { + + let inheritance_level = AccessPolicyInheritanceLevel::from_str(string_value)?; + parameters.push(Box::new(inheritance_level)); + + }, + + "permission_level" => { + + let permission_level = AccessPolicyPermissionLevel::from_str(string_value)?; + parameters.push(Box::new(permission_level)); + + }, + + _ => { + + parameters.push(Box::new(string_value)); + + } + + } + + } + + }, + + SlashstepQLParameterType::Number(number_value) => { + + parameters.push(Box::new(number_value)); + + }, + + SlashstepQLParameterType::Boolean(boolean_value) => { + + parameters.push(Box::new(boolean_value)); + + } + + } + + } + + return Ok(parameters); + + } + + /// Returns a list of access policies based on a query. + pub async fn list(query: &str, postgres_client: &mut deadpool_postgres::Client) -> Result, AccessPolicyError> { + + // Prepare the query. + let sanitizer_options = SlashstepQLSanitizeFunctionOptions { + filter: query.to_string(), + allowed_fields: ALLOWED_QUERY_KEYS.into_iter().map(|string| string.to_string()).collect(), + default_limit: Some(DEFAULT_ACCESS_POLICY_LIST_LIMIT), + maximum_limit: None, + should_ignore_limit: false, + should_ignore_offset: false + }; + let sanitized_filter = SlashstepQLFilterSanitizer::sanitize(&sanitizer_options)?; + let where_clause = sanitized_filter.where_clause.and_then(|string| Some(format!(" where {}", string))).unwrap_or("".to_string()); + let limit_clause = sanitized_filter.limit.and_then(|limit| Some(format!(" limit {}", limit))).unwrap_or("".to_string()); + let offset_clause = sanitized_filter.offset.and_then(|offset| Some(format!(" offset {}", offset))).unwrap_or("".to_string()); + let query = format!("select * from hydrated_access_policies{}{}{}", where_clause, limit_clause, offset_clause); + + // Execute the query. + let parsed_parameters = Self::parse_slashstepql_parameters(&sanitized_filter.parameters)?; + let parameters: Vec<&(dyn ToSql + Sync)> = parsed_parameters.iter().map(|parameter| parameter.as_ref() as &(dyn ToSql + Sync)).collect(); + let rows = postgres_client.query(&query, ¶meters).await?; + let access_policies = rows.iter().map(AccessPolicy::convert_from_row).collect(); + return Ok(access_policies); + + } + + /// Returns a list of access policies based on a hierarchy. + pub async fn list_by_hierarchy(resource_hierarchy: &ResourceHierarchy, action_id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result, AccessPolicyError> { + + let mut query_clauses: Vec = Vec::new(); + + for (resource_type, resource_id) in resource_hierarchy { + + match resource_type { + + AccessPolicyScopedResourceType::Instance => query_clauses.push(format!("scoped_resource_type = 'Instance'")), + AccessPolicyScopedResourceType::Workspace => query_clauses.push(format!("scoped_workspace_id = {}", resource_id.expect("A workspace ID must be provided."))), + AccessPolicyScopedResourceType::Project => query_clauses.push(format!("scoped_project_id = {}", resource_id.expect("A project ID must be provided."))), + AccessPolicyScopedResourceType::Milestone => query_clauses.push(format!("scoped_milestone_id = {}", resource_id.expect("A milestone ID must be provided."))), + AccessPolicyScopedResourceType::Item => query_clauses.push(format!("scoped_item_id = {}", resource_id.expect("An item ID must be provided."))), + AccessPolicyScopedResourceType::Action => query_clauses.push(format!("scoped_action_id = {}", resource_id.expect("An action ID must be provided."))), + AccessPolicyScopedResourceType::User => query_clauses.push(format!("scoped_user_id = {}", resource_id.expect("A user ID must be provided."))), + AccessPolicyScopedResourceType::Role => query_clauses.push(format!("scoped_role_id = {}", resource_id.expect("A role ID must be provided."))), + AccessPolicyScopedResourceType::Group => query_clauses.push(format!("scoped_group_id = {}", resource_id.expect("A group ID must be provided."))), + AccessPolicyScopedResourceType::App => query_clauses.push(format!("scoped_app_id = {}", resource_id.expect("An app ID must be provided."))), + AccessPolicyScopedResourceType::AppCredential => query_clauses.push(format!("scoped_app_credential_id = {}", resource_id.expect("An app credential ID must be provided."))) + + } + + } + + // This will turn the query into something like: + // action_id = $1 and (scoped_resource_type = 'Instance' or scoped_workspace_id = $2 or scoped_project_id = $3 or scoped_milestone_id = $4 or scoped_item_id = $5) + let mut query_filter = String::new(); + query_filter.push_str(format!("action_id = {} and (", quote_literal(&action_id.to_string())).as_str()); + for i in 0..query_clauses.len() { + + if i > 0 { + + query_filter.push_str(" or "); + + } + + query_filter.push_str(&query_clauses[i]); + + } + query_filter.push_str(")"); + + let access_policies: Vec = AccessPolicy::list(&query_filter, postgres_client).await?; + + return Ok(access_policies); + + } + + /* Instance methods */ + /// Deletes this access policy. + pub async fn delete(&self, postgres_client: &mut deadpool_postgres::Client) -> Result<(), AccessPolicyError> { + + let query = include_str!("../../queries/access-policies/delete-access-policy-row.sql"); + postgres_client.execute(query, &[&self.id]).await?; + return Ok(()); + + } + + fn add_parameter(mut parameter_boxes: Vec>, mut query: String, key: &str, parameter_value: &Option) -> (Vec>, String) { + + if let Some(parameter_value) = parameter_value.clone() { + + query.push_str(format!("{}{} = ${}", if parameter_boxes.len() > 0 { ", " } else { "" }, key, parameter_boxes.len() + 1).as_str()); + parameter_boxes.push(Box::new(parameter_value)); + + } + + return (parameter_boxes, query); + + } + + /// Updates this access policy and returns a new instance of the access policy. + pub async fn update(&self, properties: &EditableAccessPolicyProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = String::from("update access_policies set "); + let parameter_boxes: Vec> = Vec::new(); + + postgres_client.query("begin;", &[]).await?; + let (parameter_boxes, query) = Self::add_parameter(parameter_boxes, query, "permission_level", &properties.permission_level); + let (mut parameter_boxes, mut query) = Self::add_parameter(parameter_boxes, query, "inheritance_level", &properties.inheritance_level); + + query.push_str(format!(" where id = ${} returning *;", parameter_boxes.len() + 1).as_str()); + parameter_boxes.push(Box::new(&self.id)); + let parameters = parameter_boxes.iter().map(|parameter| parameter.as_ref()).collect::>(); + let row = postgres_client.query_one(&query, ¶meters).await?; + postgres_client.query("commit;", &[]).await?; + + let access_policy = AccessPolicy::convert_from_row(&row); + return Ok(access_policy); + + } + + pub async fn get_hierarchy(&self, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let mut hierarchy: ResourceHierarchy = vec![]; + let mut selected_resource_type: AccessPolicyScopedResourceType = self.scoped_resource_type.clone(); + let mut selected_resource_id: Option = match self.scoped_resource_type { + + AccessPolicyScopedResourceType::Instance => None, + + AccessPolicyScopedResourceType::Action => self.scoped_action_id, + + AccessPolicyScopedResourceType::App => self.scoped_app_id, + + AccessPolicyScopedResourceType::AppCredential => self.scoped_app_credential_id, + + AccessPolicyScopedResourceType::Group => self.scoped_group_id, + + AccessPolicyScopedResourceType::Item => self.scoped_item_id, + + AccessPolicyScopedResourceType::Milestone => self.scoped_milestone_id, + + AccessPolicyScopedResourceType::Project => self.scoped_project_id, + + AccessPolicyScopedResourceType::Role => self.scoped_role_id, + + AccessPolicyScopedResourceType::User => self.scoped_user_id, + + AccessPolicyScopedResourceType::Workspace => self.scoped_workspace_id + + }; + + loop { + + match selected_resource_type { + + // Instance + AccessPolicyScopedResourceType::Instance => break, + + // Action -> (App | Instance) + AccessPolicyScopedResourceType::Action => { + + let Some(action_id) = selected_resource_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Action)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Action, Some(action_id))); + + let action = match Action::get_by_id(&action_id, postgres_client).await { + + Ok(action) => action, + + Err(error) => match error { + + ActionError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::Action)), + + _ => return Err(AccessPolicyError::ActionError(error)) + + } + + }; + + if let Some(app_id) = action.app_id { + + selected_resource_type = AccessPolicyScopedResourceType::App; + selected_resource_id = Some(app_id); + + } else { + + selected_resource_type = AccessPolicyScopedResourceType::Instance; + selected_resource_id = None; + + } + + } + + // Workspace -> Instance + AccessPolicyScopedResourceType::Workspace => { + + let Some(scoped_workspace_id) = self.scoped_workspace_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Workspace)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Workspace, Some(scoped_workspace_id))); + + }, + + // Project -> Workspace + AccessPolicyScopedResourceType::Project => { + + let Some(scoped_project_id) = self.scoped_project_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Project)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Project, Some(scoped_project_id))); + + let project = match Project::get_by_id(&scoped_project_id, postgres_client).await { + + Ok(project) => project, + + Err(error) => match error { + + ProjectError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::Project)), + + _ => return Err(AccessPolicyError::ProjectError(error)) + + } + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Workspace; + selected_resource_id = Some(project.workspace_id); + + }, + + // Item -> Project + AccessPolicyScopedResourceType::Item => { + + let Some(scoped_item_id) = self.scoped_item_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Item)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Item, Some(scoped_item_id))); + + let item = Item::get_by_id(&scoped_item_id, postgres_client).await?; + + selected_resource_type = AccessPolicyScopedResourceType::Project; + selected_resource_id = Some(item.project_id); + + }, + + // User -> Instance + AccessPolicyScopedResourceType::User => { + + let Some(scoped_user_id) = self.scoped_user_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::User)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::User, Some(scoped_user_id))); + + }, + + // Role -> (Project | Workspace | Group | Instance) + AccessPolicyScopedResourceType::Role => { + + let Some(scoped_role_id) = self.scoped_role_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Role)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Role, Some(scoped_role_id))); + + let role = match Role::get_by_id(&scoped_role_id, postgres_client).await { + + Ok(role) => role, + + Err(error) => match error { + + RoleError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::Role)), + + _ => return Err(AccessPolicyError::RoleError(error)) + + } + + }; + + match role.parent_resource_type { + + RoleParentResourceType::Instance => { + + selected_resource_type = AccessPolicyScopedResourceType::Instance; + selected_resource_id = None; + + }, + + RoleParentResourceType::Workspace => { + + let Some(workspace_id) = role.parent_workspace_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Workspace)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Workspace; + selected_resource_id = Some(workspace_id); + + }, + + RoleParentResourceType::Project => { + + let Some(project_id) = role.parent_project_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Project)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Project; + selected_resource_id = Some(project_id); + + }, + + RoleParentResourceType::Group => { + + let Some(group_id) = role.parent_group_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Group)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Group; + selected_resource_id = Some(group_id); + + } + + } + + }, + + // Group -> Instance + AccessPolicyScopedResourceType::Group => { + + let Some(group_id) = selected_resource_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Group)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Group, Some(group_id))); + + selected_resource_type = AccessPolicyScopedResourceType::Instance; + selected_resource_id = None; + + } + + // App -> (Workspace | User | Instance) + AccessPolicyScopedResourceType::App => { + + let Some(app_id) = selected_resource_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::App)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::App, Some(app_id))); + + let app = match App::get_by_id(&app_id, postgres_client).await { + + Ok(app) => app, + + Err(error) => match error { + + AppError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::App)), + + _ => return Err(AccessPolicyError::AppError(error)) + + } + + }; + + match app.parent_resource_type { + + AppParentResourceType::Instance => { + + selected_resource_type = AccessPolicyScopedResourceType::Instance; + selected_resource_id = None; + + }, + + AppParentResourceType::Workspace => { + + let Some(workspace_id) = app.parent_workspace_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Workspace)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Workspace; + selected_resource_id = Some(workspace_id); + + }, + + AppParentResourceType::User => { + + let Some(user_id) = app.parent_user_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::User)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::User; + selected_resource_id = Some(user_id); + + } + + } + + } + + // AppCredential -> App + AccessPolicyScopedResourceType::AppCredential => { + + let Some(app_credential_id) = selected_resource_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::AppCredential)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::AppCredential, Some(app_credential_id))); + + let app_credential = match AppCredential::get_by_id(&app_credential_id, postgres_client).await { + + Ok(app_credential) => app_credential, + + Err(error) => match error { + + AppCredentialError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::AppCredential)), + + _ => return Err(AccessPolicyError::AppCredentialError(error)) + + } + + }; + + selected_resource_type = AccessPolicyScopedResourceType::App; + selected_resource_id = Some(app_credential.app_id); + + } + + // Milestone -> (Project | Workspace) + AccessPolicyScopedResourceType::Milestone => { + + let Some(milestone_id) = selected_resource_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Milestone)); + + }; + + hierarchy.push((AccessPolicyScopedResourceType::Milestone, Some(milestone_id))); + + let milestone = match Milestone::get_by_id(&milestone_id, postgres_client).await { + + Ok(milestone) => milestone, + + Err(error) => match error { + + MilestoneError::NotFoundError(_) => return Err(AccessPolicyError::OrphanedResourceError(AccessPolicyScopedResourceType::Milestone)), + + _ => return Err(AccessPolicyError::MilestoneError(error)) + + } + + }; + + match milestone.parent_resource_type { + + MilestoneParentResourceType::Project => { + + let Some(project_id) = milestone.parent_project_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Project)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Project; + selected_resource_id = Some(project_id); + + }, + + MilestoneParentResourceType::Workspace => { + + let Some(workspace_id) = milestone.parent_workspace_id else { + + return Err(AccessPolicyError::ScopedResourceIDMissingError(AccessPolicyScopedResourceType::Workspace)); + + }; + + selected_resource_type = AccessPolicyScopedResourceType::Workspace; + selected_resource_id = Some(workspace_id); + + } + + } + + } + + } + + } + + hierarchy.push((AccessPolicyScopedResourceType::Instance, None)); + + return Ok(hierarchy); + + } + +} + +/// To reduce line count, tests are in a separate module. +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/resources/access_policy/tests.rs b/src/resources/access_policy/tests.rs index 08a8f17..ec109f4 100644 --- a/src/resources/access_policy/tests.rs +++ b/src/resources/access_policy/tests.rs @@ -9,8 +9,8 @@ * */ -use crate::resources::{ - access_policy::{ +use crate::{ + resources::access_policy::{ AccessPolicy, AccessPolicyInheritanceLevel, AccessPolicyPermissionLevel, @@ -19,106 +19,9 @@ use crate::resources::{ DEFAULT_ACCESS_POLICY_LIST_LIMIT, EditableAccessPolicyProperties, InitialAccessPolicyProperties - }, - action::{ - Action, - InitialActionProperties - }, - app::App, - app_authorization::AppAuthorization, - app_authorization_credential::AppAuthorizationCredential, - app_credential::AppCredential, - group::Group, - item::Item, - milestone::Milestone, - project::Project, - role::Role, - user::{InitialUserProperties, User}, - workspace::Workspace + }, tests::TestEnvironment }; -use testcontainers_modules::{testcontainers::runners::SyncRunner}; -use testcontainers::{ImageExt}; -use uuid::Uuid; - -struct TestPostgresEnvironment { - - postgres_client: postgres::Client, - - // This is required to prevent the compiler from complaining about unused fields. - // We need a wrapper struct to fix lifetime issues, but we don't need to use the container for any test right now. - #[allow(dead_code)] - postgres_container: testcontainers::Container - -} - -fn create_test_postgres_environment() -> TestPostgresEnvironment { - - let postgres_container = testcontainers_modules::postgres::Postgres::default() - .with_tag("18") - .start() - .unwrap(); - let postgres_host = postgres_container.get_host().unwrap(); - let postgres_port = postgres_container.get_host_port_ipv4(5432).unwrap(); - let postgres_connection_string = format!("postgres://postgres:postgres@{}:{}", postgres_host, postgres_port); - let mut postgres_client = postgres::Client::connect(&postgres_connection_string, postgres::NoTls).unwrap(); - initialize_required_tables(&mut postgres_client); - - return TestPostgresEnvironment { - postgres_client, - postgres_container: postgres_container - }; - -} - -fn create_random_action(postgres_client: &mut postgres::Client) -> Action { - - let action_properties = InitialActionProperties { - name: Uuid::now_v7().to_string(), - display_name: Uuid::now_v7().to_string(), - description: Uuid::now_v7().to_string(), - app_id: None - }; - - let action = Action::create(&action_properties, postgres_client).unwrap(); - - return action; - -} - -fn create_random_user(postgres_client: &mut postgres::Client) -> User { - - let user_properties = InitialUserProperties { - username: Some(Uuid::now_v7().to_string()), - display_name: Some(Uuid::now_v7().to_string()), - hashed_password: Some(Uuid::now_v7().to_string()), - is_anonymous: false, - ip_address: None - }; - - let user = User::create(&user_properties, postgres_client).unwrap(); - - return user; - -} - -fn initialize_required_tables(postgres_client: &mut postgres::Client) { - - // Because the access_policies table depends on other tables, we need to initialize them in a specific order. - User::initialize_users_table(postgres_client).unwrap(); - Group::initialize_groups_table(postgres_client).unwrap(); - App::initialize_apps_table(postgres_client).unwrap(); - Workspace::initialize_workspaces_table(postgres_client).unwrap(); - Project::initialize_projects_table(postgres_client).unwrap(); - Role::initialize_roles_table(postgres_client).unwrap(); - Item::initialize_items_table(postgres_client).unwrap(); - Action::initialize_actions_table(postgres_client).unwrap(); - AppCredential::initialize_app_credentials_table(postgres_client).unwrap(); - AppAuthorization::initialize_app_authorizations_table(postgres_client).unwrap(); - AppAuthorizationCredential::initialize_app_authorization_credentials_table(postgres_client).unwrap(); - Milestone::initialize_milestones_table(postgres_client).unwrap(); - AccessPolicy::initialize_access_policies_table(postgres_client).unwrap(); - -} +use anyhow::{anyhow, Result}; fn assert_access_policy_is_equal_to_initial_properties(access_policy: &AccessPolicy, initial_properties: &InitialAccessPolicyProperties) { @@ -166,131 +69,83 @@ fn assert_access_policies_are_equal(access_policy_1: &AccessPolicy, access_polic } /// Verifies that an access_policies table can be initialized. -#[test] -fn initialize_access_policies_table() { +#[tokio::test] +async fn initialize_access_policies_table() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; - AccessPolicy::initialize_access_policies_table(&mut postgres_client).unwrap(); + return Ok(()); } /// Verifies that an access policy can be created. -#[test] -fn create_access_policy() { +#[tokio::test] +async fn create_access_policy() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; // Create the access policy. - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); + let action = test_environment.create_random_action().await?; + let user = test_environment.create_random_user().await?; let access_policy_properties = InitialAccessPolicyProperties { action_id: action.id, permission_level: AccessPolicyPermissionLevel::User, inheritance_level: AccessPolicyInheritanceLevel::Enabled, principal_type: AccessPolicyPrincipalType::User, principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None + ..Default::default() }; - let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); + let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).await?; // Ensure that all the properties were set correctly. assert_access_policy_is_equal_to_initial_properties(&access_policy, &access_policy_properties); + return Ok(()); + } /// Verifies that an access policy can be retrieved by its ID. -#[test] -fn get_access_policy_by_id() { +#[tokio::test] +async fn get_access_policy_by_id() -> Result<()> { // Create the access policy. - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); - let access_policy_properties = InitialAccessPolicyProperties { - action_id: action.id, - permission_level: AccessPolicyPermissionLevel::User, - inheritance_level: AccessPolicyInheritanceLevel::Enabled, - principal_type: AccessPolicyPrincipalType::User, - principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, - scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None - }; - let created_access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); - let retrieved_access_policy = AccessPolicy::get_by_id(&created_access_policy.id, &mut postgres_client).unwrap(); + let mut postgres_client = test_environment.postgres_pool.get().await?; + let created_access_policy = test_environment.create_random_access_policy().await?; + let retrieved_access_policy = AccessPolicy::get_by_id(&created_access_policy.id, &mut postgres_client).await?; assert_access_policies_are_equal(&created_access_policy, &retrieved_access_policy); + return Ok(()); + } /// Verifies that a list of access policies can be retrieved without a query. -#[test] -fn list_access_policies_without_query() { - - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; +#[tokio::test] +async fn list_access_policies_without_query() -> Result<()> { + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; + + let mut postgres_client = test_environment.postgres_pool.get().await?; const MAXIMUM_ACTION_COUNT: i32 = 25; let mut created_access_policies: Vec = Vec::new(); let mut remaining_action_count = MAXIMUM_ACTION_COUNT; while remaining_action_count > 0 { - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); - let access_policy_properties = InitialAccessPolicyProperties { - action_id: action.id, - permission_level: AccessPolicyPermissionLevel::User, - inheritance_level: AccessPolicyInheritanceLevel::Enabled, - principal_type: AccessPolicyPrincipalType::User, - principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, - scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None - }; - let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); + let access_policy = test_environment.create_random_access_policy().await?; created_access_policies.push(access_policy); remaining_action_count -= 1; } - let retrieved_access_policies = AccessPolicy::list("", &mut postgres_client).unwrap(); + let retrieved_access_policies = AccessPolicy::list("", &mut postgres_client).await?; assert_eq!(created_access_policies.len(), retrieved_access_policies.len()); for i in 0..created_access_policies.len() { @@ -302,52 +157,46 @@ fn list_access_policies_without_query() { } + return Ok(()); + } /// Verifies that a list of access policies can be retrieved with a query. -#[test] -fn list_access_policies_with_query() { +#[tokio::test] +async fn list_access_policies_with_query() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; const MAXIMUM_ACTION_COUNT: i32 = 5; let mut created_access_policies: Vec = Vec::new(); let mut remaining_action_count = MAXIMUM_ACTION_COUNT; while remaining_action_count > 0 { - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); + let action = test_environment.create_random_action().await?; + let user = test_environment.create_random_user().await?; let access_policy_properties = InitialAccessPolicyProperties { action_id: action.id, permission_level: AccessPolicyPermissionLevel::User, inheritance_level: AccessPolicyInheritanceLevel::Enabled, principal_type: AccessPolicyPrincipalType::User, principal_user_id: if remaining_action_count == 1 { created_access_policies[0].principal_user_id } else { Some(user.id) }, - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None + ..Default::default() }; - let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); + let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).await?; created_access_policies.push(access_policy); remaining_action_count -= 1; + } - let query = format!("principal_user_id = \"{}\"", created_access_policies[0].principal_user_id.unwrap()); - let retrieved_access_policies = AccessPolicy::list(&query, &mut postgres_client).unwrap(); + let principal_user_id = created_access_policies[0].principal_user_id.ok_or_else(|| anyhow!("Principal user ID is not set."))?; + let query = format!("principal_user_id = \"{}\"", principal_user_id); + let retrieved_access_policies = AccessPolicy::list(&query, &mut postgres_client).await?; - let created_access_policies_with_specific_user: Vec<&AccessPolicy> = created_access_policies.iter().filter(|access_policy| access_policy.principal_user_id == Some(created_access_policies[0].principal_user_id.unwrap())).collect(); + let created_access_policies_with_specific_user: Vec<&AccessPolicy> = created_access_policies.iter().filter(|access_policy| access_policy.principal_user_id == Some(principal_user_id)).collect(); assert_eq!(created_access_policies_with_specific_user.len(), retrieved_access_policies.len()); for i in 0..created_access_policies_with_specific_user.len() { @@ -358,217 +207,157 @@ fn list_access_policies_with_query() { } + return Ok(()); + } /// Verifies that the implementation can return up to a maximum number of access policies by default. -#[test] -fn list_access_policies_with_default_limit() { +#[tokio::test] +async fn list_access_policies_with_default_limit() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; const MAXIMUM_ACTION_COUNT: i64 = DEFAULT_ACCESS_POLICY_LIST_LIMIT + 1; let mut created_access_policies: Vec = Vec::new(); let mut remaining_action_count = MAXIMUM_ACTION_COUNT; while remaining_action_count > 0 { - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); - let access_policy_properties = InitialAccessPolicyProperties { - action_id: action.id, - permission_level: AccessPolicyPermissionLevel::User, - inheritance_level: AccessPolicyInheritanceLevel::Enabled, - principal_type: AccessPolicyPrincipalType::User, - principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, - scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None - }; - let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); + let access_policy = test_environment.create_random_access_policy().await?; created_access_policies.push(access_policy); remaining_action_count -= 1; } - let retrieved_access_policies = AccessPolicy::list("", &mut postgres_client).unwrap(); + let retrieved_access_policies = AccessPolicy::list("", &mut postgres_client).await?; assert_eq!(retrieved_access_policies.len(), DEFAULT_ACCESS_POLICY_LIST_LIMIT as usize); + + return Ok(()); } /// Verifies that the implementation can return an accurate count of access policies. -#[test] -fn count_access_policies() { +#[tokio::test] +async fn count_access_policies() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; const MAXIMUM_ACTION_COUNT: i64 = DEFAULT_ACCESS_POLICY_LIST_LIMIT + 1; let mut created_access_policies: Vec = Vec::new(); let mut remaining_action_count = MAXIMUM_ACTION_COUNT; while remaining_action_count > 0 { - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); + let action = test_environment.create_random_action().await?; + let user = test_environment.create_random_user().await?; let access_policy_properties = InitialAccessPolicyProperties { action_id: action.id, permission_level: AccessPolicyPermissionLevel::User, inheritance_level: AccessPolicyInheritanceLevel::Enabled, principal_type: AccessPolicyPrincipalType::User, principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None + ..Default::default() }; - let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).unwrap(); + let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).await?; created_access_policies.push(access_policy); remaining_action_count -= 1; } - let retrieved_access_policy_count = AccessPolicy::count("", &mut postgres_client).unwrap(); + let retrieved_access_policy_count = AccessPolicy::count("", &mut postgres_client).await?; assert_eq!(retrieved_access_policy_count, MAXIMUM_ACTION_COUNT); + return Ok(()); + } /// Verifies that the implementation can return a list of access policies in the proper order given a hierarchy. -#[test] -fn list_access_policies_by_hierarchy() { +#[tokio::test] +async fn list_access_policies_by_hierarchy() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; // Create the access policy. - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); + let mut postgres_client = test_environment.postgres_pool.get().await?; + let action = test_environment.create_random_action().await?; + let user = test_environment.create_random_user().await?; let instance_access_policy_properties = InitialAccessPolicyProperties { action_id: action.id, permission_level: AccessPolicyPermissionLevel::User, inheritance_level: AccessPolicyInheritanceLevel::Enabled, principal_type: AccessPolicyPrincipalType::User, principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None + ..Default::default() }; - let instance_access_policy = AccessPolicy::create(&instance_access_policy_properties, &mut postgres_client).unwrap(); - let access_policy_hierarchy = vec![(&instance_access_policy.scoped_resource_type, None)]; + let instance_access_policy = AccessPolicy::create(&instance_access_policy_properties, &mut postgres_client).await?; + let access_policy_hierarchy = instance_access_policy.get_hierarchy(&mut postgres_client).await?; - let retrieved_access_policies = AccessPolicy::list_by_hierarchy(&access_policy_hierarchy, &action.id, &mut postgres_client).unwrap(); + let retrieved_access_policies = AccessPolicy::list_by_hierarchy(&access_policy_hierarchy, &action.id, &mut postgres_client).await?; assert_eq!(retrieved_access_policies.len(), access_policy_hierarchy.len()); + + return Ok(()); } /// Verifies that the implementation can delete an access policy. -#[test] -fn delete_access_policy() { +#[tokio::test] +async fn delete_access_policy() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; // Create the access policy. - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); - let instance_access_policy_properties = InitialAccessPolicyProperties { - action_id: action.id, - permission_level: AccessPolicyPermissionLevel::User, - inheritance_level: AccessPolicyInheritanceLevel::Enabled, - principal_type: AccessPolicyPrincipalType::User, - principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, - scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None - }; - let instance_access_policy = AccessPolicy::create(&instance_access_policy_properties, &mut postgres_client).unwrap(); + let mut postgres_client = test_environment.postgres_pool.get().await?; + let created_access_policy = test_environment.create_random_access_policy().await?; - instance_access_policy.delete(&mut postgres_client).unwrap(); + created_access_policy.delete(&mut postgres_client).await?; // Ensure that the access policy is no longer in the database. - let retrieved_access_policy_result = AccessPolicy::get_by_id(&instance_access_policy.id, &mut postgres_client); + let retrieved_access_policy_result = AccessPolicy::get_by_id(&created_access_policy.id, &mut postgres_client).await; assert!(retrieved_access_policy_result.is_err()); + return Ok(()); + } /// Verifies that the implementation can update an access policy. -#[test] -fn update_access_policy() { +#[tokio::test] +async fn update_access_policy() -> Result<()> { - let test_postgres_environment = create_test_postgres_environment(); - let mut postgres_client = test_postgres_environment.postgres_client; + let test_environment = TestEnvironment::new().await?; + test_environment.initialize_required_tables().await?; // Create the access policy. - let action = create_random_action(&mut postgres_client); - let user = create_random_user(&mut postgres_client); + let mut postgres_client = test_environment.postgres_pool.get().await?; + let action = test_environment.create_random_action().await?; + let user = test_environment.create_random_user().await?; let instance_access_policy_properties = InitialAccessPolicyProperties { action_id: action.id, permission_level: AccessPolicyPermissionLevel::User, inheritance_level: AccessPolicyInheritanceLevel::Enabled, principal_type: AccessPolicyPrincipalType::User, principal_user_id: Some(user.id), - principal_group_id: None, - principal_role_id: None, - principal_app_id: None, scoped_resource_type: AccessPolicyScopedResourceType::Instance, - scoped_action_id: None, - scoped_app_id: None, - scoped_group_id: None, - scoped_item_id: None, - scoped_milestone_id: None, - scoped_project_id: None, - scoped_role_id: None, - scoped_user_id: None, - scoped_workspace_id: None + ..Default::default() }; - let instance_access_policy = AccessPolicy::create(&instance_access_policy_properties, &mut postgres_client).unwrap(); + let instance_access_policy = AccessPolicy::create(&instance_access_policy_properties, &mut postgres_client).await?; let updated_access_policy_properties = EditableAccessPolicyProperties { permission_level: Some(AccessPolicyPermissionLevel::Editor), inheritance_level: Some(AccessPolicyInheritanceLevel::Disabled) }; - let updated_access_policy = instance_access_policy.update(&updated_access_policy_properties, &mut postgres_client).unwrap(); + let updated_access_policy = instance_access_policy.update(&updated_access_policy_properties, &mut postgres_client).await?; assert_eq!(updated_access_policy.permission_level, AccessPolicyPermissionLevel::Editor); assert_eq!(updated_access_policy.inheritance_level, AccessPolicyInheritanceLevel::Disabled); + return Ok(()); + } diff --git a/src/resources/action.rs b/src/resources/action.rs deleted file mode 100644 index 03139e7..0000000 --- a/src/resources/action.rs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * - * This module defines the implementation and types of an action. - * - * Programmers: - * - Christian Toney (https://christiantoney.com) - * - * © 2025 Beastslash LLC - * - */ - -use postgres::error::SqlState; -use postgres_types::ToSql; -use uuid::Uuid; -use crate::{errors::resource_already_exists_error::ResourceAlreadyExistsError}; - -pub struct Action { - - /// The action's ID. - pub id: Uuid, - - /// The action's name. - pub name: String, - - /// The action's display name. - pub display_name: String, - - /// The action's description. - pub description: String, - - /// The action's app ID, if applicable. Actions without an app ID are global actions. - pub app_id: Option - -} - -pub struct InitialActionProperties { - - /// The action's name. - pub name: String, - - /// The action's display name. - pub display_name: String, - - /// The action's description. - pub description: String, - - /// The action's app ID, if applicable. Actions without an app ID are global actions. - pub app_id: Option - -} - -#[derive(Debug)] -pub enum ActionCreationError { - ResourceAlreadyExistsError(ResourceAlreadyExistsError), - String(String), - PostgresError(postgres::Error) -} - -impl Action { - - /// Creates a new action. - pub fn create(initial_properties: &InitialActionProperties, postgres_client: &mut postgres::Client) -> Result { - - // Insert the access policy into the database. - let query = include_str!("../queries/actions/insert-action-row.sql"); - let parameters: &[&(dyn ToSql + Sync)] = &[ - &initial_properties.name, - &initial_properties.display_name, - &initial_properties.description, - &initial_properties.app_id - ]; - let rows = postgres_client.query(query, parameters); - - // Return the action. - match rows { - - Ok(rows) => { - - let row = rows.get(0).ok_or(ActionCreationError::String("Client did not return a row.".to_string()))?; - let action = Action { - id: row.get("id"), - name: row.get("name"), - display_name: row.get("display_name"), - description: row.get("description"), - app_id: row.get("app_id") - }; - - return Ok(action); - - }, - - Err(error) => match error.as_db_error() { - - Some(db_error) => { - - let error_code = db_error.code(); - match error_code { - - &SqlState::UNIQUE_VIOLATION => { - - let resource_already_exists_error = ResourceAlreadyExistsError { - resource_type: "Action".to_string() - }; - - Err(ActionCreationError::ResourceAlreadyExistsError(resource_already_exists_error)) - - }, - - _ => { - Err(ActionCreationError::PostgresError(error)) - } - - } - - }, - - None => { - - Err(ActionCreationError::PostgresError(error)) - - } - - } - - } - - } - - /// Initializes the actions table. - pub fn initialize_actions_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/actions/initialize-actions-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} - -#[cfg(test)] -mod tests; \ No newline at end of file diff --git a/src/resources/action/mod.rs b/src/resources/action/mod.rs new file mode 100644 index 0000000..f1c80fc --- /dev/null +++ b/src/resources/action/mod.rs @@ -0,0 +1,176 @@ +/** + * + * This module defines the implementation and types of an action. + * + * Programmers: + * - Christian Toney (https://christiantoney.com) + * + * © 2025 Beastslash LLC + * + */ + +use postgres::error::SqlState; +use postgres_types::ToSql; +use thiserror::Error; +use uuid::Uuid; + +pub struct Action { + + /// The action's ID. + pub id: Uuid, + + /// The action's name. + pub name: String, + + /// The action's display name. + pub display_name: String, + + /// The action's description. + pub description: String, + + /// The action's app ID, if applicable. Actions without an app ID are global actions. + pub app_id: Option + +} + +pub struct InitialActionProperties { + + /// The action's name. + pub name: String, + + /// The action's display name. + pub display_name: String, + + /// The action's description. + pub description: String, + + /// The action's app ID, if applicable. Actions without an app ID are global actions. + pub app_id: Option + +} + +#[derive(Debug, Error)] +pub enum ActionError { + #[error("An action with the name \"{0}\" already exists.")] + ConflictError(String), + + #[error("Couldn't find an action with the name \"{0}\".")] + NotFoundError(String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +impl Action { + + pub fn from_row(row: &postgres::Row) -> Self { + + return Action { + id: row.get("id"), + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + app_id: row.get("app_id") + }; + + } + + /// Creates a new action. + pub async fn create(initial_properties: &InitialActionProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + // Insert the access policy into the database. + let query = include_str!("../../queries/actions/insert-action-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.name, + &initial_properties.display_name, + &initial_properties.description, + &initial_properties.app_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => { + + match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => ActionError::ConflictError(initial_properties.name.clone()), + + _ => ActionError::PostgresError(error) + + } + + }, + + None => ActionError::PostgresError(error) + + })?; + + // Return the action. + let action = Action::from_row(&row); + + return Ok(action); + + } + + pub async fn get_by_name(name: &str, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/actions/get-action-row-by-name.sql"); + let row = match postgres_client.query_opt(query, &[&name]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(ActionError::NotFoundError(name.to_string())) + + }, + + Err(error) => return Err(ActionError::PostgresError(error)) + + }; + + let action = Action::from_row(&row); + + return Ok(action); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/actions/get-action-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(ActionError::NotFoundError(id.to_string())) + + }, + + Err(error) => return Err(ActionError::PostgresError(error)) + + }; + + let action = Action::from_row(&row); + + return Ok(action); + + } + + /// Initializes the actions table. + pub async fn initialize_actions_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), ActionError> { + + let table_initialization_query = include_str!("../../queries/actions/initialize-actions-table.sql"); + postgres_client.execute(table_initialization_query, &[]).await?; + + let view_initialization_query = include_str!("../../queries/actions/initialize-hydrated-actions-view.sql"); + postgres_client.execute(view_initialization_query, &[]).await?; + + return Ok(()); + + } + +} + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/resources/app.rs b/src/resources/app.rs deleted file mode 100644 index 93aea72..0000000 --- a/src/resources/app.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct App {} - -impl App { - - /// Initializes the apps table. - pub fn initialize_apps_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/apps/initialize-apps-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/app/mod.rs b/src/resources/app/mod.rs new file mode 100644 index 0000000..5d433c2 --- /dev/null +++ b/src/resources/app/mod.rs @@ -0,0 +1,140 @@ +use postgres::error::SqlState; +use postgres_types::{FromSql, ToSql}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone)] +pub enum AppClientType { + Public, + Confidential +} + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone)] +#[postgres(name = "app_parent_resource_type")] +pub enum AppParentResourceType { + Instance, + Workspace, + User +} + +#[derive(Debug, Error)] +pub enum AppError { + #[error(transparent)] + PostgresError(#[from] postgres::Error), + + #[error("An app with the name \"{0}\" already exists.")] + ConflictError(String), + + #[error("An app with the ID \"{0}\" does not exist.")] + NotFoundError(String) +} + +#[derive(Debug, Clone)] +pub struct App { + pub id: Uuid, + pub name: String, + pub display_name: String, + pub description: Option, + pub client_type: AppClientType, + pub client_secret_hash: String, + pub parent_resource_type: AppParentResourceType, + pub parent_workspace_id: Option, + pub parent_user_id: Option +} + +pub struct InitialAppProperties<'a> { + pub name: &'a str, + pub display_name: &'a str, + pub description: Option<&'a str>, + pub client_type: &'a AppClientType, + pub client_secret_hash: &'a str, + pub parent_resource_type: &'a AppParentResourceType, + pub parent_workspace_id: Option<&'a Uuid>, + pub parent_user_id: Option<&'a Uuid> +} + +impl App { + + /// Initializes the apps table. + pub async fn initialize_apps_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), AppError> { + + let query = include_str!("../../queries/apps/initialize-apps-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + pub fn from_row(row: &postgres::Row) -> Self { + + return App { + id: row.get("id"), + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + client_type: row.get("client_type"), + client_secret_hash: row.get("client_secret_hash"), + parent_resource_type: row.get("parent_resource_type"), + parent_workspace_id: row.get("parent_workspace_id"), + parent_user_id: row.get("parent_user_id") + }; + + } + + pub async fn create(initial_properties: &InitialAppProperties<'_>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/apps/insert-app-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.name, + &initial_properties.display_name, + &initial_properties.description, + &initial_properties.client_type, + &initial_properties.client_secret_hash, + &initial_properties.parent_resource_type, + &initial_properties.parent_workspace_id, + &initial_properties.parent_user_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => AppError::ConflictError(initial_properties.name.to_string()), + + _ => AppError::PostgresError(error) + + }, + + None => AppError::PostgresError(error) + + })?; + + // Return the action. + let app = App::from_row(&row); + + return Ok(app); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/apps/get-app-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(AppError::NotFoundError(id.to_string())) + + }, + + Err(error) => return Err(AppError::PostgresError(error)) + + }; + + let app = App::from_row(&row); + + return Ok(app); + + } + +} \ No newline at end of file diff --git a/src/resources/app_authorization.rs b/src/resources/app_authorization.rs deleted file mode 100644 index 6e59906..0000000 --- a/src/resources/app_authorization.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct AppAuthorization {} - -impl AppAuthorization { - - /// Initializes the app_authorizations table. - pub fn initialize_app_authorizations_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/app-authorizations/initialize-app-authorizations-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/app_authorization/mod.rs b/src/resources/app_authorization/mod.rs new file mode 100644 index 0000000..30e1dc8 --- /dev/null +++ b/src/resources/app_authorization/mod.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppAuthorizationError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct AppAuthorization {} + +impl AppAuthorization { + + /// Initializes the app_authorizations table. + pub async fn initialize_app_authorizations_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), AppAuthorizationError> { + + let query = include_str!("../../queries/app-authorizations/initialize-app-authorizations-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/app_authorization_credential.rs b/src/resources/app_authorization_credential.rs deleted file mode 100644 index 5845ba6..0000000 --- a/src/resources/app_authorization_credential.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct AppAuthorizationCredential {} - -impl AppAuthorizationCredential { - - /// Initializes the app_authorization_credentials table. - pub fn initialize_app_authorization_credentials_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/app-authorization-credentials/initialize-app-authorization-credentials-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/app_authorization_credential/mod.rs b/src/resources/app_authorization_credential/mod.rs new file mode 100644 index 0000000..059eb35 --- /dev/null +++ b/src/resources/app_authorization_credential/mod.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppAuthorizationCredentialError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct AppAuthorizationCredential {} + +impl AppAuthorizationCredential { + + /// Initializes the app_authorization_credentials table. + pub async fn initialize_app_authorization_credentials_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), AppAuthorizationCredentialError> { + + let query = include_str!("../../queries/app-authorization-credentials/initialize-app-authorization-credentials-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/app_credential.rs b/src/resources/app_credential.rs deleted file mode 100644 index 1304fea..0000000 --- a/src/resources/app_credential.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct AppCredential {} - -impl AppCredential { - - /// Initializes the app_credentials table. - pub fn initialize_app_credentials_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/app-credentials/initialize-app-credentials-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/app_credential/mod.rs b/src/resources/app_credential/mod.rs new file mode 100644 index 0000000..8e11899 --- /dev/null +++ b/src/resources/app_credential/mod.rs @@ -0,0 +1,111 @@ +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use postgres_types::ToSql; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum AppCredentialError { + #[error("Couldn't find an app credential with the ID \"{0}\".")] + NotFoundError(Uuid), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +#[derive(Debug)] +pub struct AppCredential { + + /// The app credential's ID. + pub id: Uuid, + + /// The app credential's app ID. + pub app_id: Uuid, + + /// The app credential's expiration date. + pub expiration_date: DateTime, + + pub creation_ip_address: IpAddr + +} + +pub struct InitialAppCredentialProperties { + + /// The app credential's app ID. + pub app_id: Uuid, + + /// The app credential's expiration date. + pub expiration_date: DateTime, + + pub creation_ip_address: IpAddr + +} + +impl AppCredential { + + /// Initializes the app_credentials table. + pub async fn initialize_app_credentials_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), AppCredentialError> { + + let query = include_str!("../../queries/app-credentials/initialize-app-credentials-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + fn from_row(row: &postgres::Row) -> Self { + + return AppCredential { + id: row.get("id"), + app_id: row.get("app_id"), + expiration_date: row.get("expiration_date"), + creation_ip_address: row.get("creation_ip_address") + }; + + } + + pub async fn create(initial_properties: &InitialAppCredentialProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/app-credentials/insert-app-credential-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.app_id, + &initial_properties.expiration_date, + &initial_properties.creation_ip_address + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| { + + return AppCredentialError::PostgresError(error) + + })?; + + // Return the app credential. + let app_credential = AppCredential::from_row(&row); + + return Ok(app_credential); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/app-credentials/get-app-credential-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(AppCredentialError::NotFoundError(id.clone())) + + }, + + Err(error) => return Err(AppCredentialError::PostgresError(error)) + + }; + + let app_credential = AppCredential::from_row(&row); + + return Ok(app_credential); + + } + +} \ No newline at end of file diff --git a/src/resources/group.rs b/src/resources/group.rs deleted file mode 100644 index df9bcd0..0000000 --- a/src/resources/group.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Group {} - -impl Group { - - /// Initializes the groups table. - pub fn initialize_groups_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/groups/initialize-groups-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/group/mod.rs b/src/resources/group/mod.rs new file mode 100644 index 0000000..f4a654b --- /dev/null +++ b/src/resources/group/mod.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GroupError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct Group {} + +impl Group { + + /// Initializes the groups table. + pub async fn initialize_groups_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), GroupError> { + + let query = include_str!("../../queries/groups/initialize-groups-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/http_transaction/mod.rs b/src/resources/http_transaction/mod.rs new file mode 100644 index 0000000..be96a2c --- /dev/null +++ b/src/resources/http_transaction/mod.rs @@ -0,0 +1,98 @@ +use std::net::IpAddr; +use postgres_types::ToSql; +use thiserror::Error; +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct HTTPTransaction { + + /// The ID of the HTTP transaction. + pub id: Uuid, + + /// The HTTP method of the HTTP request. + pub method: String, + + /// The URL of the HTTP request. + pub url: String, + + /// The IP address of the HTTP request. + pub ip_address: IpAddr, + + /// The headers of the HTTP request. + pub headers: String, + + /// The status code of the HTTP request. + pub status_code: Option, + + /// The expiration date of the HTTP request. + pub expiration_date: Option> + +} + +pub struct InitialHTTPTransactionProperties { + + /// The HTTP method of the HTTP request. + pub method: String, + + /// The URL of the HTTP request. + pub url: String, + + /// The IP address of the HTTP request. + pub ip_address: IpAddr, + + /// The headers of the HTTP request. + pub headers: String, + + /// The status code of the HTTP request. + pub status_code: Option, + + /// The expiration date of the HTTP request. + pub expiration_date: Option> + +} + +#[derive(Debug, Error)] +pub enum HTTPTransactionError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +impl HTTPTransaction { + + pub async fn create(properties: &InitialHTTPTransactionProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/http-requests/insert-http-request-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &properties.method, + &properties.url, + &properties.ip_address, + &properties.headers, + &properties.status_code, + &properties.expiration_date + ]; + let row = postgres_client.query_one(query, parameters).await?; + + let http_request = HTTPTransaction { + id: row.get("id"), + method: row.get("method"), + url: row.get("url"), + ip_address: row.get("ip_address"), + headers: row.get("headers"), + status_code: row.get("status_code"), + expiration_date: row.get("expiration_date") + }; + + return Ok(http_request); + + } + + pub async fn initialize_http_transactions_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), HTTPTransactionError> { + + let query = include_str!("../../queries/http-requests/create-http-requests-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/item.rs b/src/resources/item.rs deleted file mode 100644 index 35fe0de..0000000 --- a/src/resources/item.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Item {} - -impl Item { - - /// Initializes the items table. - pub fn initialize_items_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/items/initialize-items-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/item/mod.rs b/src/resources/item/mod.rs new file mode 100644 index 0000000..901172c --- /dev/null +++ b/src/resources/item/mod.rs @@ -0,0 +1,111 @@ +use postgres::error::SqlState; +use postgres_types::ToSql; +use serde::Serialize; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum ItemError { + #[error("A item with the summary \"{0}\" already exists.")] + ConflictError(String), + + #[error("A item with the ID \"{0}\" does not exist.")] + NotFoundError(String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct InitialItemProperties<'a> { + pub summary: &'a str, + pub description: &'a str, + pub project_id: Uuid, + pub number: i64 +} + +#[derive(Debug, Clone, Serialize)] +pub struct Item { + pub id: Uuid, + pub summary: String, + pub description: String, + pub project_id: Uuid, + pub number: i64 +} + +impl Item { + + pub fn from_row(row: &postgres::Row) -> Self { + + return Item { + id: row.get("id"), + summary: row.get("summary"), + description: row.get("description"), + project_id: row.get("project_id"), + number: row.get("number") + }; + + } + + pub async fn create(initial_properties: &InitialItemProperties<'_>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/items/insert-item-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.summary, + &initial_properties.description, + &initial_properties.project_id, + &initial_properties.number + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => ItemError::ConflictError(initial_properties.summary.to_string()), + + _ => ItemError::PostgresError(error) + + }, + + None => ItemError::PostgresError(error) + + })?; + + // Return the item. + let item = Item::from_row(&row); + + return Ok(item); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/items/get-item-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(ItemError::NotFoundError(id.to_string())) + + }, + + Err(error) => return Err(ItemError::PostgresError(error)) + + }; + + let item = Item::from_row(&row); + + return Ok(item); + + } + + /// Initializes the items table. + pub async fn initialize_items_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), ItemError> { + + let query = include_str!("../../queries/items/initialize-items-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/milestone.rs b/src/resources/milestone.rs deleted file mode 100644 index b6b1ef3..0000000 --- a/src/resources/milestone.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Milestone {} - -impl Milestone { - - /// Initializes the milestones table. - pub fn initialize_milestones_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/milestones/initialize-milestones-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/milestone/mod.rs b/src/resources/milestone/mod.rs new file mode 100644 index 0000000..0b93783 --- /dev/null +++ b/src/resources/milestone/mod.rs @@ -0,0 +1,152 @@ +use postgres::error::SqlState; +use postgres_types::{FromSql, ToSql}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum MilestoneError { + #[error("Couldn't find a milestone with the ID \"{0}\".")] + NotFoundError(Uuid), + + #[error("A milestone with the name \"{0}\" already exists.")] + ConflictError(String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone)] +#[postgres(name = "milestone_parent_resource_type")] +pub enum MilestoneParentResourceType { + Project, + Workspace +} + +#[derive(Debug)] +pub struct Milestone { + + /// The milestone's ID. + pub id: Uuid, + + /// The milestone's name. + pub name: String, + + /// The milestone's display name. + pub display_name: String, + + /// The milestone's description. + pub description: String, + + pub parent_resource_type: MilestoneParentResourceType, + + /// The milestone's workspace ID. + pub parent_workspace_id: Option, + + /// The milestone's project ID. + pub parent_project_id: Option + +} + +pub struct InitialMilestoneProperties { + + /// The milestone's name. + pub name: String, + + /// The milestone's display name. + pub display_name: String, + + /// The milestone's description. + pub description: String, + + /// The milestone's parent resource type. + pub parent_resource_type: MilestoneParentResourceType, + + /// The milestone's workspace ID. + pub parent_workspace_id: Option, + + /// The milestone's project ID. + pub parent_project_id: Option + +} + +impl Milestone { + + /// Initializes the milestones table. + pub async fn initialize_milestones_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), MilestoneError> { + + let query = include_str!("../../queries/milestones/initialize-milestones-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + fn from_row(row: &postgres::Row) -> Self { + + return Milestone { + id: row.get("id"), + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + parent_resource_type: row.get("parent_resource_type"), + parent_workspace_id: row.get("parent_workspace_id"), + parent_project_id: row.get("parent_project_id") + }; + + } + + pub async fn create(initial_properties: &InitialMilestoneProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/milestones/insert-milestone-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.name, + &initial_properties.display_name, + &initial_properties.description, + &initial_properties.parent_resource_type, + &initial_properties.parent_workspace_id, + &initial_properties.parent_project_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => MilestoneError::ConflictError(initial_properties.name.clone()), + + _ => MilestoneError::PostgresError(error) + + }, + + None => MilestoneError::PostgresError(error) + + })?; + + // Return the milestone. + let milestone = Milestone::from_row(&row); + + return Ok(milestone); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/milestones/get-milestone-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(MilestoneError::NotFoundError(id.clone())) + + }, + + Err(error) => return Err(MilestoneError::PostgresError(error)) + + }; + + let milestone = Milestone::from_row(&row); + + return Ok(milestone); + + } + +} \ No newline at end of file diff --git a/src/resources/project.rs b/src/resources/project.rs deleted file mode 100644 index 165d4b0..0000000 --- a/src/resources/project.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Project {} - -impl Project { - - /// Initializes the projects table. - pub fn initialize_projects_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/projects/initialize-projects-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/project/mod.rs b/src/resources/project/mod.rs new file mode 100644 index 0000000..2fec102 --- /dev/null +++ b/src/resources/project/mod.rs @@ -0,0 +1,119 @@ +use chrono::{DateTime, Utc}; +use postgres::error::SqlState; +use postgres_types::ToSql; +use serde::Serialize; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum ProjectError { + #[error("A project with the ID \"{0}\" does not exist.")] + NotFoundError(String), + + #[error("A project with the name \"{0}\" already exists.")] + ConflictError(String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct InitialProjectProperties<'a> { + pub name: &'a str, + pub display_name: &'a str, + pub description: &'a str, + pub workspace_id: Uuid, + pub start_date: Option<&'a DateTime>, + pub end_date: Option<&'a DateTime> +} + +#[derive(Debug, Clone, Serialize)] +pub struct Project { + pub id: Uuid, + pub name: String, + pub display_name: String, + pub description: String, + pub start_date: Option>, + pub end_date: Option>, + pub workspace_id: Uuid +} + +impl Project { + + /// Initializes the projects table. + pub async fn initialize_projects_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), ProjectError> { + + let query = include_str!("../../queries/projects/initialize-projects-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + pub fn from_row(row: &postgres::Row) -> Self { + + return Project { + id: row.get("id"), + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + start_date: row.get("start_date"), + end_date: row.get("end_date"), + workspace_id: row.get("workspace_id") + }; + + } + + pub async fn create(initial_properties: &InitialProjectProperties<'_>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/projects/insert-project-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.name, + &initial_properties.display_name, + &initial_properties.description, + &initial_properties.start_date, + &initial_properties.end_date + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => ProjectError::ConflictError(initial_properties.name.to_string()), + + _ => ProjectError::PostgresError(error) + + }, + + None => ProjectError::PostgresError(error) + + })?; + + // Return the project. + let project = Project::from_row(&row); + + return Ok(project); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/projects/get-project-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(ProjectError::NotFoundError(id.to_string())) + + }, + + Err(error) => return Err(ProjectError::PostgresError(error)) + + }; + + let project = Project::from_row(&row); + + return Ok(project); + + } + +} \ No newline at end of file diff --git a/src/resources/role.rs b/src/resources/role.rs deleted file mode 100644 index c124902..0000000 --- a/src/resources/role.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Role {} - -impl Role { - - /// Initializes the roles table. - pub fn initialize_roles_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/roles/initialize-roles-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/role/mod.rs b/src/resources/role/mod.rs new file mode 100644 index 0000000..37dd6c6 --- /dev/null +++ b/src/resources/role/mod.rs @@ -0,0 +1,160 @@ +use postgres::error::SqlState; +use postgres_types::{FromSql, ToSql}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum RoleError { + #[error("A role with the name \"{0}\" already exists.")] + ConflictError(String), + + #[error("Couldn't find a role with the name \"{0}\".")] + NotFoundError(String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone)] +#[postgres(name = "role_parent_resource_type")] +pub enum RoleParentResourceType { + Instance, + Workspace, + Project, + Group +} + +#[derive(Debug, Clone)] +pub struct Role { + pub id: Uuid, + pub name: String, + pub is_pre_defined: bool, + pub display_name: String, + pub description: Option, + pub parent_resource_type: RoleParentResourceType, + pub parent_workspace_id: Option, + pub parent_project_id: Option, + pub parent_group_id: Option +} + +#[derive(Debug, Clone)] +pub struct InitialRoleProperties { + pub name: String, + pub display_name: String, + pub description: Option, + pub parent_resource_type: RoleParentResourceType, + pub parent_workspace_id: Option, + pub parent_project_id: Option, + pub parent_group_id: Option +} + +impl Role { + + pub fn from_row(row: &postgres::Row) -> Self { + + return Role { + id: row.get("id"), + name: row.get("name"), + is_pre_defined: row.get("is_pre_defined"), + display_name: row.get("display_name"), + description: row.get("description"), + parent_resource_type: row.get("parent_resource_type"), + parent_workspace_id: row.get("parent_workspace_id"), + parent_project_id: row.get("parent_project_id"), + parent_group_id: row.get("parent_group_id") + }; + + } + + pub async fn create(initial_properties: &InitialRoleProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/roles/insert-role-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.name, + &initial_properties.display_name, + &initial_properties.description, + &initial_properties.parent_resource_type, + &initial_properties.parent_workspace_id, + &initial_properties.parent_project_id, + &initial_properties.parent_group_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => RoleError::ConflictError(initial_properties.name.clone()), + + _ => RoleError::PostgresError(error) + + }, + + None => RoleError::PostgresError(error) + + })?; + + // Return the role. + let role = Role::from_row(&row); + + return Ok(role); + + } + + pub async fn get_by_name(name: &str, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/roles/get-role-row-by-name.sql"); + let row = match postgres_client.query_opt(query, &[&name]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(RoleError::NotFoundError(name.to_string())) + + }, + + Err(error) => return Err(RoleError::PostgresError(error)) + + }; + + let role = Role::from_row(&row); + + return Ok(role); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/roles/get-role-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(RoleError::NotFoundError(id.to_string())) + + }, + + Err(error) => return Err(RoleError::PostgresError(error)) + + }; + + let role = Role::from_row(&row); + + return Ok(role); + + } + + /// Initializes the roles table. + pub async fn initialize_roles_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), RoleError> { + + let query = include_str!("../../queries/roles/initialize-roles-table.sql"); + postgres_client.execute(query, &[]).await?; + + let query = include_str!("../../queries/roles/initialize-hydrated-roles-view.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/resources/role_memberships/mod.rs b/src/resources/role_memberships/mod.rs new file mode 100644 index 0000000..384ee61 --- /dev/null +++ b/src/resources/role_memberships/mod.rs @@ -0,0 +1,259 @@ +use std::str::FromStr; + +use postgres::error::SqlState; +use postgres_types::{FromSql, ToSql}; +use thiserror::Error; +use uuid::Uuid; + +use crate::utilities::slashstepql::{SlashstepQLError, SlashstepQLFilterSanitizer, SlashstepQLParameterType, SlashstepQLSanitizeFunctionOptions}; + +static ALLOWED_QUERY_KEYS: &[&str] = &[ + "id", + "role_id", + "principal_type", + "principal_user_id", + "principal_group_id", + "principal_app_id" +]; + +const DEFAULT_ROLE_MEMBERSHIP_LIST_LIMIT: i64 = 1000; + +const UUID_QUERY_KEYS: &[&str] = &[ + "id", + "role_id", + "principal_user_id", + "principal_group_id", + "principal_app_id" +]; + +#[derive(Debug, PartialEq, Eq, ToSql, FromSql, Clone)] +#[postgres(name = "role_membership_principal_type")] +pub enum RoleMembershipPrincipalType { + User, + Group, + App +} + +impl FromStr for RoleMembershipPrincipalType { + + type Err = RoleMembershipError; + + fn from_str(string: &str) -> Result { + + match string { + "User" => Ok(RoleMembershipPrincipalType::User), + "Group" => Ok(RoleMembershipPrincipalType::Group), + "App" => Ok(RoleMembershipPrincipalType::App), + _ => Err(RoleMembershipError::InvalidPrincipalType(string.to_string())) + } + + } + +} + +#[derive(Debug, Error)] +pub enum RoleMembershipError { + #[error("A role membership with the ID \"{0}\" already exists.")] + ConflictError(Uuid), + + #[error("Couldn't find a role membership with the ID \"{0}\".")] + NotFoundError(Uuid), + + #[error("Invalid principal type: {0}")] + InvalidPrincipalType(String), + + #[error(transparent)] + UUIDError(#[from] uuid::Error), + + #[error(transparent)] + PostgresError(#[from] postgres::Error), + + #[error(transparent)] + SlashstepQLError(#[from] SlashstepQLError) +} + +#[derive(Debug, Clone)] +pub struct RoleMembership { + pub id: Uuid, + pub role_id: Uuid, + pub principal_type: RoleMembershipPrincipalType, + pub principal_user_id: Option, + pub principal_group_id: Option, + pub principal_app_id: Option +} + +#[derive(Debug, Clone)] +pub struct InitialRoleMembershipProperties<'a> { + pub role_id: &'a Uuid, + pub principal_type: &'a RoleMembershipPrincipalType, + pub principal_user_id: Option<&'a Uuid>, + pub principal_group_id: Option<&'a Uuid>, + pub principal_app_id: Option<&'a Uuid> +} + +impl RoleMembership { + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/role-memberships/get-role-membership-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(RoleMembershipError::NotFoundError(id.clone())) + + }, + + Err(error) => return Err(RoleMembershipError::PostgresError(error)) + + }; + + let role_membership = RoleMembership::convert_from_row(&row); + + return Ok(role_membership); + + } + + pub fn convert_from_row(row: &postgres::Row) -> Self { + + return RoleMembership { + id: row.get("id"), + role_id: row.get("role_id"), + principal_type: row.get("principal_type"), + principal_user_id: row.get("principal_user_id"), + principal_group_id: row.get("principal_group_id"), + principal_app_id: row.get("principal_app_id") + }; + + } + + pub async fn initialize_role_memberships_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), RoleMembershipError> { + + let query = include_str!("../../queries/role-memberships/initialize-role-memberships-table.sql"); + postgres_client.execute(query, &[]).await?; + + let query = include_str!("../../queries/role-memberships/initialize-hydrated-role-memberships-view.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + fn parse_slashstepql_parameters(slashstepql_parameters: &Vec<(String, SlashstepQLParameterType)>) -> Result>, RoleMembershipError> { + + // https://users.rust-lang.org/t/axum-tokio-postgres-error-on-using-vec/114024/4 + let mut parameters: Vec> = Vec::new(); + + for (key, value) in slashstepql_parameters { + + match value { + + SlashstepQLParameterType::String(string_value) => { + + if UUID_QUERY_KEYS.contains(&key.as_str()) { + + let uuid = Uuid::parse_str(string_value)?; + parameters.push(Box::new(uuid)); + + } else { + + match key.as_str() { + + "principal_type" => { + + let principal_type = RoleMembershipPrincipalType::from_str(string_value)?; + parameters.push(Box::new(principal_type)); + + }, + + _ => { + + parameters.push(Box::new(string_value)); + + } + + } + + } + + }, + + SlashstepQLParameterType::Number(number_value) => { + + parameters.push(Box::new(number_value)); + + }, + + SlashstepQLParameterType::Boolean(boolean_value) => { + + parameters.push(Box::new(boolean_value)); + + } + + } + + } + + return Ok(parameters); + + } + + pub async fn list(filter: &str, postgres_client: &mut deadpool_postgres::Client) -> Result, RoleMembershipError> { + + // Prepare the query. + let sanitizer_options = SlashstepQLSanitizeFunctionOptions { + filter: filter.to_string(), + allowed_fields: ALLOWED_QUERY_KEYS.into_iter().map(|string| string.to_string()).collect(), + default_limit: Some(DEFAULT_ROLE_MEMBERSHIP_LIST_LIMIT), + maximum_limit: None, + should_ignore_limit: false, + should_ignore_offset: false + }; + let sanitized_filter = SlashstepQLFilterSanitizer::sanitize(&sanitizer_options)?; + let where_clause = sanitized_filter.where_clause.and_then(|string| Some(format!(" where {}", string))).unwrap_or("".to_string()); + let limit_clause = sanitized_filter.limit.and_then(|limit| Some(format!(" limit {}", limit))).unwrap_or("".to_string()); + let offset_clause = sanitized_filter.offset.and_then(|offset| Some(format!(" offset {}", offset))).unwrap_or("".to_string()); + let query = format!("select * from hydrated_role_memberships{}{}{}", where_clause, limit_clause, offset_clause); + + // Execute the query. + let parsed_parameters = Self::parse_slashstepql_parameters(&sanitized_filter.parameters)?; // This is causing an error in \{access_policy_id}\mod.rs + let parameters: Vec<&(dyn ToSql + Sync)> = parsed_parameters.iter().map(|parameter| parameter.as_ref() as &(dyn ToSql + Sync)).collect(); + let rows = postgres_client.query(&query, ¶meters).await?; + let role_memberships: Vec = rows.iter().map(RoleMembership::convert_from_row).collect(); + return Ok(role_memberships); + + } + + pub async fn create<'a>(initial_properties: &InitialRoleMembershipProperties<'a>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/role-memberships/insert-role-membership-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.role_id, + &initial_properties.principal_type, + &initial_properties.principal_user_id, + &initial_properties.principal_group_id, + &initial_properties.principal_app_id + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => RoleMembershipError::ConflictError(initial_properties.role_id.clone()), + + _ => RoleMembershipError::PostgresError(error) + + }, + + None => RoleMembershipError::PostgresError(error) + + })?; + + // Return the role membership. + let role_membership = RoleMembership::convert_from_row(&row); + + return Ok(role_membership); + + } + +} \ No newline at end of file diff --git a/src/resources/server_log_entry/mod.rs b/src/resources/server_log_entry/mod.rs new file mode 100644 index 0000000..7bd948f --- /dev/null +++ b/src/resources/server_log_entry/mod.rs @@ -0,0 +1,236 @@ +use core::fmt; +use postgres_types::{FromSql, ToSql}; +use uuid::Uuid; +use colored::Colorize; +use crate::HTTPError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ServerLogEntryError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +#[derive(Debug, ToSql, FromSql, Copy, Clone)] +#[postgres(name = "server_log_entry_level")] +pub enum ServerLogEntryLevel { + Success, + Trace, + Info, + Warning, + Error, + Critical +} + +impl fmt::Display for ServerLogEntryLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ServerLogEntryLevel::Success => write!(f, "Success"), + ServerLogEntryLevel::Trace => write!(f, "Trace"), + ServerLogEntryLevel::Info => write!(f, "Info"), + ServerLogEntryLevel::Warning => write!(f, "Warning"), + ServerLogEntryLevel::Error => write!(f, "Error"), + ServerLogEntryLevel::Critical => write!(f, "Critical") + } + } +} + +#[derive(Debug)] +pub struct ServerLogEntry { + + /// The ID of the server log entry. + pub id: Uuid, + + /// The message of the server log entry. + pub message: String, + + /// The HTTP request ID of the server log entry, if applicable. + pub http_request_id: Option, + + /// The level of the server log entry. + pub level: ServerLogEntryLevel + +} + +pub struct InitialServerLogEntryProperties<'a> { + + /// The message of the server log entry. + pub message: &'a str, + + /// The HTTP request ID of the server log entry, if applicable. + pub http_request_id: Option<&'a Uuid>, + + /// The level of the server log entry. + pub level: &'a ServerLogEntryLevel + +} + +impl ServerLogEntry { + + pub async fn critical(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Critical; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn trace(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Trace; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn info(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Info; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn warning(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Warning; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn error(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Error; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn success(message: &str, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = &ServerLogEntryLevel::Success; + let properties = InitialServerLogEntryProperties { message, http_request_id, level }; + let server_log_entry_result = ServerLogEntry::create(&properties, postgres_client, true).await; + return server_log_entry_result; + + } + + pub async fn from_http_error(http_error: &HTTPError, http_request_id: Option<&Uuid>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let level = match http_error { + HTTPError::InternalServerError(_) => &ServerLogEntryLevel::Critical, + _ => &ServerLogEntryLevel::Error + }; + let message = &http_error.to_string(); + let properties = InitialServerLogEntryProperties { + message, + http_request_id, + level + }; + let server_log_entry = ServerLogEntry::create(&properties, postgres_client, true).await?; + return Ok(server_log_entry); + + } + + pub async fn create<'a>(properties: &InitialServerLogEntryProperties<'a>, postgres_client: &mut deadpool_postgres::Client, should_print_to_console: bool) -> Result { + + let query = include_str!("../../queries/server-log-entries/insert-server-log-entry-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &properties.message, + &properties.http_request_id, + &properties.level + ]; + let row_result = postgres_client.query_one(query, parameters).await; + + let row = match row_result { + + Ok(row) => row, + + Err(error) => { + + if should_print_to_console { + + let temporary_server_log_entry = ServerLogEntry { + id: Uuid::now_v7(), + message: properties.message.to_string(), + http_request_id: properties.http_request_id.copied(), + level: *properties.level + }; + + temporary_server_log_entry.print_to_console(); + + } + + return Err(ServerLogEntryError::PostgresError(error)); + + } + + }; + + let server_log_entry = ServerLogEntry { + id: row.get("id"), + message: row.get("message"), + http_request_id: row.get("http_request_id"), + level: row.get("level") + }; + + if should_print_to_console { + + server_log_entry.print_to_console(); + + } + + return Ok(server_log_entry); + + } + + pub fn get_formatted_message(&self) -> String { + + let level_prefix = format!("[{}]", self.level); + let request_id_prefix = match &self.http_request_id { + + Some(http_request_id) => format!("[{}] ", http_request_id), + + None => String::new() + + }; + let formatted_message = format!("{} {}{}", level_prefix, request_id_prefix, self.message); + let formatted_message = match &self.level { + ServerLogEntryLevel::Success => format!("{}", formatted_message.green()), + ServerLogEntryLevel::Critical => format!("{}", formatted_message.on_red()), + ServerLogEntryLevel::Error => format!("{}", formatted_message.red()), + ServerLogEntryLevel::Warning => format!("{}", formatted_message.yellow()), + ServerLogEntryLevel::Info => format!("{}", formatted_message.blue()), + ServerLogEntryLevel::Trace => format!("{}", formatted_message.dimmed()) + }; + return formatted_message; + + } + + pub fn print_to_console(&self) { + + match &self.level { + + ServerLogEntryLevel::Critical | ServerLogEntryLevel::Error => { + + eprintln!("{}", self.get_formatted_message()); + + }, + + _ => { + + println!("{}", self.get_formatted_message()); + + } + + } + + } + +} \ No newline at end of file diff --git a/src/resources/session/mod.rs b/src/resources/session/mod.rs new file mode 100644 index 0000000..201c5f1 --- /dev/null +++ b/src/resources/session/mod.rs @@ -0,0 +1,165 @@ +use std::{net::IpAddr}; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::Header; +use postgres::error::SqlState; +use postgres_types::ToSql; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SessionError { + #[error("A session with the ID \"{0}\" does not exist.")] + NotFoundError(Uuid), + + #[error(transparent)] + PostgresError(#[from] postgres::Error), + + #[error(transparent)] + VarError(#[from] std::env::VarError), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + JSONWebTokenError(#[from] jsonwebtoken::errors::Error) +} + +#[derive(Debug, Clone)] +pub struct Session { + + /// The session's ID. + pub id: Uuid, + + /// The session's user ID. + pub user_id: Uuid, + + /// The session's expiration date. + pub expiration_date: DateTime, + + /// The IP address used to create the session. + pub creation_ip_address: IpAddr + +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTokenClaims { + pub sub: String, + pub jti: String, + pub exp: usize +} + +pub struct InitialSessionProperties<'a> { + + pub user_id: &'a Uuid, + + pub expiration_date: &'a DateTime, + + pub creation_ip_address: &'a IpAddr + +} + +impl Session { + + pub async fn get_json_web_token_public_key() -> Result { + + let jwt_public_key_path = std::env::var("JWT_PUBLIC_KEY_PATH")?; + let jwt_public_key = std::fs::read_to_string(&jwt_public_key_path)?; + + return Ok(jwt_public_key); + + } + + pub async fn get_json_web_token_private_key() -> Result { + + let jwt_private_key_path = std::env::var("JWT_PRIVATE_KEY_PATH")?; + let jwt_private_key = std::fs::read_to_string(&jwt_private_key_path)?; + + return Ok(jwt_private_key); + + } + + /// Initializes the sessions table. + pub async fn initialize_sessions_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), SessionError> { + + let query = include_str!("../../queries/sessions/initialize-sessions-table.sql"); + postgres_client.execute(query, &[]).await?; + + let query = include_str!("../../queries/sessions/initialize-hydrated-sessions-view.sql"); + postgres_client.execute(query, &[]).await?; + + return Ok(()); + + } + + pub async fn create<'a>(properties: &InitialSessionProperties<'a>, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/sessions/insert-session-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &properties.user_id, + &properties.expiration_date, + &properties.creation_ip_address + ]; + let row = postgres_client.query_one(query, parameters).await?; + + let session = Session { + id: row.get("id"), + user_id: row.get("user_id"), + expiration_date: row.get("expiration_date"), + creation_ip_address: row.get("creation_ip_address") + }; + + return Ok(session); + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/sessions/get-session-row-by-id.sql"); + let row = match postgres_client.query_one(query, &[&id]).await { + + Ok(row) => row, + + Err(error) => match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::NO_DATA_FOUND => return Err(SessionError::NotFoundError(id.clone())), + + _ => return Err(SessionError::PostgresError(error)) + + }, + + None => return Err(SessionError::PostgresError(error)) + + } + + }; + + let session = Session { + id: row.get("id"), + user_id: row.get("user_id"), + expiration_date: row.get("expiration_date"), + creation_ip_address: row.get("creation_ip_address") + }; + + return Ok(session); + + } + + pub async fn generate_json_web_token(&self, private_key: &str) -> Result { + + let claims = SessionTokenClaims { + sub: self.user_id.to_string(), + jti: self.id.to_string(), + exp: (Utc::now() + Duration::days(30)).timestamp() as usize + }; + let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_ref())?; + let token = jsonwebtoken::encode(&Header::new(jsonwebtoken::Algorithm::RS256), &claims, &encoding_key)?; + + return Ok(token); + + } + +} \ No newline at end of file diff --git a/src/resources/user.rs b/src/resources/user.rs deleted file mode 100644 index cc59235..0000000 --- a/src/resources/user.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::net::IpAddr; - -use postgres::error::SqlState; -use postgres_types::ToSql; -use uuid::Uuid; - -use crate::errors::resource_already_exists_error::ResourceAlreadyExistsError; - -/** - * - * Programmers: - * - Christian Toney (https://christiantoney.com) - * - * © 2025 Beastslash LLC - * - */ - -pub struct User { - - /// The user's ID. - pub id: Uuid, - - /// The user's username, if applicable. Only non-anonymous users have a username. - pub username: Option, - - /// The user's display name, if applicable. Only non-anonymous users have a display name. - pub display_name: Option, - - /// The user's hashed password, if applicable. Only non-anonymous users have a hashed password. - hashed_password: Option, - - /// Whether the user is anonymous. - pub is_anonymous: bool, - - /// The user's IP address, if applicable. Only anonymous users have an IP address. - pub ip_address: Option - -} - -pub struct InitialUserProperties { - - /// The user's username, if applicable. Only non-anonymous users have a username. - pub username: Option, - - /// The user's display name, if applicable. Only non-anonymous users have a display name. - pub display_name: Option, - - /// The user's hashed password, if applicable. Only non-anonymous users have a hashed password. - pub hashed_password: Option, - - /// Whether the user is anonymous. - pub is_anonymous: bool, - - /// The user's IP address, if applicable. Only anonymous users have an IP address. - pub ip_address: Option - -} - -#[derive(Debug)] -pub enum UserCreationError { - ResourceAlreadyExistsError(ResourceAlreadyExistsError), - String(String), - PostgresError(postgres::Error) -} - -impl User { - - /// Creates a new user. - pub fn create(initial_properties: &InitialUserProperties, postgres_client: &mut postgres::Client) -> Result { - - // Insert the access policy into the database. - let query = include_str!("../queries/users/insert-user-row.sql"); - let parameters: &[&(dyn ToSql + Sync)] = &[ - &initial_properties.username, - &initial_properties.display_name, - &initial_properties.hashed_password, - &initial_properties.is_anonymous, - &initial_properties.ip_address - ]; - let rows = postgres_client.query(query, parameters); - - // Return the action. - match rows { - - Ok(rows) => { - - let row = rows.get(0).ok_or(UserCreationError::String("Client did not return a row.".to_string()))?; - let user = User { - id: row.get("id"), - username: row.get("username"), - display_name: row.get("display_name"), - hashed_password: row.get("hashed_password"), - is_anonymous: row.get("is_anonymous"), - ip_address: row.get("ip_address") - }; - - return Ok(user); - - }, - - Err(error) => match error.as_db_error() { - - Some(db_error) => { - - let error_code = db_error.code(); - match error_code { - - &SqlState::UNIQUE_VIOLATION => { - - let resource_already_exists_error = ResourceAlreadyExistsError { - resource_type: "Action".to_string() - }; - - Err(UserCreationError::ResourceAlreadyExistsError(resource_already_exists_error)) - - }, - - _ => { - Err(UserCreationError::PostgresError(error)) - } - - } - - }, - - None => { - - Err(UserCreationError::PostgresError(error)) - - } - - } - - } - - } - - /// Initializes the users table. - pub fn initialize_users_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/users/initialize-users-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - - pub fn get_hashed_password(&self) -> &str { - - let hashed_password = self.hashed_password.as_ref().expect("User does not have a hashed password."); - return &hashed_password; - - } - -} \ No newline at end of file diff --git a/src/resources/user/mod.rs b/src/resources/user/mod.rs new file mode 100644 index 0000000..8af6772 --- /dev/null +++ b/src/resources/user/mod.rs @@ -0,0 +1,227 @@ +/** + * + * Programmers: + * - Christian Toney (https://christiantoney.com) + * + * © 2025 Beastslash LLC + * + */ + +use std::net::IpAddr; +use postgres::error::SqlState; +use postgres_types::ToSql; +use uuid::Uuid; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UserError { + #[error("A user with the {0} \"{1}\" does not exist.")] + NotFoundError(String, String), + + #[error("A user with the username \"{0}\" already exists.")] + ConflictError(String), + + #[error("A user with the ID \"{0}\" does not have the required permissions to perform the action \"{1}\".")] + ForbiddenError(String, String), + + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +#[derive(Debug, Clone)] +pub struct User { + + /// The user's ID. + pub id: Uuid, + + /// The user's username, if applicable. Only non-anonymous users have a username. + pub username: Option, + + /// The user's display name, if applicable. Only non-anonymous users have a display name. + pub display_name: Option, + + /// The user's hashed password, if applicable. Only non-anonymous users have a hashed password. + hashed_password: Option, + + /// Whether the user is anonymous. + pub is_anonymous: bool, + + /// The user's IP address, if applicable. Only anonymous users have an IP address. + pub ip_address: Option + +} + +pub struct InitialUserProperties { + + /// The user's username, if applicable. Only non-anonymous users have a username. + pub username: Option, + + /// The user's display name, if applicable. Only non-anonymous users have a display name. + pub display_name: Option, + + /// The user's hashed password, if applicable. Only non-anonymous users have a hashed password. + pub hashed_password: Option, + + /// Whether the user is anonymous. + pub is_anonymous: bool, + + /// The user's IP address, if applicable. Only anonymous users have an IP address. + pub ip_address: Option + +} + +impl User { + + /// Creates a new user. + pub async fn create(initial_properties: &InitialUserProperties, postgres_client: &mut deadpool_postgres::Client) -> Result { + + // Insert the access policy into the database. + let query = include_str!("../../queries/users/insert-user-row.sql"); + let parameters: &[&(dyn ToSql + Sync)] = &[ + &initial_properties.username, + &initial_properties.display_name, + &initial_properties.hashed_password, + &initial_properties.is_anonymous, + &initial_properties.ip_address + ]; + let row = postgres_client.query_one(query, parameters).await.map_err(|error| match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::UNIQUE_VIOLATION => { + + let username = match initial_properties.username.clone() { + + Some(username) => username, + + // TODO: For IP users, we should make UserError more specific. + None => return UserError::PostgresError(error) + + }; + + UserError::ConflictError(username) + + }, + + _ => UserError::PostgresError(error) + + }, + + None => UserError::PostgresError(error) + + })?; + + // Return the action. + let user = User { + id: row.get("id"), + username: row.get("username"), + display_name: row.get("display_name"), + hashed_password: row.get("hashed_password"), + is_anonymous: row.get("is_anonymous"), + ip_address: row.get("ip_address") + }; + + return Ok(user); + + } + + pub fn from_row(row: &postgres::Row) -> Self { + + return User { + id: row.get("id"), + username: row.get("username"), + display_name: row.get("display_name"), + hashed_password: row.get("hashed_password"), + is_anonymous: row.get("is_anonymous"), + ip_address: row.get("ip_address") + }; + + } + + pub async fn get_by_id(id: &Uuid, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/users/get-user-row-by-id.sql"); + let row = match postgres_client.query_opt(query, &[&id]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(UserError::NotFoundError("ID".to_string(), id.to_string())) + + }, + + Err(error) => match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::NO_DATA_FOUND => return Err(UserError::NotFoundError("ID".to_string(), id.to_string())), + + _ => return Err(UserError::PostgresError(error)) + + }, + + None => return Err(UserError::PostgresError(error)) + + } + + }; + + let user = User::from_row(&row); + + return Ok(user); + + } + + pub async fn get_by_ip_address(ip_address: &IpAddr, postgres_client: &mut deadpool_postgres::Client) -> Result { + + let query = include_str!("../../queries/users/get-user-row-by-ip-address.sql"); + let row = match postgres_client.query_opt(query, &[&ip_address]).await { + + Ok(row) => match row { + + Some(row) => row, + + None => return Err(UserError::NotFoundError("IP address".to_string(), ip_address.to_string())) + + }, + + Err(error) => match error.as_db_error() { + + Some(db_error) => match db_error.code() { + + &SqlState::NO_DATA_FOUND => return Err(UserError::NotFoundError("IP address".to_string(), ip_address.to_string())), + + _ => return Err(UserError::PostgresError(error)) + + }, + + None => return Err(UserError::PostgresError(error)) + + } + + }; + + let user = User::from_row(&row); + + return Ok(user); + + } + + /// Initializes the users table. + pub async fn initialize_users_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), UserError> { + + let query = include_str!("../../queries/users/initialize-users-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + + pub fn get_hashed_password(&self) -> &str { + + let hashed_password = self.hashed_password.as_ref().expect("User does not have a hashed password."); + return &hashed_password; + + } + +} \ No newline at end of file diff --git a/src/resources/workspace.rs b/src/resources/workspace.rs deleted file mode 100644 index fbb499f..0000000 --- a/src/resources/workspace.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct Workspace {} - -impl Workspace { - - /// Initializes the workspaces table. - pub fn initialize_workspaces_table(postgres_client: &mut postgres::Client) -> Result<(), postgres::Error> { - - let query = include_str!("../queries/workspaces/initialize-workspaces-table.sql"); - postgres_client.execute(query, &[])?; - return Ok(()); - - } - -} \ No newline at end of file diff --git a/src/resources/workspace/mod.rs b/src/resources/workspace/mod.rs new file mode 100644 index 0000000..901faad --- /dev/null +++ b/src/resources/workspace/mod.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum WorkspaceError { + #[error(transparent)] + PostgresError(#[from] postgres::Error) +} + +pub struct Workspace {} + +impl Workspace { + + /// Initializes the workspaces table. + pub async fn initialize_workspaces_table(postgres_client: &mut deadpool_postgres::Client) -> Result<(), WorkspaceError> { + + let query = include_str!("../../queries/workspaces/initialize-workspaces-table.sql"); + postgres_client.execute(query, &[]).await?; + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/routes.rs b/src/routes.rs deleted file mode 100644 index 33c406a..0000000 --- a/src/routes.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[path = "./routes/access-policies.rs"] -mod access_policies; - -use axum::{Json, Router, http::{StatusCode}, response::IntoResponse}; -use crate::errors::not_found_error::NotFoundError; - -async fn fallback() -> impl IntoResponse { - - return (StatusCode::NOT_FOUND, Json(NotFoundError::new(None))); - -} - -pub fn get_router() -> Router { - - let mut router = Router::new(); - router = router.merge(access_policies::get_router()); - router = router.fallback(fallback); - return router; - -} \ No newline at end of file diff --git a/src/routes/access-policies.rs b/src/routes/access-policies.rs deleted file mode 100644 index 73261a2..0000000 --- a/src/routes/access-policies.rs +++ /dev/null @@ -1,17 +0,0 @@ -use axum::Router; - -#[path = "./access-policies/{access_policy_id}.rs"] -mod access_policy_id; - -async fn list_access_policies() { - -} - -pub fn get_router() -> Router { - - let mut router = Router::new(); - router = router.route("/access-policies", axum::routing::get(list_access_policies)); - router = router.merge(access_policy_id::get_router()); - return router; - -} \ No newline at end of file diff --git a/src/routes/access-policies/mod.rs b/src/routes/access-policies/mod.rs new file mode 100644 index 0000000..09e269f --- /dev/null +++ b/src/routes/access-policies/mod.rs @@ -0,0 +1,22 @@ +use axum::Router; + +use crate::AppState; + +#[path = "./{access_policy_id}/mod.rs"] +mod access_policy_id; + +async fn list_access_policies() { + +} + +pub fn get_router(state: AppState) -> Router { + + let mut router = Router::::new(); + router = router.route("/access-policies", axum::routing::get(list_access_policies)); + router = router.merge(access_policy_id::get_router(state.clone())); + return router; + +} + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/routes/access-policies/tests.rs b/src/routes/access-policies/tests.rs new file mode 100644 index 0000000..2c59b73 --- /dev/null +++ b/src/routes/access-policies/tests.rs @@ -0,0 +1,24 @@ +#[test] +fn list_access_policies() { + +} + +#[test] +fn list_maximum_default_access_policies() { + +} + +#[test] +fn verify_query_when_listing_access_policies() { + +} + +#[test] +fn verify_authentication_when_listing_access_policies() { + +} + +#[test] +fn verify_permission_when_listing_access_policies() { + +} \ No newline at end of file diff --git a/src/routes/access-policies/{access_policy_id}.rs b/src/routes/access-policies/{access_policy_id}.rs deleted file mode 100644 index d0c4701..0000000 --- a/src/routes/access-policies/{access_policy_id}.rs +++ /dev/null @@ -1,24 +0,0 @@ -use axum::Router; - -async fn get_access_policy() { - -} - -async fn patch_access_policy() { - - -} - -async fn delete_access_policy() { - -} - -pub fn get_router() -> Router { - - let mut router = Router::new(); - router = router.route("/access-policies/{access_policy_id}", axum::routing::get(get_access_policy)); - router = router.route("/access-policies/{access_policy_id}", axum::routing::patch(patch_access_policy)); - router = router.route("/access-policies/{access_policy_id}", axum::routing::delete(delete_access_policy)); - return router; - -} \ No newline at end of file diff --git a/src/routes/access-policies/{access_policy_id}/mod.rs b/src/routes/access-policies/{access_policy_id}/mod.rs new file mode 100644 index 0000000..543a6ac --- /dev/null +++ b/src/routes/access-policies/{access_policy_id}/mod.rs @@ -0,0 +1,174 @@ +use std::sync::Arc; + +use axum::{Extension, Json, Router, extract::{Path, State}}; +use uuid::Uuid; +use colored::Colorize; + +use crate::{AppState, HTTPError, middleware::authentication_middleware, resources::{access_policy::{AccessPolicy, AccessPolicyError, AccessPolicyPermissionLevel}, action::Action, http_transaction::HTTPTransaction, server_log_entry::ServerLogEntry, user::User}, utilities::principal_permission_verifier::{PrincipalPermissionVerifier, PrincipalPermissionVerifierError}}; + +#[axum::debug_handler] +async fn get_access_policy( + Path(access_policy_id): Path, + State(state): State, + Extension(http_transaction): Extension>, + Extension(user): Extension>> +) -> Result, HTTPError> { + + // Make sure the access policy exists. + let http_transaction = http_transaction.clone(); + let mut postgres_client = state.database_pool.get().await.map_err(|error| { + + let http_error = HTTPError::InternalServerError(Some(error.to_string())); + eprintln!("{}", format!("Failed to get database connection, so the log cannot be saved. Printing to the console: {}", error).red()); + return http_error; + + })?; + let access_policy_id = match Uuid::parse_str(&access_policy_id) { + + Ok(access_policy_id) => access_policy_id, + + Err(_) => { + + let http_error = HTTPError::BadRequestError(Some("You must provide a valid UUID for the access policy ID.".to_string())); + let _ = ServerLogEntry::from_http_error(&http_error, Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + } + + }; + + let _ = ServerLogEntry::trace(&format!("Getting access policy {}...", access_policy_id), Some(&http_transaction.id), &mut postgres_client).await; + + let access_policy = match AccessPolicy::get_by_id(&access_policy_id, &mut postgres_client).await { + + Ok(access_policy) => access_policy, + + Err(error) => { + + let http_error = match error { + AccessPolicyError::NotFoundError(_) => HTTPError::NotFoundError(Some(error.to_string())), + _ => HTTPError::InternalServerError(Some(error.to_string())) + }; + let _ = ServerLogEntry::from_http_error(&http_error, Some(&http_transaction.id), &mut postgres_client).await; + + return Err(http_error); + + } + + }; + + // Verify the principal has permission to get the access policy. + let Some(user) = user else { + + let http_error = HTTPError::InternalServerError(Some(format!("Couldn't find a user for the request. This is a bug. Make sure the authentication middleware is installed and is working properly."))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + }; + + let _ = ServerLogEntry::trace(&format!("Getting resource hierarchy for access policy {}...", access_policy_id), Some(&http_transaction.id), &mut postgres_client).await; + let resource_hierarchy = match access_policy.get_hierarchy(&mut postgres_client).await { + + Ok(resource_hierarchy) => resource_hierarchy, + + Err(error) => { + + let http_error = match error { + AccessPolicyError::ScopedResourceIDMissingError(scoped_resource_type) => { + + let _ = ServerLogEntry::trace(&format!("Deleting orphaned access policy {}...", access_policy_id), Some(&http_transaction.id), &mut postgres_client).await; + let http_error = match access_policy.delete(&mut postgres_client).await { + + Ok(_) => HTTPError::GoneError(Some(format!("The {} resource has been deleted because it was orphaned.", scoped_resource_type))), + + Err(error) => HTTPError::InternalServerError(Some(format!("Failed to delete orphaned access policy: {:?}", error))) + + }; + + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + }, + _ => HTTPError::InternalServerError(Some(error.to_string())) + }; + let _ = ServerLogEntry::from_http_error(&http_error, Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + } + + }; + + let _ = ServerLogEntry::trace(&format!("Getting action \"slashstep.accessPolicies.get\"..."), Some(&http_transaction.id), &mut postgres_client).await; + let action = match Action::get_by_name("slashstep.accessPolicies.get", &mut postgres_client).await { + + Ok(action) => action, + + Err(error) => { + + let http_error = HTTPError::InternalServerError(Some(format!("Failed to get action \"slashstep.accessPolicies.get\": {:?}", error))); + let _ = http_error.print_and_save(Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + } + + }; + + let _ = ServerLogEntry::trace(&format!("Verifying principal's permissions to get access policy {}...", access_policy_id), Some(&http_transaction.id), &mut postgres_client).await; + + match PrincipalPermissionVerifier::verify_user_permissions(&user.id, &action.id, &resource_hierarchy, &AccessPolicyPermissionLevel::User, &mut postgres_client).await { + + Ok(_) => {}, + + Err(error) => { + + let http_error = match error { + PrincipalPermissionVerifierError::ForbiddenError { .. } => { + + if user.is_anonymous { + + HTTPError::UnauthorizedError(Some("You need at least user-level permission to the \"slashstep.accessPolicies.get\" action.".to_string())) + + } else { + + HTTPError::ForbiddenError(Some("You need at least user-level permission to the \"slashstep.accessPolicies.get\" action.".to_string())) + + } + + }, + _ => HTTPError::InternalServerError(Some(error.to_string())) + }; + let _ = ServerLogEntry::from_http_error(&http_error, Some(&http_transaction.id), &mut postgres_client).await; + return Err(http_error); + + } + + } + + let _ = ServerLogEntry::success(&format!("Successfully returned access policy {}.", access_policy_id), Some(&http_transaction.id), &mut postgres_client).await; + + return Ok(Json(access_policy)); + +} + +async fn patch_access_policy() { + + +} + +async fn delete_access_policy() { + +} + +pub fn get_router(state: AppState) -> Router { + + let router = Router::::new() + .route("/access-policies/{access_policy_id}", axum::routing::get(get_access_policy)) + .route("/access-policies/{access_policy_id}", axum::routing::patch(patch_access_policy)) + .route("/access-policies/{access_policy_id}", axum::routing::delete(delete_access_policy)) + .layer(axum::middleware::from_fn_with_state(state, authentication_middleware::authenticate_user)); + return router; + +} + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/routes/access-policies/{access_policy_id}/tests.rs b/src/routes/access-policies/{access_policy_id}/tests.rs new file mode 100644 index 0000000..eae8d78 --- /dev/null +++ b/src/routes/access-policies/{access_policy_id}/tests.rs @@ -0,0 +1,189 @@ +use std::net::SocketAddr; +use axum::middleware; +use axum_extra::extract::cookie::Cookie; +use axum_test::TestServer; +use ntest::timeout; +use crate::{AppState, SlashstepServerError, initialize_required_tables, middleware::http_request_middleware, pre_definitions::{initialize_pre_defined_actions, initialize_pre_defined_roles}, resources::session::Session, tests::TestEnvironment}; + +/// Verifies that the router can return a 200 status code and the requested access policy. +#[tokio::test] +#[timeout(15000)] +async fn get_access_policy_by_id() -> Result<(), std::io::Error> { + + return Ok(()); + +} + +/// Verifies that the router can return a 400 if the access policy ID is not a UUID. +#[tokio::test] +async fn verify_uuid_when_getting_access_policy_by_id() -> Result<(), SlashstepServerError> { + + let test_environment = TestEnvironment::new().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; + test_environment.initialize_required_tables().await?; + let _ = initialize_pre_defined_actions(&mut postgres_client).await?; + let _ = initialize_pre_defined_roles(&mut postgres_client).await?; + let state = AppState { + database_pool: test_environment.postgres_pool.clone(), + }; + + let router = super::get_router(state.clone()) + .layer(middleware::from_fn_with_state(state.clone(), http_request_middleware::create_http_request)) + .with_state(state) + .into_make_service_with_connect_info::(); + let test_server = TestServer::new(router)?; + + let response = test_server.get("/access-policies/not-a-uuid") + .await; + + assert_eq!(response.status_code(), 400); + return Ok(()); + +} + +/// Verifies that the router can return a 401 status code if the user needs authentication. +#[tokio::test] +async fn verify_authentication_when_getting_access_policy_by_id() -> Result<(), SlashstepServerError> { + + let test_environment = TestEnvironment::new().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; + test_environment.initialize_required_tables().await?; + let _ = initialize_pre_defined_actions(&mut postgres_client).await?; + let _ = initialize_pre_defined_roles(&mut postgres_client).await?; + let state = AppState { + database_pool: test_environment.postgres_pool.clone(), + }; + + let router = super::get_router(state.clone()) + .layer(middleware::from_fn_with_state(state.clone(), http_request_middleware::create_http_request)) + .with_state(state) + .into_make_service_with_connect_info::(); + let test_server = TestServer::new(router)?; + + let access_policy = test_environment.create_random_access_policy().await?; + + let response = test_server.get(&format!("/access-policies/{}", access_policy.id)) + .await; + + assert_eq!(response.status_code(), 401); + return Ok(()); + +} + +/// Verifies that the router can return a 403 status code if the user does not have permission to view the access policy. +#[tokio::test] +#[timeout(15000)] +async fn verify_permission_when_getting_access_policy_by_id() -> Result<(), SlashstepServerError> { + + let test_environment = TestEnvironment::new().await?; + let mut postgres_client = test_environment.postgres_pool.get().await?; + test_environment.initialize_required_tables().await?; + let _ = initialize_pre_defined_actions(&mut postgres_client).await?; + let _ = initialize_pre_defined_roles(&mut postgres_client).await?; + let state = AppState { + database_pool: test_environment.postgres_pool.clone(), + }; + + let router = super::get_router(state.clone()) + .layer(middleware::from_fn_with_state(state.clone(), http_request_middleware::create_http_request)) + .with_state(state) + .into_make_service_with_connect_info::(); + let test_server = TestServer::new(router)?; + + let user = test_environment.create_random_user().await?; + let session = test_environment.create_session(&user.id).await?; + let json_web_token_private_key = Session::get_json_web_token_private_key().await?; + let session_token = session.generate_json_web_token(&json_web_token_private_key).await?; + let access_policy = test_environment.create_random_access_policy().await?; + + let response = test_server.get(&format!("/access-policies/{}", access_policy.id)) + .add_cookie(Cookie::new("sessionToken", format!("Bearer {}", session_token))) + .await; + + assert_eq!(response.status_code(), 403); + return Ok(()); + +} + +/// Verifies that the router can return a 404 status code if the requested access policy doesn't exist +#[tokio::test] +#[timeout(15000)] +async fn verify_not_found_when_getting_access_policy_by_id() -> Result<(), SlashstepServerError> { + + let test_environment = TestEnvironment::new().await?; + initialize_required_tables(&mut test_environment.postgres_pool.get().await?).await?; + let state = AppState { + database_pool: test_environment.postgres_pool.clone(), + }; + + let router = super::get_router(state.clone()) + .layer(middleware::from_fn_with_state(state.clone(), http_request_middleware::create_http_request)) + .with_state(state) + .into_make_service_with_connect_info::(); + let test_server = TestServer::new(router)?; + + let user = test_environment.create_random_user().await?; + let session = test_environment.create_session(&user.id).await?; + let json_web_token_private_key = Session::get_json_web_token_private_key().await?; + let session_token = session.generate_json_web_token(&json_web_token_private_key).await?; + + let response = test_server.get(&format!("/access-policies/{}", uuid::Uuid::now_v7())) + .add_cookie(Cookie::new("sessionToken", format!("Bearer {}", session_token))) + .await; + + assert_eq!(response.status_code(), 404); + return Ok(()); + +} + +/// Verifies that the router can return a 204 status code if the access policy is successfully deleted. +#[test] +fn verify_successful_deletion_when_deleting_access_policy_by_id() { + +} + +/// Verifies that the router can return a 400 status code if the access policy ID is not a UUID. +#[test] +fn verify_uuid_when_deleting_access_policy_by_id() { + +} + +#[test] +fn verify_authentication_when_deleting_access_policy_by_id() { + +} + +#[test] +fn verify_permission_when_deleting_access_policy_by_id() { + +} + +#[test] +fn verify_access_policy_exists_when_deleting_access_policy_by_id() { + +} + +#[test] +fn patch_access_policy() { + +} + +#[test] +fn verify_uuid_when_patching_access_policy() { + +} + +#[test] +fn verify_authentication_when_patching_access_policy() { + +} + +#[test] +fn verify_permission_when_patching_access_policy() { + +} + +#[test] +fn verify_access_policy_exists_when_patching_access_policy() { + +} \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..552f735 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,20 @@ +#[path = "./access-policies/mod.rs"] +mod access_policies; + +use axum::{Router, response::IntoResponse}; +use crate::{AppState, HTTPError}; + +async fn fallback() -> impl IntoResponse { + + return HTTPError::NotFoundError(None); + +} + +pub fn get_router(state: AppState) -> Router { + + let router = Router::::new() + .merge(access_policies::get_router(state.clone())) + .fallback(fallback); + return router; + +} \ No newline at end of file diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..cb07b3e --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use anyhow::Result; +use chrono::{Duration, Utc}; +use deadpool_postgres::tokio_postgres; +use local_ip_address::local_ip; +use postgres::NoTls; +use testcontainers_modules::{testcontainers::runners::AsyncRunner}; +use testcontainers::{ImageExt}; +use uuid::Uuid; + +use crate::{DEFAULT_MAXIMUM_POSTGRES_CONNECTION_COUNT, import_env_file, initialize_required_tables, resources::{access_policy::{AccessPolicy, InitialAccessPolicyProperties}, action::{Action, InitialActionProperties}, session::{InitialSessionProperties, Session}, user::{InitialUserProperties, User}}}; + +pub struct TestEnvironment { + + pub postgres_pool: Arc, + + // This is required to prevent the compiler from complaining about unused fields. + // We need a wrapper struct to fix lifetime issues, but we don't need to use the container for any test right now. + #[allow(dead_code)] + pub postgres_container: testcontainers::ContainerAsync + +} + +impl TestEnvironment { + + pub async fn new() -> Result { + + import_env_file(); + + let postgres_container = testcontainers_modules::postgres::Postgres::default() + .with_tag("18") + .start() + .await?; + let postgres_host = postgres_container.get_host().await?; + let postgres_port = postgres_container.get_host_port_ipv4(5432).await?; + + let mut postgres_config = tokio_postgres::Config::new(); + postgres_config.host(postgres_host.to_string()); + postgres_config.port(postgres_port); + postgres_config.user("postgres"); + postgres_config.password("postgres"); + let manager_config = deadpool_postgres::ManagerConfig { + recycling_method: deadpool_postgres::RecyclingMethod::Fast + }; + let manager = deadpool_postgres::Manager::from_config(postgres_config, NoTls, manager_config); + + let postgres_pool = deadpool_postgres::Pool::builder(manager).max_size(DEFAULT_MAXIMUM_POSTGRES_CONNECTION_COUNT as usize).build()?; + + let environment = TestEnvironment { + postgres_pool: Arc::new(postgres_pool), + postgres_container: postgres_container + }; + + return Ok(environment); + + } + + pub async fn create_random_action(&self) -> Result { + + let action_properties = InitialActionProperties { + name: Uuid::now_v7().to_string(), + display_name: Uuid::now_v7().to_string(), + description: Uuid::now_v7().to_string(), + app_id: None + }; + + let mut postgres_client = self.postgres_pool.get().await?; + + let action = Action::create(&action_properties, &mut postgres_client).await?; + + return Ok(action); + + } + + pub async fn create_random_user(&self) -> Result { + + let user_properties = InitialUserProperties { + username: Some(Uuid::now_v7().to_string()), + display_name: Some(Uuid::now_v7().to_string()), + hashed_password: Some(Uuid::now_v7().to_string()), + is_anonymous: false, + ip_address: None + }; + + let mut postgres_client = self.postgres_pool.get().await?; + + let user = User::create(&user_properties, &mut postgres_client).await?; + + return Ok(user); + + } + + pub async fn initialize_required_tables(&self) -> Result<()> { + + let mut postgres_client = self.postgres_pool.get().await?; + + initialize_required_tables(&mut postgres_client).await?; + + return Ok(()); + + } + + pub async fn create_session(&self, user_id: &Uuid) -> Result { + + let local_ip = local_ip()?; + + let session_properties = InitialSessionProperties { + user_id: user_id, + expiration_date: &(Utc::now() + Duration::days(30)), + creation_ip_address: &local_ip + }; + + let mut postgres_client = self.postgres_pool.get().await?; + + let session = Session::create(&session_properties, &mut postgres_client).await?; + + return Ok(session); + + } + + pub async fn create_random_access_policy(&self) -> Result { + + let action = self.create_random_action().await?; + let user = self.create_random_user().await?; + let access_policy_properties = InitialAccessPolicyProperties { + action_id: action.id, + permission_level: crate::resources::access_policy::AccessPolicyPermissionLevel::User, + inheritance_level: crate::resources::access_policy::AccessPolicyInheritanceLevel::Enabled, + principal_type: crate::resources::access_policy::AccessPolicyPrincipalType::User, + principal_user_id: Some(user.id), + scoped_resource_type: crate::resources::access_policy::AccessPolicyScopedResourceType::Instance, + ..Default::default() + }; + + let mut postgres_client = self.postgres_pool.get().await?; + + let access_policy = AccessPolicy::create(&access_policy_properties, &mut postgres_client).await?; + + return Ok(access_policy); + + } + +} \ No newline at end of file diff --git a/src/utilities.rs b/src/utilities.rs index 5948f1b..aa8134f 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -1 +1,2 @@ -pub mod slashstepql; \ No newline at end of file +pub mod slashstepql; +pub mod principal_permission_verifier; \ No newline at end of file diff --git a/src/utilities/principal_permission_verifier.rs b/src/utilities/principal_permission_verifier.rs new file mode 100644 index 0000000..5b8e4b5 --- /dev/null +++ b/src/utilities/principal_permission_verifier.rs @@ -0,0 +1,48 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::resources::{access_policy::{AccessPolicy, AccessPolicyError, AccessPolicyPermissionLevel, ResourceHierarchy}, user::UserError}; + +#[derive(Debug, Error)] +pub enum PrincipalPermissionVerifierError { + + #[error("The principal does not have the required permissions to perform the action \"{action_id}\".")] + ForbiddenError { + user_id: String, + action_id: String, + minimum_permission_level: AccessPolicyPermissionLevel, + actual_permission_level: AccessPolicyPermissionLevel + }, + + #[error(transparent)] + UserError(#[from] UserError), + + #[error(transparent)] + AccessPolicyError(#[from] AccessPolicyError) +} + +pub struct PrincipalPermissionVerifier; + +impl PrincipalPermissionVerifier { + + pub async fn verify_user_permissions(user_id: &Uuid, action_id: &Uuid, resource_hierarchy: &ResourceHierarchy, minimum_permission_level: &AccessPolicyPermissionLevel, postgres_client: &mut deadpool_postgres::Client) -> Result<(), PrincipalPermissionVerifierError> { + + let relevant_access_policies = AccessPolicy::list_by_hierarchy(resource_hierarchy, action_id, postgres_client).await?; + let deepest_access_policy = relevant_access_policies.first(); + + if deepest_access_policy.is_none() { + + return Err(PrincipalPermissionVerifierError::ForbiddenError { + user_id: user_id.to_string(), + action_id: action_id.to_string(), + minimum_permission_level: minimum_permission_level.clone(), + actual_permission_level: AccessPolicyPermissionLevel::None + }); + + } + + return Ok(()); + + } + +} \ No newline at end of file diff --git a/src/utilities/slashstepql.rs b/src/utilities/slashstepql.rs index 3ab72d7..67db329 100644 --- a/src/utilities/slashstepql.rs +++ b/src/utilities/slashstepql.rs @@ -1,7 +1,26 @@ +use std::fmt; use pg_escape::quote_identifier; use regex::Regex; +use thiserror::Error; +use std::error::Error; -use crate::errors::slashstepql_invalid_limit_error::SlashstepQLInvalidLimitError; +/// An error that occurs when a resource does not exist. +#[derive(Debug)] +pub struct SlashstepQLInvalidLimitError { + + pub limit_string: String, + + pub maximum_limit: Option + +} + +impl Error for SlashstepQLInvalidLimitError {} + +impl fmt::Display for SlashstepQLInvalidLimitError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Invalid limit \"{}\" in filter query. It must be a non-negative integer{}.", self.limit_string, if let Some(maximum_limit) = self.maximum_limit { format!(" and must be less than or equal to {}", maximum_limit) } else { "".to_string() }) + } +} pub struct SlashstepQLSanitizedFilter { pub parameters: Vec<(String, SlashstepQLParameterType)>, @@ -18,8 +37,8 @@ pub enum SlashstepQLParameterType { pub struct SlashstepQLFilterSanitizer; -#[derive(Debug)] -pub enum SlashstepQLSanitizeError { +#[derive(Debug, Error)] +pub enum SlashstepQLError { InvalidFilterSyntaxError(String), InvalidQueryError(()), InvalidFieldError(String), @@ -29,15 +48,23 @@ pub enum SlashstepQLSanitizeError { SlashstepQLInvalidLimitError(SlashstepQLInvalidLimitError) } -impl From for SlashstepQLSanitizeError { +impl fmt::Display for SlashstepQLError { + + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } + +} + +impl From for SlashstepQLError { fn from(error: std::num::ParseIntError) -> Self { - SlashstepQLSanitizeError::ParseIntError(error) + SlashstepQLError::ParseIntError(error) } } -impl From for SlashstepQLSanitizeError { +impl From for SlashstepQLError { fn from(error: regex::Error) -> Self { - SlashstepQLSanitizeError::RegexError(error) + SlashstepQLError::RegexError(error) } } @@ -52,7 +79,7 @@ pub struct SlashstepQLSanitizeFunctionOptions { impl SlashstepQLFilterSanitizer { - pub fn sanitize(options: &SlashstepQLSanitizeFunctionOptions) -> Result { + pub fn sanitize(options: &SlashstepQLSanitizeFunctionOptions) -> Result { let mut parameters = Vec::new(); let mut where_clause = String::new(); @@ -99,7 +126,7 @@ impl SlashstepQLFilterSanitizer { let field = original_key.as_str().to_string(); if !options.allowed_fields.contains(&field) { - return Err(SlashstepQLSanitizeError::InvalidFieldError(field)); + return Err(SlashstepQLError::InvalidFieldError(field)); } @@ -157,7 +184,7 @@ impl SlashstepQLFilterSanitizer { limit_string: limit_string.as_str().to_string(), maximum_limit: maximum_limit_result }; - return Err(SlashstepQLSanitizeError::SlashstepQLInvalidLimitError(error)); + return Err(SlashstepQLError::SlashstepQLInvalidLimitError(error)); } @@ -173,7 +200,7 @@ impl SlashstepQLFilterSanitizer { limit_string: limit_string.as_str().to_string(), maximum_limit: maximum_limit_result }; - return Err(SlashstepQLSanitizeError::SlashstepQLInvalidLimitError(error)); + return Err(SlashstepQLError::SlashstepQLInvalidLimitError(error)); } @@ -190,7 +217,7 @@ impl SlashstepQLFilterSanitizer { } else { - return Err(SlashstepQLSanitizeError::InvalidOffsetError(format!("Invalid offset \"{}\" in filter query. It must be a non-negative integer.", offset_string.as_str()))); + return Err(SlashstepQLError::InvalidOffsetError(format!("Invalid offset \"{}\" in filter query. It must be a non-negative integer.", offset_string.as_str()))); } @@ -198,13 +225,13 @@ impl SlashstepQLFilterSanitizer { } else { - return Err(SlashstepQLSanitizeError::InvalidQueryError(())); + return Err(SlashstepQLError::InvalidQueryError(())); } } else { - return Err(SlashstepQLSanitizeError::InvalidQueryError(())); + return Err(SlashstepQLError::InvalidQueryError(())); } diff --git a/test.env b/test.env new file mode 100644 index 0000000..e69de29