diff --git a/.env.example b/.env.example index 311a47a38..899ef7543 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,10 @@ THOTH_GRAPHQL_API=http://localhost:8000 # THOTH_EXPORT_API is used at compile time, must be a public facing URL THOTH_EXPORT_API=http://localhost:8181 -# Authentication cookie domain -THOTH_DOMAIN=localhost # Full postgres URL DATABASE_URL=postgres://thoth:thoth@localhost/thoth # Full redis URL REDIS_URL=redis://localhost:6379 -# Authentication cookie secret key -SECRET_KEY=an_up_to_255_bytes_random_key # Logging level RUST_LOG=info diff --git a/.gitignore b/.gitignore index c4534ef29..2ed3d5dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env db/ target/ +machinekey/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cc3bf1c..6f0759e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed + - [697](https://github.com/thoth-pub/thoth/pull/697) – Migrated GraphQL API authentication to OIDC via Zitadel. Internal JWT handling has been replaced with introspection of Zitadel-issued tokens. Authorisation is now based entirely on token claims, removing the need for the internal `account` and `publisher_account` tables. + - [697](https://github.com/thoth-pub/thoth/pull/697) – Replaced legacy password-based login in the Thoth APP with Zitadel OIDC authentication using PKCE. The app now redirects to Zitadel for login, exchanges authorisation codes for tokens, and introspects tokens client-side to determine roles and permissions. - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Work.fullTitle`, `Work.title` and `Work.subtitle` into a dedicated `Title` table, supporting multilingual and rich text fields - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Work.shortAbstract` and `Work.longAbstract` into a dedicated `Abstract` table with `abstractType`, supporting multilingual and rich text fields - [689](https://github.com/thoth-pub/thoth/issues/689) - Move `Contribution.biography` into a dedicated `Biography` table, supporting multilingual and rich text fields diff --git a/Cargo.lock b/Cargo.lock index 80d3aa23d..1b8501e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,22 +73,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "actix-identity" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b8ddc6f6a8b19c4016aaa13519968da9969bc3bc1c1c883cdb0f25dd6c8cf7" -dependencies = [ - "actix-service", - "actix-session", - "actix-utils", - "actix-web", - "derive_more 1.0.0", - "futures-core", - "serde", - "tracing", -] - [[package]] name = "actix-macros" version = "0.2.4" @@ -151,23 +135,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-session" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efe6976a74f34f1b6d07a6c05aadc0ed0359304a7781c367fa5b4029418db08f" -dependencies = [ - "actix-service", - "actix-utils", - "actix-web", - "anyhow", - "derive_more 1.0.0", - "rand 0.8.5", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -239,41 +206,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.12" @@ -407,6 +339,28 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -442,11 +396,70 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 1.0.2", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" -version = "0.20.0" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" @@ -454,6 +467,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-compat" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" +dependencies = [ + "byteorder", +] + +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "bitflags" version = "1.3.2" @@ -535,9 +563,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -574,16 +602,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.5.53" @@ -653,6 +671,12 @@ 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 = "constant_time_eq" version = "0.1.5" @@ -674,14 +698,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ - "aes-gcm", - "base64 0.20.0", - "hkdf", - "hmac", "percent-encoding", - "rand 0.8.5", - "sha2", - "subtle", "time", "version_check", ] @@ -696,6 +713,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -721,13 +748,24 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", "typenum", ] @@ -776,14 +814,38 @@ dependencies = [ ] [[package]] -name = "ctr" -version = "0.9.2" +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 = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "cipher", + "proc-macro2", + "quote", + "syn 2.0.111", ] +[[package]] +name = "custom_error" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" + [[package]] name = "darling" version = "0.21.3" @@ -850,6 +912,17 @@ 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" @@ -857,6 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -870,34 +944,13 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", -] - [[package]] name = "derive_more" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ - "derive_more-impl 2.1.0", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "unicode-xid", + "derive_more-impl", ] [[package]] @@ -1019,6 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1075,6 +1129,50 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dyn-clone" +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 = "ego-tree" version = "0.6.3" @@ -1087,6 +1185,27 @@ 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 = "encode_unicode" version = "1.0.0" @@ -1147,12 +1266,34 @@ 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 = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.5" @@ -1309,12 +1450,13 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1351,16 +1493,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "graphql-introspection-query" version = "0.2.0" @@ -1420,13 +1552,24 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.3.27" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "bytes", - "fnv", + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", "futures-core", "futures-sink", "futures-util", @@ -1487,6 +1630,12 @@ 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" @@ -1540,6 +1689,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1559,7 +1719,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1575,6 +1735,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1587,8 +1771,9 @@ dependencies = [ "futures-core", "h2 0.4.12", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1597,6 +1782,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1604,12 +1803,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.35", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", "tower-service", ] @@ -1621,7 +1833,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -1641,14 +1853,14 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.1", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -1727,9 +1939,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1741,9 +1953,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1801,6 +2013,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1815,15 +2028,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "instant" version = "0.1.13" @@ -1878,6 +2082,24 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1987,6 +2209,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1994,6 +2219,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2067,6 +2298,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.6" @@ -2132,6 +2369,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.14" @@ -2144,7 +2387,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2171,6 +2414,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-conv" version = "0.1.0" @@ -2186,6 +2445,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2193,6 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2205,6 +2476,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.16", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2217,12 +2508,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openapiv3-paper" version = "2.0.2" @@ -2234,6 +2519,38 @@ dependencies = [ "serde_json", ] +[[package]] +name = "openidconnect" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 0.2.12", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.75" @@ -2278,6 +2595,39 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[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 = "paperclip" version = "0.9.5" @@ -2285,7 +2635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa5b33308ca3f5902ccef8aa51f72dd71d6ee9f1c3cd04ac2e77eec33fe1da4f" dependencies = [ "anyhow", - "itertools", + "itertools 0.10.5", "once_cell", "openapiv3-paper", "paperclip-actix", @@ -2403,6 +2753,43 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbjson" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e6349fa080353f4a597daffd05cb81572a9c031a6d4fff7e504947496fcc68" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eea3058763d6e656105d1403cb04e0a41b7bbac6362d413e7c33be0c32279c9" +dependencies = [ + "heck 0.5.0", + "itertools 0.13.0", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54e5e7bfb1652f95bc361d76f3c780d8e526b134b85417e774166ee941f0887" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + [[package]] name = "pem" version = "3.0.6" @@ -2413,12 +2800,31 @@ dependencies = [ "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.12.1", +] + [[package]] name = "phf" version = "0.10.1" @@ -2509,6 +2915,26 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2522,23 +2948,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] [[package]] -name = "polyval" -version = "0.6.2" +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", + "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" @@ -2595,6 +3030,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.111", +] + +[[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-error-attr2" version = "2.0.0" @@ -2626,6 +3080,58 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.111", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "pulldown-cmark" version = "0.13.0" @@ -2778,6 +3284,26 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "regex" version = "1.12.2" @@ -2815,9 +3341,50 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64 0.22.1", "bytes", @@ -2825,10 +3392,10 @@ dependencies = [ "futures-core", "h2 0.4.12", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -2841,10 +3408,10 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -2862,7 +3429,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.4.0", - "reqwest", + "reqwest 0.12.26", "serde", "thiserror 1.0.69", "tower-service", @@ -2879,9 +3446,9 @@ dependencies = [ "futures", "getrandom 0.2.16", "http 1.4.0", - "hyper", + "hyper 1.8.1", "parking_lot 0.11.2", - "reqwest", + "reqwest 0.12.26", "reqwest-middleware", "retry-policies", "thiserror 1.0.69", @@ -2899,6 +3466,16 @@ dependencies = [ "rand 0.8.5", ] +[[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" @@ -2922,6 +3499,26 @@ dependencies = [ "xmlparser", ] +[[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 = "rustc_version" version = "0.4.1" @@ -2944,19 +3541,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.13.1" @@ -2966,6 +3607,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.8" @@ -2990,21 +3641,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "schannel" -version = "0.1.28" +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot 0.12.5", +] + +[[package]] +name = "schemars" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ - "windows-sys 0.61.2", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] -name = "scheduled-thread-pool" -version = "0.2.7" +name = "schemars" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ - "parking_lot 0.12.5", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] @@ -3035,6 +3710,30 @@ dependencies = [ "tendril", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[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" @@ -3042,7 +3741,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3093,6 +3805,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3126,6 +3848,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -3147,6 +3889,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3193,9 +3966,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -3212,11 +3985,21 @@ 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 = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_asn1" @@ -3285,6 +4068,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" @@ -3396,6 +4195,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3416,6 +4221,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -3423,8 +4239,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation", - "system-configuration-sys", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3505,6 +4331,7 @@ dependencies = [ name = "thoth" version = "0.13.15" dependencies = [ + "base64 0.22.1", "clap", "dialoguer", "dotenv", @@ -3514,6 +4341,7 @@ dependencies = [ "thoth-errors", "thoth-export-server", "tokio", + "zitadel", ] [[package]] @@ -3544,6 +4372,7 @@ dependencies = [ "thoth-errors", "tokio", "uuid", + "zitadel", ] [[package]] @@ -3552,16 +4381,15 @@ version = "0.13.15" dependencies = [ "actix-cors", "actix-http", - "actix-identity", - "actix-session", "actix-web", + "base64 0.22.1", "env_logger", "futures-util", "log", "serde", - "serde_json", "thoth-api", "thoth-errors", + "zitadel", ] [[package]] @@ -3570,7 +4398,7 @@ version = "0.13.15" dependencies = [ "chrono", "graphql_client", - "reqwest", + "reqwest 0.12.26", "reqwest-middleware", "reqwest-retry", "serde", @@ -3593,11 +4421,12 @@ dependencies = [ "juniper", "marc", "phf 0.11.3", - "reqwest", + "reqwest 0.12.26", "reqwest-middleware", "serde", "serde_json", "thiserror 2.0.17", + "tonic", "uuid", "xml-rs", ] @@ -3708,13 +4537,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] @@ -3762,6 +4612,70 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "socket2 0.5.10", + "tokio", + "tokio-rustls 0.26.4", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0081d8ee0847d01271392a5aebe960a4600f5d4da6c67648a6382a0940f8b367" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3771,7 +4685,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -3779,18 +4693,18 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -3881,16 +4795,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4070,6 +4974,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "winapi" version = "0.3.9" @@ -4162,6 +5072,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4198,6 +5117,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4231,6 +5165,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4243,6 +5183,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4255,6 +5201,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4279,6 +5231,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4291,6 +5249,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4303,6 +5267,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4315,6 +5285,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4333,6 +5309,16 @@ version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4460,6 +5446,30 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zitadel" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168b66027ca4fd1aa3c529f1359a59f94495db612b57223bf933b2900df4e052" +dependencies = [ + "actix-web", + "base64-compat", + "custom_error", + "jsonwebtoken", + "openidconnect", + "pbjson-types", + "prost", + "prost-types", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_urlencoded", + "time", + "tokio", + "tonic", + "tonic-types", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 4919b26de..ad60d07fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,10 @@ thoth-api = { version = "=0.13.15", path = "thoth-api", features = ["backend"] } thoth-api-server = { version = "=0.13.15", path = "thoth-api-server" } thoth-errors = { version = "=0.13.15", path = "thoth-errors" } thoth-export-server = { version = "=0.13.15", path = "thoth-export-server" } +base64 = "0.22.1" clap = { version = "4.5.32", features = ["cargo", "env"] } dialoguer = { version = "0.11.0", features = ["password"] } dotenv = "0.15.0" lazy_static = "1.5.0" tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "macros"] } +zitadel = { version = "5.5.1", features = ["api", "interceptors"]} diff --git a/Makefile b/Makefile index c2aa7560f..4ccf6d6d6 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ .PHONY: \ help \ run-db \ + run-zitadel-db \ run-redis \ + run-zitadel \ run-graphql-api \ run-export-api \ build \ @@ -23,7 +25,9 @@ help: @echo "Available targets:" @echo " help Show this help" @echo " run-db Start PostgreSQL (docker)" + @echo " run-zitadel-db Start Zitadel PostgreSQL (docker)" @echo " run-redis Start Redis (docker)" + @echo " run-zitadel Start Zitadel (docker)" @echo " run-graphql-api Run GraphQL API (cargo)" @echo " run-export-api Run export API (cargo)" @echo " build Build the workspace" @@ -38,9 +42,15 @@ help: run-db: docker compose up db +run-zitadel-db: + docker compose up zitadel-db + run-redis: docker compose up redis +run-zitadel: + docker compose up zitadel + run-graphql-api: build RUST_BACKTRACE=1 cargo run init @@ -74,3 +84,4 @@ migration: mkdir -p $$dir; \ touch $$dir/up.sql; \ touch $$dir/down.sql; + diff --git a/docker-compose.yml b/docker-compose.yml index 99300d357..e2b86094e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,29 @@ services: env_file: - .env + zitadel-db: + image: postgres:17 + container_name: "zitadel_db" + volumes: + - ./db/_zitadel:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: postgres + redis: image: redis:alpine container_name: "thoth_redis" ports: - "6379:6379" + + zitadel: + image: ghcr.io/zitadel/zitadel:v3.2.2 + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled' + container_name: "zitadel" + ports: + - "8282:8080" + env_file: + - .env + volumes: + - ./machinekey:/machinekey + depends_on: + - zitadel-db diff --git a/src/bin/arguments/mod.rs b/src/bin/arguments/mod.rs index 53d75d51f..d363c496c 100644 --- a/src/bin/arguments/mod.rs +++ b/src/bin/arguments/mod.rs @@ -42,37 +42,14 @@ pub fn port(default_value: &'static str, env_value: &'static str) -> Arg { .num_args(1) } -pub fn domain() -> Arg { - Arg::new("domain") - .short('d') - .long("domain") - .value_name("THOTH_DOMAIN") - .env("THOTH_DOMAIN") - .default_value("localhost") - .help("Authentication cookie domain") - .num_args(1) -} - pub fn key() -> Arg { Arg::new("key") .short('k') - .long("secret-key") - .value_name("SECRET") - .env("SECRET_KEY") - .help("Authentication cookie secret key") - .num_args(1) -} - -pub fn session() -> Arg { - Arg::new("duration") - .short('s') - .long("session-length") - .value_name("DURATION") - .env("SESSION_DURATION_SECONDS") - .default_value("3600") - .help("Authentication cookie session duration (seconds)") + .long("private-key") + .value_name("PRIVATE_KEY") + .env("PRIVATE_KEY") + .help("Thoth's GraphQL API zitadel private key (base64-encoded JSON key)") .num_args(1) - .value_parser(value_parser!(i64)) } pub fn gql_url() -> Arg { @@ -108,6 +85,27 @@ pub fn export_url() -> Arg { .num_args(1) } +pub fn zitadel_url() -> Arg { + Arg::new("zitadel-url") + .short('z') + .long("zitadel-url") + .value_name("ZITADEL_URL") + .env("ZITADEL_URL") + .default_value("http://localhost:8282") + .help("Zitadel's, public facing, root URL.") + .num_args(1) +} + +pub fn thoth_pat() -> Arg { + Arg::new("thoth-pat") + .short('P') + .long("thoth-pat") + .value_name("THOTH_PAT") + .env("THOTH_PAT") + .help("Thoth service account Personal Access Token (PAT)") + .num_args(1) +} + pub fn threads(env_value: &'static str) -> Arg { Arg::new("threads") .short('t') diff --git a/src/bin/commands/mod.rs b/src/bin/commands/mod.rs index f6a585c9e..797914d61 100644 --- a/src/bin/commands/mod.rs +++ b/src/bin/commands/mod.rs @@ -3,18 +3,15 @@ use clap::Command; use lazy_static::lazy_static; use thoth::{ api::{ - db::{ - init_pool as init_pg_pool, revert_migrations as revert_db_migrations, - run_migrations as run_db_migrations, PgPool, - }, + db::{revert_migrations as revert_db_migrations, run_migrations as run_db_migrations}, redis::{init_pool as init_redis_pool, RedisPool}, }, errors::ThothResult, }; -pub(super) mod account; pub(super) mod cache; pub(super) mod start; +pub(super) mod zitadel; lazy_static! { pub(super) static ref INIT: Command = Command::new("init") @@ -25,9 +22,7 @@ lazy_static! { .arg(arguments::threads("GRAPHQL_API_THREADS")) .arg(arguments::keep_alive("GRAPHQL_API_KEEP_ALIVE")) .arg(arguments::gql_url()) - .arg(arguments::domain()) - .arg(arguments::key()) - .arg(arguments::session()); + .arg(arguments::key()); } lazy_static! { @@ -37,11 +32,6 @@ lazy_static! { .arg(arguments::revert()); } -fn get_pg_pool(arguments: &clap::ArgMatches) -> PgPool { - let database_url = arguments.get_one::("db").unwrap(); - init_pg_pool(database_url) -} - fn get_redis_pool(arguments: &clap::ArgMatches) -> RedisPool { let redis_url = arguments.get_one::("redis").unwrap(); init_redis_pool(redis_url) diff --git a/src/bin/commands/start.rs b/src/bin/commands/start.rs index 0235581ce..bfc7f471f 100644 --- a/src/bin/commands/start.rs +++ b/src/bin/commands/start.rs @@ -17,9 +17,7 @@ lazy_static! { .arg(arguments::threads("GRAPHQL_API_THREADS")) .arg(arguments::keep_alive("GRAPHQL_API_KEEP_ALIVE")) .arg(arguments::gql_url()) - .arg(arguments::domain()) - .arg(arguments::key()) - .arg(arguments::session()), + .arg(arguments::key()), ) .subcommand( Command::new("export-api") @@ -41,9 +39,7 @@ pub fn graphql_api(arguments: &ArgMatches) -> ThothResult<()> { let threads = *arguments.get_one::("threads").unwrap(); let keep_alive = *arguments.get_one::("keep-alive").unwrap(); let url = arguments.get_one::("gql-url").unwrap().to_owned(); - let domain = arguments.get_one::("domain").unwrap().to_owned(); - let secret_str = arguments.get_one::("key").unwrap().to_owned(); - let session_duration = *arguments.get_one::("duration").unwrap(); + let private_key = arguments.get_one::("key").unwrap().to_owned(); api_server( database_url, host, @@ -51,9 +47,7 @@ pub fn graphql_api(arguments: &ArgMatches) -> ThothResult<()> { threads, keep_alive, url, - domain, - secret_str, - session_duration, + private_key, ) .map_err(|e| e.into()) } diff --git a/src/bin/commands/zitadel.rs b/src/bin/commands/zitadel.rs new file mode 100644 index 000000000..2859ea841 --- /dev/null +++ b/src/bin/commands/zitadel.rs @@ -0,0 +1,158 @@ +use crate::arguments; +use base64::{engine::general_purpose, Engine as _}; +use clap::{ArgMatches, Command}; +use lazy_static::lazy_static; +use thoth::errors::{ThothError, ThothResult}; +use zitadel::api::{ + clients::ClientBuilder, + zitadel::app::v1::{ + ApiAuthMethodType, OidcAppType, OidcAuthMethodType, OidcGrantType, OidcResponseType, + OidcTokenType, OidcVersion, + }, + zitadel::authn::v1::KeyType, + zitadel::management::v1::{ + AddApiAppRequest, AddAppKeyRequest, AddOidcAppRequest, AddProjectRequest, + AddProjectRoleRequest, AddUserGrantRequest, + }, + zitadel::project::v1::PrivateLabelingSetting, + zitadel::user::v2::{ListUsersRequest, UserFieldName}, +}; + +lazy_static! { + pub(crate) static ref COMMAND: Command = Command::new("zitadel") + .about("Manage Zitadel workflows") + .arg(arguments::zitadel_url()) + .arg(arguments::thoth_pat()) + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand(Command::new("setup").about("Intial setup of OIDC APPs in zitadel")); +} + +pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { + let zitadel_url = arguments.get_one::("zitadel-url").unwrap(); + let pat = arguments.get_one::("thoth-pat").unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build()?; + + runtime.block_on(async { + let mut management_client = ClientBuilder::new(zitadel_url) + .with_access_token(pat) + .build_management_client() + .await?; + let mut user_client = ClientBuilder::new(zitadel_url) + .with_access_token(pat) + .build_user_client() + .await?; + + // Create Zitadel project + let project_name = "Thoth"; + let project = management_client + .add_project(AddProjectRequest { + name: project_name.to_string(), + project_role_assertion: false, + project_role_check: false, + has_project_check: false, + private_labeling_setting: PrivateLabelingSetting::EnforceProjectResourceOwnerPolicy + as i32, + }) + .await? + .into_inner(); + println!("\n✅ Created Zitadel project: {}", project_name); + + // Create project user roles + let roles = [ + ("SUPERUSER", "Superuser", "Superusers"), + ("PUBLISHER_ADMIN", "Publisher Admin", "Publisher admins"), + ("PUBLISHER_USER", "Publisher User", "Publisher users"), + ]; + for (role_key, display_name, group) in roles { + management_client + .add_project_role(AddProjectRoleRequest { + project_id: project.id.clone(), + role_key: role_key.to_string(), + display_name: display_name.to_string(), + group: group.to_string(), + }) + .await?; + println!("\n✅ Added project role: {}", role_key); + } + + // Assign SUPERUSER role to default accounts + let users = user_client + .list_users(ListUsersRequest { + query: None, + sorting_column: UserFieldName::CreationDate as i32, + queries: vec![], + }) + .await? + .into_inner() + .result; + for user in users { + management_client + .add_user_grant(AddUserGrantRequest { + user_id: user.user_id.clone(), + project_id: project.id.clone(), + project_grant_id: "".to_string(), + role_keys: vec!["SUPERUSER".to_string()], + }) + .await?; + println!("\n✅ Granted SUPERUSER role to user: {}", user.username); + } + + // Create Zitadel APPs for GraphQL API and APP + let graphql_api_name = "Thoth GraphQL API"; + let graphql_api = management_client + .add_api_app(AddApiAppRequest { + project_id: project.id.clone(), + name: graphql_api_name.to_string(), + auth_method_type: ApiAuthMethodType::PrivateKeyJwt as i32, + }) + .await? + .into_inner(); + println!("\n✅ Created API app: {}", graphql_api_name); + + let graphql_api_key = management_client + .add_app_key(AddAppKeyRequest { + project_id: project.id.clone(), + app_id: graphql_api.app_id, + r#type: KeyType::Json as i32, + expiration_date: None, + }) + .await? + .into_inner(); + let encoded_key = general_purpose::STANDARD.encode(&graphql_api_key.key_details); + println!("\n✅ {} application key generated.", graphql_api_name); + println!("👉 Please copy the following and add it to the `.env` file as `PRIVATE_KEY`:\n"); + println!("PRIVATE_KEY={}\n", encoded_key); + + let app_name = "Thoth APP"; + management_client + .add_oidc_app(AddOidcAppRequest { + project_id: project.id.clone(), + name: app_name.to_string(), + redirect_uris: vec!["http://localhost:8080/callback".to_string()], + response_types: vec![OidcResponseType::Code as i32], + grant_types: vec![OidcGrantType::AuthorizationCode as i32], + app_type: OidcAppType::UserAgent as i32, + auth_method_type: OidcAuthMethodType::None as i32, // PKCE + post_logout_redirect_uris: vec!["http://localhost:8080/logout".to_string()], + version: OidcVersion::OidcVersion10 as i32, + dev_mode: true, + access_token_type: OidcTokenType::Bearer as i32, + access_token_role_assertion: false, + id_token_role_assertion: false, + id_token_userinfo_assertion: false, + clock_skew: None, + additional_origins: vec!["http://localhost:8080".to_string()], + skip_native_app_success_page: false, + back_channel_logout_uri: "".to_string(), + login_version: None, + }) + .await?; + println!("\n✅ Created OIDC app: {}", app_name); + + Ok::<(), ThothError>(()) + }) +} diff --git a/src/bin/thoth.rs b/src/bin/thoth.rs index 2d263c37a..6ee60f80c 100644 --- a/src/bin/thoth.rs +++ b/src/bin/thoth.rs @@ -11,8 +11,8 @@ lazy_static::lazy_static! { .subcommand(commands::MIGRATE.clone()) .subcommand(commands::start::COMMAND.clone()) .subcommand(commands::INIT.clone()) - .subcommand(commands::account::COMMAND.clone()) - .subcommand(commands::cache::COMMAND.clone()); + .subcommand(commands::cache::COMMAND.clone()) + .subcommand(commands::zitadel::COMMAND.clone()); } fn main() -> thoth::errors::ThothResult<()> { @@ -30,16 +30,14 @@ fn main() -> thoth::errors::ThothResult<()> { commands::run_migrations(arguments)?; commands::start::graphql_api(arguments) } - Some(("account", arguments)) => match arguments.subcommand() { - Some(("register", _)) => commands::account::register(arguments), - Some(("publishers", _)) => commands::account::publishers(arguments), - Some(("password", _)) => commands::account::password(arguments), - _ => unreachable!(), - }, Some(("cache", arguments)) => match arguments.subcommand() { Some(("delete", _)) => commands::cache::delete(arguments), _ => unreachable!(), }, + Some(("zitadel", arguments)) => match arguments.subcommand() { + Some(("setup", _)) => commands::zitadel::setup(arguments), + _ => unreachable!(), + }, _ => unreachable!(), } } diff --git a/thoth-api-server/Cargo.toml b/thoth-api-server/Cargo.toml index 833837b6b..11430e9e8 100644 --- a/thoth-api-server/Cargo.toml +++ b/thoth-api-server/Cargo.toml @@ -14,10 +14,9 @@ thoth-errors = { version = "=0.13.15", path = "../thoth-errors" } actix-web = "4.10" actix-cors = "0.7.1" actix-http = "3.10.0" -actix-identity = "0.8.0" -actix-session = { version = "0.10.1", features = ["cookie-session"] } +base64 = "0.22.1" env_logger = "0.11.7" futures-util = "0.3.31" log = "0.4.26" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +zitadel = { version = "5.5.1", features = ["actix"]} diff --git a/thoth-api-server/src/lib.rs b/thoth-api-server/src/lib.rs index 318faaf0d..08bd8b5a9 100644 --- a/thoth-api-server/src/lib.rs +++ b/thoth-api-server/src/lib.rs @@ -4,28 +4,27 @@ mod logger; use std::{io, sync::Arc, time::Duration}; use actix_cors::Cors; -use actix_identity::{Identity, IdentityMiddleware}; -use actix_session::{config::PersistentSession, storage::CookieSessionStore, SessionMiddleware}; use actix_web::{ - cookie::{time::Duration as CookieDuration, Key}, - error, get, + get, http::header, middleware::Compress, post, web::{Data, Json}, - App, Error, HttpMessage, HttpRequest, HttpResponse, HttpServer, Result, + App, Error, HttpResponse, HttpServer, Result, }; +use base64::{engine::general_purpose, Engine as _}; use serde::Serialize; use thoth_api::{ - account::model::{AccountDetails, DecodedToken, LoginCredentials}, - account::service::{get_account, get_account_details, login}, db::{init_pool, PgPool}, graphql::{ model::{create_schema, Context, Schema}, GraphQLRequest, }, }; -use thoth_errors::ThothError; +use zitadel::{ + actix::introspection::{IntrospectedUser, IntrospectionConfigBuilder}, + credentials::Application, +}; use crate::graphiql::graphiql_source; use crate::logger::{BodyLogger, Logger}; @@ -91,10 +90,10 @@ async fn graphql_schema(st: Data>) -> HttpResponse { async fn graphql( st: Data>, pool: Data, - token: DecodedToken, + user: Option, data: Json, ) -> Result { - let ctx = Context::new(pool.into_inner(), token); + let ctx = Context::new(pool.into_inner(), user); let result = data.execute(&st, &ctx).await; match result.is_ok() { true => Ok(HttpResponse::Ok().json(result)), @@ -102,86 +101,6 @@ async fn graphql( } } -#[post("/account/login")] -async fn login_credentials( - request: HttpRequest, - payload: Json, - pool: Data, -) -> Result { - let r = payload.into_inner(); - - login(&r.email, &r.password, &pool) - .and_then(|account| { - account.issue_token(&pool)?; - let details = get_account_details(&account.email, &pool).unwrap(); - let user_string = serde_json::to_string(&details) - .map_err(|_| ThothError::InternalError("Serder error".into()))?; - Identity::login(&request.extensions(), user_string) - .map_err(|_| ThothError::InternalError("Failed to store session cookie".into()))?; - Ok(HttpResponse::Ok().json(details)) - }) - .map_err(error::ErrorUnauthorized) -} - -#[post("/account/token/renew")] -async fn login_session( - request: HttpRequest, - token: DecodedToken, - identity: Option, - pool: Data, -) -> Result { - let email = match identity { - Some(session) => { - let id = session.id().map_err(|_| ThothError::Unauthorised)?; - let details: AccountDetails = - serde_json::from_str(&id).map_err(|_| ThothError::Unauthorised)?; - details.email - } - None => { - token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let t = token.jwt.unwrap(); - t.sub - } - }; - - get_account(&email, &pool) - .and_then(|account| { - account.issue_token(&pool)?; - let details = get_account_details(&account.email, &pool).unwrap(); - let user_string = serde_json::to_string(&details) - .map_err(|_| ThothError::InternalError("Serder error".into()))?; - Identity::login(&request.extensions(), user_string) - .map_err(|_| ThothError::InternalError("Failed to store session cookie".into()))?; - Ok(HttpResponse::Ok().json(details)) - }) - .map_err(error::ErrorUnauthorized) -} - -#[get("/account")] -async fn account_details( - token: DecodedToken, - identity: Option, - pool: Data, -) -> Result { - let email = match identity { - Some(session) => { - let id = session.id().map_err(|_| ThothError::Unauthorised)?; - let details: AccountDetails = - serde_json::from_str(&id).map_err(|_| ThothError::Unauthorised)?; - details.email - } - None => { - token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let t = token.jwt.unwrap(); - t.sub - } - }; - - get_account_details(&email, &pool) - .map(|account_details| HttpResponse::Ok().json(account_details)) - .map_err(error::ErrorUnauthorized) -} - #[allow(clippy::too_many_arguments)] #[actix_web::main] pub async fn start_server( @@ -191,33 +110,26 @@ pub async fn start_server( threads: usize, keep_alive: u64, public_url: String, - domain: String, - secret_str: String, - session_duration: i64, + private_key: String, ) -> io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + let decoded_private_key = general_purpose::STANDARD + .decode(&private_key) + .expect("Failed to base64-decode private key"); + let decoded_str = + std::str::from_utf8(&decoded_private_key).expect("Decoded key is not valid UTF-8"); + let auth = IntrospectionConfigBuilder::new("http://localhost:8282") + .with_jwt_profile(Application::load_from_json(decoded_str).unwrap()) + .build() + .await + .unwrap(); + HttpServer::new(move || { App::new() .wrap(Compress::default()) .wrap(Logger::default()) .wrap(BodyLogger) - .wrap(IdentityMiddleware::default()) - .wrap( - SessionMiddleware::builder( - CookieSessionStore::default(), - Key::from(secret_str.as_bytes()), - ) - .cookie_name("auth".to_string()) - .cookie_path("/".to_string()) - .cookie_domain(Some(domain.clone())) - .cookie_secure(domain.clone().ne("localhost")) // Authentication requires https unless running on localhost - .session_lifecycle( - PersistentSession::default() - .session_ttl(CookieDuration::seconds(session_duration)), - ) - .build(), - ) .wrap( Cors::default() .allowed_methods(vec!["GET", "POST", "OPTIONS"]) @@ -226,6 +138,7 @@ pub async fn start_server( .allowed_header(header::CONTENT_TYPE) .supports_credentials(), ) + .app_data(auth.clone()) .app_data(Data::new(ApiConfig::new(public_url.clone()))) .app_data(Data::new(init_pool(&database_url))) .app_data(Data::new(Arc::new(create_schema()))) @@ -233,9 +146,6 @@ pub async fn start_server( .service(graphql_index) .service(graphql) .service(graphiql_interface) - .service(login_credentials) - .service(login_session) - .service(account_details) .service(graphql_schema) }) .workers(threads) diff --git a/thoth-api/Cargo.toml b/thoth-api/Cargo.toml index 637a03572..e403f6f60 100644 --- a/thoth-api/Cargo.toml +++ b/thoth-api/Cargo.toml @@ -21,7 +21,8 @@ backend = [ "jsonwebtoken", "deadpool-redis", "rand", - "argon2rs" + "argon2rs", + "zitadel" ] [dependencies] @@ -48,6 +49,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" strum = { version = "0.27.1", features = ["derive"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } +zitadel = { version = "5.5.1", features = ["actix"], optional = true} [dev-dependencies] tokio = { version = "1.44", features = ["macros"] } diff --git a/thoth-api/migrations/20260107_v1.0.0/down.sql b/thoth-api/migrations/20260107_v1.0.0/down.sql new file mode 100644 index 000000000..a94277fe8 --- /dev/null +++ b/thoth-api/migrations/20260107_v1.0.0/down.sql @@ -0,0 +1,98 @@ +-- Recreate the `account` table +CREATE TABLE account ( + account_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL CHECK (octet_length(name) >= 1), + surname TEXT NOT NULL CHECK (octet_length(surname) >= 1), + email TEXT NOT NULL CHECK (octet_length(email) >= 1), + hash BYTEA NOT NULL, + salt TEXT NOT NULL CHECK (octet_length(salt) >= 1), + is_superuser BOOLEAN NOT NULL DEFAULT False, + is_bot BOOLEAN NOT NULL DEFAULT False, + is_active BOOLEAN NOT NULL DEFAULT True, + token TEXT NULL CHECK (OCTET_LENGTH(token) >= 1), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +SELECT diesel_manage_updated_at('account'); + +-- case-insensitive UNIQ index on email +CREATE UNIQUE INDEX email_uniq_idx ON account(lower(email)); + +-- Recreate the `publisher_account` table +CREATE TABLE publisher_account ( + account_id UUID NOT NULL REFERENCES account(account_id) ON DELETE CASCADE, + publisher_id UUID NOT NULL REFERENCES publisher(publisher_id) ON DELETE CASCADE, + is_admin BOOLEAN NOT NULL DEFAULT False, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, publisher_id) +); +SELECT diesel_manage_updated_at('publisher_account'); + +-- Rename column user_id → account_id and change type to UUID +ALTER TABLE abstract_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE affiliation_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE biography_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contact_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contribution_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE contributor_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE funding_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE imprint_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE institution_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE issue_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE language_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE location_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE price_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE publication_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE publisher_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE reference_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE series_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE subject_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE title_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE work_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; +ALTER TABLE work_relation_history ALTER COLUMN user_id TYPE UUID USING user_id::uuid; + +ALTER TABLE abstract_history RENAME COLUMN user_id TO account_id; +ALTER TABLE affiliation_history RENAME COLUMN user_id TO account_id; +ALTER TABLE biography_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contact_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contribution_history RENAME COLUMN user_id TO account_id; +ALTER TABLE contributor_history RENAME COLUMN user_id TO account_id; +ALTER TABLE funding_history RENAME COLUMN user_id TO account_id; +ALTER TABLE imprint_history RENAME COLUMN user_id TO account_id; +ALTER TABLE institution_history RENAME COLUMN user_id TO account_id; +ALTER TABLE issue_history RENAME COLUMN user_id TO account_id; +ALTER TABLE language_history RENAME COLUMN user_id TO account_id; +ALTER TABLE location_history RENAME COLUMN user_id TO account_id; +ALTER TABLE price_history RENAME COLUMN user_id TO account_id; +ALTER TABLE publication_history RENAME COLUMN user_id TO account_id; +ALTER TABLE publisher_history RENAME COLUMN user_id TO account_id; +ALTER TABLE reference_history RENAME COLUMN user_id TO account_id; +ALTER TABLE series_history RENAME COLUMN user_id TO account_id; +ALTER TABLE subject_history RENAME COLUMN user_id TO account_id; +ALTER TABLE title_history RENAME COLUMN user_id TO account_id; +ALTER TABLE work_history RENAME COLUMN user_id TO account_id; +ALTER TABLE work_relation_history RENAME COLUMN user_id TO account_id; + +-- Restore foreign key constraints +ALTER TABLE abstract_history ADD CONSTRAINT abstract_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE affiliation_history ADD CONSTRAINT affiliation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE biography_history ADD CONSTRAINT biography_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contact_history ADD CONSTRAINT contact_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contribution_history ADD CONSTRAINT contribution_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE contributor_history ADD CONSTRAINT contributor_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE funding_history ADD CONSTRAINT funding_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE imprint_history ADD CONSTRAINT imprint_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE institution_history ADD CONSTRAINT institution_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE issue_history ADD CONSTRAINT issue_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE language_history ADD CONSTRAINT language_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE location_history ADD CONSTRAINT location_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE price_history ADD CONSTRAINT price_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE publication_history ADD CONSTRAINT publication_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE publisher_history ADD CONSTRAINT publisher_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE reference_history ADD CONSTRAINT reference_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE series_history ADD CONSTRAINT series_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE subject_history ADD CONSTRAINT subject_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE title_history ADD CONSTRAINT title_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE work_history ADD CONSTRAINT work_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); +ALTER TABLE work_relation_history ADD CONSTRAINT work_relation_history_account_id_fkey FOREIGN KEY (account_id) REFERENCES account(account_id); \ No newline at end of file diff --git a/thoth-api/migrations/20260107_v1.0.0/up.sql b/thoth-api/migrations/20260107_v1.0.0/up.sql new file mode 100644 index 000000000..156921b8c --- /dev/null +++ b/thoth-api/migrations/20260107_v1.0.0/up.sql @@ -0,0 +1,72 @@ +-- Drop foreign key constraints +ALTER TABLE abstract_history DROP CONSTRAINT IF EXISTS abstract_history_account_id_fkey; +ALTER TABLE affiliation_history DROP CONSTRAINT IF EXISTS affiliation_history_account_id_fkey; +ALTER TABLE biography_history DROP CONSTRAINT IF EXISTS biography_history_account_id_fkey; +ALTER TABLE contact_history DROP CONSTRAINT IF EXISTS contact_history_account_id_fkey; +ALTER TABLE contribution_history DROP CONSTRAINT IF EXISTS contribution_history_account_id_fkey; +ALTER TABLE contributor_history DROP CONSTRAINT IF EXISTS contributor_history_account_id_fkey; +ALTER TABLE funding_history DROP CONSTRAINT IF EXISTS funding_history_account_id_fkey; +ALTER TABLE imprint_history DROP CONSTRAINT IF EXISTS imprint_history_account_id_fkey; +ALTER TABLE institution_history DROP CONSTRAINT IF EXISTS institution_history_account_id_fkey; +ALTER TABLE institution_history DROP CONSTRAINT IF EXISTS funder_history_account_id_fkey; -- historical +ALTER TABLE issue_history DROP CONSTRAINT IF EXISTS issue_history_account_id_fkey; +ALTER TABLE language_history DROP CONSTRAINT IF EXISTS language_history_account_id_fkey; +ALTER TABLE location_history DROP CONSTRAINT IF EXISTS location_history_account_id_fkey; +ALTER TABLE price_history DROP CONSTRAINT IF EXISTS price_history_account_id_fkey; +ALTER TABLE publication_history DROP CONSTRAINT IF EXISTS publication_history_account_id_fkey; +ALTER TABLE publisher_history DROP CONSTRAINT IF EXISTS publisher_history_account_id_fkey; +ALTER TABLE reference_history DROP CONSTRAINT IF EXISTS reference_history_account_id_fkey; +ALTER TABLE series_history DROP CONSTRAINT IF EXISTS series_history_account_id_fkey; +ALTER TABLE subject_history DROP CONSTRAINT IF EXISTS subject_history_account_id_fkey; +ALTER TABLE title_history DROP CONSTRAINT IF EXISTS title_history_account_id_fkey; +ALTER TABLE work_history DROP CONSTRAINT IF EXISTS work_history_account_id_fkey; +ALTER TABLE work_relation_history DROP CONSTRAINT IF EXISTS work_relation_history_account_id_fkey; + +-- Rename column account_id to user_id and change type to TEXT +ALTER TABLE abstract_history RENAME COLUMN account_id TO user_id; +ALTER TABLE affiliation_history RENAME COLUMN account_id TO user_id; +ALTER TABLE biography_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contact_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contribution_history RENAME COLUMN account_id TO user_id; +ALTER TABLE contributor_history RENAME COLUMN account_id TO user_id; +ALTER TABLE funding_history RENAME COLUMN account_id TO user_id; +ALTER TABLE imprint_history RENAME COLUMN account_id TO user_id; +ALTER TABLE institution_history RENAME COLUMN account_id TO user_id; +ALTER TABLE issue_history RENAME COLUMN account_id TO user_id; +ALTER TABLE language_history RENAME COLUMN account_id TO user_id; +ALTER TABLE location_history RENAME COLUMN account_id TO user_id; +ALTER TABLE price_history RENAME COLUMN account_id TO user_id; +ALTER TABLE publication_history RENAME COLUMN account_id TO user_id; +ALTER TABLE publisher_history RENAME COLUMN account_id TO user_id; +ALTER TABLE reference_history RENAME COLUMN account_id TO user_id; +ALTER TABLE series_history RENAME COLUMN account_id TO user_id; +ALTER TABLE subject_history RENAME COLUMN account_id TO user_id; +ALTER TABLE title_history RENAME COLUMN account_id TO user_id; +ALTER TABLE work_history RENAME COLUMN account_id TO user_id; +ALTER TABLE work_relation_history RENAME COLUMN account_id TO user_id; + +ALTER TABLE abstract_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE affiliation_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE biography_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contact_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contribution_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE contributor_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE funding_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE imprint_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE institution_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE issue_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE language_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE location_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE price_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE publication_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE publisher_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE reference_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE series_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE subject_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE title_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE work_history ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE work_relation_history ALTER COLUMN user_id TYPE TEXT; + +-- Drop the obsolete tables +DROP TABLE IF EXISTS publisher_account; +DROP TABLE IF EXISTS account; \ No newline at end of file diff --git a/thoth-api/src/account/handler.rs b/thoth-api/src/account/handler.rs deleted file mode 100644 index dfa36608c..000000000 --- a/thoth-api/src/account/handler.rs +++ /dev/null @@ -1,216 +0,0 @@ -use diesel::prelude::*; -use dotenv::dotenv; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use regex::Regex; -use std::env; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; -use uuid::Uuid; - -use crate::account::{ - model::{ - Account, AccountAccess, AccountData, DecodedToken, LinkedPublisher, NewAccount, - NewPassword, NewPublisherAccount, PublisherAccount, Token, - }, - service::get_account, - util::{make_hash, make_salt}, -}; -use crate::db::PgPool; -use thoth_errors::{ThothError, ThothResult}; - -impl Account { - pub fn get_permissions(&self, pool: &PgPool) -> ThothResult> { - let publisher_accounts = self.get_publisher_accounts(pool)?; - let permissions: Vec = - publisher_accounts.into_iter().map(|p| p.into()).collect(); - Ok(permissions) - } - - pub fn get_publisher_accounts(&self, pool: &PgPool) -> ThothResult> { - use crate::schema::publisher_account::dsl::*; - let mut conn = pool.get()?; - - let publisher_accounts = publisher_account - .filter(account_id.eq(self.account_id)) - .load::(&mut conn) - .expect("Error loading publisher accounts"); - Ok(publisher_accounts) - } - - pub fn add_publisher_account( - &self, - pool: &PgPool, - linked_publisher: LinkedPublisher, - ) -> ThothResult { - use crate::schema::publisher_account::dsl::*; - let mut conn = pool.get()?; - let new_publisher_account = NewPublisherAccount { - account_id: self.account_id, - publisher_id: linked_publisher.publisher_id, - is_admin: linked_publisher.is_admin, - }; - diesel::insert_into(publisher_account) - .values(&new_publisher_account) - .get_result::(&mut conn) - .map_err(Into::into) - } - - pub fn get_account_access(&self, linked_publishers: Vec) -> AccountAccess { - AccountAccess { - is_superuser: self.is_superuser, - is_bot: self.is_bot, - linked_publishers, - } - } - - pub fn issue_token(&self, pool: &PgPool) -> ThothResult { - const DEFAULT_TOKEN_VALIDITY: i64 = 24 * 60 * 60; - let mut connection = pool.get()?; - dotenv().ok(); - let linked_publishers: Vec = - self.get_permissions(pool).unwrap_or_default(); - let namespace = self.get_account_access(linked_publishers); - let secret_str = env::var("SECRET_KEY").expect("SECRET_KEY must be set"); - let secret: &[u8] = secret_str.as_bytes(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| ThothError::InternalError("Unable to set token iat".into()))?; - let claim = Token { - sub: self.email.clone(), - exp: now.as_secs() as i64 + DEFAULT_TOKEN_VALIDITY, - iat: now.as_secs() as i64, - jti: Uuid::new_v4().to_string(), - namespace, - }; - let token = encode( - &Header::default(), - &claim, - &EncodingKey::from_secret(secret), - ) - .map_err(|_| ThothError::InternalError("Unable to create token".into())); - - use crate::schema::account::dsl; - let updated_account = diesel::update(dsl::account.find(self.account_id)) - .set(dsl::token.eq(token?)) - .get_result::(&mut connection) - .expect("Unable to set token"); - Ok(updated_account.token.unwrap()) - } -} - -impl From for NewAccount { - fn from(account_data: AccountData) -> Self { - let AccountData { - name, - surname, - email, - password, - is_superuser, - is_bot, - .. - } = account_data; - - let salt = make_salt(); - let hash = make_hash(&password, &salt).to_vec(); - Self { - name, - surname, - email, - hash, - salt, - is_superuser, - is_bot, - } - } -} - -impl From for LinkedPublisher { - fn from(publisher_account: PublisherAccount) -> Self { - let PublisherAccount { - publisher_id, - is_admin, - .. - } = publisher_account; - Self { - publisher_id, - is_admin, - } - } -} - -impl Token { - pub fn verify(token: &str) -> ThothResult { - dotenv().ok(); - let secret_str = env::var("SECRET_KEY").expect("SECRET_KEY must be set"); - let secret: &[u8] = secret_str.as_bytes(); - - let data = decode::( - token, - &DecodingKey::from_secret(secret), - &Validation::default(), - ) - .map_err(|_| ThothError::InvalidToken)?; - Ok(data.claims) - } - - pub fn account_id(&self, pool: &PgPool) -> Uuid { - get_account(&self.sub, pool).unwrap().account_id - } -} - -lazy_static::lazy_static! { - static ref BEARER_REGEXP : Regex = Regex::new(r"^Bearer\s(.*)$").expect("Bearer regexp failed!"); -} - -impl actix_web::FromRequest for DecodedToken { - type Error = actix_web::Error; - type Future = futures::future::Ready>; - - fn from_request(req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { - let token = req - .headers() - .get(actix_web::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|authorization| { - BEARER_REGEXP - .captures(authorization) - .and_then(|captures| captures.get(1)) - }) - .map(|v| v.as_str()); - - futures::future::ready(Ok(match token { - None => DecodedToken { jwt: None }, - Some(token) => match Token::verify(token) { - Ok(decoded) => DecodedToken { jwt: Some(decoded) }, - Err(_) => DecodedToken { jwt: None }, - }, - })) - } -} - -impl NewPassword { - pub fn new(email: String, password: String) -> Self { - let salt = make_salt(); - let hash = make_hash(&password, &salt).to_vec(); - Self { email, hash, salt } - } -} - -impl PublisherAccount { - pub fn delete(&self, pool: &PgPool) -> ThothResult<()> { - use crate::schema::publisher_account::dsl::*; - - pool.get()?.transaction(|connection| { - diesel::delete( - publisher_account.filter( - account_id - .eq(self.account_id) - .and(publisher_id.eq(self.publisher_id)), - ), - ) - .execute(connection) - .map(|_| ()) - .map_err(Into::into) - }) - } -} diff --git a/thoth-api/src/account/mod.rs b/thoth-api/src/account/mod.rs deleted file mode 100644 index 225e37d96..000000000 --- a/thoth-api/src/account/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[cfg(feature = "backend")] -pub mod handler; -pub mod model; -#[cfg(feature = "backend")] -pub mod service; -#[cfg(feature = "backend")] -pub mod util; diff --git a/thoth-api/src/account/model.rs b/thoth-api/src/account/model.rs deleted file mode 100644 index 773c54ccd..000000000 --- a/thoth-api/src/account/model.rs +++ /dev/null @@ -1,164 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::model::Timestamp; -#[cfg(feature = "backend")] -use crate::schema::account; -#[cfg(feature = "backend")] -use crate::schema::publisher_account; -use thoth_errors::ThothError; -use thoth_errors::ThothResult; - -#[cfg_attr(feature = "backend", derive(Queryable))] -#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] -pub struct Account { - pub account_id: Uuid, - pub name: String, - pub surname: String, - pub email: String, - pub hash: Vec, - pub salt: String, - pub is_superuser: bool, - pub is_bot: bool, - pub is_active: bool, - pub created_at: Timestamp, - pub updated_at: Timestamp, - pub token: Option, -} - -#[cfg_attr(feature = "backend", derive(Insertable))] -#[cfg_attr(feature = "backend", diesel(table_name = account))] -pub struct NewAccount { - pub name: String, - pub surname: String, - pub email: String, - pub hash: Vec, - pub salt: String, - pub is_superuser: bool, - pub is_bot: bool, -} - -#[derive(Debug)] -pub struct AccountData { - pub name: String, - pub surname: String, - pub email: String, - pub password: String, - pub is_superuser: bool, - pub is_bot: bool, -} - -#[cfg_attr(feature = "backend", derive(Queryable))] -pub struct PublisherAccount { - pub account_id: Uuid, - pub publisher_id: Uuid, - pub is_admin: bool, - pub created_at: Timestamp, - pub updated_at: Timestamp, -} - -#[cfg_attr(feature = "backend", derive(Insertable))] -#[cfg_attr(feature = "backend", diesel(table_name = publisher_account))] -pub struct NewPublisherAccount { - pub account_id: Uuid, - pub publisher_id: Uuid, - pub is_admin: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AccountAccess { - pub is_superuser: bool, - pub is_bot: bool, - pub linked_publishers: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct LinkedPublisher { - pub publisher_id: Uuid, - pub is_admin: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Token { - pub sub: String, - pub exp: i64, - pub iat: i64, - pub jti: String, - #[serde(rename = "https://thoth.pub/resource_access")] - pub namespace: AccountAccess, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AccountDetails { - pub account_id: Uuid, - pub name: String, - pub surname: String, - pub email: String, - pub token: Option, - pub created_at: Timestamp, - pub updated_at: Timestamp, - pub resource_access: AccountAccess, -} - -#[derive(Debug, Clone)] -pub struct DecodedToken { - pub jwt: Option, -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] -pub struct LoginCredentials { - pub email: String, - pub password: String, -} - -#[cfg_attr(feature = "backend", derive(AsChangeset), diesel(table_name = account))] -pub struct NewPassword { - pub email: String, - pub hash: Vec, - pub salt: String, -} - -impl DecodedToken { - pub fn get_user_permissions(&self) -> AccountAccess { - if let Some(jwt) = &self.jwt { - jwt.namespace.clone() - } else { - AccountAccess { - is_superuser: false, - is_bot: false, - linked_publishers: vec![], - } - } - } -} - -impl AccountAccess { - pub fn can_edit(&self, publisher_id: Uuid) -> ThothResult<()> { - if self.is_superuser - || self - .linked_publishers - .iter() - .any(|publisher| publisher.publisher_id == publisher_id) - { - Ok(()) - } else { - Err(ThothError::Unauthorised) - } - } - - pub fn restricted_to(&self) -> Option> { - if self.is_superuser { - None - } else { - Some( - self.linked_publishers - .iter() - .map(|publisher| publisher.publisher_id.to_string()) - .collect(), - ) - } - } -} diff --git a/thoth-api/src/account/service.rs b/thoth-api/src/account/service.rs deleted file mode 100644 index 45943391c..000000000 --- a/thoth-api/src/account/service.rs +++ /dev/null @@ -1,126 +0,0 @@ -use diesel::prelude::*; - -use crate::account::{ - model::{Account, AccountData, AccountDetails, LinkedPublisher, NewAccount, NewPassword}, - util::verify, -}; -use crate::db::PgPool; -use crate::model::publisher::Publisher; -use thoth_errors::{ThothError, ThothResult}; - -pub fn login(user_email: &str, user_password: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(user_email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - - if verify(&account, user_password) { - Ok(account) - } else { - Err(ThothError::Unauthorised) - } -} - -pub fn get_account(email: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - Ok(account) -} - -pub fn get_account_details(email: &str, pool: &PgPool) -> ThothResult { - use crate::schema::account::dsl; - - let mut conn = pool.get()?; - let account = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut conn) - .map_err(|_| ThothError::Unauthorised)?; - let linked_publishers: Vec = account.get_permissions(pool).unwrap_or_default(); - let resource_access = account.get_account_access(linked_publishers); - let account_details = AccountDetails { - account_id: account.account_id, - name: account.name, - surname: account.surname, - email: account.email, - token: account.token, - created_at: account.created_at, - updated_at: account.updated_at, - resource_access, - }; - Ok(account_details) -} - -pub fn register( - pool: &PgPool, - name: String, - surname: String, - email: String, - password: String, - is_superuser: bool, - is_bot: bool, -) -> ThothResult { - use crate::schema::account::dsl; - - let mut connection = pool.get()?; - let account: NewAccount = AccountData { - name, - surname, - email, - password, - is_superuser, - is_bot, - } - .into(); - let created_account: Account = diesel::insert_into(dsl::account) - .values(&account) - .get_result::(&mut connection)?; - Ok(created_account) -} - -pub fn all_emails(pool: &PgPool) -> ThothResult> { - let mut connection = pool.get()?; - - use crate::schema::account::dsl; - let emails = dsl::account - .select((dsl::email, dsl::is_superuser, dsl::is_bot, dsl::is_active)) - .order(dsl::email.asc()) - .load::<(String, bool, bool, bool)>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok(emails) -} - -pub fn all_publishers(pool: &PgPool) -> ThothResult> { - let mut connection = pool.get()?; - - use crate::schema::publisher::dsl; - let publishers = dsl::publisher - .order(dsl::publisher_name.asc()) - .load::(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok(publishers) -} - -pub fn update_password(email: &str, password: &str, pool: &PgPool) -> ThothResult { - let mut connection = pool.get()?; - - let new_password = NewPassword::new(email.to_string(), password.to_string()); - use crate::schema::account::dsl; - - let account_obj = dsl::account - .filter(dsl::email.eq(email)) - .first::(&mut connection) - .map_err(Into::::into)?; - - diesel::update(dsl::account.find(&account_obj.account_id)) - .set(&new_password) - .get_result(&mut connection) - .map_err(Into::into) -} diff --git a/thoth-api/src/account/util.rs b/thoth-api/src/account/util.rs deleted file mode 100644 index 79d86dcaf..000000000 --- a/thoth-api/src/account/util.rs +++ /dev/null @@ -1,30 +0,0 @@ -use argon2rs::argon2i_simple; - -use super::model::Account; - -pub fn make_salt() -> String { - use rand::Rng; - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - abcdefghijklmnopqrstuvwxyz\ - 0123456789)(*&^%$#@!~"; - const PASSWORD_LEN: usize = 128; - let mut rng = rand::rng(); - - let password: String = (0..PASSWORD_LEN) - .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect(); - password -} - -pub fn make_hash(password: &str, salt: &str) -> [u8; argon2rs::defaults::LENGTH] { - argon2i_simple(password, salt) -} - -pub fn verify(account: &Account, password: &str) -> bool { - let Account { hash, salt, .. } = account; - - make_hash(password, salt) == hash.as_ref() -} diff --git a/thoth-api/src/graphql/inputs.rs b/thoth-api/src/graphql/inputs.rs new file mode 100644 index 000000000..f562e02b1 --- /dev/null +++ b/thoth-api/src/graphql/inputs.rs @@ -0,0 +1,425 @@ +use crate::model::contribution::ContributionField; +use crate::model::funding::FundingField; +use crate::model::issue::IssueField; +use crate::model::language::LanguageField; +use crate::model::price::PriceField; +use crate::model::subject::SubjectField; +use crate::model::Timestamp; +use serde::Deserialize; +use serde::Serialize; +use strum::{Display, EnumString}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] +#[graphql(description = "Order in which to sort query results")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Direction { + #[cfg_attr(feature = "backend", graphql(description = "Ascending order"))] + #[default] + Asc, + #[cfg_attr(feature = "backend", graphql(description = "Descending order"))] + Desc, +} + +#[test] +fn test_direction_default() { + let dir: Direction = Default::default(); + assert_eq!(dir, Direction::Asc); +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] +#[graphql(description = "Expression to use when filtering by numeric value")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Expression { + #[cfg_attr( + feature = "backend", + graphql( + description = "Return only results with values which are greater than the value supplied" + ) + )] + #[default] + GreaterThan, + #[cfg_attr( + feature = "backend", + graphql( + description = "Return only results with values which are less than the value supplied" + ) + )] + LessThan, +} + +#[test] +fn test_expression_default() { + let dir: Expression = Default::default(); + assert_eq!(dir, Expression::GreaterThan); +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting contributions list")] +pub struct ContributionOrderBy { + pub field: ContributionField, + pub direction: Direction, +} + +impl Default for ContributionOrderBy { + fn default() -> ContributionOrderBy { + ContributionOrderBy { + field: ContributionField::ContributionType, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting issues list")] +pub struct IssueOrderBy { + pub field: IssueField, + pub direction: Direction, +} + +impl Default for IssueOrderBy { + fn default() -> IssueOrderBy { + IssueOrderBy { + field: IssueField::IssueOrdinal, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting languages list")] +pub struct LanguageOrderBy { + pub field: LanguageField, + pub direction: Direction, +} + +impl Default for LanguageOrderBy { + fn default() -> LanguageOrderBy { + LanguageOrderBy { + field: LanguageField::LanguageCode, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting prices list")] +pub struct PriceOrderBy { + pub field: PriceField, + pub direction: Direction, +} + +impl Default for PriceOrderBy { + fn default() -> PriceOrderBy { + PriceOrderBy { + field: PriceField::CurrencyCode, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting subjects list")] +pub struct SubjectOrderBy { + pub field: SubjectField, + pub direction: Direction, +} + +impl Default for SubjectOrderBy { + fn default() -> SubjectOrderBy { + SubjectOrderBy { + field: SubjectField::SubjectType, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Field and order to use when sorting fundings list")] +pub struct FundingOrderBy { + pub field: FundingField, + pub direction: Direction, +} + +impl Default for FundingOrderBy { + fn default() -> FundingOrderBy { + FundingOrderBy { + field: FundingField::Program, + direction: Default::default(), + } + } +} + +#[derive(juniper::GraphQLInputObject)] +#[graphql( + description = "Timestamp and choice out of greater than/less than to use when filtering by a time field (e.g. updated_at)" +)] +pub struct TimeExpression { + pub timestamp: Timestamp, + pub expression: Expression, +} + +#[derive( + Debug, + Copy, + Clone, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + EnumString, + Display, + juniper::GraphQLEnum, +)] +#[graphql(description = "Unit of measurement for physical Work dimensions (mm, cm or in)")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum LengthUnit { + #[cfg_attr(feature = "backend", graphql(description = "Millimetres"))] + #[default] + Mm, + #[cfg_attr(feature = "backend", graphql(description = "Centimetres"))] + Cm, + #[cfg_attr(feature = "backend", graphql(description = "Inches"))] + In, +} + +#[derive( + Debug, + Copy, + Clone, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + EnumString, + Display, + juniper::GraphQLEnum, +)] +#[graphql(description = "Unit of measurement for physical Work weight (grams or ounces)")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum WeightUnit { + #[cfg_attr(feature = "backend", graphql(description = "Grams"))] + #[default] + G, + #[cfg_attr(feature = "backend", graphql(description = "Ounces"))] + Oz, +} + +pub trait Convert { + fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64; + fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64; +} + +impl Convert for f64 { + fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64 { + match (current_units, new_units) { + // If current units and new units are the same, no conversion is needed + (LengthUnit::Mm, LengthUnit::Mm) + | (LengthUnit::Cm, LengthUnit::Cm) + | (LengthUnit::In, LengthUnit::In) => *self, + // Return cm values rounded to max 1 decimal place (1 cm = 10 mm) + (LengthUnit::Mm, LengthUnit::Cm) => self.round() / 10.0, + // Return mm values rounded to nearest mm (1 cm = 10 mm) + (LengthUnit::Cm, LengthUnit::Mm) => (self * 10.0).round(), + // Return inch values rounded to 2 decimal places (1 inch = 25.4 mm) + (LengthUnit::Mm, LengthUnit::In) => { + let unrounded_inches = self / 25.4; + // To round to a non-integer scale, multiply by the appropriate factor, + // round to the nearest integer, then divide again by the same factor + (unrounded_inches * 100.0).round() / 100.0 + } + // Return mm values rounded to nearest mm (1 inch = 25.4 mm) + (LengthUnit::In, LengthUnit::Mm) => (self * 25.4).round(), + // We don't currently support conversion between cm and in as it is not required + _ => unimplemented!(), + } + } + + fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64 { + match (current_units, new_units) { + // If current units and new units are the same, no conversion is needed + (WeightUnit::G, WeightUnit::G) | (WeightUnit::Oz, WeightUnit::Oz) => *self, + // Return ounce values rounded to 4 decimal places (1 ounce = 28.349523125 grams) + (WeightUnit::G, WeightUnit::Oz) => { + let unrounded_ounces = self / 28.349523125; + // To round to a non-integer scale, multiply by the appropriate factor, + // round to the nearest integer, then divide again by the same factor + (unrounded_ounces * 10000.0).round() / 10000.0 + } + // Return gram values rounded to nearest gram (1 ounce = 28.349523125 grams) + (WeightUnit::Oz, WeightUnit::G) => (self * 28.349523125).round(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{Convert, LengthUnit::*, WeightUnit::*}; + + #[test] + // Float equality comparison is fine here because the floats + // have already been rounded by the functions under test + #[allow(clippy::float_cmp)] + fn test_convert_length_from_to() { + assert_eq!(123.456.convert_length_from_to(&Mm, &Cm), 12.3); + assert_eq!(123.456.convert_length_from_to(&Mm, &In), 4.86); + assert_eq!(123.456.convert_length_from_to(&Cm, &Mm), 1235.0); + assert_eq!(123.456.convert_length_from_to(&In, &Mm), 3136.0); + // Test some standard print sizes + assert_eq!(4.25.convert_length_from_to(&In, &Mm), 108.0); + assert_eq!(108.0.convert_length_from_to(&Mm, &In), 4.25); + assert_eq!(6.0.convert_length_from_to(&In, &Mm), 152.0); + assert_eq!(152.0.convert_length_from_to(&Mm, &In), 5.98); + assert_eq!(8.5.convert_length_from_to(&In, &Mm), 216.0); + assert_eq!(216.0.convert_length_from_to(&Mm, &In), 8.5); + // Test that converting and then converting back again + // returns a value within a reasonable margin of error + assert_eq!( + 5.06.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 5.08 + ); + assert_eq!( + 6.5.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 6.5 + ); + assert_eq!( + 7.44.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 7.44 + ); + assert_eq!( + 8.27.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 8.27 + ); + assert_eq!( + 9.0.convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 9.02 + ); + assert_eq!( + 10.88 + .convert_length_from_to(&In, &Mm) + .convert_length_from_to(&Mm, &In), + 10.87 + ); + assert_eq!( + 102.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 102.0 + ); + assert_eq!( + 120.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 120.0 + ); + assert_eq!( + 168.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 168.0 + ); + assert_eq!( + 190.0 + .convert_length_from_to(&Mm, &In) + .convert_length_from_to(&In, &Mm), + 190.0 + ); + } + #[test] + // Float equality comparison is fine here because the floats + // have already been rounded by the functions under test + #[allow(clippy::float_cmp)] + fn test_convert_weight_from_to() { + assert_eq!(123.456.convert_weight_from_to(&G, &Oz), 4.3548); + assert_eq!(123.456.convert_weight_from_to(&Oz, &G), 3500.0); + assert_eq!(4.25.convert_weight_from_to(&Oz, &G), 120.0); + assert_eq!(108.0.convert_weight_from_to(&G, &Oz), 3.8096); + assert_eq!(6.0.convert_weight_from_to(&Oz, &G), 170.0); + assert_eq!(152.0.convert_weight_from_to(&G, &Oz), 5.3616); + assert_eq!(8.5.convert_weight_from_to(&Oz, &G), 241.0); + assert_eq!(216.0.convert_weight_from_to(&G, &Oz), 7.6192); + // Test that converting and then converting back again + // returns a value within a reasonable margin of error + assert_eq!( + 5.0.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 5.0089 + ); + assert_eq!( + 5.125 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 5.1147 + ); + assert_eq!( + 6.5.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 6.4904 + ); + assert_eq!( + 7.25.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 7.2664 + ); + assert_eq!( + 7.44.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 7.4428 + ); + assert_eq!( + 8.0625 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 8.0777 + ); + assert_eq!( + 9.0.convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 8.9949 + ); + assert_eq!( + 10.75 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 10.7586 + ); + assert_eq!( + 10.88 + .convert_weight_from_to(&Oz, &G) + .convert_weight_from_to(&G, &Oz), + 10.8644 + ); + assert_eq!( + 102.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 102.0 + ); + assert_eq!( + 120.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 120.0 + ); + assert_eq!( + 168.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 168.0 + ); + assert_eq!( + 190.0 + .convert_weight_from_to(&G, &Oz) + .convert_weight_from_to(&Oz, &G), + 190.0 + ); + } +} diff --git a/thoth-api/src/graphql/mod.rs b/thoth-api/src/graphql/mod.rs index 51e333633..70dcd0b1e 100644 --- a/thoth-api/src/graphql/mod.rs +++ b/thoth-api/src/graphql/mod.rs @@ -1,6 +1,4 @@ -#[cfg(feature = "backend")] +pub mod inputs; pub mod model; -pub mod utils; -#[cfg(feature = "backend")] pub use juniper::http::GraphQLRequest; diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 238e21e53..45698d32b 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -3,172 +3,85 @@ use std::sync::Arc; use chrono::naive::NaiveDate; use juniper::{EmptySubscription, FieldError, FieldResult, RootNode}; use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; -use super::utils::{Direction, Expression, MAX_SHORT_ABSTRACT_CHAR_LIMIT}; -use crate::account::model::{AccountAccess, DecodedToken}; +use super::inputs::{ + ContributionOrderBy, Convert, Direction, FundingOrderBy, IssueOrderBy, LanguageOrderBy, + LengthUnit, PriceOrderBy, SubjectOrderBy, TimeExpression, WeightUnit, +}; use crate::db::PgPool; +use crate::markup::{convert_from_jats, convert_to_jats, ConversionLimit, MarkupFormat}; use crate::model::{ - affiliation::{Affiliation, AffiliationOrderBy, NewAffiliation, PatchAffiliation}, - biography::{Biography, BiographyOrderBy, NewBiography, PatchBiography}, - contact::{Contact, ContactOrderBy, ContactType, NewContact, PatchContact}, + affiliation::{ + Affiliation, AffiliationOrderBy, AffiliationPolicy, NewAffiliation, PatchAffiliation, + }, + biography::{Biography, BiographyOrderBy, BiographyPolicy, NewBiography, PatchBiography}, + contact::{Contact, ContactOrderBy, ContactPolicy, ContactType, NewContact, PatchContact}, contribution::{ - Contribution, ContributionField, ContributionType, NewContribution, PatchContribution, + Contribution, ContributionPolicy, ContributionType, NewContribution, PatchContribution, + }, + contributor::{ + Contributor, ContributorOrderBy, ContributorPolicy, NewContributor, PatchContributor, + }, + funding::{Funding, FundingPolicy, NewFunding, PatchFunding}, + imprint::{Imprint, ImprintField, ImprintOrderBy, ImprintPolicy, NewImprint, PatchImprint}, + institution::{ + CountryCode, Institution, InstitutionOrderBy, InstitutionPolicy, NewInstitution, + PatchInstitution, }, - contributor::{Contributor, ContributorOrderBy, NewContributor, PatchContributor}, - convert_from_jats, convert_to_jats, - funding::{Funding, FundingField, NewFunding, PatchFunding}, - imprint::{Imprint, ImprintField, ImprintOrderBy, NewImprint, PatchImprint}, - institution::{CountryCode, Institution, InstitutionOrderBy, NewInstitution, PatchInstitution}, - issue::{Issue, IssueField, NewIssue, PatchIssue}, + issue::{Issue, IssuePolicy, NewIssue, PatchIssue}, language::{ - Language, LanguageCode, LanguageField, LanguageRelation, NewLanguage, PatchLanguage, + Language, LanguageCode, LanguagePolicy, LanguageRelation, NewLanguage, PatchLanguage, }, locale::LocaleCode, - location::{Location, LocationOrderBy, LocationPlatform, NewLocation, PatchLocation}, - price::{CurrencyCode, NewPrice, PatchPrice, Price, PriceField}, + location::{ + Location, LocationOrderBy, LocationPlatform, LocationPolicy, NewLocation, PatchLocation, + }, + price::{CurrencyCode, NewPrice, PatchPrice, Price, PricePolicy}, publication::{ AccessibilityException, AccessibilityStandard, NewPublication, PatchPublication, - Publication, PublicationOrderBy, PublicationProperties, PublicationType, + Publication, PublicationOrderBy, PublicationPolicy, PublicationType, + }, + publisher::{NewPublisher, PatchPublisher, Publisher, PublisherOrderBy, PublisherPolicy}, + r#abstract::{ + Abstract, AbstractOrderBy, AbstractPolicy, AbstractType, NewAbstract, PatchAbstract, }, - publisher::{NewPublisher, PatchPublisher, Publisher, PublisherOrderBy}, - r#abstract::{Abstract, AbstractOrderBy, AbstractType, NewAbstract, PatchAbstract}, - reference::{NewReference, PatchReference, Reference, ReferenceOrderBy}, - series::{NewSeries, PatchSeries, Series, SeriesOrderBy, SeriesType}, - subject::{check_subject, NewSubject, PatchSubject, Subject, SubjectField, SubjectType}, - title::{NewTitle, PatchTitle, Title, TitleOrderBy}, - work::{NewWork, PatchWork, Work, WorkOrderBy, WorkProperties, WorkStatus, WorkType}, + reference::{NewReference, PatchReference, Reference, ReferenceOrderBy, ReferencePolicy}, + series::{NewSeries, PatchSeries, Series, SeriesOrderBy, SeriesPolicy, SeriesType}, + subject::{NewSubject, PatchSubject, Subject, SubjectPolicy, SubjectType}, + title::{convert_title_to_jats, NewTitle, PatchTitle, Title, TitleOrderBy, TitlePolicy}, + work::{NewWork, PatchWork, Work, WorkOrderBy, WorkPolicy, WorkStatus, WorkType}, work_relation::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, + WorkRelationPolicy, }, - ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, Reorder, Ror, - Timestamp, WeightUnit, + Crud, Doi, Isbn, Orcid, Reorder, Ror, Timestamp, }; -use thoth_errors::{ThothError, ThothResult}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothError; impl juniper::Context for Context {} -#[derive(Clone)] pub struct Context { pub db: Arc, - pub account_access: AccountAccess, - pub token: DecodedToken, + pub user: Option, } impl Context { - pub fn new(pool: Arc, token: DecodedToken) -> Self { - Self { - db: pool, - account_access: token.get_user_permissions(), - token, - } + pub fn new(pool: Arc, user: Option) -> Self { + Self { db: pool, user } } } -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting contributions list")] -pub struct ContributionOrderBy { - pub field: ContributionField, - pub direction: Direction, -} - -impl Default for ContributionOrderBy { - fn default() -> ContributionOrderBy { - ContributionOrderBy { - field: ContributionField::ContributionType, - direction: Default::default(), - } +impl PolicyContext for Context { + fn db(&self) -> &PgPool { + &self.db } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting issues list")] -pub struct IssueOrderBy { - pub field: IssueField, - pub direction: Direction, -} - -impl Default for IssueOrderBy { - fn default() -> IssueOrderBy { - IssueOrderBy { - field: IssueField::IssueOrdinal, - direction: Default::default(), - } - } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting languages list")] -pub struct LanguageOrderBy { - pub field: LanguageField, - pub direction: Direction, -} - -impl Default for LanguageOrderBy { - fn default() -> LanguageOrderBy { - LanguageOrderBy { - field: LanguageField::LanguageCode, - direction: Default::default(), - } + fn user(&self) -> Option<&IntrospectedUser> { + self.user.as_ref() } } -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting prices list")] -pub struct PriceOrderBy { - pub field: PriceField, - pub direction: Direction, -} - -impl Default for PriceOrderBy { - fn default() -> PriceOrderBy { - PriceOrderBy { - field: PriceField::CurrencyCode, - direction: Default::default(), - } - } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting subjects list")] -pub struct SubjectOrderBy { - pub field: SubjectField, - pub direction: Direction, -} - -impl Default for SubjectOrderBy { - fn default() -> SubjectOrderBy { - SubjectOrderBy { - field: SubjectField::SubjectType, - direction: Default::default(), - } - } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql(description = "Field and order to use when sorting fundings list")] -pub struct FundingOrderBy { - pub field: FundingField, - pub direction: Direction, -} - -impl Default for FundingOrderBy { - fn default() -> FundingOrderBy { - FundingOrderBy { - field: FundingField::Program, - direction: Default::default(), - } - } -} - -#[derive(juniper::GraphQLInputObject)] -#[graphql( - description = "Timestamp and choice out of greater than/less than to use when filtering by a time field (e.g. updated_at)" -)] -pub struct TimeExpression { - pub timestamp: Timestamp, - pub expression: Expression, -} - pub struct QueryRoot; #[juniper::graphql_object(Context = Context)] @@ -1815,13 +1728,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work to be created")] data: NewWork, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - - data.validate()?; - + WorkPolicy::can_create(context, &data, ())?; Work::create(&context.db, &data).map_err(Into::into) } @@ -1830,12 +1737,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publisher to be created")] data: NewPublisher, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Only superusers can create new publishers - NewPublisher has no ID field - if !context.account_access.is_superuser { - return Err(ThothError::Unauthorised.into()); - } - + PublisherPolicy::can_create(context, &data, ())?; Publisher::create(&context.db, &data).map_err(Into::into) } @@ -1844,9 +1746,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for imprint to be created")] data: NewImprint, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context.account_access.can_edit(data.publisher_id)?; - + ImprintPolicy::can_create(context, &data, ())?; Imprint::create(&context.db, &data).map_err(Into::into) } @@ -1855,7 +1755,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contributor to be created")] data: NewContributor, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + ContributorPolicy::can_create(context, &data, ())?; Contributor::create(&context.db, &data).map_err(Into::into) } @@ -1864,11 +1764,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contribution to be created")] data: NewContribution, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - + ContributionPolicy::can_create(context, &data, ())?; Contribution::create(&context.db, &data).map_err(Into::into) } @@ -1877,13 +1773,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publication to be created")] data: NewPublication, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - data.validate(&context.db)?; - + PublicationPolicy::can_create(context, &data, ())?; Publication::create(&context.db, &data).map_err(Into::into) } @@ -1892,11 +1782,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for series to be created")] data: NewSeries, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - + SeriesPolicy::can_create(context, &data, ())?; Series::create(&context.db, &data).map_err(Into::into) } @@ -1905,13 +1791,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for issue to be created")] data: NewIssue, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - data.imprints_match(&context.db)?; - + IssuePolicy::can_create(context, &data, ())?; Issue::create(&context.db, &data).map_err(Into::into) } @@ -1920,11 +1800,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for language to be created")] data: NewLanguage, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - + LanguagePolicy::can_create(context, &data, ())?; Language::create(&context.db, &data).map_err(Into::into) } @@ -1934,32 +1810,12 @@ impl MutationRoot { #[graphql(description = "The markup format of the title")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values for title to be created")] data: NewTitle, + #[graphql(description = "Values for title to be created")] mut data: NewTitle, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - let has_canonical_title = Work::from_id(&context.db, &data.work_id)? - .title(context) - .is_ok(); + TitlePolicy::can_create(context, &data, markup_format)?; - if has_canonical_title && data.canonical { - return Err(ThothError::CanonicalTitleExistsError.into()); - } - - let mut data = data.clone(); - - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; - data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; - data.subtitle = data - .subtitle - .map(|subtitle_content| { - convert_to_jats(subtitle_content, markup, ConversionLimit::Title) - }) - .transpose()?; - data.full_title = convert_to_jats(data.full_title, markup, ConversionLimit::Title)?; + let markup = markup_format.expect("Validated by policy"); + convert_title_to_jats(&mut data, markup)?; Title::create(&context.db, &data).map_err(Into::into) } @@ -1970,44 +1826,13 @@ impl MutationRoot { #[graphql(description = "The markup format of the abstract")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values for abstract to be created")] data: NewAbstract, + #[graphql(description = "Values for abstract to be created")] mut data: NewAbstract, ) -> FieldResult<Abstract> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - let has_canonical_abstract = Abstract::all( - &context.db, - 1, - 0, - None, - AbstractOrderBy::default(), - vec![], - Some(data.work_id), - None, - vec![], - vec![], - None, - None, - )? - .iter() - .any(|abstract_item| abstract_item.canonical); - - if has_canonical_abstract && data.canonical { - return Err(ThothError::CanonicalAbstractExistsError.into()); - } + AbstractPolicy::can_create(context, &data, markup_format)?; - let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; - if data.abstract_type == AbstractType::Short - && data.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize - { - return Err(ThothError::ShortAbstractLimitExceedError.into()); - }; - Abstract::create(&context.db, &data).map_err(Into::into) } @@ -2017,39 +1842,11 @@ impl MutationRoot { #[graphql(description = "The markup format of the biography")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values for biography to be created")] data: NewBiography, + #[graphql(description = "Values for biography to be created")] mut data: NewBiography, ) -> FieldResult<Biography> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - - let has_canonical_biography = Biography::all( - &context.db, - 0, - 0, - None, - BiographyOrderBy::default(), - vec![], - None, - Some(data.contribution_id), - vec![], - vec![], - None, - None, - )? - .iter() - .any(|biography_item| biography_item.canonical); - - if has_canonical_biography && data.canonical { - return Err(ThothError::CanonicalBiographyExistsError.into()); - } + BiographyPolicy::can_create(context, &data, markup_format)?; - let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; Biography::create(&context.db, &data).map_err(Into::into) @@ -2060,7 +1857,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for institution to be created")] data: NewInstitution, ) -> FieldResult<Institution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + InstitutionPolicy::can_create(context, &data, ())?; Institution::create(&context.db, &data).map_err(Into::into) } @@ -2069,11 +1866,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for funding to be created")] data: NewFunding, ) -> FieldResult<Funding> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - + FundingPolicy::can_create(context, &data, ())?; Funding::create(&context.db, &data).map_err(Into::into) } @@ -2082,25 +1875,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for location to be created")] data: NewLocation, ) -> FieldResult<Location> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Only superusers can create new locations where Location Platform is Thoth - if !context.account_access.is_superuser && data.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - - if data.canonical { - data.canonical_record_complete(&context.db)?; - } else { - data.can_be_non_canonical(&context.db)?; - } - + LocationPolicy::can_create(context, &data, ())?; Location::create(&context.db, &data).map_err(Into::into) } @@ -2109,19 +1884,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for price to be created")] data: NewPrice, ) -> FieldResult<Price> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - - if data.unit_price <= 0.0 { - // Prices must be non-zero (and non-negative). - return Err(ThothError::PriceZeroError.into()); - } - + PricePolicy::can_create(context, &data, ())?; Price::create(&context.db, &data).map_err(Into::into) } @@ -2130,13 +1893,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for subject to be created")] data: NewSubject, ) -> FieldResult<Subject> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - - check_subject(&data.subject_type, &data.subject_code)?; - + SubjectPolicy::can_create(context, &data, ())?; Subject::create(&context.db, &data).map_err(Into::into) } @@ -2145,14 +1902,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, ) -> FieldResult<Affiliation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - + AffiliationPolicy::can_create(context, &data, ())?; Affiliation::create(&context.db, &data).map_err(Into::into) } @@ -2161,18 +1911,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, ) -> FieldResult<WorkRelation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.related_work_id, - )?)?; - + WorkRelationPolicy::can_create(context, &data, ())?; WorkRelation::create(&context.db, &data).map_err(Into::into) } @@ -2181,11 +1920,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for reference to be created")] data: NewReference, ) -> FieldResult<Reference> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - + ReferencePolicy::can_create(context, &data, ())?; Reference::create(&context.db, &data).map_err(Into::into) } @@ -2194,9 +1929,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contact to be created")] data: NewContact, ) -> FieldResult<Contact> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context.account_access.can_edit(data.publisher_id)?; - + ContactPolicy::can_create(context, &data, ())?; Contact::create(&context.db, &data).map_err(Into::into) } @@ -2205,55 +1938,24 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing work")] data: PatchWork, ) -> FieldResult<Work> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &data.work_id)?; - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; - - if data.imprint_id != work.imprint_id { - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - work.can_update_imprint(&context.db)?; - } - - if data.work_type == WorkType::BookChapter { - work.can_be_chapter(&context.db)?; - } - - data.validate()?; - - if work.is_published() && !data.is_published() && !context.account_access.is_superuser { - return Err(ThothError::ThothSetWorkStatusError.into()); - } + let work = context.load_current(&data.work_id)?; + WorkPolicy::can_update(context, &work, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); // update the work and, if it succeeds, synchronise its children statuses and pub. date - match work.update(&context.db, &data, &account_id) { - Ok(w) => { - // update chapters if their pub. data, withdrawn_date or work_status doesn't match the parent's - for child in work.children(&context.db)? { - if child.publication_date != w.publication_date - || child.work_status != w.work_status - || child.withdrawn_date != w.withdrawn_date - { - let mut data: PatchWork = child.clone().into(); - data.publication_date = w.publication_date; - data.withdrawn_date = w.withdrawn_date; - data.work_status = w.work_status; - child.update(&context.db, &data, &account_id)?; - } - } - Ok(w) + let w = work.update(context, &data)?; + for child in work.children(&context.db)? { + if child.publication_date != w.publication_date + || child.work_status != w.work_status + || child.withdrawn_date != w.withdrawn_date + { + let mut data: PatchWork = child.clone().into(); + data.publication_date = w.publication_date; + data.withdrawn_date = w.withdrawn_date; + data.work_status = w.work_status; + child.update(context, &data)?; } - Err(e) => Err(e.into()), } + Ok(w) } #[graphql(description = "Update an existing publisher with the specified values")] @@ -2261,22 +1963,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, ) -> FieldResult<Publisher> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; - context.account_access.can_edit(publisher.publisher_id)?; + let publisher = context.load_current(&data.publisher_id)?; + PublisherPolicy::can_update(context, &publisher, &data, ())?; - if data.publisher_id != publisher.publisher_id { - context.account_access.can_edit(data.publisher_id)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - publisher - .update(&context.db, &data, &account_id) - .map_err(Into::into) + publisher.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing imprint with the specified values")] @@ -2284,22 +1974,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, ) -> FieldResult<Imprint> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; - context.account_access.can_edit(imprint.publisher_id())?; + let imprint = context.load_current(&data.imprint_id)?; + ImprintPolicy::can_update(context, &imprint, &data, ())?; - if data.publisher_id != imprint.publisher_id { - context.account_access.can_edit(data.publisher_id)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - imprint - .update(&context.db, &data, &account_id) - .map_err(Into::into) + imprint.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contributor with the specified values")] @@ -2307,16 +1985,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, ) -> FieldResult<Contributor> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - Contributor::from_id(&context.db, &data.contributor_id)? - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let contributor = context.load_current(&data.contributor_id)?; + ContributorPolicy::can_update(context, &contributor, &data, ())?; + + contributor.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contribution with the specified values")] @@ -2325,26 +1997,10 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contribution")] data: PatchContribution, ) -> FieldResult<Contribution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; - - if data.work_id != contribution.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - contribution - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let contribution = context.load_current(&data.contribution_id)?; + ContributionPolicy::can_update(context, &contribution, &data, ())?; + + contribution.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing publication with the specified values")] @@ -2352,29 +2008,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, ) -> FieldResult<Publication> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &data.publication_id)?; - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; - - if data.work_id != publication.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let publication = context.load_current(&data.publication_id)?; + PublicationPolicy::can_update(context, &publication, &data, ())?; - data.validate(&context.db)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - publication - .update(&context.db, &data, &account_id) - .map_err(Into::into) + publication.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing series with the specified values")] @@ -2382,26 +2019,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing series")] data: PatchSeries, ) -> FieldResult<Series> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &data.series_id)?; - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; - - if data.imprint_id != series.imprint_id { - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - series - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let series = context.load_current(&data.series_id)?; + SeriesPolicy::can_update(context, &series, &data, ())?; + + series.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing issue with the specified values")] @@ -2409,28 +2030,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, ) -> FieldResult<Issue> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &data.issue_id)?; - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; - - data.imprints_match(&context.db)?; - - if data.work_id != issue.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - issue - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let issue = context.load_current(&data.issue_id)?; + IssuePolicy::can_update(context, &issue, &data, ())?; + + issue.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing language with the specified values")] @@ -2438,27 +2041,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, ) -> FieldResult<Language> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &data.language_id)?; - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; - - if data.work_id != language.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let language = context.load_current(&data.language_id)?; + LanguagePolicy::can_update(context, &language, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - language - .update(&context.db, &data, &account_id) - .map_err(Into::into) + language.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing institution with the specified values")] @@ -2466,16 +2052,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, ) -> FieldResult<Institution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - Institution::from_id(&context.db, &data.institution_id)? - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let institution = context.load_current(&data.institution_id)?; + InstitutionPolicy::can_update(context, &institution, &data, ())?; + + institution.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing funding with the specified values")] @@ -2483,27 +2063,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, ) -> FieldResult<Funding> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &data.funding_id)?; - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; - - if data.work_id != funding.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let funding = context.load_current(&data.funding_id)?; + FundingPolicy::can_update(context, &funding, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - funding - .update(&context.db, &data, &account_id) - .map_err(Into::into) + funding.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing location with the specified values")] @@ -2511,55 +2074,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing location")] data: PatchLocation, ) -> FieldResult<Location> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let current_location = Location::from_id(&context.db, &data.location_id)?; - let has_canonical_thoth_location = Publication::from_id(&context.db, &data.publication_id)? - .locations( - context, - Some(1), - None, - None, - Some(vec![LocationPlatform::Thoth]), - )? - .first() - .is_some_and(|location| location.canonical); - // Only superusers can update the canonical location when a Thoth Location Platform canonical location already exists - if has_canonical_thoth_location && data.canonical && !context.account_access.is_superuser { - return Err(ThothError::ThothUpdateCanonicalError.into()); - } - - // Only superusers can edit locations where Location Platform is Thoth - if !context.account_access.is_superuser - && current_location.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(current_location.publisher_id(&context.db)?)?; - - if data.publication_id != current_location.publication_id { - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - } - - if data.canonical { - data.canonical_record_complete(&context.db)?; - } + let current_location = context.load_current(&data.location_id)?; + LocationPolicy::can_update(context, ¤t_location, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - current_location - .update(&context.db, &data, &account_id) - .map_err(Into::into) + current_location.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing price with the specified values")] @@ -2567,35 +2085,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing price")] data: PatchPrice, ) -> FieldResult<Price> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &data.price_id)?; - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; - - if data.publication_id != price.publication_id { - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; - } - - if data.unit_price <= 0.0 { - // Prices must be non-zero (and non-negative). - return Err(ThothError::PriceZeroError.into()); - } + let price = context.load_current(&data.price_id)?; + PricePolicy::can_update(context, &price, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - price - .update(&context.db, &data, &account_id) - .map_err(Into::into) + price.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing subject with the specified values")] @@ -2603,29 +2096,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, ) -> FieldResult<Subject> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &data.subject_id)?; - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; - - if data.work_id != subject.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let subject = context.load_current(&data.subject_id)?; + SubjectPolicy::can_update(context, &subject, &data, ())?; - check_subject(&data.subject_type, &data.subject_code)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - subject - .update(&context.db, &data, &account_id) - .map_err(Into::into) + subject.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing affiliation with the specified values")] @@ -2633,30 +2107,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, ) -> FieldResult<Affiliation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; - - if data.contribution_id != affiliation.contribution_id { - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - } + let affiliation = context.load_current(&data.affiliation_id)?; + AffiliationPolicy::can_update(context, &affiliation, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - affiliation - .update(&context.db, &data, &account_id) - .map_err(Into::into) + affiliation.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing work relation with the specified values")] @@ -2665,41 +2119,10 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing work relation")] data: PatchWorkRelation, ) -> FieldResult<WorkRelation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id)?; - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.related_work_id, - )?)?; - - if data.relator_work_id != work_relation.relator_work_id { - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.relator_work_id, - )?)?; - } - if data.related_work_id != work_relation.related_work_id { - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - data.related_work_id, - )?)?; - } + let work_relation = context.load_current(&data.work_relation_id)?; + WorkRelationPolicy::can_update(context, &work_relation, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - work_relation - .update(&context.db, &data, &account_id) - .map_err(Into::into) + work_relation.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing reference with the specified values")] @@ -2707,27 +2130,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing reference")] data: PatchReference, ) -> FieldResult<Reference> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &data.reference_id)?; - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; - - if data.work_id != reference.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let reference = context.load_current(&data.reference_id)?; + ReferencePolicy::can_update(context, &reference, &data, ())?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - reference - .update(&context.db, &data, &account_id) - .map_err(Into::into) + reference.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contact with the specified values")] @@ -2735,22 +2141,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contact")] data: PatchContact, ) -> FieldResult<Contact> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contact = Contact::from_id(&context.db, &data.contact_id)?; - context.account_access.can_edit(contact.publisher_id())?; + let contact = context.load_current(&data.contact_id)?; + ContactPolicy::can_update(context, &contact, &data, ())?; - if data.publisher_id != contact.publisher_id { - context.account_access.can_edit(data.publisher_id)?; - } - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - contact - .update(&context.db, &data, &account_id) - .map_err(Into::into) + contact.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing title with the specified values")] @@ -2759,40 +2153,15 @@ impl MutationRoot { #[graphql(description = "The markup format of the title")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values to apply to existing title")] data: PatchTitle, + #[graphql(description = "Values to apply to existing title")] mut data: PatchTitle, ) -> FieldResult<Title> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let title = Title::from_id(&context.db, &data.title_id)?; - context - .account_access - .can_edit(title.publisher_id(&context.db)?)?; - - if data.work_id != title.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let title = context.load_current(&data.title_id)?; + TitlePolicy::can_update(context, &title, &data, markup_format)?; - let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; - data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; - data.subtitle = data - .subtitle - .map(|subtitle_content| { - convert_to_jats(subtitle_content, markup, ConversionLimit::Title) - }) - .transpose()?; - data.full_title = convert_to_jats(data.full_title, markup, ConversionLimit::Title)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - title - .update(&context.db, &data, &account_id) - .map_err(Into::into) + let markup = markup_format.expect("Validated by policy"); + convert_title_to_jats(&mut data, markup)?; + + title.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing abstract with the specified values")] @@ -2801,39 +2170,15 @@ impl MutationRoot { #[graphql(description = "The markup format of the abstract")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values to apply to existing abstract")] data: PatchAbstract, + #[graphql(description = "Values to apply to existing abstract")] mut data: PatchAbstract, ) -> FieldResult<Abstract> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; - context - .account_access - .can_edit(r#abstract.publisher_id(&context.db)?)?; - - if data.work_id != r#abstract.work_id { - context - .account_access - .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; - } + let r#abstract = context.load_current(&data.abstract_id)?; + AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; - let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; - if data.abstract_type == AbstractType::Short - && data.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize - { - return Err(ThothError::ShortAbstractLimitExceedError.into()); - } - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - r#abstract - .update(&context.db, &data, &account_id) - .map_err(Into::into) + r#abstract.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing biography with the specified values")] @@ -2842,37 +2187,15 @@ impl MutationRoot { #[graphql(description = "The markup format of the biography")] markup_format: Option< MarkupFormat, >, - #[graphql(description = "Values to apply to existing biography")] data: PatchBiography, + #[graphql(description = "Values to apply to existing biography")] mut data: PatchBiography, ) -> FieldResult<Biography> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let biography = Biography::from_id(&context.db, &data.biography_id)?; - context - .account_access - .can_edit(biography.publisher_id(&context.db)?)?; - - // If contribution changes, ensure permission on the new work via contribution - if data.contribution_id != biography.contribution_id { - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - } + let biography = context.load_current(&data.biography_id)?; + BiographyPolicy::can_update(context, &biography, &data, markup_format)?; - let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); - biography - .update(&context.db, &data, &account_id) - .map_err(Into::into) + biography.update(context, &data).map_err(Into::into) } #[graphql(description = "Delete a single work using its ID")] @@ -2880,15 +2203,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work to be deleted")] work_id: Uuid, ) -> FieldResult<Work> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &work_id)?; - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; - - if work.is_published() && !context.account_access.is_superuser { - return Err(ThothError::ThothDeleteWorkError.into()); - } + let work = context.load_current(&work_id)?; + WorkPolicy::can_delete(context, &work)?; work.delete(&context.db).map_err(Into::into) } @@ -2898,9 +2214,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publisher to be deleted")] publisher_id: Uuid, ) -> FieldResult<Publisher> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &publisher_id)?; - context.account_access.can_edit(publisher_id)?; + let publisher = context.load_current(&publisher_id)?; + PublisherPolicy::can_delete(context, &publisher)?; publisher.delete(&context.db).map_err(Into::into) } @@ -2910,9 +2225,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of imprint to be deleted")] imprint_id: Uuid, ) -> FieldResult<Imprint> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &imprint_id)?; - context.account_access.can_edit(imprint.publisher_id())?; + let imprint = context.load_current(&imprint_id)?; + ImprintPolicy::can_delete(context, &imprint)?; imprint.delete(&context.db).map_err(Into::into) } @@ -2922,11 +2236,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contributor to be deleted")] contributor_id: Uuid, ) -> FieldResult<Contributor> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contributor = Contributor::from_id(&context.db, &contributor_id)?; - for linked_publisher_id in contributor.linked_publisher_ids(&context.db)? { - context.account_access.can_edit(linked_publisher_id)?; - } + let contributor = context.load_current(&contributor_id)?; + ContributorPolicy::can_delete(context, &contributor)?; contributor.delete(&context.db).map_err(Into::into) } @@ -2936,11 +2247,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contribution to be deleted")] contribution_id: Uuid, ) -> FieldResult<Contribution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &contribution_id)?; - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; + let contribution = context.load_current(&contribution_id)?; + ContributionPolicy::can_delete(context, &contribution)?; contribution.delete(&context.db).map_err(Into::into) } @@ -2950,11 +2258,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publication to be deleted")] publication_id: Uuid, ) -> FieldResult<Publication> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &publication_id)?; - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; + let publication = context.load_current(&publication_id)?; + PublicationPolicy::can_delete(context, &publication)?; publication.delete(&context.db).map_err(Into::into) } @@ -2964,11 +2269,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of series to be deleted")] series_id: Uuid, ) -> FieldResult<Series> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &series_id)?; - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; + let series = context.load_current(&series_id)?; + SeriesPolicy::can_delete(context, &series)?; series.delete(&context.db).map_err(Into::into) } @@ -2978,11 +2280,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of issue to be deleted")] issue_id: Uuid, ) -> FieldResult<Issue> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &issue_id)?; - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; + let issue = context.load_current(&issue_id)?; + IssuePolicy::can_delete(context, &issue)?; issue.delete(&context.db).map_err(Into::into) } @@ -2992,11 +2291,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of language to be deleted")] language_id: Uuid, ) -> FieldResult<Language> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &language_id)?; - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; + let language = context.load_current(&language_id)?; + LanguagePolicy::can_delete(context, &language)?; language.delete(&context.db).map_err(Into::into) } @@ -3006,11 +2302,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of title to be deleted")] title_id: Uuid, ) -> FieldResult<Title> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let title = Title::from_id(&context.db, &title_id)?; - context - .account_access - .can_edit(title.publisher_id(&context.db)?)?; + let title = context.load_current(&title_id)?; + TitlePolicy::can_delete(context, &title)?; title.delete(&context.db).map_err(Into::into) } @@ -3020,11 +2313,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of institution to be deleted")] institution_id: Uuid, ) -> FieldResult<Institution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let institution = Institution::from_id(&context.db, &institution_id)?; - for linked_publisher_id in institution.linked_publisher_ids(&context.db)? { - context.account_access.can_edit(linked_publisher_id)?; - } + let institution = context.load_current(&institution_id)?; + InstitutionPolicy::can_delete(context, &institution)?; institution.delete(&context.db).map_err(Into::into) } @@ -3034,11 +2324,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of funding to be deleted")] funding_id: Uuid, ) -> FieldResult<Funding> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &funding_id)?; - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; + let funding = context.load_current(&funding_id)?; + FundingPolicy::can_delete(context, &funding)?; funding.delete(&context.db).map_err(Into::into) } @@ -3048,17 +2335,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, ) -> FieldResult<Location> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let location = Location::from_id(&context.db, &location_id)?; - // Only superusers can delete locations where Location Platform is Thoth - if !context.account_access.is_superuser - && location.location_platform == LocationPlatform::Thoth - { - return Err(ThothError::ThothLocationError.into()); - } - context - .account_access - .can_edit(location.publisher_id(&context.db)?)?; + let location = context.load_current(&location_id)?; + LocationPolicy::can_delete(context, &location)?; location.delete(&context.db).map_err(Into::into) } @@ -3068,11 +2346,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of price to be deleted")] price_id: Uuid, ) -> FieldResult<Price> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &price_id)?; - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; + let price = context.load_current(&price_id)?; + PricePolicy::can_delete(context, &price)?; price.delete(&context.db).map_err(Into::into) } @@ -3082,11 +2357,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of subject to be deleted")] subject_id: Uuid, ) -> FieldResult<Subject> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &subject_id)?; - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; + let subject = context.load_current(&subject_id)?; + SubjectPolicy::can_delete(context, &subject)?; subject.delete(&context.db).map_err(Into::into) } @@ -3096,11 +2368,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of affiliation to be deleted")] affiliation_id: Uuid, ) -> FieldResult<Affiliation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; + let affiliation = context.load_current(&affiliation_id)?; + AffiliationPolicy::can_delete(context, &affiliation)?; affiliation.delete(&context.db).map_err(Into::into) } @@ -3110,18 +2379,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work relation to be deleted")] work_relation_id: Uuid, ) -> FieldResult<WorkRelation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &work_relation_id)?; - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.related_work_id, - )?)?; + let work_relation = context.load_current(&work_relation_id)?; + WorkRelationPolicy::can_delete(context, &work_relation)?; work_relation.delete(&context.db).map_err(Into::into) } @@ -3131,11 +2390,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of reference to be deleted")] reference_id: Uuid, ) -> FieldResult<Reference> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &reference_id)?; - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; + let reference = context.load_current(&reference_id)?; + ReferencePolicy::can_delete(context, &reference)?; reference.delete(&context.db).map_err(Into::into) } @@ -3145,11 +2401,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of abstract to be deleted")] abstract_id: Uuid, ) -> FieldResult<Abstract> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let r#abstract = Abstract::from_id(&context.db, &abstract_id)?; - context - .account_access - .can_edit(r#abstract.publisher_id(&context.db)?)?; + let r#abstract = context.load_current(&abstract_id)?; + AbstractPolicy::can_delete(context, &r#abstract)?; r#abstract.delete(&context.db).map_err(Into::into) } @@ -3159,11 +2412,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of biography to be deleted")] biography_id: Uuid, ) -> FieldResult<Biography> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let biography = Biography::from_id(&context.db, &biography_id)?; - context - .account_access - .can_edit(biography.publisher_id(&context.db)?)?; + let biography = context.load_current(&biography_id)?; + BiographyPolicy::can_delete(context, &biography)?; biography.delete(&context.db).map_err(Into::into) } @@ -3177,31 +2427,16 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Affiliation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; + let affiliation = context.load_current(&affiliation_id)?; + AffiliationPolicy::can_move(context, &affiliation)?; if new_ordinal == affiliation.affiliation_ordinal { // No action required return Ok(affiliation); } - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); affiliation - .change_ordinal( - &context.db, - affiliation.affiliation_ordinal, - new_ordinal, - &account_id, - ) + .change_ordinal(context, affiliation.affiliation_ordinal, new_ordinal) .map_err(Into::into) } @@ -3214,31 +2449,16 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Contribution> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &contribution_id)?; + let contribution = context.load_current(&contribution_id)?; + ContributionPolicy::can_move(context, &contribution)?; if new_ordinal == contribution.contribution_ordinal { // No action required return Ok(contribution); } - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); contribution - .change_ordinal( - &context.db, - contribution.contribution_ordinal, - new_ordinal, - &account_id, - ) + .change_ordinal(context, contribution.contribution_ordinal, new_ordinal) .map_err(Into::into) } @@ -3249,26 +2469,16 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which issue should be moved")] new_ordinal: i32, ) -> FieldResult<Issue> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &issue_id)?; + let issue = context.load_current(&issue_id)?; + IssuePolicy::can_move(context, &issue)?; if new_ordinal == issue.issue_ordinal { // No action required return Ok(issue); } - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); issue - .change_ordinal(&context.db, issue.issue_ordinal, new_ordinal, &account_id) + .change_ordinal(context, issue.issue_ordinal, new_ordinal) .map_err(Into::into) } @@ -3281,31 +2491,16 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Reference> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &reference_id)?; + let reference = context.load_current(&reference_id)?; + ReferencePolicy::can_move(context, &reference)?; if new_ordinal == reference.reference_ordinal { // No action required return Ok(reference); } - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); reference - .change_ordinal( - &context.db, - reference.reference_ordinal, - new_ordinal, - &account_id, - ) + .change_ordinal(context, reference.reference_ordinal, new_ordinal) .map_err(Into::into) } @@ -3316,31 +2511,16 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which subject should be moved")] new_ordinal: i32, ) -> FieldResult<Subject> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &subject_id)?; + let subject = context.load_current(&subject_id)?; + SubjectPolicy::can_move(context, &subject)?; if new_ordinal == subject.subject_ordinal { // No action required return Ok(subject); } - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); subject - .change_ordinal( - &context.db, - subject.subject_ordinal, - new_ordinal, - &account_id, - ) + .change_ordinal(context, subject.subject_ordinal, new_ordinal) .map_err(Into::into) } @@ -3353,37 +2533,16 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<WorkRelation> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &work_relation_id)?; + let work_relation = context.load_current(&work_relation_id)?; + WorkRelationPolicy::can_move(context, &work_relation)?; + if new_ordinal == work_relation.relation_ordinal { // No action required return Ok(work_relation); } - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.relator_work_id, - )?)?; - context.account_access.can_edit(publisher_id_from_work_id( - &context.db, - work_relation.related_work_id, - )?)?; - - let account_id = context - .token - .jwt - .as_ref() - .ok_or(ThothError::Unauthorised)? - .account_id(&context.db); work_relation - .change_ordinal( - &context.db, - work_relation.relation_ordinal, - new_ordinal, - &account_id, - ) + .change_ordinal(context, work_relation.relation_ordinal, new_ordinal) .map_err(Into::into) } @@ -3392,9 +2551,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contact to be deleted")] contact_id: Uuid, ) -> FieldResult<Contact> { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contact = Contact::from_id(&context.db, &contact_id)?; - context.account_access.can_edit(contact.publisher_id())?; + let contact = context.load_current(&contact_id)?; + ContactPolicy::can_delete(context, &contact)?; contact.delete(&context.db).map_err(Into::into) } @@ -5618,19 +4776,3 @@ pub type Schema = RootNode<'static, QueryRoot, MutationRoot, EmptySubscription<C pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, MutationRoot {}, EmptySubscription::new()) } - -fn publisher_id_from_imprint_id(db: &PgPool, imprint_id: Uuid) -> ThothResult<Uuid> { - Ok(Imprint::from_id(db, &imprint_id)?.publisher_id) -} - -fn publisher_id_from_work_id(db: &PgPool, work_id: Uuid) -> ThothResult<Uuid> { - Work::from_id(db, &work_id)?.publisher_id(db) -} - -fn publisher_id_from_publication_id(db: &PgPool, publication_id: Uuid) -> ThothResult<Uuid> { - Publication::from_id(db, &publication_id)?.publisher_id(db) -} - -fn publisher_id_from_contribution_id(db: &PgPool, contribution_id: Uuid) -> ThothResult<Uuid> { - Contribution::from_id(db, &contribution_id)?.publisher_id(db) -} diff --git a/thoth-api/src/graphql/utils.rs b/thoth-api/src/graphql/utils.rs deleted file mode 100644 index 1f4e033ea..000000000 --- a/thoth-api/src/graphql/utils.rs +++ /dev/null @@ -1,48 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -pub const MAX_SHORT_ABSTRACT_CHAR_LIMIT: u16 = 350; - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] -#[graphql(description = "Order in which to sort query results")] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Direction { - #[cfg_attr(feature = "backend", graphql(description = "Ascending order"))] - #[default] - Asc, - #[cfg_attr(feature = "backend", graphql(description = "Descending order"))] - Desc, -} - -#[test] -fn test_direction_default() { - let dir: Direction = Default::default(); - assert_eq!(dir, Direction::Asc); -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, juniper::GraphQLEnum)] -#[graphql(description = "Expression to use when filtering by numeric value")] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Expression { - #[cfg_attr( - feature = "backend", - graphql( - description = "Return only results with values which are greater than the value supplied" - ) - )] - #[default] - GreaterThan, - #[cfg_attr( - feature = "backend", - graphql( - description = "Return only results with values which are less than the value supplied" - ) - )] - LessThan, -} - -#[test] -fn test_expression_default() { - let dir: Expression = Default::default(); - assert_eq!(dir, Expression::GreaterThan); -} diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index 3965e644a..8e65138bc 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -14,14 +14,16 @@ extern crate diesel_migrations; extern crate dotenv; extern crate juniper; -pub mod account; -pub mod ast; #[cfg(feature = "backend")] pub mod db; +#[cfg(feature = "backend")] pub mod graphql; +pub mod markup; #[macro_use] pub mod model; #[cfg(feature = "backend")] +pub(crate) mod policy; +#[cfg(feature = "backend")] pub mod redis; #[cfg(feature = "backend")] mod schema; diff --git a/thoth-api/src/ast/mod.rs b/thoth-api/src/markup/ast.rs similarity index 99% rename from thoth-api/src/ast/mod.rs rename to thoth-api/src/markup/ast.rs index bf0404577..f1fcde377 100644 --- a/thoth-api/src/ast/mod.rs +++ b/thoth-api/src/markup/ast.rs @@ -1,4 +1,4 @@ -use crate::model::ConversionLimit; +use super::ConversionLimit; use pulldown_cmark::{Event, Parser, Tag}; use scraper::{ElementRef, Html, Selector}; use thoth_errors::{ThothError, ThothResult}; diff --git a/thoth-api/src/markup/mod.rs b/thoth-api/src/markup/mod.rs new file mode 100644 index 000000000..1632710c5 --- /dev/null +++ b/thoth-api/src/markup/mod.rs @@ -0,0 +1,490 @@ +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use thoth_errors::{ThothError, ThothResult}; + +pub mod ast; + +use ast::{ + ast_to_html, ast_to_jats, ast_to_markdown, ast_to_plain_text, html_to_ast, jats_to_ast, + markdown_to_ast, plain_text_ast_to_jats, plain_text_to_ast, + strip_structural_elements_from_ast_for_conversion, validate_ast_content, +}; + +/// Enum to represent the markup format +#[cfg_attr( + feature = "backend", + derive(DbEnum, juniper::GraphQLEnum), + graphql( + description = "Allowed markup formats for text fields that support structured content" + ), + ExistingTypePath = "crate::schema::sql_types::MarkupFormat" +)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum MarkupFormat { + #[cfg_attr(feature = "backend", graphql(description = "HTML format"))] + Html, + #[cfg_attr(feature = "backend", graphql(description = "Markdown format"))] + Markdown, + #[cfg_attr(feature = "backend", graphql(description = "Plain text format"))] + PlainText, + #[cfg_attr(feature = "backend", graphql(description = "JATS XML format"))] + #[default] + JatsXml, +} + +/// Limits how much structure is preserved/allowed when converting to/from JATS. +/// +/// - `Abstract`/`Biography`: allow basic structural elements (paragraphs, lists, emphasis, links). +/// - `Title`: disallow structure; structural tags are stripped to plain inline text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversionLimit { + Abstract, + Biography, + Title, +} + +/// Validate content format based on markup format +pub fn validate_format(content: &str, format: &MarkupFormat) -> ThothResult<()> { + match format { + MarkupFormat::Html | MarkupFormat::JatsXml => { + // Basic HTML validation - check for opening and closing tags + if !content.contains('<') || !content.contains('>') || !content.contains("</") { + return Err(ThothError::UnsupportedFileFormatError); + } + } + MarkupFormat::Markdown => { + // Basic Markdown validation - check for markdown syntax + if content.contains('<') && content.contains('>') { + // At least one markdown element should be present + return Err(ThothError::UnsupportedFileFormatError); + } + } + MarkupFormat::PlainText => {} + } + Ok(()) +} + +/// Convert content to JATS XML format with specified tag +pub fn convert_to_jats( + content: String, + format: MarkupFormat, + conversion_limit: ConversionLimit, +) -> ThothResult<String> { + validate_format(&content, &format)?; + let mut output = content.clone(); + + match format { + MarkupFormat::Html => { + // Use ast library to parse HTML and convert to JATS + let ast = html_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = ast_to_jats(&processed_ast); + } + + MarkupFormat::Markdown => { + // Use ast library to parse Markdown and convert to JATS + let ast = markdown_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = ast_to_jats(&processed_ast); + } + + MarkupFormat::PlainText => { + // Use ast library to parse plain text and convert to JATS + let ast = plain_text_to_ast(&content); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + validate_ast_content(&processed_ast, conversion_limit)?; + output = if conversion_limit == ConversionLimit::Title { + // Title JATS should remain inline (no paragraph wrapper) + ast_to_jats(&processed_ast) + } else { + plain_text_ast_to_jats(&processed_ast) + }; + } + + MarkupFormat::JatsXml => {} + } + + Ok(output) +} + +/// Convert from JATS XML to specified format using a specific tag name +pub fn convert_from_jats( + jats_xml: &str, + format: MarkupFormat, + conversion_limit: ConversionLimit, +) -> ThothResult<String> { + // Allow plain-text content that was stored without JATS markup for titles. + if !jats_xml.contains('<') || !jats_xml.contains("</") { + let ast = plain_text_to_ast(jats_xml); + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + validate_ast_content(&processed_ast, conversion_limit)?; + return Ok(match format { + MarkupFormat::Html => ast_to_html(&processed_ast), + MarkupFormat::Markdown => ast_to_markdown(&processed_ast), + MarkupFormat::PlainText => ast_to_plain_text(&processed_ast), + MarkupFormat::JatsXml => { + if conversion_limit == ConversionLimit::Title { + ast_to_jats(&processed_ast) + } else { + plain_text_ast_to_jats(&processed_ast) + } + } + }); + } + + validate_format(jats_xml, &MarkupFormat::JatsXml)?; + + // Parse JATS to AST first for better handling + let ast = jats_to_ast(jats_xml); + + // For title conversion, strip structural elements before validation + let processed_ast = if conversion_limit == ConversionLimit::Title { + strip_structural_elements_from_ast_for_conversion(&ast) + } else { + ast + }; + + // Validate the AST content based on conversion limit + validate_ast_content(&processed_ast, conversion_limit)?; + + let output = match format { + MarkupFormat::Html => { + // Use the dedicated AST to HTML converter + ast_to_html(&processed_ast) + } + + MarkupFormat::Markdown => { + // Use the dedicated AST to Markdown converter + ast_to_markdown(&processed_ast) + } + + MarkupFormat::PlainText => { + // Use the dedicated AST to plain text converter + ast_to_plain_text(&processed_ast) + } + + MarkupFormat::JatsXml => { + // Return the AST converted back to JATS (should be identical) + jats_xml.to_string() + } + }; + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- convert_to_jats tests start --- + #[test] + fn test_html_basic_formatting() { + let input = "<em>Italic</em> and <strong>Bold</strong>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Biography, + ) + .unwrap(); + assert_eq!(output, "<italic>Italic</italic> and <bold>Bold</bold>"); + } + + #[test] + fn test_html_link_conversion() { + let input = r#"<a href="https://example.com">Link</a>"#; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Abstract, + ) + .unwrap(); + assert_eq!( + output, + r#"<ext-link xlink:href="https://example.com">Link</ext-link>"# + ); + } + + #[test] + fn test_html_with_structure_allowed() { + let input = "<ul><li>One</li><li>Two</li></ul>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Abstract, + ) + .unwrap(); + assert_eq!( + output, + "<list><list-item>One</list-item><list-item>Two</list-item></list>" + ); + } + + #[test] + fn test_html_with_structure_stripped() { + let input = "<ul><li>One</li></ul>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "One"); + } + + #[test] + fn test_html_small_caps_conversion() { + let input = "<text>Small caps text</text>"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Html, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "<sc>Small caps text</sc>"); + } + + #[test] + fn test_markdown_basic_formatting() { + let input = "**Bold** and *Italic* and `code`"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!( + output, + "<bold>Bold</bold> and <italic>Italic</italic> and <monospace>code</monospace>" + ); + } + + #[test] + fn test_markdown_link_conversion() { + let input = "[text](https://example.com)"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!( + output, + r#"<ext-link xlink:href="https://example.com">text</ext-link>"# + ); + } + + #[test] + fn test_markdown_with_structure() { + let input = "- Item 1\n- Item 2\n\nParagraph text"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::Markdown, + ConversionLimit::Abstract, + ) + .unwrap(); + + assert!( + output.contains( + "<list><list-item>Item 1</list-item><list-item>Item 2</list-item></list>" + ) && output.contains("<p>Paragraph text</p>") + ); + } + + #[test] + fn test_plain_text_with_url() { + let input = "Hello https://example.com world"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Biography, + ) + .unwrap(); + assert_eq!( + output, + "<p>Hello </p><ext-link xlink:href=\"https://example.com\"><p>https://example.com</p></ext-link><p> world</p>" + ); + } + + #[test] + fn test_plain_text_no_url() { + let input = "Just plain text."; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "Just plain text."); + } + // --- convert_to_jats tests end --- + + // --- convert_from_jats tests start --- + #[test] + fn test_convert_from_jats_html_with_structure() { + let input = r#" + <p>Paragraph text</p> + <list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> + <italic>Italic</italic> and <bold>Bold</bold> + <ext-link xlink:href="https://example.com">Link</ext-link> + "#; + let output = + convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("<p>Paragraph text</p>")); + assert!(output.contains("<ul><li>Item 1</li><li>Item 2</li></ul>")); + assert!(output.contains("<em>Italic</em>")); + assert!(output.contains("<strong>Bold</strong>")); + assert!(output.contains(r#"<a href="https://example.com">Link</a>"#)); + } + + #[test] + fn test_convert_from_jats_html_no_structure() { + let input = r#" + <p>Text</p><list><list-item>Item</list-item></list><bold>Bold</bold> + "#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(!output.contains("<ul>")); + assert!(output.contains("<strong>Bold</strong>")); + } + + #[test] + fn test_convert_from_jats_html_title_limit() { + let input = r#"<p>Title</p><bold>Bold</bold>"#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(output.contains("<strong>Bold</strong>")); + } + + #[test] + fn test_convert_from_jats_markdown_with_structure() { + let input = r#" + <p>Text</p><list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> + <italic>It</italic> and <bold>Bold</bold> + <ext-link xlink:href="https://link.com">Here</ext-link> + "#; + let output = + convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Biography).unwrap(); + + assert!(output.contains("Text")); + assert!(output.contains("- Item 1")); + assert!(output.contains("*It*")); + assert!(output.contains("**Bold**")); + assert!(output.contains("[Here](https://link.com)")); + } + + #[test] + fn test_convert_from_jats_markdown_title_limit() { + let input = r#"<p>Title</p><italic>It</italic>"#; + let output = + convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(output.contains("*It*")); + } + + #[test] + fn test_convert_from_jats_plain_text_basic() { + let input = r#" + <p>Text</p> and <ext-link xlink:href="https://ex.com">Link</ext-link> and <sc>SC</sc> + "#; + let output = + convert_from_jats(input, MarkupFormat::PlainText, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("Text")); + assert!(output.contains("Link (https://ex.com)")); + assert!(!output.contains("<sc>")); + assert!(!output.contains("<")); + } + + #[test] + fn test_convert_from_jats_preserves_inline_html() { + let input = r#"<italic>i</italic> <bold>b</bold> <monospace>code</monospace>"#; + let output = + convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); + + assert!(output.contains("<em>i</em>")); + assert!(output.contains("<strong>b</strong>")); + assert!(output.contains("<code>code</code>")); + } + + #[test] + fn test_convert_from_jats_jatsxml_noop() { + let input = r#"<p>Do nothing</p>"#; + let output = + convert_from_jats(input, MarkupFormat::JatsXml, ConversionLimit::Biography).unwrap(); + assert_eq!(input, output); + } + + #[test] + fn test_convert_from_jats_html_allow_structure_false() { + let input = r#"<p>Para</p><list><list-item>Item</list-item></list>"#; + let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); + + assert!(!output.contains("<p>")); + assert!(!output.contains("<ul>")); + assert!(output.contains("Para")); + assert!(output.contains("Item")); + } + + #[test] + fn test_title_plain_text_to_jats_has_no_paragraph() { + let input = "Plain title"; + let output = convert_to_jats( + input.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert_eq!(output, "Plain title"); + } + + #[test] + fn test_title_plain_text_roundtrip_no_paragraphs() { + let plain = "Another plain title"; + let jats = convert_to_jats( + plain.to_string(), + MarkupFormat::PlainText, + ConversionLimit::Title, + ) + .unwrap(); + assert!(!jats.contains("<p>")); + + let back = convert_from_jats(&jats, MarkupFormat::JatsXml, ConversionLimit::Title).unwrap(); + assert_eq!(back, plain); + } + // --- convert_from_jats tests end +} diff --git a/thoth-api/src/model/abstract/crud.rs b/thoth-api/src/model/abstract/crud.rs index 6879a34ec..e044830ea 100644 --- a/thoth-api/src/model/abstract/crud.rs +++ b/thoth-api/src/model/abstract/crud.rs @@ -3,8 +3,8 @@ use super::{ Abstract, AbstractField, AbstractHistory, AbstractOrderBy, AbstractType, NewAbstract, NewAbstractHistory, PatchAbstract, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::work_abstract::dsl; use crate::schema::{abstract_history, work_abstract}; use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; @@ -146,21 +146,21 @@ impl Crud for Abstract { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let work = crate::model::work::Work::from_id(db, &self.work_id)?; - <crate::model::work::Work as Crud>::publisher_id(&work, db) - } - crud_methods!(work_abstract::table, work_abstract::dsl::work_abstract); } +publisher_id_impls!(Abstract, NewAbstract, PatchAbstract, |s, db| { + let work = crate::model::work::Work::from_id(db, &s.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + impl HistoryEntry for Abstract { type NewHistoryEntity = NewAbstractHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { abstract_id: self.abstract_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } diff --git a/thoth-api/src/model/abstract/mod.rs b/thoth-api/src/model/abstract/mod.rs index 6336ec673..d7a7d2a6a 100644 --- a/thoth-api/src/model/abstract/mod.rs +++ b/thoth-api/src/model/abstract/mod.rs @@ -4,7 +4,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; #[cfg(feature = "backend")] use crate::schema::abstract_history; @@ -123,7 +123,7 @@ pub struct PatchAbstract { )] pub struct NewAbstractHistory { pub abstract_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -131,10 +131,14 @@ pub struct NewAbstractHistory { pub struct AbstractHistory { pub abstract_history_id: Uuid, pub abstract_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: chrono::DateTime<chrono::Utc>, } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AbstractPolicy; diff --git a/thoth-api/src/model/abstract/policy.rs b/thoth-api/src/model/abstract/policy.rs new file mode 100644 index 000000000..009cf78c7 --- /dev/null +++ b/thoth-api/src/model/abstract/policy.rs @@ -0,0 +1,85 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use super::{Abstract, AbstractType, NewAbstract, PatchAbstract}; +use crate::markup::MarkupFormat; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::work_abstract; +use thoth_errors::{ThothError, ThothResult}; + +pub const MAX_SHORT_ABSTRACT_CHAR_LIMIT: u16 = 350; + +/// Write policies for `Abstract`. +/// +/// `Abstract` spans two works and therefore potentially two publisher scopes. +/// This policy enforces: +/// - authentication +/// - membership for *all* publishers involved (via `PublisherIds`) +pub struct AbstractPolicy; + +fn has_canonical_abstract(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = work_abstract::table + .filter(work_abstract::work_id.eq(work_id)) + .filter(work_abstract::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewAbstract, Option<MarkupFormat>> for AbstractPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewAbstract, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Abstract creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + // Canonical abstracts: only one canonical abstract is allowed per work. + if data.canonical && has_canonical_abstract(ctx.db(), &data.work_id)? { + return Err(ThothError::CanonicalAbstractExistsError); + } + + if data.abstract_type == AbstractType::Short + && data.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + { + return Err(ThothError::ShortAbstractLimitExceedError); + }; + + Ok(()) + } +} + +impl UpdatePolicy<Abstract, PatchAbstract, Option<MarkupFormat>> for AbstractPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Abstract, + patch: &PatchAbstract, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Abstract creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + if patch.abstract_type == AbstractType::Short + && patch.content.len() > MAX_SHORT_ABSTRACT_CHAR_LIMIT as usize + { + return Err(ThothError::ShortAbstractLimitExceedError); + }; + + Ok(()) + } +} + +impl DeletePolicy<Abstract> for AbstractPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Abstract) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/affiliation/crud.rs b/thoth-api/src/model/affiliation/crud.rs index 0fdd282ff..743a3f5e5 100644 --- a/thoth-api/src/model/affiliation/crud.rs +++ b/thoth-api/src/model/affiliation/crud.rs @@ -2,7 +2,7 @@ use super::{ Affiliation, AffiliationField, AffiliationHistory, AffiliationOrderBy, NewAffiliation, NewAffiliationHistory, PatchAffiliation, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{affiliation, affiliation_history}; use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -115,21 +115,20 @@ impl Crud for Affiliation { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::contribution::Contribution::from_id(db, &self.contribution_id)? - .publisher_id(db) - } - crud_methods!(affiliation::table, affiliation::dsl::affiliation); } +publisher_id_impls!(Affiliation, NewAffiliation, PatchAffiliation, |s, db| { + crate::model::contribution::Contribution::from_id(db, &s.contribution_id)?.publisher_id(db) +}); + impl HistoryEntry for Affiliation { type NewHistoryEntity = NewAffiliationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { affiliation_id: self.affiliation_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -180,13 +179,13 @@ mod tests { #[test] fn test_new_affiliation_history_from_affiliation() { let affiliation: Affiliation = Default::default(); - let account_id: Uuid = Default::default(); - let new_affiliation_history = affiliation.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_affiliation_history = affiliation.new_history_entry(&user_id); assert_eq!( new_affiliation_history.affiliation_id, affiliation.affiliation_id ); - assert_eq!(new_affiliation_history.account_id, account_id); + assert_eq!(new_affiliation_history.user_id, user_id); assert_eq!( new_affiliation_history.data, serde_json::Value::String(serde_json::to_string(&affiliation).unwrap()) diff --git a/thoth-api/src/model/affiliation/mod.rs b/thoth-api/src/model/affiliation/mod.rs index ae82db756..47ea0cd5f 100644 --- a/thoth-api/src/model/affiliation/mod.rs +++ b/thoth-api/src/model/affiliation/mod.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::affiliation; @@ -67,7 +67,7 @@ pub struct PatchAffiliation { pub struct AffiliationHistory { pub affiliation_history_id: Uuid, pub affiliation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -79,7 +79,7 @@ pub struct AffiliationHistory { )] pub struct NewAffiliationHistory { pub affiliation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -104,3 +104,7 @@ impl Default for AffiliationOrderBy { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::AffiliationPolicy; diff --git a/thoth-api/src/model/affiliation/policy.rs b/thoth-api/src/model/affiliation/policy.rs new file mode 100644 index 000000000..ebb2e4537 --- /dev/null +++ b/thoth-api/src/model/affiliation/policy.rs @@ -0,0 +1,49 @@ +use crate::model::affiliation::{Affiliation, NewAffiliation, PatchAffiliation}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Affiliation`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct AffiliationPolicy; + +impl CreatePolicy<NewAffiliation> for AffiliationPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewAffiliation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Affiliation, PatchAffiliation> for AffiliationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Affiliation, + patch: &PatchAffiliation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Affiliation> for AffiliationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Affiliation) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Affiliation> for AffiliationPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Affiliation) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/biography/crud.rs b/thoth-api/src/model/biography/crud.rs index 752a3ca7a..a3260d9ed 100644 --- a/thoth-api/src/model/biography/crud.rs +++ b/thoth-api/src/model/biography/crud.rs @@ -3,8 +3,8 @@ use super::{ Biography, BiographyField, BiographyHistory, BiographyOrderBy, NewBiography, NewBiographyHistory, PatchBiography, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{biography, biography_history}; use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; @@ -130,23 +130,22 @@ impl Crud for Biography { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let contribution = - crate::model::contribution::Contribution::from_id(db, &self.contribution_id)?; - let work = crate::model::work::Work::from_id(db, &contribution.work_id)?; - <crate::model::work::Work as Crud>::publisher_id(&work, db) - } - crud_methods!(biography::table, biography::dsl::biography); } +publisher_id_impls!(Biography, NewBiography, PatchBiography, |s, db| { + let contribution = crate::model::contribution::Contribution::from_id(db, &s.contribution_id)?; + let work = crate::model::work::Work::from_id(db, &contribution.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + impl HistoryEntry for Biography { type NewHistoryEntity = NewBiographyHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { biography_id: self.biography_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } diff --git a/thoth-api/src/model/biography/mod.rs b/thoth-api/src/model/biography/mod.rs index 16fc9a24d..6fcf46db1 100644 --- a/thoth-api/src/model/biography/mod.rs +++ b/thoth-api/src/model/biography/mod.rs @@ -2,7 +2,7 @@ use crate::model::locale::LocaleCode; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; #[cfg(feature = "backend")] use crate::schema::biography; @@ -87,7 +87,7 @@ pub struct PatchBiography { )] pub struct NewBiographyHistory { pub biography_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -95,10 +95,14 @@ pub struct NewBiographyHistory { pub struct BiographyHistory { pub biography_history_id: Uuid, pub biography_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: chrono::DateTime<chrono::Utc>, } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::BiographyPolicy; diff --git a/thoth-api/src/model/biography/policy.rs b/thoth-api/src/model/biography/policy.rs new file mode 100644 index 000000000..f2b2306da --- /dev/null +++ b/thoth-api/src/model/biography/policy.rs @@ -0,0 +1,70 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::markup::MarkupFormat; +use crate::model::biography::{Biography, NewBiography, PatchBiography}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::biography; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Biography`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - requiring a markup format for biography writes +pub struct BiographyPolicy; + +fn has_canonical_biography(db: &crate::db::PgPool, contribution_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = biography::table + .filter(biography::contribution_id.eq(contribution_id)) + .filter(biography::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewBiography, Option<MarkupFormat>> for BiographyPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewBiography, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Biography creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + if data.canonical && has_canonical_biography(ctx.db(), &data.contribution_id)? { + return Err(ThothError::CanonicalBiographyExistsError); + } + + Ok(()) + } +} + +impl UpdatePolicy<Biography, PatchBiography, Option<MarkupFormat>> for BiographyPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Biography, + patch: &PatchBiography, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Biography updates require a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + Ok(()) + } +} + +impl DeletePolicy<Biography> for BiographyPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Biography) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contact/crud.rs b/thoth-api/src/model/contact/crud.rs index 44441b506..726330486 100644 --- a/thoth-api/src/model/contact/crud.rs +++ b/thoth-api/src/model/contact/crud.rs @@ -2,7 +2,7 @@ use super::{ Contact, ContactField, ContactHistory, ContactOrderBy, ContactType, NewContact, NewContactHistory, PatchContact, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{contact, contact_history}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -112,20 +112,20 @@ impl Crud for Contact { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Ok(self.publisher_id) - } - crud_methods!(contact::table, contact::dsl::contact); } +publisher_id_impls!(Contact, NewContact, PatchContact, |s, _db| { + Ok(s.publisher_id) +}); + impl HistoryEntry for Contact { type NewHistoryEntity = NewContactHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { contact_id: self.contact_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -150,10 +150,10 @@ mod tests { #[test] fn test_new_contact_history_from_contact() { let contact: Contact = Default::default(); - let account_id: Uuid = Default::default(); - let new_contact_history = contact.new_history_entry(&account_id); + let user_id = "12345"; + let new_contact_history = contact.new_history_entry(user_id); assert_eq!(new_contact_history.contact_id, contact.contact_id); - assert_eq!(new_contact_history.account_id, account_id); + assert_eq!(new_contact_history.user_id, user_id); assert_eq!( new_contact_history.data, serde_json::Value::String(serde_json::to_string(&contact).unwrap()) diff --git a/thoth-api/src/model/contact/mod.rs b/thoth-api/src/model/contact/mod.rs index 62e5c1288..c6bd66a6f 100644 --- a/thoth-api/src/model/contact/mod.rs +++ b/thoth-api/src/model/contact/mod.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::contact; @@ -87,7 +87,7 @@ pub struct PatchContact { pub struct ContactHistory { pub contact_history_id: Uuid, pub contact_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -99,7 +99,7 @@ pub struct ContactHistory { )] pub struct NewContactHistory { pub contact_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -122,3 +122,7 @@ fn test_contactfield_default() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContactPolicy; diff --git a/thoth-api/src/model/contact/policy.rs b/thoth-api/src/model/contact/policy.rs new file mode 100644 index 000000000..dc4bb4fdc --- /dev/null +++ b/thoth-api/src/model/contact/policy.rs @@ -0,0 +1,37 @@ +use crate::model::contact::{Contact, NewContact, PatchContact}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contact`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ContactPolicy; + +impl CreatePolicy<NewContact> for ContactPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewContact, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Contact, PatchContact> for ContactPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Contact, + patch: &PatchContact, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + Ok(()) + } +} + +impl DeletePolicy<Contact> for ContactPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contact) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contribution/crud.rs b/thoth-api/src/model/contribution/crud.rs index f10456014..30b970b10 100644 --- a/thoth-api/src/model/contribution/crud.rs +++ b/thoth-api/src/model/contribution/crud.rs @@ -3,8 +3,8 @@ use super::{ NewContributionHistory, PatchContribution, }; use crate::diesel::JoinOnDsl; -use crate::graphql::model::ContributionOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::ContributionOrderBy; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{contribution, contribution_history}; use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -152,20 +152,20 @@ impl Crud for Contribution { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(contribution::table, contribution::dsl::contribution); } +publisher_id_impls!(Contribution, NewContribution, PatchContribution, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Contribution { type NewHistoryEntity = NewContributionHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { contribution_id: self.contribution_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -216,13 +216,13 @@ mod tests { #[test] fn test_new_contribution_history_from_contribution() { let contribution: Contribution = Default::default(); - let account_id: Uuid = Default::default(); - let new_contribution_history = contribution.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_contribution_history = contribution.new_history_entry(&user_id); assert_eq!( new_contribution_history.contribution_id, contribution.contribution_id ); - assert_eq!(new_contribution_history.account_id, account_id); + assert_eq!(new_contribution_history.user_id, user_id); assert_eq!( new_contribution_history.data, serde_json::Value::String(serde_json::to_string(&contribution).unwrap()) diff --git a/thoth-api/src/model/contribution/mod.rs b/thoth-api/src/model/contribution/mod.rs index 0b05a152a..2347ab503 100644 --- a/thoth-api/src/model/contribution/mod.rs +++ b/thoth-api/src/model/contribution/mod.rs @@ -173,7 +173,7 @@ pub struct PatchContribution { pub struct ContributionHistory { pub contribution_history_id: Uuid, pub contribution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -185,7 +185,7 @@ pub struct ContributionHistory { )] pub struct NewContributionHistory { pub contribution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -306,3 +306,7 @@ fn test_contributiontype_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContributionPolicy; diff --git a/thoth-api/src/model/contribution/policy.rs b/thoth-api/src/model/contribution/policy.rs new file mode 100644 index 000000000..9d7abdef3 --- /dev/null +++ b/thoth-api/src/model/contribution/policy.rs @@ -0,0 +1,52 @@ +use crate::model::contribution::{Contribution, NewContribution, PatchContribution}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contribution`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// +/// `Contribution` is scoped to a parent `Work`, and publisher membership is derived from the +/// `PublisherId` implementation (via `work_id`). +pub struct ContributionPolicy; + +impl CreatePolicy<NewContribution> for ContributionPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewContribution, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Contribution, PatchContribution> for ContributionPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Contribution, + patch: &PatchContribution, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Contribution> for ContributionPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contribution) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Contribution> for ContributionPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Contribution) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index 27cd37ecc..09696be20 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -2,13 +2,14 @@ use super::{ Contributor, ContributorField, ContributorHistory, ContributorOrderBy, NewContributor, NewContributorHistory, PatchContributor, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{contributor, contributor_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, }; -use thoth_errors::{ThothError, ThothResult}; +use thoth_errors::ThothResult; use uuid::Uuid; impl Crud for Contributor { @@ -122,23 +123,31 @@ impl Crud for Contributor { .map(|t| t.to_string().parse::<i32>().unwrap()) .map_err(Into::into) } + crud_methods!(contributor::table, contributor::dsl::contributor); +} - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Contributor objects".to_string(), - )) +impl PublisherIds for Contributor { + fn publisher_ids(&self, db: &PgPool) -> ThothResult<Vec<Uuid>> { + let mut connection = db.get()?; + crate::schema::publisher::table + .inner_join(crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join(crate::schema::contribution::table), + )) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::contribution::contributor_id.eq(self.contributor_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(Into::into) } - - crud_methods!(contributor::table, contributor::dsl::contributor); } impl HistoryEntry for Contributor { type NewHistoryEntity = NewContributorHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { contributor_id: self.contributor_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -150,30 +159,6 @@ impl DbInsert for NewContributorHistory { db_insert!(contributor_history::table); } -impl Contributor { - pub fn linked_publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>> { - contributor_linked_publisher_ids(self.contributor_id, db) - } -} - -fn contributor_linked_publisher_ids( - contributor_id: Uuid, - db: &crate::db::PgPool, -) -> ThothResult<Vec<Uuid>> { - let mut connection = db.get()?; - crate::schema::publisher::table - .inner_join( - crate::schema::imprint::table.inner_join( - crate::schema::work::table.inner_join(crate::schema::contribution::table), - ), - ) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::contribution::contributor_id.eq(contributor_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(Into::into) -} - #[cfg(test)] mod tests { use super::*; @@ -187,13 +172,13 @@ mod tests { #[test] fn test_new_contributor_history_from_contributor() { let contributor: Contributor = Default::default(); - let account_id: Uuid = Default::default(); - let new_contributor_history = contributor.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_contributor_history = contributor.new_history_entry(&user_id); assert_eq!( new_contributor_history.contributor_id, contributor.contributor_id ); - assert_eq!(new_contributor_history.account_id, account_id); + assert_eq!(new_contributor_history.user_id, user_id); assert_eq!( new_contributor_history.data, serde_json::Value::String(serde_json::to_string(&contributor).unwrap()) diff --git a/thoth-api/src/model/contributor/mod.rs b/thoth-api/src/model/contributor/mod.rs index 67d97fd21..dacbac198 100644 --- a/thoth-api/src/model/contributor/mod.rs +++ b/thoth-api/src/model/contributor/mod.rs @@ -5,7 +5,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Orcid; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -82,7 +82,7 @@ pub struct PatchContributor { pub struct ContributorHistory { pub contributor_history_id: Uuid, pub contributor_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -94,7 +94,7 @@ pub struct ContributorHistory { )] pub struct NewContributorHistory { pub contributor_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -171,3 +171,7 @@ fn test_contributorfield_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ContributorPolicy; diff --git a/thoth-api/src/model/contributor/policy.rs b/thoth-api/src/model/contributor/policy.rs new file mode 100644 index 000000000..4b0d5c58e --- /dev/null +++ b/thoth-api/src/model/contributor/policy.rs @@ -0,0 +1,41 @@ +use crate::model::contributor::{Contributor, NewContributor, PatchContributor}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Contributor`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ContributorPolicy; + +impl CreatePolicy<NewContributor> for ContributorPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + _data: &NewContributor, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl UpdatePolicy<Contributor, PatchContributor> for ContributorPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + _current: &Contributor, + _patch: &PatchContributor, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + + Ok(()) + } +} + +impl DeletePolicy<Contributor> for ContributorPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Contributor) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/funding/crud.rs b/thoth-api/src/model/funding/crud.rs index 49e7be660..4a094aa79 100644 --- a/thoth-api/src/model/funding/crud.rs +++ b/thoth-api/src/model/funding/crud.rs @@ -1,6 +1,6 @@ use super::{Funding, FundingField, FundingHistory, NewFunding, NewFundingHistory, PatchFunding}; -use crate::graphql::model::FundingOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; +use crate::graphql::inputs::FundingOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{funding, funding_history}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -122,20 +122,20 @@ impl Crud for Funding { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(funding::table, funding::dsl::funding); } +publisher_id_impls!(Funding, NewFunding, PatchFunding, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Funding { type NewHistoryEntity = NewFundingHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { funding_id: self.funding_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -160,10 +160,10 @@ mod tests { #[test] fn test_new_funding_history_from_funding() { let funding: Funding = Default::default(); - let account_id: Uuid = Default::default(); - let new_funding_history = funding.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_funding_history = funding.new_history_entry(&user_id); assert_eq!(new_funding_history.funding_id, funding.funding_id); - assert_eq!(new_funding_history.account_id, account_id); + assert_eq!(new_funding_history.user_id, user_id); assert_eq!( new_funding_history.data, serde_json::Value::String(serde_json::to_string(&funding).unwrap()) diff --git a/thoth-api/src/model/funding/mod.rs b/thoth-api/src/model/funding/mod.rs index 157f3f354..99491035a 100644 --- a/thoth-api/src/model/funding/mod.rs +++ b/thoth-api/src/model/funding/mod.rs @@ -78,7 +78,7 @@ pub struct PatchFunding { pub struct FundingHistory { pub funding_history_id: Uuid, pub funding_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -90,9 +90,13 @@ pub struct FundingHistory { )] pub struct NewFundingHistory { pub funding_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::FundingPolicy; diff --git a/thoth-api/src/model/funding/policy.rs b/thoth-api/src/model/funding/policy.rs new file mode 100644 index 000000000..9f4f38c1f --- /dev/null +++ b/thoth-api/src/model/funding/policy.rs @@ -0,0 +1,38 @@ +use crate::model::funding::{Funding, NewFunding, PatchFunding}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Funding`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct FundingPolicy; + +impl CreatePolicy<NewFunding> for FundingPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewFunding, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Funding, PatchFunding> for FundingPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Funding, + patch: &PatchFunding, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Funding> for FundingPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Funding) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/imprint/crud.rs b/thoth-api/src/model/imprint/crud.rs index 727158285..37b41844d 100644 --- a/thoth-api/src/model/imprint/crud.rs +++ b/thoth-api/src/model/imprint/crud.rs @@ -2,7 +2,7 @@ use super::{ Imprint, ImprintField, ImprintHistory, ImprintOrderBy, NewImprint, NewImprintHistory, PatchImprint, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{imprint, imprint_history}; use diesel::{ @@ -122,20 +122,20 @@ impl Crud for Imprint { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Ok(self.publisher_id) - } - crud_methods!(imprint::table, imprint::dsl::imprint); } +publisher_id_impls!(Imprint, NewImprint, PatchImprint, |s, _db| { + Ok(s.publisher_id) +}); + impl HistoryEntry for Imprint { type NewHistoryEntity = NewImprintHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { imprint_id: self.imprint_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -160,10 +160,10 @@ mod tests { #[test] fn test_new_imprint_history_from_imprint() { let imprint: Imprint = Default::default(); - let account_id: Uuid = Default::default(); - let new_imprint_history = imprint.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_imprint_history = imprint.new_history_entry(&user_id); assert_eq!(new_imprint_history.imprint_id, imprint.imprint_id); - assert_eq!(new_imprint_history.account_id, account_id); + assert_eq!(new_imprint_history.user_id, user_id); assert_eq!( new_imprint_history.data, serde_json::Value::String(serde_json::to_string(&imprint).unwrap()) diff --git a/thoth-api/src/model/imprint/mod.rs b/thoth-api/src/model/imprint/mod.rs index 1f6c2df4f..310c83353 100644 --- a/thoth-api/src/model/imprint/mod.rs +++ b/thoth-api/src/model/imprint/mod.rs @@ -5,7 +5,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::imprint; @@ -77,7 +77,7 @@ pub struct PatchImprint { pub struct ImprintHistory { pub imprint_history_id: Uuid, pub imprint_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -89,7 +89,7 @@ pub struct ImprintHistory { )] pub struct NewImprintHistory { pub imprint_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -154,3 +154,7 @@ fn test_imprintfield_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ImprintPolicy; diff --git a/thoth-api/src/model/imprint/policy.rs b/thoth-api/src/model/imprint/policy.rs new file mode 100644 index 000000000..cf194abbf --- /dev/null +++ b/thoth-api/src/model/imprint/policy.rs @@ -0,0 +1,37 @@ +use crate::model::imprint::{Imprint, NewImprint, PatchImprint}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Imprint`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct ImprintPolicy; + +impl CreatePolicy<NewImprint> for ImprintPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewImprint, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Imprint, PatchImprint> for ImprintPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Imprint, + patch: &PatchImprint, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + Ok(()) + } +} + +impl DeletePolicy<Imprint> for ImprintPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Imprint) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 9cb328126..43f764fe7 100644 --- a/thoth-api/src/model/institution/crud.rs +++ b/thoth-api/src/model/institution/crud.rs @@ -2,8 +2,9 @@ use super::{ Institution, InstitutionField, InstitutionHistory, InstitutionOrderBy, NewInstitution, NewInstitutionHistory, PatchInstitution, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{institution, institution_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -119,22 +120,47 @@ impl Crud for Institution { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Institution objects".to_string(), - )) - } - crud_methods!(institution::table, institution::dsl::institution); } +impl PublisherIds for Institution { + fn publisher_ids(&self, db: &PgPool) -> ThothResult<Vec<Uuid>> { + let mut connection = db.get()?; + let publishers_via_affiliation = crate::schema::publisher::table + .inner_join( + crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join( + crate::schema::contribution::table + .inner_join(crate::schema::affiliation::table), + ), + ), + ) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::affiliation::institution_id.eq(self.institution_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; + let publishers_via_funding = + crate::schema::publisher::table + .inner_join(crate::schema::imprint::table.inner_join( + crate::schema::work::table.inner_join(crate::schema::funding::table), + )) + .select(crate::schema::publisher::publisher_id) + .filter(crate::schema::funding::institution_id.eq(self.institution_id)) + .distinct() + .load::<Uuid>(&mut connection) + .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; + Ok([publishers_via_affiliation, publishers_via_funding].concat()) + } +} + impl HistoryEntry for Institution { type NewHistoryEntity = NewInstitutionHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { institution_id: self.institution_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -146,41 +172,6 @@ impl DbInsert for NewInstitutionHistory { db_insert!(institution_history::table); } -impl Institution { - pub fn linked_publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>> { - institution_linked_publisher_ids(self.institution_id, db) - } -} - -fn institution_linked_publisher_ids( - institution_id: Uuid, - db: &crate::db::PgPool, -) -> ThothResult<Vec<Uuid>> { - let mut connection = db.get()?; - let publishers_via_affiliation = crate::schema::publisher::table - .inner_join(crate::schema::imprint::table.inner_join( - crate::schema::work::table.inner_join( - crate::schema::contribution::table.inner_join(crate::schema::affiliation::table), - ), - )) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::affiliation::institution_id.eq(institution_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - let publishers_via_funding = crate::schema::publisher::table - .inner_join( - crate::schema::imprint::table - .inner_join(crate::schema::work::table.inner_join(crate::schema::funding::table)), - ) - .select(crate::schema::publisher::publisher_id) - .filter(crate::schema::funding::institution_id.eq(institution_id)) - .distinct() - .load::<Uuid>(&mut connection) - .map_err(|_| ThothError::InternalError("Unable to load records".into()))?; - Ok([publishers_via_affiliation, publishers_via_funding].concat()) -} - #[cfg(test)] mod tests { use super::*; @@ -194,13 +185,13 @@ mod tests { #[test] fn test_new_institution_history_from_institution() { let institution: Institution = Default::default(); - let account_id: Uuid = Default::default(); - let new_institution_history = institution.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_institution_history = institution.new_history_entry(&user_id); assert_eq!( new_institution_history.institution_id, institution.institution_id ); - assert_eq!(new_institution_history.account_id, account_id); + assert_eq!(new_institution_history.user_id, user_id); assert_eq!( new_institution_history.data, serde_json::Value::String(serde_json::to_string(&institution).unwrap()) diff --git a/thoth-api/src/model/institution/mod.rs b/thoth-api/src/model/institution/mod.rs index ad47910ac..db1330c3a 100644 --- a/thoth-api/src/model/institution/mod.rs +++ b/thoth-api/src/model/institution/mod.rs @@ -5,7 +5,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Doi; use crate::model::Ror; use crate::model::Timestamp; @@ -869,7 +869,7 @@ pub enum CountryCode { pub struct InstitutionHistory { pub institution_history_id: Uuid, pub institution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -881,7 +881,7 @@ pub struct InstitutionHistory { )] pub struct NewInstitutionHistory { pub institution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -1818,3 +1818,7 @@ fn test_countrycode_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::InstitutionPolicy; diff --git a/thoth-api/src/model/institution/policy.rs b/thoth-api/src/model/institution/policy.rs new file mode 100644 index 000000000..1de2cbeec --- /dev/null +++ b/thoth-api/src/model/institution/policy.rs @@ -0,0 +1,40 @@ +use crate::model::institution::{Institution, NewInstitution, PatchInstitution}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Institution`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct InstitutionPolicy; + +impl CreatePolicy<NewInstitution> for InstitutionPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + _data: &NewInstitution, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl UpdatePolicy<Institution, PatchInstitution> for InstitutionPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + _current: &Institution, + _patch: &PatchInstitution, + _params: (), + ) -> ThothResult<()> { + ctx.require_authentication()?; + Ok(()) + } +} + +impl DeletePolicy<Institution> for InstitutionPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Institution) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/issue/crud.rs b/thoth-api/src/model/issue/crud.rs index f4275e923..a21b723b9 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -1,10 +1,10 @@ use super::{Issue, IssueField, IssueHistory, NewIssue, NewIssueHistory, PatchIssue}; -use crate::graphql::model::IssueOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; +use crate::graphql::inputs::IssueOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{issue, issue_history}; use diesel::{BoolExpressionMethods, Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; -use thoth_errors::{ThothError, ThothResult}; +use thoth_errors::ThothResult; use uuid::Uuid; impl Crud for Issue { @@ -106,20 +106,20 @@ impl Crud for Issue { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(issue::table, issue::dsl::issue); } +publisher_id_impls!(Issue, NewIssue, PatchIssue, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Issue { type NewHistoryEntity = NewIssueHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { issue_id: self.issue_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -154,39 +154,6 @@ impl Reorder for Issue { } } -impl NewIssue { - pub fn imprints_match(&self, db: &crate::db::PgPool) -> ThothResult<()> { - issue_imprints_match(self.work_id, self.series_id, db) - } -} - -impl PatchIssue { - pub fn imprints_match(&self, db: &crate::db::PgPool) -> ThothResult<()> { - issue_imprints_match(self.work_id, self.series_id, db) - } -} - -fn issue_imprints_match(work_id: Uuid, series_id: Uuid, db: &crate::db::PgPool) -> ThothResult<()> { - use diesel::prelude::*; - - let mut connection = db.get()?; - let series_imprint = crate::schema::series::table - .select(crate::schema::series::imprint_id) - .filter(crate::schema::series::series_id.eq(series_id)) - .first::<Uuid>(&mut connection) - .expect("Error loading series for issue"); - let work_imprint = crate::schema::work::table - .select(crate::schema::work::imprint_id) - .filter(crate::schema::work::work_id.eq(work_id)) - .first::<Uuid>(&mut connection) - .expect("Error loading work for issue"); - if work_imprint == series_imprint { - Ok(()) - } else { - Err(ThothError::IssueImprintsError) - } -} - #[cfg(test)] mod tests { use super::*; @@ -200,10 +167,10 @@ mod tests { #[test] fn test_new_issue_history_from_issue() { let issue: Issue = Default::default(); - let account_id: Uuid = Default::default(); - let new_issue_history = issue.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_issue_history = issue.new_history_entry(&user_id); assert_eq!(new_issue_history.issue_id, issue.issue_id); - assert_eq!(new_issue_history.account_id, account_id); + assert_eq!(new_issue_history.user_id, user_id); assert_eq!( new_issue_history.data, serde_json::Value::String(serde_json::to_string(&issue).unwrap()) diff --git a/thoth-api/src/model/issue/mod.rs b/thoth-api/src/model/issue/mod.rs index ef83b42de..31e16b419 100644 --- a/thoth-api/src/model/issue/mod.rs +++ b/thoth-api/src/model/issue/mod.rs @@ -62,7 +62,7 @@ pub struct PatchIssue { pub struct IssueHistory { pub issue_history_id: Uuid, pub issue_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -70,9 +70,13 @@ pub struct IssueHistory { #[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = issue_history))] pub struct NewIssueHistory { pub issue_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::IssuePolicy; diff --git a/thoth-api/src/model/issue/policy.rs b/thoth-api/src/model/issue/policy.rs new file mode 100644 index 000000000..a3c5a7538 --- /dev/null +++ b/thoth-api/src/model/issue/policy.rs @@ -0,0 +1,67 @@ +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::model::issue::{Issue, NewIssue, PatchIssue}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Issue`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct IssuePolicy; + +/// Ensure the work's imprint matches the series imprint for an issue. +fn issue_imprints_match(db: &crate::db::PgPool, work_id: Uuid, series_id: Uuid) -> ThothResult<()> { + use crate::schema::{series, work}; + + let mut conn = db.get()?; + + let query = series::table + .inner_join(work::table.on(work::imprint_id.eq(series::imprint_id))) + .filter(series::series_id.eq(series_id)) + .filter(work::work_id.eq(work_id)); + + match select(exists(query)).get_result(&mut conn)? { + true => Ok(()), + false => Err(ThothError::IssueImprintsError), + } +} + +impl CreatePolicy<NewIssue> for IssuePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewIssue, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + issue_imprints_match(ctx.db(), data.work_id, data.series_id) + } +} + +impl UpdatePolicy<Issue, PatchIssue> for IssuePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Issue, + patch: &PatchIssue, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + issue_imprints_match(ctx.db(), patch.work_id, patch.series_id) + } +} + +impl DeletePolicy<Issue> for IssuePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Issue) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Issue> for IssuePolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Issue) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/language/crud.rs b/thoth-api/src/model/language/crud.rs index 73f92eb29..e8f115979 100644 --- a/thoth-api/src/model/language/crud.rs +++ b/thoth-api/src/model/language/crud.rs @@ -2,8 +2,8 @@ use super::{ Language, LanguageCode, LanguageField, LanguageHistory, LanguageRelation, NewLanguage, NewLanguageHistory, PatchLanguage, }; -use crate::graphql::model::LanguageOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; +use crate::graphql::inputs::LanguageOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{language, language_history}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -122,20 +122,20 @@ impl Crud for Language { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(language::table, language::dsl::language); } +publisher_id_impls!(Language, NewLanguage, PatchLanguage, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Language { type NewHistoryEntity = NewLanguageHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { language_id: self.language_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -160,10 +160,10 @@ mod tests { #[test] fn test_new_language_history_from_language() { let language: Language = Default::default(); - let account_id: Uuid = Default::default(); - let new_language_history = language.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_language_history = language.new_history_entry(&user_id); assert_eq!(new_language_history.language_id, language.language_id); - assert_eq!(new_language_history.account_id, account_id); + assert_eq!(new_language_history.user_id, user_id); assert_eq!( new_language_history.data, serde_json::Value::String(serde_json::to_string(&language).unwrap()) diff --git a/thoth-api/src/model/language/mod.rs b/thoth-api/src/model/language/mod.rs index f81259dab..c70fad8dd 100644 --- a/thoth-api/src/model/language/mod.rs +++ b/thoth-api/src/model/language/mod.rs @@ -1151,7 +1151,7 @@ pub enum LanguageCode { pub struct LanguageHistory { pub language_history_id: Uuid, pub language_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -1163,7 +1163,7 @@ pub struct LanguageHistory { )] pub struct NewLanguageHistory { pub language_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -2213,3 +2213,7 @@ fn test_languagecode_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::LanguagePolicy; diff --git a/thoth-api/src/model/language/policy.rs b/thoth-api/src/model/language/policy.rs new file mode 100644 index 000000000..1b481681a --- /dev/null +++ b/thoth-api/src/model/language/policy.rs @@ -0,0 +1,37 @@ +use crate::model::language::{Language, NewLanguage, PatchLanguage}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Language`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct LanguagePolicy; + +impl CreatePolicy<NewLanguage> for LanguagePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewLanguage, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Language, PatchLanguage> for LanguagePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Language, + patch: &PatchLanguage, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + Ok(()) + } +} + +impl DeletePolicy<Language> for LanguagePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Language) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index 050dbf1d7..a71474f40 100644 --- a/thoth-api/src/model/location/crud.rs +++ b/thoth-api/src/model/location/crud.rs @@ -2,7 +2,7 @@ use super::{ Location, LocationField, LocationHistory, LocationOrderBy, LocationPlatform, NewLocation, NewLocationHistory, PatchLocation, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{location, location_history}; use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -122,10 +122,6 @@ impl Crud for Location { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::publication::Publication::from_id(db, &self.publication_id)?.publisher_id(db) - } - // `crud_methods!` cannot be used for update(), because we need to execute multiple statements // in the same transaction for changing a non-canonical location to canonical. // These functions recreate the `crud_methods!` logic. @@ -146,13 +142,12 @@ impl Crud for Location { }) } - fn update( + fn update<C: crate::policy::PolicyContext>( &self, - db: &crate::db::PgPool, + ctx: &C, data: &PatchLocation, - account_id: &Uuid, ) -> ThothResult<Self> { - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection .transaction(|connection| { if data.canonical == self.canonical { @@ -167,7 +162,7 @@ impl Crud for Location { } else { // Update the existing canonical location to non-canonical let mut old_canonical_location = - PatchLocation::from(self.get_canonical_location(db)?); + PatchLocation::from(self.get_canonical_location(ctx.db())?); old_canonical_location.canonical = false; diesel::update(location::table.find(old_canonical_location.location_id)) .set(old_canonical_location) @@ -179,7 +174,7 @@ impl Crud for Location { } }) .and_then(|location| { - self.new_history_entry(account_id) + self.new_history_entry(ctx.user_id()?) .insert(&mut connection) .map(|_| location) }) @@ -195,13 +190,17 @@ impl Crud for Location { } } +publisher_id_impls!(Location, NewLocation, PatchLocation, |s, db| { + crate::model::publication::Publication::from_id(db, &s.publication_id)?.publisher_id(db) +}); + impl HistoryEntry for Location { type NewHistoryEntity = NewLocationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { location_id: self.location_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -315,10 +314,10 @@ mod tests { #[test] fn test_new_location_history_from_location() { let location: Location = Default::default(); - let account_id: Uuid = Default::default(); - let new_location_history = location.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_location_history = location.new_history_entry(&user_id); assert_eq!(new_location_history.location_id, location.location_id); - assert_eq!(new_location_history.account_id, account_id); + assert_eq!(new_location_history.user_id, user_id); assert_eq!( new_location_history.data, serde_json::Value::String(serde_json::to_string(&location).unwrap()) diff --git a/thoth-api/src/model/location/mod.rs b/thoth-api/src/model/location/mod.rs index c96a26f30..96901d3d8 100644 --- a/thoth-api/src/model/location/mod.rs +++ b/thoth-api/src/model/location/mod.rs @@ -3,7 +3,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::location; @@ -216,7 +216,7 @@ pub struct PatchLocation { pub struct LocationHistory { pub location_history_id: Uuid, pub location_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -228,7 +228,7 @@ pub struct LocationHistory { )] pub struct NewLocationHistory { pub location_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -405,3 +405,7 @@ fn test_locationplatform_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::LocationPolicy; diff --git a/thoth-api/src/model/location/policy.rs b/thoth-api/src/model/location/policy.rs new file mode 100644 index 000000000..151799a65 --- /dev/null +++ b/thoth-api/src/model/location/policy.rs @@ -0,0 +1,97 @@ +use diesel::dsl::exists; +use diesel::prelude::*; +use diesel::select; +use uuid::Uuid; + +use super::{Location, LocationPlatform, NewLocation, PatchLocation}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; +use crate::schema::location; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Location`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - enforcing any additional business rules (e.g. Thoth platform restrictions) +pub struct LocationPolicy; + +fn has_canonical_thoth_location( + db: &crate::db::PgPool, + publication_id: &Uuid, +) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = location::table + .filter(location::publication_id.eq(publication_id)) + .filter(location::location_platform.eq(LocationPlatform::Thoth)) + .filter(location::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewLocation> for LocationPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewLocation, _params: ()) -> ThothResult<()> { + let user = ctx.require_publisher_for(data)?; + + // Only superusers can create new locations where Location Platform is Thoth. + if !user.is_superuser() && data.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + // Canonical locations must be complete; non-canonical locations must satisfy rules. + if data.canonical { + data.canonical_record_complete(ctx.db())?; + } else { + data.can_be_non_canonical(ctx.db())?; + } + + Ok(()) + } +} + +impl UpdatePolicy<Location, PatchLocation> for LocationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Location, + patch: &PatchLocation, + _params: (), + ) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Only superusers can edit locations where Location Platform is Thoth. + if !user.is_superuser() && current.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + // Only superusers can update the canonical location when a Thoth Location Platform + // canonical location already exists for the publication. + if patch.canonical + && has_canonical_thoth_location(ctx.db(), &patch.publication_id)? + && !user.is_superuser() + { + return Err(ThothError::ThothUpdateCanonicalError); + } + + // If setting canonical to true, require record completeness. + if patch.canonical { + patch.canonical_record_complete(ctx.db())?; + } + + Ok(()) + } +} + +impl DeletePolicy<Location> for LocationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, location: &Location) -> ThothResult<()> { + let user = ctx.require_publisher_for(location)?; + + // Thoth platform locations are superuser-restricted. + if !user.is_superuser() && location.location_platform == LocationPlatform::Thoth { + return Err(ThothError::ThothLocationError); + } + + Ok(()) + } +} diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 7bd79f391..9309b88a0 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -1,61 +1,16 @@ -use crate::ast::{ - ast_to_html, ast_to_jats, ast_to_markdown, ast_to_plain_text, html_to_ast, jats_to_ast, - markdown_to_ast, plain_text_ast_to_jats, plain_text_to_ast, - strip_structural_elements_from_ast_for_conversion, validate_ast_content, -}; +use crate::policy::PolicyContext; use chrono::{DateTime, TimeZone, Utc}; use isbn::Isbn13; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; -use strum::Display; -use strum::EnumString; use thoth_errors::{ThothError, ThothResult}; -#[cfg(feature = "backend")] use uuid::Uuid; pub const DOI_DOMAIN: &str = "https://doi.org/"; pub const ORCID_DOMAIN: &str = "https://orcid.org/"; pub const ROR_DOMAIN: &str = "https://ror.org/"; -#[cfg_attr( - feature = "backend", - derive(juniper::GraphQLEnum), - graphql(description = "Unit of measurement for physical Work dimensions (mm, cm or in)") -)] -#[derive( - Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq, EnumString, Display, -)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "lowercase")] -pub enum LengthUnit { - #[cfg_attr(feature = "backend", graphql(description = "Millimetres"))] - #[default] - Mm, - #[cfg_attr(feature = "backend", graphql(description = "Centimetres"))] - Cm, - #[cfg_attr(feature = "backend", graphql(description = "Inches"))] - In, -} - -#[cfg_attr( - feature = "backend", - derive(juniper::GraphQLEnum), - graphql(description = "Unit of measurement for physical Work weight (grams or ounces)") -)] -#[derive( - Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq, EnumString, Display, -)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "lowercase")] -pub enum WeightUnit { - #[cfg_attr(feature = "backend", graphql(description = "Grams"))] - #[default] - G, - #[cfg_attr(feature = "backend", graphql(description = "Ounces"))] - Oz, -} - #[cfg_attr( feature = "backend", derive(DieselNewType, juniper::GraphQLScalar), @@ -300,7 +255,7 @@ impl Orcid { #[cfg(feature = "backend")] #[allow(clippy::too_many_arguments)] /// Common functionality to perform basic CRUD actions on Thoth entities -pub trait Crud +pub(crate) trait Crud where Self: Sized, { @@ -362,20 +317,160 @@ where fn create(db: &crate::db::PgPool, data: &Self::NewEntity) -> ThothResult<Self>; /// Modify the record in the database and obtain the resulting instance - fn update( - &self, - db: &crate::db::PgPool, - data: &Self::PatchEntity, - account_id: &Uuid, - ) -> ThothResult<Self>; + fn update<C: PolicyContext>(&self, ctx: &C, data: &Self::PatchEntity) -> ThothResult<Self>; /// Delete the record from the database and obtain the deleted instance fn delete(self, db: &crate::db::PgPool) -> ThothResult<Self>; +} - /// Retrieve the ID of the publisher linked to this entity (if applicable) +#[cfg(feature = "backend")] +/// Retrieve the ID of the publisher linked to an entity or input type (if applicable). +pub trait PublisherId +where + Self: Sized, +{ fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid>; } +#[cfg(feature = "backend")] +/// Retrieve the IDs of the publishers linked to an entity or input type (if applicable). +/// +/// This is intended for entities that span more than one publisher scope, e.g. `WorkRelation`, +/// where authorisation must be checked against all referenced publishers. +pub trait PublisherIds +where + Self: Sized, +{ + fn publisher_ids(&self, db: &crate::db::PgPool) -> ThothResult<Vec<Uuid>>; +} + +/// Implements `PublisherId` for a main entity type, its `New*` type, and its `Patch*` type. +/// +/// Due to macro hygiene, the implementation body is written as a block that uses **explicit** +/// identifiers provided to the macro (e.g. `s` and `db`). The macro will bind those identifiers +/// to the method's `self` and `db` parameters before expanding the body. +/// +/// Example: +/// ```ignore +/// publisher_id_impls!( +/// Contribution, +/// NewContribution, +/// PatchContribution, +/// |s, db| { +/// Work::from_id(db, &s.work_id)?.publisher_id(db) +/// } +/// ); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! publisher_id_impls { + ( + $main_ty:ty, + $new_ty:ty, + $patch_ty:ty, + |$s:ident, $db:ident| $body:block $(,)? + ) => { + impl $crate::model::PublisherId for $main_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherId for $new_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherId for $patch_ty { + fn publisher_id( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<uuid::Uuid> { + let $s = self; + let $db = db; + $body + } + } + }; +} + +/// Implements `PublisherIds` for a main entity type, its `New*` type, and its `Patch*` type. +/// +/// The implementation body is written as a block that uses **explicit** identifiers provided to the +/// macro (e.g. `s` and `db`). The macro will bind those identifiers to the method's `self` and `db` +/// parameters before expanding the body. +/// +/// Example: +/// ```ignore +/// publisher_ids_impls!( +/// WorkRelation, +/// NewWorkRelation, +/// PatchWorkRelation, +/// |s, db| { +/// let a = Work::from_id(db, &s.relator_work_id)?.publisher_id(db)?; +/// let b = Work::from_id(db, &s.related_work_id)?.publisher_id(db)?; +/// let mut v = vec![a, b]; +/// v.sort(); +/// v.dedup(); +/// Ok(v) +/// } +/// ); +/// ``` +#[cfg(feature = "backend")] +#[macro_export] +macro_rules! publisher_ids_impls { + ( + $main_ty:ty, + $new_ty:ty, + $patch_ty:ty, + |$s:ident, $db:ident| $body:block $(,)? + ) => { + impl $crate::model::PublisherIds for $main_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherIds for $new_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + + impl $crate::model::PublisherIds for $patch_ty { + fn publisher_ids( + &self, + db: &$crate::db::PgPool, + ) -> $crate::model::ThothResult<Vec<uuid::Uuid>> { + let $s = self; + let $db = db; + $body + } + } + }; +} + #[cfg(feature = "backend")] /// Common functionality to store history pub trait HistoryEntry @@ -385,7 +480,7 @@ where /// The structure used to create a new history entity, e.g. `NewImprintHistory` for `Imprint` type NewHistoryEntity; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity; + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity; } #[cfg(feature = "backend")] @@ -402,16 +497,15 @@ where #[cfg(feature = "backend")] /// Common functionality to correctly renumber all relevant database objects /// on a request to change the ordinal of one of them -pub trait Reorder +pub(crate) trait Reorder where Self: Sized + Clone, { - fn change_ordinal( + fn change_ordinal<C: PolicyContext>( &self, - db: &crate::db::PgPool, + ctx: &C, current_ordinal: i32, new_ordinal: i32, - account_id: &Uuid, ) -> ThothResult<Self>; fn get_other_objects( @@ -468,22 +562,21 @@ macro_rules! crud_methods { /// Makes a database transaction that first updates the entity and then creates a new /// history entity record. - fn update( + fn update<C: $crate::policy::PolicyContext>( &self, - db: &$crate::db::PgPool, + ctx: &C, data: &Self::PatchEntity, - account_id: &Uuid, ) -> ThothResult<Self> { use diesel::{Connection, QueryDsl, RunQueryDsl}; - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection.transaction(|connection| { diesel::update($entity_dsl.find(&self.pk())) .set(data) .get_result(connection) .map_err(Into::into) .and_then(|c| { - self.new_history_entry(&account_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| c) }) @@ -601,14 +694,13 @@ macro_rules! db_change_ordinal { ($table_dsl:expr, $ordinal_field:expr, $constraint_name:literal) => { - fn change_ordinal( + fn change_ordinal<C: $crate::policy::PolicyContext>( &self, - db: &$crate::db::PgPool, + ctx: &C, current_ordinal: i32, new_ordinal: i32, - account_id: &Uuid, ) -> ThothResult<Self> { - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; // Execute all updates within the same transaction, // because if one fails, the others need to be reverted. connection.transaction(|connection| { @@ -648,7 +740,7 @@ macro_rules! db_change_ordinal { .and_then(|t| { // On success, create a new history table entry. // Only record the original update, not the automatic reorderings. - self.new_history_entry(account_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| t) }) @@ -657,53 +749,6 @@ macro_rules! db_change_ordinal { }; } -pub trait Convert { - fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64; - fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64; -} - -impl Convert for f64 { - fn convert_length_from_to(&self, current_units: &LengthUnit, new_units: &LengthUnit) -> f64 { - match (current_units, new_units) { - // If current units and new units are the same, no conversion is needed - (LengthUnit::Mm, LengthUnit::Mm) - | (LengthUnit::Cm, LengthUnit::Cm) - | (LengthUnit::In, LengthUnit::In) => *self, - // Return cm values rounded to max 1 decimal place (1 cm = 10 mm) - (LengthUnit::Mm, LengthUnit::Cm) => self.round() / 10.0, - // Return mm values rounded to nearest mm (1 cm = 10 mm) - (LengthUnit::Cm, LengthUnit::Mm) => (self * 10.0).round(), - // Return inch values rounded to 2 decimal places (1 inch = 25.4 mm) - (LengthUnit::Mm, LengthUnit::In) => { - let unrounded_inches = self / 25.4; - // To round to a non-integer scale, multiply by the appropriate factor, - // round to the nearest integer, then divide again by the same factor - (unrounded_inches * 100.0).round() / 100.0 - } - // Return mm values rounded to nearest mm (1 inch = 25.4 mm) - (LengthUnit::In, LengthUnit::Mm) => (self * 25.4).round(), - // We don't currently support conversion between cm and in as it is not required - _ => unimplemented!(), - } - } - - fn convert_weight_from_to(&self, current_units: &WeightUnit, new_units: &WeightUnit) -> f64 { - match (current_units, new_units) { - // If current units and new units are the same, no conversion is needed - (WeightUnit::G, WeightUnit::G) | (WeightUnit::Oz, WeightUnit::Oz) => *self, - // Return ounce values rounded to 4 decimal places (1 ounce = 28.349523125 grams) - (WeightUnit::G, WeightUnit::Oz) => { - let unrounded_ounces = self / 28.349523125; - // To round to a non-integer scale, multiply by the appropriate factor, - // round to the nearest integer, then divide again by the same factor - (unrounded_ounces * 10000.0).round() / 10000.0 - } - // Return gram values rounded to nearest gram (1 ounce = 28.349523125 grams) - (WeightUnit::Oz, WeightUnit::G) => (self * 28.349523125).round(), - } - } -} - /// Assign the leading domain of an identifier pub trait UrlIdentifier { fn domain(&self) -> &'static str; @@ -741,491 +786,10 @@ impl IdentifierWithDomain for Doi {} impl IdentifierWithDomain for Orcid {} impl IdentifierWithDomain for Ror {} -/// Enum to represent the markup format -#[cfg_attr( - feature = "backend", - derive(DbEnum, juniper::GraphQLEnum), - graphql( - description = "Allowed markup formats for text fields that support structured content" - ), - ExistingTypePath = "crate::schema::sql_types::MarkupFormat" -)] -#[derive( - Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, -)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "UPPERCASE")] -pub enum MarkupFormat { - #[cfg_attr(feature = "backend", graphql(description = "HTML format"))] - Html, - #[cfg_attr(feature = "backend", graphql(description = "Markdown format"))] - Markdown, - #[cfg_attr(feature = "backend", graphql(description = "Plain text format"))] - PlainText, - #[cfg_attr(feature = "backend", graphql(description = "JATS XML format"))] - #[default] - JatsXml, -} - -/// Limits how much structure is preserved/allowed when converting to/from JATS. -/// -/// - `Abstract`/`Biography`: allow basic structural elements (paragraphs, lists, emphasis, links). -/// - `Title`: disallow structure; structural tags are stripped to plain inline text. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversionLimit { - Abstract, - Biography, - Title, -} - -/// Enum to represent abstract types -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum AbstractType { - Short, - Long, -} - -/// Validate content format based on markup format -pub fn validate_format(content: &str, format: &MarkupFormat) -> ThothResult<()> { - match format { - MarkupFormat::Html | MarkupFormat::JatsXml => { - // Basic HTML validation - check for opening and closing tags - if !content.contains('<') || !content.contains('>') || !content.contains("</") { - return Err(ThothError::UnsupportedFileFormatError); - } - } - MarkupFormat::Markdown => { - // Basic Markdown validation - check for markdown syntax - if content.contains('<') && content.contains('>') { - // At least one markdown element should be present - return Err(ThothError::UnsupportedFileFormatError); - } - } - MarkupFormat::PlainText => {} - } - Ok(()) -} - -/// Convert content to JATS XML format with specified tag -pub fn convert_to_jats( - content: String, - format: MarkupFormat, - conversion_limit: ConversionLimit, -) -> ThothResult<String> { - validate_format(&content, &format)?; - let mut output = content.clone(); - - match format { - MarkupFormat::Html => { - // Use ast library to parse HTML and convert to JATS - let ast = html_to_ast(&content); - - // For title conversion, strip structural elements before validation - let processed_ast = if conversion_limit == ConversionLimit::Title { - strip_structural_elements_from_ast_for_conversion(&ast) - } else { - ast - }; - - validate_ast_content(&processed_ast, conversion_limit)?; - output = ast_to_jats(&processed_ast); - } - - MarkupFormat::Markdown => { - // Use ast library to parse Markdown and convert to JATS - let ast = markdown_to_ast(&content); - - // For title conversion, strip structural elements before validation - let processed_ast = if conversion_limit == ConversionLimit::Title { - strip_structural_elements_from_ast_for_conversion(&ast) - } else { - ast - }; - - validate_ast_content(&processed_ast, conversion_limit)?; - output = ast_to_jats(&processed_ast); - } - - MarkupFormat::PlainText => { - // Use ast library to parse plain text and convert to JATS - let ast = plain_text_to_ast(&content); - - // For title conversion, strip structural elements before validation - let processed_ast = if conversion_limit == ConversionLimit::Title { - strip_structural_elements_from_ast_for_conversion(&ast) - } else { - ast - }; - - validate_ast_content(&processed_ast, conversion_limit)?; - output = if conversion_limit == ConversionLimit::Title { - // Title JATS should remain inline (no paragraph wrapper) - ast_to_jats(&processed_ast) - } else { - plain_text_ast_to_jats(&processed_ast) - }; - } - - MarkupFormat::JatsXml => {} - } - - Ok(output) -} - -/// Convert from JATS XML to specified format using a specific tag name -pub fn convert_from_jats( - jats_xml: &str, - format: MarkupFormat, - conversion_limit: ConversionLimit, -) -> ThothResult<String> { - // Allow plain-text content that was stored without JATS markup for titles. - if !jats_xml.contains('<') || !jats_xml.contains("</") { - let ast = plain_text_to_ast(jats_xml); - let processed_ast = if conversion_limit == ConversionLimit::Title { - strip_structural_elements_from_ast_for_conversion(&ast) - } else { - ast - }; - validate_ast_content(&processed_ast, conversion_limit)?; - return Ok(match format { - MarkupFormat::Html => ast_to_html(&processed_ast), - MarkupFormat::Markdown => ast_to_markdown(&processed_ast), - MarkupFormat::PlainText => ast_to_plain_text(&processed_ast), - MarkupFormat::JatsXml => { - if conversion_limit == ConversionLimit::Title { - ast_to_jats(&processed_ast) - } else { - plain_text_ast_to_jats(&processed_ast) - } - } - }); - } - - validate_format(jats_xml, &MarkupFormat::JatsXml)?; - - // Parse JATS to AST first for better handling - let ast = jats_to_ast(jats_xml); - - // For title conversion, strip structural elements before validation - let processed_ast = if conversion_limit == ConversionLimit::Title { - strip_structural_elements_from_ast_for_conversion(&ast) - } else { - ast - }; - - // Validate the AST content based on conversion limit - validate_ast_content(&processed_ast, conversion_limit)?; - - let output = match format { - MarkupFormat::Html => { - // Use the dedicated AST to HTML converter - ast_to_html(&processed_ast) - } - - MarkupFormat::Markdown => { - // Use the dedicated AST to Markdown converter - ast_to_markdown(&processed_ast) - } - - MarkupFormat::PlainText => { - // Use the dedicated AST to plain text converter - ast_to_plain_text(&processed_ast) - } - - MarkupFormat::JatsXml => { - // Return the AST converted back to JATS (should be identical) - jats_xml.to_string() - } - }; - - Ok(output) -} - #[cfg(test)] mod tests { use super::*; - // --- convert_to_jats tests start --- - #[test] - fn test_html_basic_formatting() { - let input = "<em>Italic</em> and <strong>Bold</strong>"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Html, - ConversionLimit::Biography, - ) - .unwrap(); - assert_eq!(output, "<italic>Italic</italic> and <bold>Bold</bold>"); - } - - #[test] - fn test_html_link_conversion() { - let input = r#"<a href="https://example.com">Link</a>"#; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Html, - ConversionLimit::Abstract, - ) - .unwrap(); - assert_eq!( - output, - r#"<ext-link xlink:href="https://example.com">Link</ext-link>"# - ); - } - - #[test] - fn test_html_with_structure_allowed() { - let input = "<ul><li>One</li><li>Two</li></ul>"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Html, - ConversionLimit::Abstract, - ) - .unwrap(); - assert_eq!( - output, - "<list><list-item>One</list-item><list-item>Two</list-item></list>" - ); - } - - #[test] - fn test_html_with_structure_stripped() { - let input = "<ul><li>One</li></ul>"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Html, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!(output, "One"); - } - - #[test] - fn test_html_small_caps_conversion() { - let input = "<text>Small caps text</text>"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Html, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!(output, "<sc>Small caps text</sc>"); - } - - #[test] - fn test_markdown_basic_formatting() { - let input = "**Bold** and *Italic* and `code`"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Markdown, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!( - output, - "<bold>Bold</bold> and <italic>Italic</italic> and <monospace>code</monospace>" - ); - } - - #[test] - fn test_markdown_link_conversion() { - let input = "[text](https://example.com)"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Markdown, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!( - output, - r#"<ext-link xlink:href="https://example.com">text</ext-link>"# - ); - } - - #[test] - fn test_markdown_with_structure() { - let input = "- Item 1\n- Item 2\n\nParagraph text"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::Markdown, - ConversionLimit::Abstract, - ) - .unwrap(); - - assert!( - output.contains( - "<list><list-item>Item 1</list-item><list-item>Item 2</list-item></list>" - ) && output.contains("<p>Paragraph text</p>") - ); - } - - #[test] - fn test_plain_text_with_url() { - let input = "Hello https://example.com world"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::PlainText, - ConversionLimit::Biography, - ) - .unwrap(); - assert_eq!( - output, - "<p>Hello </p><ext-link xlink:href=\"https://example.com\"><p>https://example.com</p></ext-link><p> world</p>" - ); - } - - #[test] - fn test_plain_text_no_url() { - let input = "Just plain text."; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::PlainText, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!(output, "Just plain text."); - } - // --- convert_to_jats tests end --- - - // --- convert_from_jats tests start --- - #[test] - fn test_convert_from_jats_html_with_structure() { - let input = r#" - <p>Paragraph text</p> - <list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> - <italic>Italic</italic> and <bold>Bold</bold> - <ext-link xlink:href="https://example.com">Link</ext-link> - "#; - let output = - convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); - - assert!(output.contains("<p>Paragraph text</p>")); - assert!(output.contains("<ul><li>Item 1</li><li>Item 2</li></ul>")); - assert!(output.contains("<em>Italic</em>")); - assert!(output.contains("<strong>Bold</strong>")); - assert!(output.contains(r#"<a href="https://example.com">Link</a>"#)); - } - - #[test] - fn test_convert_from_jats_html_no_structure() { - let input = r#" - <p>Text</p><list><list-item>Item</list-item></list><bold>Bold</bold> - "#; - let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); - - assert!(!output.contains("<p>")); - assert!(!output.contains("<ul>")); - assert!(output.contains("<strong>Bold</strong>")); - } - - #[test] - fn test_convert_from_jats_html_title_limit() { - let input = r#"<p>Title</p><bold>Bold</bold>"#; - let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); - - assert!(!output.contains("<p>")); - assert!(output.contains("<strong>Bold</strong>")); - } - - #[test] - fn test_convert_from_jats_markdown_with_structure() { - let input = r#" - <p>Text</p><list><list-item>Item 1</list-item><list-item>Item 2</list-item></list> - <italic>It</italic> and <bold>Bold</bold> - <ext-link xlink:href="https://link.com">Here</ext-link> - "#; - let output = - convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Biography).unwrap(); - - assert!(output.contains("Text")); - assert!(output.contains("- Item 1")); - assert!(output.contains("*It*")); - assert!(output.contains("**Bold**")); - assert!(output.contains("[Here](https://link.com)")); - } - - #[test] - fn test_convert_from_jats_markdown_title_limit() { - let input = r#"<p>Title</p><italic>It</italic>"#; - let output = - convert_from_jats(input, MarkupFormat::Markdown, ConversionLimit::Title).unwrap(); - - assert!(!output.contains("<p>")); - assert!(output.contains("*It*")); - } - - #[test] - fn test_convert_from_jats_plain_text_basic() { - let input = r#" - <p>Text</p> and <ext-link xlink:href="https://ex.com">Link</ext-link> and <sc>SC</sc> - "#; - let output = - convert_from_jats(input, MarkupFormat::PlainText, ConversionLimit::Abstract).unwrap(); - - assert!(output.contains("Text")); - assert!(output.contains("Link (https://ex.com)")); - assert!(!output.contains("<sc>")); - assert!(!output.contains("<")); - } - - #[test] - fn test_convert_from_jats_preserves_inline_html() { - let input = r#"<italic>i</italic> <bold>b</bold> <monospace>code</monospace>"#; - let output = - convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Abstract).unwrap(); - - assert!(output.contains("<em>i</em>")); - assert!(output.contains("<strong>b</strong>")); - assert!(output.contains("<code>code</code>")); - } - - #[test] - fn test_convert_from_jats_jatsxml_noop() { - let input = r#"<p>Do nothing</p>"#; - let output = - convert_from_jats(input, MarkupFormat::JatsXml, ConversionLimit::Biography).unwrap(); - assert_eq!(input, output); - } - - #[test] - fn test_convert_from_jats_html_allow_structure_false() { - let input = r#"<p>Para</p><list><list-item>Item</list-item></list>"#; - let output = convert_from_jats(input, MarkupFormat::Html, ConversionLimit::Title).unwrap(); - - assert!(!output.contains("<p>")); - assert!(!output.contains("<ul>")); - assert!(output.contains("Para")); - assert!(output.contains("Item")); - } - - #[test] - fn test_title_plain_text_to_jats_has_no_paragraph() { - let input = "Plain title"; - let output = convert_to_jats( - input.to_string(), - MarkupFormat::PlainText, - ConversionLimit::Title, - ) - .unwrap(); - assert_eq!(output, "Plain title"); - } - - #[test] - fn test_title_plain_text_roundtrip_no_paragraphs() { - let plain = "Another plain title"; - let jats = convert_to_jats( - plain.to_string(), - MarkupFormat::PlainText, - ConversionLimit::Title, - ) - .unwrap(); - assert!(!jats.contains("<p>")); - - let back = convert_from_jats(&jats, MarkupFormat::JatsXml, ConversionLimit::Title).unwrap(); - assert_eq!(back, plain); - } - // --- convert_from_jats tests end --- - #[test] fn test_doi_default() { let doi: Doi = Default::default(); @@ -1499,173 +1063,6 @@ mod tests { assert_eq!(hyphenless_orcid, "0000000212345678"); } - #[test] - // Float equality comparison is fine here because the floats - // have already been rounded by the functions under test - #[allow(clippy::float_cmp)] - fn test_convert_length_from_to() { - use LengthUnit::*; - assert_eq!(123.456.convert_length_from_to(&Mm, &Cm), 12.3); - assert_eq!(123.456.convert_length_from_to(&Mm, &In), 4.86); - assert_eq!(123.456.convert_length_from_to(&Cm, &Mm), 1235.0); - assert_eq!(123.456.convert_length_from_to(&In, &Mm), 3136.0); - // Test some standard print sizes - assert_eq!(4.25.convert_length_from_to(&In, &Mm), 108.0); - assert_eq!(108.0.convert_length_from_to(&Mm, &In), 4.25); - assert_eq!(6.0.convert_length_from_to(&In, &Mm), 152.0); - assert_eq!(152.0.convert_length_from_to(&Mm, &In), 5.98); - assert_eq!(8.5.convert_length_from_to(&In, &Mm), 216.0); - assert_eq!(216.0.convert_length_from_to(&Mm, &In), 8.5); - // Test that converting and then converting back again - // returns a value within a reasonable margin of error - assert_eq!( - 5.06.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 5.08 - ); - assert_eq!( - 6.5.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 6.5 - ); - assert_eq!( - 7.44.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 7.44 - ); - assert_eq!( - 8.27.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 8.27 - ); - assert_eq!( - 9.0.convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 9.02 - ); - assert_eq!( - 10.88 - .convert_length_from_to(&In, &Mm) - .convert_length_from_to(&Mm, &In), - 10.87 - ); - assert_eq!( - 102.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 102.0 - ); - assert_eq!( - 120.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 120.0 - ); - assert_eq!( - 168.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 168.0 - ); - assert_eq!( - 190.0 - .convert_length_from_to(&Mm, &In) - .convert_length_from_to(&In, &Mm), - 190.0 - ); - } - - #[test] - // Float equality comparison is fine here because the floats - // have already been rounded by the functions under test - #[allow(clippy::float_cmp)] - fn test_convert_weight_from_to() { - use WeightUnit::*; - assert_eq!(123.456.convert_weight_from_to(&G, &Oz), 4.3548); - assert_eq!(123.456.convert_weight_from_to(&Oz, &G), 3500.0); - assert_eq!(4.25.convert_weight_from_to(&Oz, &G), 120.0); - assert_eq!(108.0.convert_weight_from_to(&G, &Oz), 3.8096); - assert_eq!(6.0.convert_weight_from_to(&Oz, &G), 170.0); - assert_eq!(152.0.convert_weight_from_to(&G, &Oz), 5.3616); - assert_eq!(8.5.convert_weight_from_to(&Oz, &G), 241.0); - assert_eq!(216.0.convert_weight_from_to(&G, &Oz), 7.6192); - // Test that converting and then converting back again - // returns a value within a reasonable margin of error - assert_eq!( - 5.0.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 5.0089 - ); - assert_eq!( - 5.125 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 5.1147 - ); - assert_eq!( - 6.5.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 6.4904 - ); - assert_eq!( - 7.25.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 7.2664 - ); - assert_eq!( - 7.44.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 7.4428 - ); - assert_eq!( - 8.0625 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 8.0777 - ); - assert_eq!( - 9.0.convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 8.9949 - ); - assert_eq!( - 10.75 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 10.7586 - ); - assert_eq!( - 10.88 - .convert_weight_from_to(&Oz, &G) - .convert_weight_from_to(&G, &Oz), - 10.8644 - ); - assert_eq!( - 102.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 102.0 - ); - assert_eq!( - 120.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 120.0 - ); - assert_eq!( - 168.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 168.0 - ); - assert_eq!( - 190.0 - .convert_weight_from_to(&G, &Oz) - .convert_weight_from_to(&Oz, &G), - 190.0 - ); - } - #[test] fn test_doi_with_domain() { let doi = "https://doi.org/10.12345/Test-Suffix.01"; diff --git a/thoth-api/src/model/price/crud.rs b/thoth-api/src/model/price/crud.rs index b273ee6ec..6f35dec5f 100644 --- a/thoth-api/src/model/price/crud.rs +++ b/thoth-api/src/model/price/crud.rs @@ -1,6 +1,6 @@ use super::{CurrencyCode, NewPrice, NewPriceHistory, PatchPrice, Price, PriceField, PriceHistory}; -use crate::graphql::model::PriceOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; +use crate::graphql::inputs::PriceOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{price, price_history}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; @@ -112,20 +112,20 @@ impl Crud for Price { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::publication::Publication::from_id(db, &self.publication_id)?.publisher_id(db) - } - crud_methods!(price::table, price::dsl::price); } +publisher_id_impls!(Price, NewPrice, PatchPrice, |s, db| { + crate::model::publication::Publication::from_id(db, &s.publication_id)?.publisher_id(db) +}); + impl HistoryEntry for Price { type NewHistoryEntity = NewPriceHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { price_id: self.price_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -150,10 +150,10 @@ mod tests { #[test] fn test_new_price_history_from_price() { let price: Price = Default::default(); - let account_id: Uuid = Default::default(); - let new_price_history = price.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_price_history = price.new_history_entry(&user_id); assert_eq!(new_price_history.price_id, price.price_id); - assert_eq!(new_price_history.account_id, account_id); + assert_eq!(new_price_history.user_id, user_id); assert_eq!( new_price_history.data, serde_json::Value::String(serde_json::to_string(&price).unwrap()) diff --git a/thoth-api/src/model/price/mod.rs b/thoth-api/src/model/price/mod.rs index cccf672f2..e171d3085 100644 --- a/thoth-api/src/model/price/mod.rs +++ b/thoth-api/src/model/price/mod.rs @@ -827,7 +827,7 @@ pub enum CurrencyCode { pub struct PriceHistory { pub price_history_id: Uuid, pub price_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -835,7 +835,7 @@ pub struct PriceHistory { #[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = price_history))] pub struct NewPriceHistory { pub price_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -1460,3 +1460,7 @@ fn test_currencycode_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PricePolicy; diff --git a/thoth-api/src/model/price/policy.rs b/thoth-api/src/model/price/policy.rs new file mode 100644 index 000000000..239709c1b --- /dev/null +++ b/thoth-api/src/model/price/policy.rs @@ -0,0 +1,48 @@ +use crate::model::price::{NewPrice, PatchPrice, Price}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Price`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +/// - enforcing business rules (e.g. non-zero unit price) +pub struct PricePolicy; + +fn validate_unit_price(unit_price: f64) -> ThothResult<()> { + // Prices must be non-zero (and non-negative). + if unit_price <= 0.0 { + return Err(ThothError::PriceZeroError); + } + Ok(()) +} + +impl CreatePolicy<NewPrice> for PricePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewPrice, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + validate_unit_price(data.unit_price) + } +} + +impl UpdatePolicy<Price, PatchPrice> for PricePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Price, + patch: &PatchPrice, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Enforce non-zero unit price. + validate_unit_price(patch.unit_price) + } +} + +impl DeletePolicy<Price> for PricePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Price) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/publication/crud.rs b/thoth-api/src/model/publication/crud.rs index 520a8ecdf..7af43314a 100644 --- a/thoth-api/src/model/publication/crud.rs +++ b/thoth-api/src/model/publication/crud.rs @@ -2,7 +2,7 @@ use super::{ NewPublication, NewPublicationHistory, PatchPublication, Publication, PublicationField, PublicationHistory, PublicationOrderBy, PublicationType, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry}; use crate::schema::{publication, publication_history}; use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; @@ -177,20 +177,20 @@ impl Crud for Publication { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(publication::table, publication::dsl::publication); } +publisher_id_impls!(Publication, NewPublication, PatchPublication, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Publication { type NewHistoryEntity = NewPublicationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { publication_id: self.publication_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -215,13 +215,13 @@ mod tests { #[test] fn test_new_publication_history_from_publication() { let publication: Publication = Default::default(); - let account_id: Uuid = Default::default(); - let new_publication_history = publication.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_publication_history = publication.new_history_entry(&user_id); assert_eq!( new_publication_history.publication_id, publication.publication_id ); - assert_eq!(new_publication_history.account_id, account_id); + assert_eq!(new_publication_history.user_id, user_id); assert_eq!( new_publication_history.data, serde_json::Value::String(serde_json::to_string(&publication).unwrap()) diff --git a/thoth-api/src/model/publication/mod.rs b/thoth-api/src/model/publication/mod.rs index b7baa0ab4..ae2351db5 100644 --- a/thoth-api/src/model/publication/mod.rs +++ b/thoth-api/src/model/publication/mod.rs @@ -4,7 +4,7 @@ use strum::EnumString; use thoth_errors::{ThothError, ThothResult}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Isbn; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -323,7 +323,7 @@ pub struct PatchPublication { pub struct PublicationHistory { pub publication_history_id: Uuid, pub publication_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -335,7 +335,7 @@ pub struct PublicationHistory { )] pub struct NewPublicationHistory { pub publication_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -754,3 +754,7 @@ mod tests { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PublicationPolicy; diff --git a/thoth-api/src/model/publication/policy.rs b/thoth-api/src/model/publication/policy.rs new file mode 100644 index 000000000..ba996ee88 --- /dev/null +++ b/thoth-api/src/model/publication/policy.rs @@ -0,0 +1,44 @@ +use crate::model::publication::{ + NewPublication, PatchPublication, Publication, PublicationProperties, +}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Publication`. +/// +/// These policies are responsible for: +/// - requiring authentication +/// - requiring publisher membership (tenant boundary) +pub struct PublicationPolicy; + +impl CreatePolicy<NewPublication> for PublicationPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewPublication, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + data.validate(ctx.db()) + } +} + +impl UpdatePolicy<Publication, PatchPublication> for PublicationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Publication, + patch: &PatchPublication, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + patch.validate(ctx.db()) + } +} + +impl DeletePolicy<Publication> for PublicationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Publication) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/publisher/crud.rs b/thoth-api/src/model/publisher/crud.rs index 06ea747fa..6dd297620 100644 --- a/thoth-api/src/model/publisher/crud.rs +++ b/thoth-api/src/model/publisher/crud.rs @@ -2,8 +2,9 @@ use super::{ NewPublisher, NewPublisherHistory, PatchPublisher, Publisher, PublisherField, PublisherHistory, PublisherOrderBy, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::db::PgPool; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{publisher, publisher_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -127,20 +128,28 @@ impl Crud for Publisher { .map_err(Into::into) } - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Ok(self.pk()) + crud_methods!(publisher::table, publisher::dsl::publisher); +} + +impl PublisherId for Publisher { + fn publisher_id(&self, _db: &PgPool) -> ThothResult<Uuid> { + Ok(self.publisher_id) } +} - crud_methods!(publisher::table, publisher::dsl::publisher); +impl PublisherId for PatchPublisher { + fn publisher_id(&self, _db: &PgPool) -> ThothResult<Uuid> { + Ok(self.publisher_id) + } } impl HistoryEntry for Publisher { type NewHistoryEntity = NewPublisherHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { publisher_id: self.publisher_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -165,10 +174,10 @@ mod tests { #[test] fn test_new_publisher_history_from_publisher() { let publisher: Publisher = Default::default(); - let account_id: Uuid = Default::default(); - let new_publisher_history = publisher.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_publisher_history = publisher.new_history_entry(&user_id); assert_eq!(new_publisher_history.publisher_id, publisher.publisher_id); - assert_eq!(new_publisher_history.account_id, account_id); + assert_eq!(new_publisher_history.user_id, user_id); assert_eq!( new_publisher_history.data, serde_json::Value::String(serde_json::to_string(&publisher).unwrap()) diff --git a/thoth-api/src/model/publisher/mod.rs b/thoth-api/src/model/publisher/mod.rs index cd989c1f3..9b862a7cb 100644 --- a/thoth-api/src/model/publisher/mod.rs +++ b/thoth-api/src/model/publisher/mod.rs @@ -4,7 +4,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::publisher; @@ -81,7 +81,7 @@ pub struct PatchPublisher { pub struct PublisherHistory { pub publisher_history_id: Uuid, pub publisher_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -93,7 +93,7 @@ pub struct PublisherHistory { )] pub struct NewPublisherHistory { pub publisher_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -167,3 +167,7 @@ fn test_publisherfield_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +pub mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::PublisherPolicy; diff --git a/thoth-api/src/model/publisher/policy.rs b/thoth-api/src/model/publisher/policy.rs new file mode 100644 index 000000000..ff667d283 --- /dev/null +++ b/thoth-api/src/model/publisher/policy.rs @@ -0,0 +1,36 @@ +use crate::model::publisher::{NewPublisher, PatchPublisher, Publisher}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Publisher`. +/// +/// Publisher records define tenancy boundaries. As such, write access is restricted to superusers. +pub struct PublisherPolicy; + +impl CreatePolicy<NewPublisher> for PublisherPolicy { + fn can_create<C: PolicyContext>(ctx: &C, _data: &NewPublisher, _params: ()) -> ThothResult<()> { + ctx.require_superuser()?; + Ok(()) + } +} + +impl UpdatePolicy<Publisher, PatchPublisher> for PublisherPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Publisher, + patch: &PatchPublisher, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Publisher> for PublisherPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, _current: &Publisher) -> ThothResult<()> { + ctx.require_superuser()?; + Ok(()) + } +} diff --git a/thoth-api/src/model/reference/crud.rs b/thoth-api/src/model/reference/crud.rs index 073495f09..b6d0a94a7 100644 --- a/thoth-api/src/model/reference/crud.rs +++ b/thoth-api/src/model/reference/crud.rs @@ -2,7 +2,7 @@ use super::{ NewReference, NewReferenceHistory, PatchReference, Reference, ReferenceField, ReferenceHistory, ReferenceOrderBy, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{reference, reference_history}; use diesel::{ @@ -230,19 +230,20 @@ impl Crud for Reference { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } crud_methods!(reference::table, reference::dsl::reference); } +publisher_id_impls!(Reference, NewReference, PatchReference, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Reference { type NewHistoryEntity = NewReferenceHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { reference_id: self.reference_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -290,10 +291,10 @@ mod tests { #[test] fn test_new_publisher_history_from_publisher() { let reference: Reference = Default::default(); - let account_id: Uuid = Default::default(); - let new_reference_history = reference.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_reference_history = reference.new_history_entry(&user_id); assert_eq!(new_reference_history.reference_id, reference.reference_id); - assert_eq!(new_reference_history.account_id, account_id); + assert_eq!(new_reference_history.user_id, user_id); assert_eq!( new_reference_history.data, serde_json::Value::String(serde_json::to_string(&reference).unwrap()) diff --git a/thoth-api/src/model/reference/mod.rs b/thoth-api/src/model/reference/mod.rs index f1ba9cb6c..4a708eb31 100644 --- a/thoth-api/src/model/reference/mod.rs +++ b/thoth-api/src/model/reference/mod.rs @@ -2,7 +2,7 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Doi, Isbn, Timestamp}; #[cfg(feature = "backend")] use crate::schema::reference; @@ -143,7 +143,7 @@ pub struct PatchReference { pub struct ReferenceHistory { pub reference_history_id: Uuid, pub reference_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -155,7 +155,7 @@ pub struct ReferenceHistory { )] pub struct NewReferenceHistory { pub reference_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -178,3 +178,7 @@ fn test_referencefield_default() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::ReferencePolicy; diff --git a/thoth-api/src/model/reference/policy.rs b/thoth-api/src/model/reference/policy.rs new file mode 100644 index 000000000..e8749151d --- /dev/null +++ b/thoth-api/src/model/reference/policy.rs @@ -0,0 +1,45 @@ +use crate::model::reference::{NewReference, PatchReference, Reference}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Reference`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct ReferencePolicy; + +impl CreatePolicy<NewReference> for ReferencePolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewReference, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Reference, PatchReference> for ReferencePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Reference, + patch: &PatchReference, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Reference> for ReferencePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Reference) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Reference> for ReferencePolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Reference) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/series/crud.rs b/thoth-api/src/model/series/crud.rs index 6f6b32d8f..0ef584967 100644 --- a/thoth-api/src/model/series/crud.rs +++ b/thoth-api/src/model/series/crud.rs @@ -2,8 +2,8 @@ use super::{ NewSeries, NewSeriesHistory, PatchSeries, Series, SeriesField, SeriesHistory, SeriesOrderBy, SeriesType, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{series, series_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -152,21 +152,21 @@ impl Crud for Series { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let imprint = crate::model::imprint::Imprint::from_id(db, &self.imprint_id)?; - <crate::model::imprint::Imprint as Crud>::publisher_id(&imprint, db) - } - crud_methods!(series::table, series::dsl::series); } +publisher_id_impls!(Series, NewSeries, PatchSeries, |s, db| { + let imprint = crate::model::imprint::Imprint::from_id(db, &s.imprint_id)?; + <crate::model::imprint::Imprint as PublisherId>::publisher_id(&imprint, db) +}); + impl HistoryEntry for Series { type NewHistoryEntity = NewSeriesHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { series_id: self.series_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -191,10 +191,10 @@ mod tests { #[test] fn test_new_series_history_from_series() { let series: Series = Default::default(); - let account_id: Uuid = Default::default(); - let new_series_history = series.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_series_history = series.new_history_entry(&user_id); assert_eq!(new_series_history.series_id, series.series_id); - assert_eq!(new_series_history.account_id, account_id); + assert_eq!(new_series_history.user_id, user_id); assert_eq!( new_series_history.data, serde_json::Value::String(serde_json::to_string(&series).unwrap()) diff --git a/thoth-api/src/model/series/mod.rs b/thoth-api/src/model/series/mod.rs index 7bd097979..b2d65d5b3 100644 --- a/thoth-api/src/model/series/mod.rs +++ b/thoth-api/src/model/series/mod.rs @@ -3,7 +3,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::series; @@ -121,7 +121,7 @@ pub struct PatchSeries { pub struct SeriesHistory { pub series_history_id: Uuid, pub series_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -129,7 +129,7 @@ pub struct SeriesHistory { #[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = series_history))] pub struct NewSeriesHistory { pub series_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -241,3 +241,7 @@ fn test_seriesfield_fromstr() { } #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::SeriesPolicy; diff --git a/thoth-api/src/model/series/policy.rs b/thoth-api/src/model/series/policy.rs new file mode 100644 index 000000000..60d63a93c --- /dev/null +++ b/thoth-api/src/model/series/policy.rs @@ -0,0 +1,38 @@ +use crate::model::series::{NewSeries, PatchSeries, Series}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `Series`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct SeriesPolicy; + +impl CreatePolicy<NewSeries> for SeriesPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewSeries, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<Series, PatchSeries> for SeriesPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Series, + patch: &PatchSeries, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<Series> for SeriesPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Series) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/subject/crud.rs b/thoth-api/src/model/subject/crud.rs index e20323e82..fef11fff8 100644 --- a/thoth-api/src/model/subject/crud.rs +++ b/thoth-api/src/model/subject/crud.rs @@ -1,8 +1,8 @@ use super::{ NewSubject, NewSubjectHistory, PatchSubject, Subject, SubjectField, SubjectHistory, SubjectType, }; -use crate::graphql::model::SubjectOrderBy; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; +use crate::graphql::inputs::SubjectOrderBy; use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; use crate::schema::{subject, subject_history}; use diesel::{ @@ -125,20 +125,20 @@ impl Crud for Subject { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - crate::model::work::Work::from_id(db, &self.work_id)?.publisher_id(db) - } - crud_methods!(subject::table, subject::dsl::subject); } +publisher_id_impls!(Subject, NewSubject, PatchSubject, |s, db| { + crate::model::work::Work::from_id(db, &s.work_id)?.publisher_id(db) +}); + impl HistoryEntry for Subject { type NewHistoryEntity = NewSubjectHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { subject_id: self.subject_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -187,10 +187,10 @@ mod tests { #[test] fn test_new_subject_history_from_subject() { let subject: Subject = Default::default(); - let account_id: Uuid = Default::default(); - let new_subject_history = subject.new_history_entry(&account_id); + let user_id = "1234567".to_string(); + let new_subject_history = subject.new_history_entry(&user_id); assert_eq!(new_subject_history.subject_id, subject.subject_id); - assert_eq!(new_subject_history.account_id, account_id); + assert_eq!(new_subject_history.user_id, user_id); assert_eq!( new_subject_history.data, serde_json::Value::String(serde_json::to_string(&subject).unwrap()) diff --git a/thoth-api/src/model/subject/mod.rs b/thoth-api/src/model/subject/mod.rs index 100f084d1..b39125de3 100644 --- a/thoth-api/src/model/subject/mod.rs +++ b/thoth-api/src/model/subject/mod.rs @@ -8,8 +8,6 @@ use crate::model::Timestamp; use crate::schema::subject; #[cfg(feature = "backend")] use crate::schema::subject_history; -use thoth_errors::ThothError; -use thoth_errors::ThothResult; #[cfg_attr( feature = "backend", @@ -104,7 +102,7 @@ pub struct PatchSubject { pub struct SubjectHistory { pub subject_history_id: Uuid, pub subject_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -116,22 +114,10 @@ pub struct SubjectHistory { )] pub struct NewSubjectHistory { pub subject_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } -pub fn check_subject(subject_type: &SubjectType, code: &str) -> ThothResult<()> { - if matches!(subject_type, SubjectType::Thema) - && thema::THEMA_CODES.binary_search(&code).is_err() - { - return Err(ThothError::InvalidSubjectCode { - input: code.to_string(), - subject_type: subject_type.to_string(), - }); - } - Ok(()) -} - impl Default for Subject { fn default() -> Subject { Subject { @@ -182,23 +168,10 @@ fn test_subjecttype_fromstr() { assert!(SubjectType::from_str("Library of Congress Subject Code").is_err()); } -#[test] -fn test_check_subject() { - // Valid codes for specific schemas - assert!(check_subject(&SubjectType::Bic, "HRQX9").is_ok()); - assert!(check_subject(&SubjectType::Bisac, "BIB004060").is_ok()); - assert!(check_subject(&SubjectType::Thema, "ATXZ1").is_ok()); - - // Custom fields: no validity restrictions - assert!(check_subject(&SubjectType::Custom, "A custom subject").is_ok()); - assert!(check_subject(&SubjectType::Keyword, "keyword").is_ok()); - - // Invalid codes for specific schemas: only validate Thema - assert!(check_subject(&SubjectType::Bic, "ABCD0").is_ok()); - assert!(check_subject(&SubjectType::Bisac, "BLA123456").is_ok()); - assert!(check_subject(&SubjectType::Thema, "AHBW").is_err()); -} - #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; mod thema; +#[cfg(feature = "backend")] +pub(crate) use policy::SubjectPolicy; diff --git a/thoth-api/src/model/subject/policy.rs b/thoth-api/src/model/subject/policy.rs new file mode 100644 index 000000000..c111f2d50 --- /dev/null +++ b/thoth-api/src/model/subject/policy.rs @@ -0,0 +1,71 @@ +use crate::model::subject::{thema::THEMA_CODES, NewSubject, PatchSubject, Subject, SubjectType}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Subject`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct SubjectPolicy; + +fn check_subject(subject_type: &SubjectType, code: &str) -> ThothResult<()> { + if matches!(subject_type, SubjectType::Thema) && THEMA_CODES.binary_search(&code).is_err() { + return Err(ThothError::InvalidSubjectCode { + input: code.to_string(), + subject_type: subject_type.to_string(), + }); + } + Ok(()) +} + +impl CreatePolicy<NewSubject> for SubjectPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewSubject, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + check_subject(&data.subject_type, &data.subject_code) + } +} + +impl UpdatePolicy<Subject, PatchSubject> for SubjectPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Subject, + patch: &PatchSubject, + _params: (), + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + check_subject(&patch.subject_type, &patch.subject_code) + } +} + +impl DeletePolicy<Subject> for SubjectPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Subject) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +impl MovePolicy<Subject> for SubjectPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &Subject) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} + +#[test] +fn test_check_subject() { + // Valid codes for specific schemas + assert!(check_subject(&SubjectType::Bic, "HRQX9").is_ok()); + assert!(check_subject(&SubjectType::Bisac, "BIB004060").is_ok()); + assert!(check_subject(&SubjectType::Thema, "ATXZ1").is_ok()); + + // Custom fields: no validity restrictions + assert!(check_subject(&SubjectType::Custom, "A custom subject").is_ok()); + assert!(check_subject(&SubjectType::Keyword, "keyword").is_ok()); + + // Invalid codes for specific schemas: only validate Thema + assert!(check_subject(&SubjectType::Bic, "ABCD0").is_ok()); + assert!(check_subject(&SubjectType::Bisac, "BLA123456").is_ok()); + assert!(check_subject(&SubjectType::Thema, "AHBW").is_err()); +} diff --git a/thoth-api/src/model/title/crud.rs b/thoth-api/src/model/title/crud.rs index 50ab57bad..cab1849ec 100644 --- a/thoth-api/src/model/title/crud.rs +++ b/thoth-api/src/model/title/crud.rs @@ -2,8 +2,8 @@ use super::{ LocaleCode, NewTitle, NewTitleHistory, PatchTitle, Title, TitleField, TitleHistory, TitleOrderBy, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{title_history, work_title}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -144,21 +144,21 @@ impl Crud for Title { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let work = crate::model::work::Work::from_id(db, &self.work_id)?; - <crate::model::work::Work as Crud>::publisher_id(&work, db) - } - crud_methods!(work_title::table, work_title::dsl::work_title); } +publisher_id_impls!(Title, NewTitle, PatchTitle, |s, db| { + let work = crate::model::work::Work::from_id(db, &s.work_id)?; + <crate::model::work::Work as PublisherId>::publisher_id(&work, db) +}); + impl HistoryEntry for Title { type NewHistoryEntity = NewTitleHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { title_id: self.title_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } diff --git a/thoth-api/src/model/title/mod.rs b/thoth-api/src/model/title/mod.rs index 7b0d6eaa4..66c87e0e1 100644 --- a/thoth-api/src/model/title/mod.rs +++ b/thoth-api/src/model/title/mod.rs @@ -1,8 +1,10 @@ +use crate::markup::{convert_to_jats, ConversionLimit, MarkupFormat}; use crate::model::locale::LocaleCode; use serde::{Deserialize, Serialize}; +use thoth_errors::ThothResult; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; #[cfg(feature = "backend")] use crate::schema::title_history; @@ -95,7 +97,7 @@ pub struct PatchTitle { )] pub struct NewTitleHistory { pub title_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -103,7 +105,7 @@ pub struct NewTitleHistory { pub struct TitleHistory { pub title_history_id: Uuid, pub title_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: chrono::DateTime<chrono::Utc>, } @@ -111,6 +113,7 @@ pub struct TitleHistory { pub trait TitleProperties { fn title(&self) -> &str; fn subtitle(&self) -> Option<&str>; + fn full_title(&self) -> &str; fn locale_code(&self) -> &LocaleCode; fn canonical(&self) -> bool; fn compile_fulltitle(&self) -> String { @@ -135,6 +138,9 @@ pub trait TitleProperties { }, ) } + fn set_title(&mut self, value: String); + fn set_subtitle(&mut self, value: Option<String>); + fn set_full_title(&mut self, value: String); } macro_rules! title_properties { @@ -146,12 +152,24 @@ macro_rules! title_properties { fn subtitle(&self) -> Option<&str> { self.subtitle.as_deref() } + fn full_title(&self) -> &str { + &self.full_title + } fn locale_code(&self) -> &LocaleCode { &self.locale_code } fn canonical(&self) -> bool { self.canonical } + fn set_title(&mut self, value: String) { + self.title = value; + } + fn set_subtitle(&mut self, value: Option<String>) { + self.subtitle = value; + } + fn set_full_title(&mut self, value: String) { + self.full_title = value; + } } }; } @@ -160,5 +178,25 @@ title_properties!(Title); title_properties!(NewTitle); title_properties!(PatchTitle); +pub(crate) fn convert_title_to_jats<T>(data: &mut T, format: MarkupFormat) -> ThothResult<()> +where + T: TitleProperties, +{ + let title = convert_to_jats(data.title().to_owned(), format, ConversionLimit::Title)?; + let subtitle = data + .subtitle() + .map(|s| convert_to_jats(s.to_owned(), format, ConversionLimit::Title)) + .transpose()?; + let full_title = convert_to_jats(data.full_title().to_owned(), format, ConversionLimit::Title)?; + + data.set_title(title); + data.set_subtitle(subtitle); + data.set_full_title(full_title); + Ok(()) +} + #[cfg(feature = "backend")] pub mod crud; +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::TitlePolicy; diff --git a/thoth-api/src/model/title/policy.rs b/thoth-api/src/model/title/policy.rs new file mode 100644 index 000000000..46ee42867 --- /dev/null +++ b/thoth-api/src/model/title/policy.rs @@ -0,0 +1,70 @@ +use crate::markup::MarkupFormat; +use crate::model::title::{NewTitle, PatchTitle, Title}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; +use crate::schema::work_title; + +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use thoth_errors::{ThothError, ThothResult}; +use uuid::Uuid; + +/// Write policies for `Title`. +/// +/// For now this policy enforces the tenant boundary only: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct TitlePolicy; + +fn has_canonical_title(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult<bool> { + let mut connection = db.get()?; + let query = work_title::table + .filter(work_title::work_id.eq(work_id)) + .filter(work_title::canonical.eq(true)); + + let result: bool = select(exists(query)).get_result(&mut connection)?; + Ok(result) +} + +impl CreatePolicy<NewTitle, Option<MarkupFormat>> for TitlePolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewTitle, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + + // Title creation requires a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + // Canonical titles: only one canonical title is allowed per work. + if data.canonical && has_canonical_title(ctx.db(), &data.work_id)? { + return Err(ThothError::CanonicalTitleExistsError); + } + + Ok(()) + } +} + +impl UpdatePolicy<Title, PatchTitle, Option<MarkupFormat>> for TitlePolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Title, + patch: &PatchTitle, + markup: Option<MarkupFormat>, + ) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + + // Title updates require a markup format. + markup.ok_or(ThothError::MissingMarkupFormat)?; + + Ok(()) + } +} + +impl DeletePolicy<Title> for TitlePolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Title) -> ThothResult<()> { + ctx.require_publisher_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index d4c618edf..2d8b71d4e 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -2,10 +2,10 @@ use super::{ NewWork, NewWorkHistory, PatchWork, Work, WorkField, WorkHistory, WorkOrderBy, WorkStatus, WorkType, }; -use crate::graphql::model::TimeExpression; -use crate::graphql::utils::{Direction, Expression}; +use crate::graphql::inputs::TimeExpression; +use crate::graphql::inputs::{Direction, Expression}; use crate::model::work_relation::{RelationType, WorkRelation, WorkRelationOrderBy}; -use crate::model::{Crud, DbInsert, Doi, HistoryEntry}; +use crate::model::{Crud, DbInsert, Doi, HistoryEntry, PublisherId}; use crate::schema::{work, work_abstract, work_history, work_title}; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, PgTextExpressionMethods, QueryDsl, @@ -447,21 +447,21 @@ impl Crud for Work { .map_err(Into::into) } - fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult<Uuid> { - let imprint = crate::model::imprint::Imprint::from_id(db, &self.imprint_id)?; - <crate::model::imprint::Imprint as Crud>::publisher_id(&imprint, db) - } - crud_methods!(work::table, work::dsl::work); } +publisher_id_impls!(Work, NewWork, PatchWork, |s, db| { + let imprint = crate::model::imprint::Imprint::from_id(db, &s.imprint_id)?; + <crate::model::imprint::Imprint as PublisherId>::publisher_id(&imprint, db) +}); + impl HistoryEntry for Work { type NewHistoryEntity = NewWorkHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { work_id: self.work_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -486,10 +486,10 @@ mod tests { #[test] fn test_new_work_history_from_work() { let work: Work = Default::default(); - let account_id: Uuid = Default::default(); - let new_work_history = work.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_work_history = work.new_history_entry(&user_id); assert_eq!(new_work_history.work_id, work.work_id); - assert_eq!(new_work_history.account_id, account_id); + assert_eq!(new_work_history.user_id, user_id); assert_eq!( new_work_history.data, serde_json::Value::String(serde_json::to_string(&work).unwrap()) diff --git a/thoth-api/src/model/work/mod.rs b/thoth-api/src/model/work/mod.rs index 9c450055c..046aa48ef 100644 --- a/thoth-api/src/model/work/mod.rs +++ b/thoth-api/src/model/work/mod.rs @@ -1,4 +1,4 @@ -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Doi; use crate::model::Timestamp; #[cfg(feature = "backend")] @@ -295,7 +295,7 @@ pub struct PatchWork { pub struct WorkHistory { pub work_history_id: Uuid, pub work_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -303,7 +303,7 @@ pub struct WorkHistory { #[cfg_attr(feature = "backend", derive(Insertable), diesel(table_name = work_history))] pub struct NewWorkHistory { pub work_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -893,3 +893,7 @@ mod tests { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::WorkPolicy; diff --git a/thoth-api/src/model/work/policy.rs b/thoth-api/src/model/work/policy.rs new file mode 100644 index 000000000..d5a1ae936 --- /dev/null +++ b/thoth-api/src/model/work/policy.rs @@ -0,0 +1,51 @@ +use crate::model::work::{NewWork, PatchWork, Work, WorkProperties, WorkType}; +use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; +use thoth_errors::{ThothError, ThothResult}; + +/// Write policies for `Work`. +/// +/// This policy layer enforces: +/// - authentication +/// - publisher membership derived from the entity / input via `PublisherId` +pub struct WorkPolicy; + +impl CreatePolicy<NewWork> for WorkPolicy { + fn can_create<C: PolicyContext>(ctx: &C, data: &NewWork, _params: ()) -> ThothResult<()> { + ctx.require_publisher_for(data)?; + data.validate() + } +} + +impl UpdatePolicy<Work, PatchWork> for WorkPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Work, + patch: &PatchWork, + _params: (), + ) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + ctx.require_publisher_for(patch)?; + current.can_update_imprint(ctx.db())?; + + if patch.work_type == WorkType::BookChapter { + current.can_be_chapter(ctx.db())?; + } + + patch.validate()?; + + if current.is_published() && !patch.is_published() && !user.is_superuser() { + return Err(ThothError::ThothSetWorkStatusError); + } + Ok(()) + } +} + +impl DeletePolicy<Work> for WorkPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Work) -> ThothResult<()> { + let user = ctx.require_publisher_for(current)?; + if current.is_published() && !user.is_superuser() { + return Err(ThothError::ThothDeleteWorkError); + } + Ok(()) + } +} diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index b8d563a46..b57026ceb 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -2,8 +2,8 @@ use super::{ NewWorkRelation, NewWorkRelationHistory, PatchWorkRelation, RelationType, WorkRelation, WorkRelationField, WorkRelationHistory, WorkRelationOrderBy, }; -use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +use crate::graphql::inputs::Direction; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId, Reorder}; use crate::schema::{work_relation, work_relation_history}; use diesel::{ dsl::max, sql_query, sql_types::Text, BoolExpressionMethods, Connection, ExpressionMethods, @@ -177,15 +177,14 @@ impl Crud for WorkRelation { }) } - fn update( + fn update<C: crate::policy::PolicyContext>( &self, - db: &crate::db::PgPool, + ctx: &C, data: &PatchWorkRelation, - account_id: &Uuid, ) -> ThothResult<Self> { // For each Relator - Relationship - Related record we update, we must also // update the corresponding Related - InverseRelationship - Relator record. - let inverse_work_relation = self.get_inverse(db)?; + let inverse_work_relation = self.get_inverse(ctx.db())?; let inverse_data = PatchWorkRelation { work_relation_id: inverse_work_relation.work_relation_id, relator_work_id: data.related_work_id, @@ -195,7 +194,7 @@ impl Crud for WorkRelation { }; // Execute both updates within the same transaction, // because if one fails, both need to be reverted. - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection.transaction(|connection| { diesel::update(work_relation::table.find(inverse_work_relation.work_relation_id)) .set(inverse_data) @@ -207,7 +206,7 @@ impl Crud for WorkRelation { .and_then(|t| { // On success, create a new history table entry. // Only record the original update, not the automatic inverse update. - self.new_history_entry(account_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| t) }) @@ -230,21 +229,24 @@ impl Crud for WorkRelation { .map_err(Into::into) }) } - - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Work Relation objects".to_string(), - )) - } } +publisher_ids_impls!(WorkRelation, NewWorkRelation, PatchWorkRelation, |s, db| { + let a = crate::model::work::Work::from_id(db, &s.relator_work_id)?.publisher_id(db)?; + let b = crate::model::work::Work::from_id(db, &s.related_work_id)?.publisher_id(db)?; + let mut v = vec![a, b]; + v.sort(); + v.dedup(); + Ok(v) +}); + impl HistoryEntry for WorkRelation { type NewHistoryEntity = NewWorkRelationHistory; - fn new_history_entry(&self, account_id: &Uuid) -> Self::NewHistoryEntity { + fn new_history_entry(&self, user_id: &str) -> Self::NewHistoryEntity { Self::NewHistoryEntity { work_relation_id: self.work_relation_id, - account_id: *account_id, + user_id: user_id.to_string(), data: serde_json::Value::String(serde_json::to_string(&self).unwrap()), } } @@ -323,13 +325,13 @@ mod tests { #[test] fn test_new_work_relation_history_from_work_relation() { let work_relation: WorkRelation = Default::default(); - let account_id: Uuid = Default::default(); - let new_work_relation_history = work_relation.new_history_entry(&account_id); + let user_id = "123456".to_string(); + let new_work_relation_history = work_relation.new_history_entry(&user_id); assert_eq!( new_work_relation_history.work_relation_id, work_relation.work_relation_id ); - assert_eq!(new_work_relation_history.account_id, account_id); + assert_eq!(new_work_relation_history.user_id, user_id); assert_eq!( new_work_relation_history.data, serde_json::Value::String(serde_json::to_string(&work_relation).unwrap()) diff --git a/thoth-api/src/model/work_relation/mod.rs b/thoth-api/src/model/work_relation/mod.rs index 9cb22a72d..e3b7cde37 100644 --- a/thoth-api/src/model/work_relation/mod.rs +++ b/thoth-api/src/model/work_relation/mod.rs @@ -3,7 +3,7 @@ use strum::Display; use strum::EnumString; use uuid::Uuid; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::Timestamp; #[cfg(feature = "backend")] use crate::schema::work_relation; @@ -150,7 +150,7 @@ pub struct PatchWorkRelation { pub struct WorkRelationHistory { pub work_relation_history_id: Uuid, pub work_relation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, pub timestamp: Timestamp, } @@ -162,7 +162,7 @@ pub struct WorkRelationHistory { )] pub struct NewWorkRelationHistory { pub work_relation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } @@ -265,3 +265,7 @@ fn test_relationtype_fromstr() { #[cfg(feature = "backend")] pub mod crud; +#[cfg(feature = "backend")] +mod policy; +#[cfg(feature = "backend")] +pub(crate) use policy::WorkRelationPolicy; diff --git a/thoth-api/src/model/work_relation/policy.rs b/thoth-api/src/model/work_relation/policy.rs new file mode 100644 index 000000000..22634043c --- /dev/null +++ b/thoth-api/src/model/work_relation/policy.rs @@ -0,0 +1,50 @@ +use crate::model::work_relation::{NewWorkRelation, PatchWorkRelation, WorkRelation}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; +use thoth_errors::ThothResult; + +/// Write policies for `WorkRelation`. +/// +/// `WorkRelation` spans two works and therefore potentially two publisher scopes. +/// This policy enforces: +/// - authentication +/// - membership for *all* publishers involved (via `PublisherIds`) +pub struct WorkRelationPolicy; + +impl CreatePolicy<NewWorkRelation> for WorkRelationPolicy { + fn can_create<C: PolicyContext>( + ctx: &C, + data: &NewWorkRelation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publishers_for(data)?; + Ok(()) + } +} + +impl UpdatePolicy<WorkRelation, PatchWorkRelation> for WorkRelationPolicy { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &WorkRelation, + patch: &PatchWorkRelation, + _params: (), + ) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + ctx.require_publishers_for(patch)?; + + Ok(()) + } +} + +impl DeletePolicy<WorkRelation> for WorkRelationPolicy { + fn can_delete<C: PolicyContext>(ctx: &C, current: &WorkRelation) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} + +impl MovePolicy<WorkRelation> for WorkRelationPolicy { + fn can_move<C: PolicyContext>(ctx: &C, current: &WorkRelation) -> ThothResult<()> { + ctx.require_publishers_for(current)?; + Ok(()) + } +} diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs new file mode 100644 index 000000000..ba58b238e --- /dev/null +++ b/thoth-api/src/policy.rs @@ -0,0 +1,143 @@ +use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; + +use crate::db::PgPool; +use crate::model::{Crud, PublisherId, PublisherIds}; +use thoth_errors::{ThothError, ThothResult}; + +pub(crate) trait UserAccess { + fn is_superuser(&self) -> bool; + fn can_edit(&self, publisher_id: &Uuid) -> ThothResult<()>; +} + +impl UserAccess for IntrospectedUser { + fn is_superuser(&self) -> bool { + self.project_roles + .as_ref() + .is_some_and(|roles| roles.contains_key("SUPERUSER")) + } + + /// Determines whether the user has edit permissions for the given `publisher_id`. + /// + /// A user is authorized to edit a publisher if: + /// - They have the `SUPERUSER` role (see [`is_superuser`]) — or + /// - Their `metadata` includes a `publishers` key containing a + /// comma-separated list of UUIDs they are associated with. + /// + /// ### Expected Metadata Format + /// + /// ```json + /// { + /// "publishers": "85fd969a-a16c-480b-b641-cb9adf979c3b, 12345678-9abc-def0-1234-56789abcdef0" + /// } + /// ``` + /// + /// The value **must** be a single string of UUIDs, separated by commas, + /// with optional whitespace. + /// + /// If the `publishers` key is missing, or does not contain the provided `publisher_id`, + /// the user is considered unauthorised. + /// + /// # Errors + /// + /// Returns [`ThothError::Unauthorised`] if the user is not a superuser and + /// does not have access to the given publisher. + fn can_edit(&self, publisher_id: &Uuid) -> ThothResult<()> { + if self.is_superuser() { + return Ok(()); + } + + self.metadata + .as_ref() + .and_then(|meta| meta.get("publishers")) + .map(|val| val.as_str()) + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter_map(|s| Uuid::parse_str(s).ok()) + .any(|id| id == *publisher_id) + }) + .filter(|&matches| matches) + .map(|_| ()) + .ok_or(ThothError::Unauthorised) + } +} + +pub(crate) trait PolicyContext { + fn db(&self) -> &PgPool; + fn user(&self) -> Option<&IntrospectedUser>; + + fn require_authentication(&self) -> ThothResult<&IntrospectedUser> { + self.user().ok_or(ThothError::Unauthorised) + } + + fn user_id(&self) -> ThothResult<&str> { + self.user() + .map(|u| u.user_id.as_str()) + .ok_or(ThothError::Unauthorised) + } + + fn require_superuser(&self) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + if user.is_superuser() { + Ok(user) + } else { + Err(ThothError::Unauthorised) + } + } + + /// Authorise the current user against the publisher derived from the given value. + fn require_publisher_for<T: PublisherId>(&self, value: &T) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + let publisher_id = value.publisher_id(self.db())?; + user.can_edit(&publisher_id)?; + Ok(user) + } + + /// Authorise the current user against all publishers derived from the given value. + /// + /// This is intended for entities that span more than one publisher scope, e.g. `WorkRelation`. + fn require_publishers_for<T: PublisherIds>(&self, value: &T) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + for publisher_id in value.publisher_ids(self.db())? { + user.can_edit(&publisher_id)?; + } + Ok(user) + } + + /// Load an entity by primary key after requiring authentication. + fn load_current<T: Crud>(&self, id: &Uuid) -> ThothResult<T> { + self.require_authentication()?; + T::from_id(self.db(), id) + } +} + +/// A policy for create actions. +/// +/// Some create operations require additional parameters beyond the `New*` input (e.g. markup +/// format). Use the `Params` type parameter for those cases. +pub(crate) trait CreatePolicy<New, Params = ()> { + fn can_create<C: PolicyContext>(ctx: &C, data: &New, params: Params) -> ThothResult<()>; +} + +/// A policy for update actions. +/// +/// Some update operations require additional parameters beyond the `Patch*` input. +pub(crate) trait UpdatePolicy<Model, Patch, Params = ()> { + fn can_update<C: PolicyContext>( + ctx: &C, + current: &Model, + patch: &Patch, + params: Params, + ) -> ThothResult<()>; +} + +/// A policy for delete actions. +pub(crate) trait DeletePolicy<Model> { + fn can_delete<C: PolicyContext>(ctx: &C, current: &Model) -> ThothResult<()>; +} + +/// A policy for move / reorder actions. +pub(crate) trait MovePolicy<Model> { + fn can_move<C: PolicyContext>(ctx: &C, current: &Model) -> ThothResult<()>; +} diff --git a/thoth-api/src/schema.rs b/thoth-api/src/schema.rs index 71fc7e01f..a7ebe4e33 100644 --- a/thoth-api/src/schema.rs +++ b/thoth-api/src/schema.rs @@ -72,25 +72,6 @@ pub mod sql_types { pub struct AccessibilityException; } -table! { - use diesel::sql_types::*; - - account (account_id) { - account_id -> Uuid, - name -> Text, - surname -> Text, - email -> Text, - hash -> Bytea, - salt -> Text, - is_superuser -> Bool, - is_bot -> Bool, - is_active -> Bool, - created_at -> Timestamptz, - updated_at -> Timestamptz, - token -> Nullable<Text>, - } -} - table! { use diesel::sql_types::*; use super::sql_types::{LocaleCode, MarkupFormat, AbstractType}; @@ -139,7 +120,7 @@ table! { affiliation_history (affiliation_history_id) { affiliation_history_id -> Uuid, affiliation_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -165,7 +146,7 @@ table! { contact_history (contact_history_id) { contact_history_id -> Uuid, contact_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -196,7 +177,7 @@ table! { contribution_history (contribution_history_id) { contribution_history_id -> Uuid, contribution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -223,7 +204,7 @@ table! { contributor_history (contributor_history_id) { contributor_history_id -> Uuid, contributor_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -252,7 +233,7 @@ table! { funding_history (funding_history_id) { funding_history_id -> Uuid, funding_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -278,7 +259,7 @@ table! { imprint_history (imprint_history_id) { imprint_history_id -> Uuid, imprint_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -305,7 +286,7 @@ table! { institution_history (institution_history_id) { institution_history_id -> Uuid, institution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -330,7 +311,7 @@ table! { issue_history (issue_history_id) { issue_history_id -> Uuid, issue_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -358,7 +339,7 @@ table! { language_history (language_history_id) { language_history_id -> Uuid, language_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -386,7 +367,7 @@ table! { location_history (location_history_id) { location_history_id -> Uuid, location_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -412,7 +393,7 @@ table! { price_history (price_history_id) { price_history_id -> Uuid, price_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -452,7 +433,7 @@ table! { publication_history (publication_history_id) { publication_history_id -> Uuid, publication_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -473,25 +454,13 @@ table! { } } -table! { - use diesel::sql_types::*; - - publisher_account (account_id, publisher_id) { - account_id -> Uuid, - publisher_id -> Uuid, - is_admin -> Bool, - created_at -> Timestamptz, - updated_at -> Timestamptz, - } -} - table! { use diesel::sql_types::*; publisher_history (publisher_history_id) { publisher_history_id -> Uuid, publisher_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -535,7 +504,7 @@ table! { reference_history (reference_history_id) { reference_history_id -> Uuid, reference_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -566,7 +535,7 @@ table! { series_history (series_history_id) { series_history_id -> Uuid, series_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -593,7 +562,7 @@ table! { subject_history (subject_history_id) { subject_history_id -> Uuid, subject_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -646,7 +615,7 @@ table! { work_history (work_history_id) { work_history_id -> Uuid, work_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -673,7 +642,7 @@ table! { work_relation_history (work_relation_history_id) { work_relation_history_id -> Uuid, work_relation_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -702,7 +671,7 @@ table! { title_history (title_history_id) { title_history_id -> Uuid, title_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -714,7 +683,7 @@ table! { abstract_history (abstract_history_id) { abstract_history_id -> Uuid, abstract_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -726,81 +695,57 @@ table! { biography_history (biography_history_id) { biography_history_id -> Uuid, biography_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } } joinable!(abstract_history -> work_abstract (abstract_id)); -joinable!(abstract_history -> account (account_id)); joinable!(affiliation -> contribution (contribution_id)); joinable!(affiliation -> institution (institution_id)); -joinable!(affiliation_history -> account (account_id)); joinable!(affiliation_history -> affiliation (affiliation_id)); joinable!(biography_history -> biography (biography_id)); -joinable!(biography_history -> account (account_id)); joinable!(contact -> publisher (publisher_id)); -joinable!(contact_history -> account (account_id)); joinable!(contact_history -> contact (contact_id)); joinable!(contribution -> contributor (contributor_id)); joinable!(contribution -> work (work_id)); -joinable!(contribution_history -> account (account_id)); joinable!(contribution_history -> contribution (contribution_id)); -joinable!(contributor_history -> account (account_id)); joinable!(contributor_history -> contributor (contributor_id)); joinable!(funding -> institution (institution_id)); joinable!(funding -> work (work_id)); -joinable!(funding_history -> account (account_id)); joinable!(funding_history -> funding (funding_id)); joinable!(imprint -> publisher (publisher_id)); -joinable!(imprint_history -> account (account_id)); joinable!(imprint_history -> imprint (imprint_id)); -joinable!(institution_history -> account (account_id)); joinable!(institution_history -> institution (institution_id)); joinable!(issue -> series (series_id)); joinable!(issue -> work (work_id)); -joinable!(issue_history -> account (account_id)); joinable!(issue_history -> issue (issue_id)); joinable!(language -> work (work_id)); -joinable!(language_history -> account (account_id)); joinable!(language_history -> language (language_id)); joinable!(location -> publication (publication_id)); -joinable!(location_history -> account (account_id)); joinable!(location_history -> location (location_id)); joinable!(price -> publication (publication_id)); -joinable!(price_history -> account (account_id)); joinable!(price_history -> price (price_id)); joinable!(publication -> work (work_id)); -joinable!(publication_history -> account (account_id)); joinable!(publication_history -> publication (publication_id)); -joinable!(publisher_account -> account (account_id)); -joinable!(publisher_account -> publisher (publisher_id)); -joinable!(publisher_history -> account (account_id)); joinable!(publisher_history -> publisher (publisher_id)); joinable!(reference -> work (work_id)); -joinable!(reference_history -> account (account_id)); joinable!(reference_history -> reference (reference_id)); joinable!(series -> imprint (imprint_id)); -joinable!(series_history -> account (account_id)); joinable!(series_history -> series (series_id)); joinable!(subject -> work (work_id)); -joinable!(subject_history -> account (account_id)); joinable!(subject_history -> subject (subject_id)); joinable!(title_history -> work_title (title_id)); -joinable!(title_history -> account (account_id)); joinable!(work -> imprint (imprint_id)); joinable!(work_abstract -> work (work_id)); -joinable!(work_history -> account (account_id)); joinable!(work_history -> work (work_id)); joinable!(work_relation -> work (relator_work_id)); -joinable!(work_relation_history -> account (account_id)); joinable!(work_relation_history -> work_relation (work_relation_id)); joinable!(work_title -> work (work_id)); allow_tables_to_appear_in_same_query!( abstract_history, - account, affiliation, affiliation_history, biography, @@ -828,7 +773,6 @@ allow_tables_to_appear_in_same_query!( publication, publication_history, publisher, - publisher_account, publisher_history, reference, reference_history, diff --git a/thoth-app/src/models/work/create_work_mutation.rs b/thoth-app/src/models/work/create_work_mutation.rs new file mode 100644 index 000000000..cedc16da2 --- /dev/null +++ b/thoth-app/src/models/work/create_work_mutation.rs @@ -0,0 +1,117 @@ +use chrono::NaiveDate; +use serde::Deserialize; +use serde::Serialize; +use thoth_api::model::work::Work; +use thoth_api::model::work::WorkStatus; +use thoth_api::model::work::WorkType; +use thoth_api::model::Doi; +use uuid::Uuid; + +const CREATE_WORK_MUTATION: &str = " + mutation CreateWork( + $workType: WorkType!, + $workStatus: WorkStatus!, + ) { + createWork( + data: { + workType: $workType + workStatus: $workStatus + fullTitle: $fullTitle + title: $title + subtitle: $subtitle + reference: $reference + edition: $edition + imprintId: $imprintId + doi: $doi + publicationDate: $publicationDate + withdrawnDate: $withdrawnDate + place: $place + pageCount: $pageCount + pageBreakdown: $pageBreakdown + imageCount: $imageCount + tableCount: $tableCount + audioCount: $audioCount + videoCount: $videoCount + license: $license + copyrightHolder: $copyrightHolder + landingPage: $landingPage + lccn: $lccn + oclc: $oclc + shortAbstract: $shortAbstract + longAbstract: $longAbstract + generalNote: $generalNote + bibliographyNote: $bibliographyNote + toc: $toc + coverUrl: $coverUrl + coverCaption: $coverCaption + firstPage: $firstPage + lastPage: $lastPage + pageInterval: $pageInterval + }){ + workId + workType + workStatus + fullTitle + title + imprintId + createdAt + updatedAt + updatedAtWithRelations + } + } +"; + +graphql_query_builder! { + CreateWorkRequest, + CreateWorkRequestBody, + Variables, + CREATE_WORK_MUTATION, + CreateWorkResponseBody, + CreateWorkResponseData, + PushCreateWork, + PushActionCreateWork +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Variables { + pub work_type: WorkType, + pub work_status: WorkStatus, + pub full_title: String, + pub title: String, + pub subtitle: Option<String>, + pub reference: Option<String>, + pub edition: Option<i32>, + pub doi: Option<Doi>, + pub publication_date: Option<NaiveDate>, + pub withdrawn_date: Option<NaiveDate>, + pub place: Option<String>, + pub page_count: Option<i32>, + pub page_breakdown: Option<String>, + pub image_count: Option<i32>, + pub table_count: Option<i32>, + pub audio_count: Option<i32>, + pub video_count: Option<i32>, + pub license: Option<String>, + pub copyright_holder: Option<String>, + pub landing_page: Option<String>, + pub lccn: Option<String>, + pub oclc: Option<String>, + pub short_abstract: Option<String>, + pub long_abstract: Option<String>, + pub general_note: Option<String>, + pub bibliography_note: Option<String>, + pub toc: Option<String>, + pub cover_url: Option<String>, + pub cover_caption: Option<String>, + pub imprint_id: Uuid, + pub first_page: Option<String>, + pub last_page: Option<String>, + pub page_interval: Option<String>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateWorkResponseData { + pub create_work: Option<Work>, +} diff --git a/thoth-errors/Cargo.toml b/thoth-errors/Cargo.toml index dd55eb90b..5e0c4edc4 100644 --- a/thoth-errors/Cargo.toml +++ b/thoth-errors/Cargo.toml @@ -23,5 +23,6 @@ reqwest-middleware = "0.4" serde = "1.0" serde_json = "1.0" thiserror = "2.0" +tonic = "0.12.1" uuid = { package = "uuid", version = "1.16.0", features = ["serde", "v4"] } xml-rs = "0.8.25" diff --git a/thoth-errors/src/lib.rs b/thoth-errors/src/lib.rs index 74222c450..ff9935864 100644 --- a/thoth-errors/src/lib.rs +++ b/thoth-errors/src/lib.rs @@ -273,6 +273,18 @@ impl From<Box<dyn std::error::Error + Send + Sync>> for ThothError { } } +impl From<Box<dyn std::error::Error>> for ThothError { + fn from(e: Box<dyn std::error::Error>) -> Self { + ThothError::InternalError(e.to_string()) + } +} + +impl From<tonic::Status> for ThothError { + fn from(e: tonic::Status) -> Self { + ThothError::InternalError(e.to_string()) + } +} + impl From<serde_json::Error> for ThothError { fn from(e: serde_json::Error) -> Self { ThothError::InternalError(e.to_string()) diff --git a/thoth-export-server/src/bibtex/bibtex_thoth.rs b/thoth-export-server/src/bibtex/bibtex_thoth.rs index 3ac40f9da..21376bf5f 100644 --- a/thoth-export-server/src/bibtex/bibtex_thoth.rs +++ b/thoth-export-server/src/bibtex/bibtex_thoth.rs @@ -1,7 +1,7 @@ use std::convert::TryFrom; use std::fmt; use std::io::Write; -use thoth_api::ast::{ast_to_plain_text, jats_to_ast}; +use thoth_api::markup::ast::{ast_to_plain_text, jats_to_ast}; use thoth_client::{ AbstractType, ContributionType, PublicationType, RelationType, Work, WorkContributions, WorkType, diff --git a/thoth-export-server/src/csv/csv_thoth.rs b/thoth-export-server/src/csv/csv_thoth.rs index 5095b914b..886c9d07a 100644 --- a/thoth-export-server/src/csv/csv_thoth.rs +++ b/thoth-export-server/src/csv/csv_thoth.rs @@ -1,7 +1,7 @@ use csv::Writer; use serde::Serialize; use std::io::Write; -use thoth_api::ast::{ast_to_plain_text, jats_to_ast}; +use thoth_api::markup::ast::{ast_to_plain_text, jats_to_ast}; use thoth_client::{ AbstractType, SubjectType, Work, WorkContributions, WorkContributionsAffiliations, WorkFundings, WorkIssues, WorkLanguages, WorkPublications, WorkPublicationsLocations, diff --git a/thoth-export-server/src/marc21/marc21record_thoth.rs b/thoth-export-server/src/marc21/marc21record_thoth.rs index 559c0ad21..3c4e2f572 100644 --- a/thoth-export-server/src/marc21/marc21record_thoth.rs +++ b/thoth-export-server/src/marc21/marc21record_thoth.rs @@ -2,7 +2,7 @@ use crate::marc21::{Marc21Field, MARC_ORGANIZATION_CODE}; use cc_license::License; use chrono::{Datelike, Utc}; use marc::{DescriptiveCatalogingForm, EncodingLevel, FieldRepr, Record, RecordBuilder}; -use thoth_api::ast::{ast_to_plain_text, jats_to_ast}; +use thoth_api::markup::ast::{ast_to_plain_text, jats_to_ast}; use thoth_api::model::contribution::ContributionType; use thoth_api::model::publication::PublicationType; use thoth_api::model::IdentifierWithDomain;