From 73331c75929099e1eeb5a8b8e5ac9ac261a737bf Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Tue, 3 Jun 2025 13:50:56 +0100 Subject: [PATCH 01/21] Implement zitadel in graphql --- .env.example | 4 - .gitignore | 1 + Cargo.lock | 1357 ++++++++++++++--- Cargo.toml | 1 + Makefile | 8 + docker-compose.dev.yml | 22 + src/bin/arguments/mod.rs | 52 +- src/bin/commands/account.rs | 175 --- src/bin/commands/mod.rs | 16 +- src/bin/commands/start.rs | 12 +- src/bin/commands/zitadel.rs | 132 ++ src/bin/thoth.rs | 14 +- thoth-api-server/Cargo.toml | 6 +- thoth-api-server/src/lib.rs | 134 +- thoth-api/Cargo.toml | 4 +- thoth-api/migrations/v0.14.0/down.sql | 86 ++ thoth-api/migrations/v0.14.0/up.sql | 59 + thoth-api/src/account/handler.rs | 216 --- thoth-api/src/account/mod.rs | 6 - thoth-api/src/account/model.rs | 40 - thoth-api/src/account/service.rs | 126 -- thoth-api/src/account/util.rs | 30 - thoth-api/src/graphql/model.rs | 942 ++++++------ thoth-api/src/model/affiliation/crud.rs | 10 +- thoth-api/src/model/affiliation/mod.rs | 4 +- thoth-api/src/model/contribution/crud.rs | 10 +- thoth-api/src/model/contribution/mod.rs | 4 +- thoth-api/src/model/contributor/crud.rs | 10 +- thoth-api/src/model/contributor/mod.rs | 4 +- thoth-api/src/model/funding/crud.rs | 10 +- thoth-api/src/model/funding/mod.rs | 4 +- thoth-api/src/model/imprint/crud.rs | 10 +- thoth-api/src/model/imprint/mod.rs | 4 +- thoth-api/src/model/institution/crud.rs | 10 +- thoth-api/src/model/institution/mod.rs | 4 +- thoth-api/src/model/issue/crud.rs | 10 +- thoth-api/src/model/issue/mod.rs | 4 +- thoth-api/src/model/language/crud.rs | 10 +- thoth-api/src/model/language/mod.rs | 4 +- thoth-api/src/model/location/crud.rs | 14 +- thoth-api/src/model/location/mod.rs | 4 +- thoth-api/src/model/mod.rs | 8 +- thoth-api/src/model/price/crud.rs | 10 +- thoth-api/src/model/price/mod.rs | 4 +- thoth-api/src/model/publication/crud.rs | 10 +- thoth-api/src/model/publication/mod.rs | 4 +- thoth-api/src/model/publisher/crud.rs | 10 +- thoth-api/src/model/publisher/mod.rs | 4 +- thoth-api/src/model/reference/crud.rs | 10 +- thoth-api/src/model/reference/mod.rs | 4 +- thoth-api/src/model/series/crud.rs | 10 +- thoth-api/src/model/series/mod.rs | 4 +- thoth-api/src/model/subject/crud.rs | 10 +- thoth-api/src/model/subject/mod.rs | 4 +- thoth-api/src/model/work/crud.rs | 10 +- thoth-api/src/model/work/mod.rs | 4 +- thoth-api/src/model/work_relation/crud.rs | 14 +- thoth-api/src/model/work_relation/mod.rs | 4 +- thoth-api/src/schema.rs | 86 +- .../src/models/work/create_work_mutation.rs | 31 - thoth-errors/Cargo.toml | 1 + thoth-errors/src/lib.rs | 14 + 62 files changed, 2120 insertions(+), 1709 deletions(-) delete mode 100644 src/bin/commands/account.rs create mode 100644 src/bin/commands/zitadel.rs create mode 100644 thoth-api/migrations/v0.14.0/down.sql create mode 100644 thoth-api/migrations/v0.14.0/up.sql delete mode 100644 thoth-api/src/account/handler.rs delete mode 100644 thoth-api/src/account/service.rs delete mode 100644 thoth-api/src/account/util.rs 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/Cargo.lock b/Cargo.lock index ce6d8f19e..15adaf174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more 2.0.1", + "derive_more", "futures-util", "log", "once_cell", @@ -49,7 +49,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more 2.0.1", + "derive_more", "encoding_rs", "flate2", "foldhash", @@ -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" @@ -197,7 +164,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more 2.0.1", + "derive_more", "encoding_rs", "foldhash", "futures-core", @@ -248,41 +215,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[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 = "aho-corasick" version = "1.1.3" @@ -421,6 +353,28 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[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.100", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -456,6 +410,53 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[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.3.1", + "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.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -471,11 +472,23 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -483,6 +496,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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + [[package]] name = "bincode" version = "1.3.3" @@ -624,16 +652,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.32" @@ -713,6 +731,12 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -725,14 +749,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", ] @@ -747,6 +764,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" @@ -771,6 +798,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -778,7 +817,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -804,14 +842,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.100", ] +[[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.20.10" @@ -878,21 +940,24 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.4.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "powerfmt", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "derive_more" -version = "1.0.0" +name = "deranged" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ - "derive_more-impl 1.0.0", + "powerfmt", + "serde", ] [[package]] @@ -901,19 +966,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ - "derive_more-impl 2.0.1", -] - -[[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.100", - "unicode-xid", + "derive_more-impl", ] [[package]] @@ -1032,6 +1085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1067,12 +1121,77 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1133,6 +1252,28 @@ 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 = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.0" @@ -1276,6 +1417,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1303,16 +1445,6 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] -[[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 = "gimli" version = "0.31.1" @@ -1560,6 +1692,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1629,8 +1772,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] -name = "hkdf" -version = "0.12.4" +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ @@ -1668,6 +1817,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" @@ -1687,7 +1847,7 @@ dependencies = [ "bytes", "futures-core", "http 1.3.1", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1703,6 +1863,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.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1714,8 +1898,9 @@ dependencies = [ "futures-util", "h2 0.4.8", "http 1.3.1", - "http-body", + "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1723,6 +1908,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.5" @@ -1731,12 +1930,25 @@ checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", "http 1.3.1", - "hyper", + "hyper 1.6.0", "hyper-util", - "rustls", + "rustls 0.23.25", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", + "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.6.0", + "hyper-util", + "pin-project-lite", + "tokio", "tower-service", ] @@ -1748,7 +1960,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -1766,8 +1978,8 @@ dependencies = [ "futures-channel", "futures-util", "http 1.3.1", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.6.0", "pin-project-lite", "socket2", "tokio", @@ -1957,6 +2169,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1970,15 +2183,6 @@ dependencies = [ "serde", ] -[[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" @@ -2023,6 +2227,15 @@ 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 = "itoa" version = "1.0.15" @@ -2131,6 +2344,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" @@ -2138,6 +2354,12 @@ version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + [[package]] name = "linux-raw-sys" version = "0.9.3" @@ -2192,6 +2414,12 @@ dependencies = [ "xml-rs", ] +[[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.4" @@ -2256,6 +2484,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -2268,7 +2502,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2289,6 +2523,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2304,6 +2555,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" @@ -2311,6 +2573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2323,6 +2586,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.15", + "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 = "object" version = "0.36.7" @@ -2338,12 +2621,6 @@ version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" -[[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" @@ -2355,6 +2632,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.72" @@ -2399,6 +2708,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" @@ -2406,7 +2748,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", @@ -2524,6 +2866,43 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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.5" @@ -2534,12 +2913,31 @@ dependencies = [ "serde", ] +[[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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.8.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -2582,6 +2980,26 @@ dependencies = [ "siphasher", ] +[[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.100", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2595,23 +3013,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.0" @@ -2652,6 +3079,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "prettyplease" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +dependencies = [ + "proc-macro2", + "syn 2.0.100", +] + +[[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" version = "1.0.4" @@ -2707,6 +3153,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.4.1", + "itertools 0.10.5", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.100", + "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.10.5", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.40" @@ -2867,6 +3365,47 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +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.26", + "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.14" @@ -2880,10 +3419,10 @@ dependencies = [ "futures-util", "h2 0.4.8", "http 1.3.1", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.6.0", + "hyper-rustls 0.27.5", "hyper-tls", "hyper-util", "ipnet", @@ -2894,15 +3433,15 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.2", "tower-service", "url", "wasm-bindgen", @@ -2920,7 +3459,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.3.1", - "reqwest", + "reqwest 0.12.14", "serde", "thiserror 1.0.69", "tower-service", @@ -2937,9 +3476,9 @@ dependencies = [ "futures", "getrandom 0.2.15", "http 1.3.1", - "hyper", + "hyper 1.6.0", "parking_lot 0.11.2", - "reqwest", + "reqwest 0.12.14", "reqwest-middleware", "retry-policies", "thiserror 1.0.69", @@ -2957,6 +3496,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" @@ -2986,12 +3535,41 @@ dependencies = [ "xmlparser", ] +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +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-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.2" @@ -3005,17 +3583,52 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.0", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.2.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "base64 0.21.7", ] [[package]] @@ -3033,6 +3646,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +[[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.0" @@ -3092,6 +3715,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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" @@ -3099,7 +3746,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.9.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3130,6 +3790,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-wasm-bindgen" version = "0.3.1" @@ -3165,6 +3835,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[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 = "0.6.8" @@ -3186,6 +3875,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.8.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3242,6 +3961,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simple_asn1" version = "0.6.3" @@ -3296,6 +4025,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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.0" @@ -3383,6 +4128,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" @@ -3403,6 +4154,17 @@ dependencies = [ "syn 2.0.100", ] +[[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" @@ -3410,8 +4172,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.9.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]] @@ -3491,6 +4263,7 @@ dependencies = [ "thoth-errors", "thoth-export-server", "tokio", + "zitadel", ] [[package]] @@ -3520,6 +4293,7 @@ dependencies = [ "thoth-errors", "tokio", "uuid", + "zitadel", ] [[package]] @@ -3528,16 +4302,14 @@ version = "0.13.12" 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]] @@ -3548,7 +4320,7 @@ dependencies = [ "dotenv", "gloo-storage 0.3.0", "gloo-timers 0.3.0", - "reqwest", + "reqwest 0.12.14", "semver", "serde", "serde_json", @@ -3581,7 +4353,7 @@ version = "0.13.12" dependencies = [ "chrono", "graphql_client", - "reqwest", + "reqwest 0.12.14", "reqwest-middleware", "reqwest-retry", "serde", @@ -3604,11 +4376,12 @@ dependencies = [ "juniper", "marc", "phf", - "reqwest", + "reqwest 0.12.14", "reqwest-middleware", "serde", "serde_json", "thiserror 2.0.12", + "tonic", "uuid", "xml-rs", "yewtil", @@ -3720,13 +4493,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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls", + "rustls 0.23.25", + "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", ] @@ -3777,6 +4571,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.8", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "socket2", + "tokio", + "tokio-rustls 0.26.2", + "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" @@ -3786,7 +4644,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -3872,16 +4730,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" @@ -3903,6 +4751,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -4086,6 +4935,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" @@ -4152,6 +5007,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" @@ -4170,6 +5034,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -4202,6 +5081,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[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" @@ -4214,6 +5099,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[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" @@ -4226,6 +5117,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[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" @@ -4250,6 +5147,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[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" @@ -4262,6 +5165,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[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" @@ -4274,6 +5183,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[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" @@ -4286,6 +5201,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[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" @@ -4307,6 +5228,16 @@ dependencies = [ "memchr", ] +[[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-rt" version = "0.39.0" @@ -4572,6 +5503,30 @@ dependencies = [ "syn 2.0.100", ] +[[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 70c32873a..6c4d19caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ 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 6b0bf3bc7..14de5d27c 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ docker-dev-run \ docker-dev-db \ docker-dev-redis \ + docker-dev-zitadel \ + docker-dev-zitadel-db \ build \ test \ clippy \ @@ -48,6 +50,12 @@ docker-dev-db: docker-dev-redis: docker compose -f docker-compose.dev.yml up redis +docker-dev-zitadel: + docker compose -f docker-compose.dev.yml up zitadel + +docker-dev-zitadel-db: + docker compose -f docker-compose.dev.yml up zitadel-db + build: cargo build -vv diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 245d7cff5..7a8fee84d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,6 +9,14 @@ 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" @@ -27,6 +35,7 @@ services: - .env depends_on: - db + - zitadel export-api: build: @@ -54,3 +63,16 @@ services: depends_on: - graphql-api - export-api + + 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/account.rs b/src/bin/commands/account.rs deleted file mode 100644 index 629c637ed..000000000 --- a/src/bin/commands/account.rs +++ /dev/null @@ -1,175 +0,0 @@ -use super::get_pg_pool; -use crate::arguments; -use clap::Command; -use dialoguer::{console::Term, theme::ColorfulTheme, Input, MultiSelect, Password, Select}; -use lazy_static::lazy_static; -use std::collections::HashSet; -use thoth::{ - api::{ - account::{ - model::{Account, LinkedPublisher}, - service::{ - all_emails, all_publishers, get_account, register as register_account, - update_password, - }, - }, - db::PgPool, - }, - errors::{ThothError, ThothResult}, -}; - -lazy_static! { - pub(crate) static ref COMMAND: Command = Command::new("account") - .about("Manage user accounts") - .arg(arguments::database()) - .subcommand_required(true) - .arg_required_else_help(true) - .subcommand(Command::new("register").about("Create a new user account")) - .subcommand( - Command::new("publishers").about("Select which publisher(s) this account can manage"), - ) - .subcommand(Command::new("password").about("Reset a password")); -} - -pub fn register(arguments: &clap::ArgMatches) -> ThothResult<()> { - let pool = get_pg_pool(arguments); - - let name = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Enter given name") - .interact_on(&Term::stdout())?; - let surname = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Enter family name") - .interact_on(&Term::stdout())?; - let email = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Enter email address") - .interact_on(&Term::stdout())?; - let password = password_input()?; - let is_superuser: bool = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Is this a superuser account") - .default(false) - .interact_on(&Term::stdout())?; - let is_bot: bool = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Is this a bot account") - .default(false) - .interact_on(&Term::stdout())?; - - let account = register_account(&pool, name, surname, email, password, is_superuser, is_bot)?; - select_and_link_publishers(&pool, &account) -} - -pub fn publishers(arguments: &clap::ArgMatches) -> ThothResult<()> { - let pool = get_pg_pool(arguments); - let account = email_selection(&pool).and_then(|email| get_account(&email, &pool))?; - select_and_link_publishers(&pool, &account) -} - -pub fn password(arguments: &clap::ArgMatches) -> ThothResult<()> { - let pool = get_pg_pool(arguments); - let email = email_selection(&pool)?; - let password = password_input()?; - - update_password(&email, &password, &pool).map(|_| ()) -} - -fn email_selection(pool: &PgPool) -> ThothResult { - let all_emails = all_emails(pool).expect("No user accounts present in database."); - let email_labels: Vec = all_emails - .iter() - .map(|(email, is_superuser, is_bot, is_active)| { - let mut label = email.clone(); - if *is_superuser { - label.push_str(" 👑"); - } - if *is_bot { - label.push_str(" 🤖"); - } - if !is_active { - label.push_str(" ❌"); - } - label - }) - .collect(); - let email_selection = Select::with_theme(&ColorfulTheme::default()) - .items(&email_labels) - .default(0) - .with_prompt("Select a user account") - .interact_on(&Term::stdout())?; - all_emails - .get(email_selection) - .map(|(email, _, _, _)| email.clone()) - .ok_or_else(|| ThothError::InternalError("Invalid user selection".into())) -} - -fn password_input() -> ThothResult { - Password::with_theme(&ColorfulTheme::default()) - .with_prompt("Enter password") - .with_confirmation("Confirm password", "Passwords do not match") - .interact_on(&Term::stdout()) - .map_err(Into::into) -} - -fn is_admin_input(publisher_name: &str) -> ThothResult { - Input::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Make user an admin of '{}'?", publisher_name)) - .default(false) - .interact_on(&Term::stdout()) - .map_err(Into::into) -} - -fn select_and_link_publishers(pool: &PgPool, account: &Account) -> ThothResult<()> { - let publishers = all_publishers(pool)?; - let publisher_accounts = account.get_publisher_accounts(pool)?; - let current_ids: HashSet<(_, _)> = publisher_accounts - .iter() - .map(|pa| (pa.publisher_id, pa.is_admin)) - .collect(); - - let items_checked: Vec<(_, _)> = publishers - .iter() - .map(|p| { - let is_admin = current_ids - .iter() - .find(|(id, _)| *id == p.publisher_id) - .is_some_and(|(_, admin)| *admin); - let is_linked = current_ids.iter().any(|(id, _)| *id == p.publisher_id); - let mut publisher = p.clone(); - if is_admin { - publisher.publisher_name = format!("{} 🔑", publisher.publisher_name); - } - (publisher, is_linked) - }) - .collect(); - - let chosen: Vec = MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select publishers to link this account to") - .items_checked(&items_checked) - .interact_on(&Term::stdout())?; - let chosen_ids: HashSet<_> = chosen - .iter() - .map(|&index| items_checked[index].0.publisher_id) - .collect(); - let to_add: Vec<_> = publishers - .iter() - .filter(|p| { - chosen_ids.contains(&p.publisher_id) - && !current_ids.iter().any(|(id, _)| id == &p.publisher_id) - }) - .collect(); - let to_remove: Vec<_> = publisher_accounts - .iter() - .filter(|pa| !chosen_ids.contains(&pa.publisher_id)) - .collect(); - - for publisher in to_add { - let is_admin: bool = is_admin_input(&publisher.publisher_name)?; - let linked_publisher = LinkedPublisher { - publisher_id: publisher.publisher_id, - is_admin, - }; - account.add_publisher_account(pool, linked_publisher)?; - } - for publisher_account in to_remove { - publisher_account.delete(pool)?; - } - Ok(()) -} 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 9ef2f3c8d..1c40521af 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("app") @@ -49,9 +47,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, @@ -59,9 +55,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..c93973b25 --- /dev/null +++ b/src/bin/commands/zitadel.rs @@ -0,0 +1,132 @@ +use crate::arguments; +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::management::v1::{ + AddApiAppRequest, 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 = management_client + .add_project(AddProjectRequest { + name: "Thoth".to_string(), + project_role_assertion: false, + project_role_check: false, + has_project_check: false, + private_labeling_setting: PrivateLabelingSetting::EnforceProjectResourceOwnerPolicy + as i32, + }) + .await? + .into_inner(); + + // 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?; + } + + // 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?; + } + + // Create Zitadel APPs for GraphQL API and APP + management_client + .add_api_app(AddApiAppRequest { + project_id: project.id.clone(), + name: "Thoth GraphQL API".to_string(), + auth_method_type: ApiAuthMethodType::Basic as i32, + }) + .await?; + management_client + .add_oidc_app(AddOidcAppRequest { + project_id: project.id, + name: "Thoth APP".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?; + + Ok::<(), ThothError>(()) + }) +} diff --git a/src/bin/thoth.rs b/src/bin/thoth.rs index 42597884b..d424874f9 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<()> { @@ -31,16 +31,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 a41e07ace..b8e09e72d 100644 --- a/thoth-api-server/Cargo.toml +++ b/thoth-api-server/Cargo.toml @@ -10,14 +10,12 @@ readme = "README.md" [dependencies] thoth-api = { version = "=0.13.12", path = "../thoth-api", features = ["backend"] } -thoth-errors = { version = "=0.13.12", 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 de0e2bbf9..c2ee6af59 100644 --- a/thoth-api/Cargo.toml +++ b/thoth-api/Cargo.toml @@ -21,7 +21,8 @@ backend = [ "jsonwebtoken", "deadpool-redis", "rand", - "argon2rs" + "argon2rs", + "zitadel" ] [dependencies] @@ -47,6 +48,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/v0.14.0/down.sql b/thoth-api/migrations/v0.14.0/down.sql new file mode 100644 index 000000000..554df754b --- /dev/null +++ b/thoth-api/migrations/v0.14.0/down.sql @@ -0,0 +1,86 @@ +-- 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 affiliation_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 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 affiliation_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 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 affiliation_history ADD CONSTRAINT affiliation_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 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/v0.14.0/up.sql b/thoth-api/migrations/v0.14.0/up.sql new file mode 100644 index 000000000..b7e8d46d6 --- /dev/null +++ b/thoth-api/migrations/v0.14.0/up.sql @@ -0,0 +1,59 @@ +-- Drop foreign key constraints +ALTER TABLE affiliation_history DROP CONSTRAINT IF EXISTS affiliation_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 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 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 affiliation_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 work_history RENAME COLUMN account_id TO user_id; +ALTER TABLE work_relation_history RENAME COLUMN account_id TO user_id; + +ALTER TABLE affiliation_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 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 index 225e37d96..65880be0e 100644 --- a/thoth-api/src/account/mod.rs +++ b/thoth-api/src/account/mod.rs @@ -1,7 +1 @@ -#[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 index 773c54ccd..b3a2be5bd 100644 --- a/thoth-api/src/account/model.rs +++ b/thoth-api/src/account/model.rs @@ -2,10 +2,6 @@ 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; @@ -26,18 +22,6 @@ pub struct Account { 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, @@ -48,23 +32,6 @@ pub struct AccountData { 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 { @@ -114,13 +81,6 @@ pub struct LoginCredentials { 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 { 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/model.rs b/thoth-api/src/graphql/model.rs index 8a157c526..6207aa608 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -1,11 +1,3 @@ -use chrono::naive::NaiveDate; -use juniper::RootNode; -use juniper::{EmptySubscription, FieldResult}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::account::model::AccountAccess; -use crate::account::model::DecodedToken; use crate::db::PgPool; use crate::model::affiliation::*; use crate::model::contribution::*; @@ -33,27 +25,104 @@ use crate::model::Orcid; use crate::model::Ror; use crate::model::Timestamp; use crate::model::WeightUnit; +use chrono::naive::NaiveDate; +use juniper::RootNode; +use juniper::{EmptySubscription, FieldResult}; +use std::sync::Arc; use thoth_errors::{ThothError, ThothResult}; +use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; use super::utils::{Direction, Expression}; impl juniper::Context for Context {} -#[derive(Clone)] pub struct Context { pub db: Arc, - pub account_access: AccountAccess, - pub token: DecodedToken, + pub user: Option, +} + +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) + } } 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 } + } + + fn require_authentication(&self) -> ThothResult<&IntrospectedUser> { + self.user.as_ref().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) } } + + fn require_publisher(&self, publisher_id: &Uuid) -> ThothResult<&IntrospectedUser> { + let user = self.require_authentication()?; + user.can_edit(publisher_id)?; + Ok(user) + } } #[derive(juniper::GraphQLInputObject)] @@ -221,7 +290,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single work using its ID")] @@ -229,7 +298,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth work ID to search on")] work_id: Uuid, ) -> FieldResult { - Work::from_id(&context.db, &work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &work_id).map_err(Into::into) } #[graphql(description = "Query a single work using its DOI")] @@ -237,7 +306,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Work DOI to search on")] doi: Doi, ) -> FieldResult { - Work::from_doi(&context.db, doi, vec![]).map_err(|e| e.into()) + Work::from_doi(&context.db, doi, vec![]).map_err(Into::into) } #[graphql(description = "Get the total number of works")] @@ -283,7 +352,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -342,7 +411,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single book using its DOI")] @@ -360,7 +429,7 @@ impl QueryRoot { WorkType::JournalIssue, ], ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql( @@ -408,7 +477,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -462,7 +531,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single chapter using its DOI")] @@ -470,7 +539,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Chapter DOI to search on")] doi: Doi, ) -> FieldResult { - Work::from_doi(&context.db, doi, vec![WorkType::BookChapter]).map_err(|e| e.into()) + Work::from_doi(&context.db, doi, vec![WorkType::BookChapter]).map_err(Into::into) } #[graphql( @@ -513,7 +582,7 @@ impl QueryRoot { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of publications")] @@ -555,7 +624,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single publication using its ID")] @@ -563,7 +632,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth publication ID to search on")] publication_id: Uuid, ) -> FieldResult { - Publication::from_id(&context.db, &publication_id).map_err(|e| e.into()) + Publication::from_id(&context.db, &publication_id).map_err(Into::into) } #[graphql(description = "Get the total number of publications")] @@ -593,7 +662,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of publishers")] @@ -630,7 +699,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single publisher using its ID")] @@ -638,7 +707,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth publisher ID to search on")] publisher_id: Uuid, ) -> FieldResult { - Publisher::from_id(&context.db, &publisher_id).map_err(|e| e.into()) + Publisher::from_id(&context.db, &publisher_id).map_err(Into::into) } #[graphql(description = "Get the total number of publishers")] @@ -663,7 +732,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of imprints")] @@ -700,7 +769,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single imprint using its ID")] @@ -708,7 +777,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth imprint ID to search on")] imprint_id: Uuid, ) -> FieldResult { - Imprint::from_id(&context.db, &imprint_id).map_err(|e| e.into()) + Imprint::from_id(&context.db, &imprint_id).map_err(Into::into) } #[graphql(description = "Get the total number of imprints")] @@ -733,7 +802,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of contributors")] @@ -765,7 +834,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single contributor using its ID")] @@ -773,7 +842,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth contributor ID to search on")] contributor_id: Uuid, ) -> FieldResult { - Contributor::from_id(&context.db, &contributor_id).map_err(|e| e.into()) + Contributor::from_id(&context.db, &contributor_id).map_err(Into::into) } #[graphql(description = "Get the total number of contributors")] @@ -785,7 +854,7 @@ impl QueryRoot { )] filter: Option, ) -> FieldResult { - Contributor::count(&context.db, filter, vec![], vec![], vec![], None).map_err(|e| e.into()) + Contributor::count(&context.db, filter, vec![], vec![], vec![], None).map_err(Into::into) } #[graphql(description = "Query the full list of contributions")] @@ -822,7 +891,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single contribution using its ID")] @@ -830,7 +899,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth contribution ID to search on")] contribution_id: Uuid, ) -> FieldResult { - Contribution::from_id(&context.db, &contribution_id).map_err(|e| e.into()) + Contribution::from_id(&context.db, &contribution_id).map_err(Into::into) } #[graphql(description = "Get the total number of contributions")] @@ -850,7 +919,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of series")] @@ -892,7 +961,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single series using its ID")] @@ -900,7 +969,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth series ID to search on")] series_id: Uuid, ) -> FieldResult { - Series::from_id(&context.db, &series_id).map_err(|e| e.into()) + Series::from_id(&context.db, &series_id).map_err(Into::into) } #[graphql(description = "Get the total number of series")] @@ -930,7 +999,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of issues")] @@ -962,7 +1031,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single issue using its ID")] @@ -970,12 +1039,12 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth issue ID to search on")] issue_id: Uuid, ) -> FieldResult { - Issue::from_id(&context.db, &issue_id).map_err(|e| e.into()) + Issue::from_id(&context.db, &issue_id).map_err(Into::into) } #[graphql(description = "Get the total number of issues")] fn issue_count(context: &Context) -> FieldResult { - Issue::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) + Issue::count(&context.db, None, vec![], vec![], vec![], None).map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -1026,7 +1095,7 @@ impl QueryRoot { relations, None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single language using its ID")] @@ -1034,7 +1103,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth language ID to search on")] language_id: Uuid, ) -> FieldResult { - Language::from_id(&context.db, &language_id).map_err(|e| e.into()) + Language::from_id(&context.db, &language_id).map_err(Into::into) } #[graphql(description = "Get the total number of languages associated to works")] @@ -1067,7 +1136,7 @@ impl QueryRoot { relations, None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of locations")] @@ -1104,7 +1173,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single location using its ID")] @@ -1112,7 +1181,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth location ID to search on")] location_id: Uuid, ) -> FieldResult { - Location::from_id(&context.db, &location_id).map_err(|e| e.into()) + Location::from_id(&context.db, &location_id).map_err(Into::into) } #[graphql(description = "Get the total number of locations associated to works")] @@ -1132,7 +1201,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of prices")] @@ -1169,7 +1238,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single price using its ID")] @@ -1177,7 +1246,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth price ID to search on")] price_id: Uuid, ) -> FieldResult { - Price::from_id(&context.db, &price_id).map_err(|e| e.into()) + Price::from_id(&context.db, &price_id).map_err(Into::into) } #[graphql(description = "Get the total number of prices associated to works")] @@ -1197,7 +1266,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of subjects")] @@ -1239,7 +1308,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single subject using its ID")] @@ -1247,7 +1316,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth subject ID to search on")] subject_id: Uuid, ) -> FieldResult { - Subject::from_id(&context.db, &subject_id).map_err(|e| e.into()) + Subject::from_id(&context.db, &subject_id).map_err(Into::into) } #[graphql(description = "Get the total number of subjects associated to works")] @@ -1272,7 +1341,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query the full list of institutions")] @@ -1304,7 +1373,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single institution using its ID")] @@ -1312,7 +1381,7 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth institution ID to search on")] institution_id: Uuid, ) -> FieldResult { - Institution::from_id(&context.db, &institution_id).map_err(|e| e.into()) + Institution::from_id(&context.db, &institution_id).map_err(Into::into) } #[graphql(description = "Get the total number of institutions")] @@ -1324,7 +1393,7 @@ impl QueryRoot { )] filter: Option, ) -> FieldResult { - Institution::count(&context.db, filter, vec![], vec![], vec![], None).map_err(|e| e.into()) + Institution::count(&context.db, filter, vec![], vec![], vec![], None).map_err(Into::into) } #[graphql(description = "Query the full list of fundings")] @@ -1356,7 +1425,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single funding using its ID")] @@ -1364,12 +1433,12 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth funding ID to search on")] funding_id: Uuid, ) -> FieldResult { - Funding::from_id(&context.db, &funding_id).map_err(|e| e.into()) + Funding::from_id(&context.db, &funding_id).map_err(Into::into) } #[graphql(description = "Get the total number of funding instances associated to works")] fn funding_count(context: &Context) -> FieldResult { - Funding::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) + Funding::count(&context.db, None, vec![], vec![], vec![], None).map_err(Into::into) } #[graphql(description = "Query the full list of affiliations")] @@ -1401,7 +1470,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single affiliation using its ID")] @@ -1409,12 +1478,12 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth affiliation ID to search on")] affiliation_id: Uuid, ) -> FieldResult { - Affiliation::from_id(&context.db, &affiliation_id).map_err(|e| e.into()) + Affiliation::from_id(&context.db, &affiliation_id).map_err(Into::into) } #[graphql(description = "Get the total number of affiliations")] fn affiliation_count(context: &Context) -> FieldResult { - Affiliation::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) + Affiliation::count(&context.db, None, vec![], vec![], vec![], None).map_err(Into::into) } #[graphql(description = "Query the full list of references")] @@ -1446,7 +1515,7 @@ impl QueryRoot { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Query a single reference using its ID")] @@ -1454,12 +1523,12 @@ impl QueryRoot { context: &Context, #[graphql(description = "Thoth reference ID to search on")] reference_id: Uuid, ) -> FieldResult { - Reference::from_id(&context.db, &reference_id).map_err(|e| e.into()) + Reference::from_id(&context.db, &reference_id).map_err(Into::into) } #[graphql(description = "Get the total number of references")] fn reference_count(context: &Context) -> FieldResult { - Reference::count(&context.db, None, vec![], vec![], vec![], None).map_err(|e| e.into()) + Reference::count(&context.db, None, vec![], vec![], vec![], None).map_err(Into::into) } } @@ -1472,14 +1541,12 @@ 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)?)?; - + context.require_publisher(&publisher_id_from_imprint_id( + &context.db, + &data.imprint_id, + )?)?; data.validate()?; - - Work::create(&context.db, &data).map_err(|e| e.into()) + Work::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new publisher with the specified values")] @@ -1487,13 +1554,8 @@ 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()); - } - - Publisher::create(&context.db, &data).map_err(|e| e.into()) + context.require_superuser()?; + Publisher::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new imprint with the specified values")] @@ -1501,10 +1563,8 @@ 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)?; - - Imprint::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&data.publisher_id)?; + Imprint::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new contributor with the specified values")] @@ -1512,8 +1572,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contributor to be created")] data: NewContributor, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - Contributor::create(&context.db, &data).map_err(|e| e.into()) + context.require_authentication()?; + Contributor::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new contribution with the specified values")] @@ -1521,12 +1581,8 @@ 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)?)?; - - Contribution::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + Contribution::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new publication with the specified values")] @@ -1534,14 +1590,9 @@ 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)?)?; - + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; data.validate(&context.db)?; - - Publication::create(&context.db, &data).map_err(|e| e.into()) + Publication::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new series with the specified values")] @@ -1549,12 +1600,11 @@ 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)?)?; - - Series::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_imprint_id( + &context.db, + &data.imprint_id, + )?)?; + Series::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new issue with the specified values")] @@ -1562,14 +1612,9 @@ 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)?)?; - + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; data.imprints_match(&context.db)?; - - Issue::create(&context.db, &data).map_err(|e| e.into()) + Issue::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new language with the specified values")] @@ -1577,12 +1622,8 @@ 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)?)?; - - Language::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + Language::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new institution with the specified values")] @@ -1590,8 +1631,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for institution to be created")] data: NewInstitution, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - Institution::create(&context.db, &data).map_err(|e| e.into()) + context.require_authentication()?; + Institution::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new funding with the specified values")] @@ -1599,12 +1640,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for funding to be created")] data: NewFunding, ) -> 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)?)?; - - Funding::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + Funding::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new location with the specified values")] @@ -1612,18 +1649,14 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for location to be created")] data: NewLocation, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + let user = context.require_publisher(&publisher_id_from_publication_id( + &context.db, + &data.publication_id, + )?)?; // Only superusers can create new locations where Location Platform is Thoth - if !context.account_access.is_superuser && data.location_platform == LocationPlatform::Thoth - { + if !user.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)?; @@ -1631,7 +1664,7 @@ impl MutationRoot { data.can_be_non_canonical(&context.db)?; } - Location::create(&context.db, &data).map_err(|e| e.into()) + Location::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new price with the specified values")] @@ -1639,20 +1672,17 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for price to be created")] data: NewPrice, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_publication_id( - &context.db, - data.publication_id, - )?)?; + context.require_publisher(&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()); } - Price::create(&context.db, &data).map_err(|e| e.into()) + Price::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new subject with the specified values")] @@ -1660,14 +1690,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for subject to be created")] data: NewSubject, ) -> 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)?)?; - + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; check_subject(&data.subject_type, &data.subject_code)?; - - Subject::create(&context.db, &data).map_err(|e| e.into()) + Subject::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new affiliation with the specified values")] @@ -1675,15 +1700,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - context - .account_access - .can_edit(publisher_id_from_contribution_id( - &context.db, - data.contribution_id, - )?)?; - - Affiliation::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_contribution_id( + &context.db, + &data.contribution_id, + )?)?; + Affiliation::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new work relation with the specified values")] @@ -1691,19 +1712,18 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, ) -> FieldResult { - 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.require_publisher(&publisher_id_from_work_id( &context.db, - data.relator_work_id, + &data.relator_work_id, )?)?; - context.account_access.can_edit(publisher_id_from_work_id( + context.require_publisher(&publisher_id_from_work_id( &context.db, - data.related_work_id, + &data.related_work_id, )?)?; - WorkRelation::create(&context.db, &data).map_err(|e| e.into()) + WorkRelation::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Create a new reference with the specified values")] @@ -1711,12 +1731,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for reference to be created")] data: NewReference, ) -> 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)?)?; - - Reference::create(&context.db, &data).map_err(|e| e.into()) + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + Reference::create(&context.db, &data).map_err(Into::into) } #[graphql(description = "Update an existing work with the specified values")] @@ -1724,16 +1740,15 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing work")] data: PatchWork, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &data.work_id).unwrap(); - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; + context.require_authentication()?; + let work = Work::from_id(&context.db, &data.work_id)?; + let user = context.require_publisher(&work.work_id)?; if data.imprint_id != work.imprint_id { - context - .account_access - .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; + context.require_publisher(&publisher_id_from_imprint_id( + &context.db, + &data.imprint_id, + )?)?; work.can_update_imprint(&context.db)?; } @@ -1743,13 +1758,12 @@ impl MutationRoot { data.validate()?; - if work.is_published() && !data.is_published() && !context.account_access.is_superuser { + if work.is_published() && !data.is_published() && !user.is_superuser() { return Err(ThothError::ThothSetWorkStatusError.into()); } - let account_id = context.token.jwt.as_ref().unwrap().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) { + match work.update(&context.db, &data, &user.user_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)? { @@ -1761,7 +1775,7 @@ impl MutationRoot { 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)?; + child.update(&context.db, &data, &user.user_id)?; } } Ok(w) @@ -1775,17 +1789,16 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &data.publisher_id).unwrap(); - context.account_access.can_edit(publisher.publisher_id)?; + context.require_authentication()?; + let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; + let user = context.require_publisher(&publisher.publisher_id)?; if data.publisher_id != publisher.publisher_id { - context.account_access.can_edit(data.publisher_id)?; + context.require_publisher(&data.publisher_id)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); publisher - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing imprint with the specified values")] @@ -1793,17 +1806,16 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &data.imprint_id).unwrap(); - context.account_access.can_edit(imprint.publisher_id())?; + context.require_authentication()?; + let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; + let user = context.require_publisher(&imprint.publisher_id())?; if data.publisher_id != imprint.publisher_id { - context.account_access.can_edit(data.publisher_id)?; + context.require_publisher(&data.publisher_id)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); imprint - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing contributor with the specified values")] @@ -1811,12 +1823,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - Contributor::from_id(&context.db, &data.contributor_id) - .unwrap() - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + let user = context.require_authentication()?; + Contributor::from_id(&context.db, &data.contributor_id)? + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing contribution with the specified values")] @@ -1825,21 +1835,16 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contribution")] data: PatchContribution, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &data.contribution_id).unwrap(); - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; + context.require_authentication()?; + let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); contribution - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing publication with the specified values")] @@ -1847,24 +1852,19 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &data.publication_id).unwrap(); - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; + context.require_authentication()?; + let publication = Publication::from_id(&context.db, &data.publication_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } data.validate(&context.db)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); publication - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing series with the specified values")] @@ -1872,21 +1872,19 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing series")] data: PatchSeries, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &data.series_id).unwrap(); - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; + context.require_authentication()?; + let series = Series::from_id(&context.db, &data.series_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_imprint_id( + &context.db, + &data.imprint_id, + )?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); series - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing issue with the specified values")] @@ -1894,23 +1892,18 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &data.issue_id).unwrap(); - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; + context.require_authentication()?; + let issue = Issue::from_id(&context.db, &data.issue_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); issue - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing language with the specified values")] @@ -1918,22 +1911,17 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &data.language_id).unwrap(); - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; + context.require_authentication()?; + let language = Language::from_id(&context.db, &data.language_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); language - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing institution with the specified values")] @@ -1941,12 +1929,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); - Institution::from_id(&context.db, &data.institution_id) - .unwrap() - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + let user = context.require_authentication()?; + Institution::from_id(&context.db, &data.institution_id)? + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing funding with the specified values")] @@ -1954,22 +1940,17 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &data.funding_id).unwrap(); - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; + context.require_authentication()?; + let funding = Funding::from_id(&context.db, &data.funding_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); funding - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing location with the specified values")] @@ -1977,8 +1958,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing location")] data: PatchLocation, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let current_location = Location::from_id(&context.db, &data.location_id).unwrap(); + context.require_authentication()?; + let current_location = Location::from_id(&context.db, &data.location_id)?; + let user = context.require_publisher(¤t_location.publisher_id(&context.db)?)?; + let has_canonical_thoth_location = Publication::from_id(&context.db, &data.publication_id)? .locations( context, @@ -1990,37 +1973,29 @@ impl MutationRoot { .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 { + if has_canonical_thoth_location && data.canonical && !user.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 - { + if !user.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, - )?)?; + context.require_publisher(&publisher_id_from_publication_id( + &context.db, + &data.publication_id, + )?)?; } if data.canonical { data.canonical_record_complete(&context.db)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); current_location - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing price with the specified values")] @@ -2028,19 +2003,15 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing price")] data: PatchPrice, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &data.price_id).unwrap(); - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; + context.require_authentication()?; + let price = Price::from_id(&context.db, &data.price_id)?; + let user = context.require_publisher(&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, - )?)?; + context.require_publisher(&publisher_id_from_publication_id( + &context.db, + &data.publication_id, + )?)?; } if data.unit_price <= 0.0 { @@ -2048,10 +2019,9 @@ impl MutationRoot { return Err(ThothError::PriceZeroError.into()); } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); price - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing subject with the specified values")] @@ -2059,24 +2029,19 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &data.subject_id).unwrap(); - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; + context.require_authentication()?; + let subject = Subject::from_id(&context.db, &data.subject_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } check_subject(&data.subject_type, &data.subject_code)?; - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); subject - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing affiliation with the specified values")] @@ -2084,25 +2049,20 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id).unwrap(); - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; + context.require_authentication()?; + let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; + let user = context.require_publisher(&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, - )?)?; + context.require_publisher(&publisher_id_from_contribution_id( + &context.db, + &data.contribution_id, + )?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); affiliation - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing work relation with the specified values")] @@ -2111,36 +2071,35 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing work relation")] data: PatchWorkRelation, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id).unwrap(); + let user = context.require_authentication()?; + 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.require_publisher(&publisher_id_from_work_id( &context.db, - work_relation.relator_work_id, + &work_relation.relator_work_id, )?)?; - context.account_access.can_edit(publisher_id_from_work_id( + context.require_publisher(&publisher_id_from_work_id( &context.db, - work_relation.related_work_id, + &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.require_publisher(&publisher_id_from_work_id( &context.db, - data.relator_work_id, + &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.require_publisher(&publisher_id_from_work_id( &context.db, - data.related_work_id, + &data.related_work_id, )?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); work_relation - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Update an existing reference with the specified values")] @@ -2148,22 +2107,17 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing reference")] data: PatchReference, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &data.reference_id).unwrap(); - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; + context.require_authentication()?; + let reference = Reference::from_id(&context.db, &data.reference_id)?; + let user = context.require_publisher(&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)?)?; + context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; } - let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); reference - .update(&context.db, &data, &account_id) - .map_err(|e| e.into()) + .update(&context.db, &data, &user.user_id) + .map_err(Into::into) } #[graphql(description = "Delete a single work using its ID")] @@ -2171,17 +2125,15 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work to be deleted")] work_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work = Work::from_id(&context.db, &work_id).unwrap(); - context - .account_access - .can_edit(work.publisher_id(&context.db)?)?; + context.require_authentication()?; + let work = Work::from_id(&context.db, &work_id)?; + let user = context.require_publisher(&work.publisher_id(&context.db)?)?; - if work.is_published() && !context.account_access.is_superuser { + if work.is_published() && !user.is_superuser() { return Err(ThothError::ThothDeleteWorkError.into()); } - work.delete(&context.db).map_err(|e| e.into()) + work.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single publisher using its ID")] @@ -2189,11 +2141,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publisher to be deleted")] publisher_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publisher = Publisher::from_id(&context.db, &publisher_id).unwrap(); - context.account_access.can_edit(publisher_id)?; + context.require_authentication()?; + let publisher = Publisher::from_id(&context.db, &publisher_id)?; + context.require_publisher(&publisher_id)?; - publisher.delete(&context.db).map_err(|e| e.into()) + publisher.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single imprint using its ID")] @@ -2201,11 +2153,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of imprint to be deleted")] imprint_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let imprint = Imprint::from_id(&context.db, &imprint_id).unwrap(); - context.account_access.can_edit(imprint.publisher_id())?; + context.require_authentication()?; + let imprint = Imprint::from_id(&context.db, &imprint_id)?; + context.require_publisher(&imprint.publisher_id())?; - imprint.delete(&context.db).map_err(|e| e.into()) + imprint.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single contributor using its ID")] @@ -2213,13 +2165,13 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contributor to be deleted")] contributor_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contributor = Contributor::from_id(&context.db, &contributor_id).unwrap(); + context.require_authentication()?; + 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)?; + context.require_publisher(&linked_publisher_id)?; } - contributor.delete(&context.db).map_err(|e| e.into()) + contributor.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single contribution using its ID")] @@ -2227,13 +2179,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contribution to be deleted")] contribution_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let contribution = Contribution::from_id(&context.db, &contribution_id).unwrap(); - context - .account_access - .can_edit(contribution.publisher_id(&context.db)?)?; + context.require_authentication()?; + let contribution = Contribution::from_id(&context.db, &contribution_id)?; + context.require_publisher(&contribution.publisher_id(&context.db)?)?; - contribution.delete(&context.db).map_err(|e| e.into()) + contribution.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single publication using its ID")] @@ -2241,13 +2191,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publication to be deleted")] publication_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let publication = Publication::from_id(&context.db, &publication_id).unwrap(); - context - .account_access - .can_edit(publication.publisher_id(&context.db)?)?; + context.require_authentication()?; + let publication = Publication::from_id(&context.db, &publication_id)?; + context.require_publisher(&publication.publisher_id(&context.db)?)?; - publication.delete(&context.db).map_err(|e| e.into()) + publication.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single series using its ID")] @@ -2255,13 +2203,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of series to be deleted")] series_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let series = Series::from_id(&context.db, &series_id).unwrap(); - context - .account_access - .can_edit(series.publisher_id(&context.db)?)?; + context.require_authentication()?; + let series = Series::from_id(&context.db, &series_id)?; + context.require_publisher(&series.publisher_id(&context.db)?)?; - series.delete(&context.db).map_err(|e| e.into()) + series.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single issue using its ID")] @@ -2269,13 +2215,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of issue to be deleted")] issue_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let issue = Issue::from_id(&context.db, &issue_id).unwrap(); - context - .account_access - .can_edit(issue.publisher_id(&context.db)?)?; + context.require_authentication()?; + let issue = Issue::from_id(&context.db, &issue_id)?; + context.require_publisher(&issue.publisher_id(&context.db)?)?; - issue.delete(&context.db).map_err(|e| e.into()) + issue.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single language using its ID")] @@ -2283,13 +2227,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of language to be deleted")] language_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let language = Language::from_id(&context.db, &language_id).unwrap(); - context - .account_access - .can_edit(language.publisher_id(&context.db)?)?; + context.require_authentication()?; + let language = Language::from_id(&context.db, &language_id)?; + context.require_publisher(&language.publisher_id(&context.db)?)?; - language.delete(&context.db).map_err(|e| e.into()) + language.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single institution using its ID")] @@ -2297,13 +2239,13 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of institution to be deleted")] institution_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let institution = Institution::from_id(&context.db, &institution_id).unwrap(); + context.require_authentication()?; + 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)?; + context.require_publisher(&linked_publisher_id)?; } - institution.delete(&context.db).map_err(|e| e.into()) + institution.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single funding using its ID")] @@ -2311,13 +2253,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of funding to be deleted")] funding_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let funding = Funding::from_id(&context.db, &funding_id).unwrap(); - context - .account_access - .can_edit(funding.publisher_id(&context.db)?)?; + context.require_authentication()?; + let funding = Funding::from_id(&context.db, &funding_id)?; + context.require_publisher(&funding.publisher_id(&context.db)?)?; - funding.delete(&context.db).map_err(|e| e.into()) + funding.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single location using its ID")] @@ -2325,19 +2265,15 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let location = Location::from_id(&context.db, &location_id).unwrap(); + let user = context.require_authentication()?; + 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 - { + if !user.is_superuser() && location.location_platform == LocationPlatform::Thoth { return Err(ThothError::ThothLocationError.into()); } - context - .account_access - .can_edit(location.publisher_id(&context.db)?)?; + context.require_publisher(&location.publisher_id(&context.db)?)?; - location.delete(&context.db).map_err(|e| e.into()) + location.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single price using its ID")] @@ -2345,13 +2281,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of price to be deleted")] price_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let price = Price::from_id(&context.db, &price_id).unwrap(); - context - .account_access - .can_edit(price.publisher_id(&context.db)?)?; + context.require_authentication()?; + let price = Price::from_id(&context.db, &price_id)?; + context.require_publisher(&price.publisher_id(&context.db)?)?; - price.delete(&context.db).map_err(|e| e.into()) + price.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single subject using its ID")] @@ -2359,13 +2293,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of subject to be deleted")] subject_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let subject = Subject::from_id(&context.db, &subject_id).unwrap(); - context - .account_access - .can_edit(subject.publisher_id(&context.db)?)?; + context.require_authentication()?; + let subject = Subject::from_id(&context.db, &subject_id)?; + context.require_publisher(&subject.publisher_id(&context.db)?)?; - subject.delete(&context.db).map_err(|e| e.into()) + subject.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single affiliation using its ID")] @@ -2373,13 +2305,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of affiliation to be deleted")] affiliation_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let affiliation = Affiliation::from_id(&context.db, &affiliation_id).unwrap(); - context - .account_access - .can_edit(affiliation.publisher_id(&context.db)?)?; + context.require_authentication()?; + let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; + context.require_publisher(&affiliation.publisher_id(&context.db)?)?; - affiliation.delete(&context.db).map_err(|e| e.into()) + affiliation.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single work relation using its ID")] @@ -2387,20 +2317,20 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work relation to be deleted")] work_relation_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let work_relation = WorkRelation::from_id(&context.db, &work_relation_id).unwrap(); + context.require_authentication()?; + 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.require_publisher(&publisher_id_from_work_id( &context.db, - work_relation.relator_work_id, + &work_relation.relator_work_id, )?)?; - context.account_access.can_edit(publisher_id_from_work_id( + context.require_publisher(&publisher_id_from_work_id( &context.db, - work_relation.related_work_id, + &work_relation.related_work_id, )?)?; - work_relation.delete(&context.db).map_err(|e| e.into()) + work_relation.delete(&context.db).map_err(Into::into) } #[graphql(description = "Delete a single reference using its ID")] @@ -2408,13 +2338,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of reference to be deleted")] reference_id: Uuid, ) -> FieldResult { - context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; - let reference = Reference::from_id(&context.db, &reference_id).unwrap(); - context - .account_access - .can_edit(reference.publisher_id(&context.db)?)?; + context.require_authentication()?; + let reference = Reference::from_id(&context.db, &reference_id)?; + context.require_publisher(&reference.publisher_id(&context.db)?)?; - reference.delete(&context.db).map_err(|e| e.into()) + reference.delete(&context.db).map_err(Into::into) } } @@ -2633,7 +2561,7 @@ impl Work { #[graphql(description = "Get this work's imprint")] pub fn imprint(&self, context: &Context) -> FieldResult { - Imprint::from_id(&context.db, &self.imprint_id).map_err(|e| e.into()) + Imprint::from_id(&context.db, &self.imprint_id).map_err(Into::into) } #[graphql(description = "Get contributions linked to this work")] @@ -2666,7 +2594,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -2713,7 +2641,7 @@ impl Work { relations, None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get publications linked to this work")] @@ -2751,7 +2679,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get subjects linked to this work")] @@ -2789,7 +2717,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get fundings linked to this work")] @@ -2817,7 +2745,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get issues linked to this work")] @@ -2845,7 +2773,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get other works related to this work")] pub fn relations( @@ -2877,7 +2805,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get references cited by this work")] pub fn references( @@ -2909,7 +2837,7 @@ impl Work { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3054,7 +2982,7 @@ impl Publication { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get locations linked to this publication")] @@ -3087,12 +3015,12 @@ impl Publication { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get the work to which this publication belongs")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } @@ -3163,7 +3091,7 @@ impl Publisher { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3209,7 +3137,7 @@ impl Imprint { #[graphql(description = "Get the publisher to which this imprint belongs")] pub fn publisher(&self, context: &Context) -> FieldResult { - Publisher::from_id(&context.db, &self.publisher_id).map_err(|e| e.into()) + Publisher::from_id(&context.db, &self.publisher_id).map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -3264,7 +3192,7 @@ impl Imprint { statuses, updated_at_with_relations, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3344,7 +3272,7 @@ impl Contributor { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3422,12 +3350,12 @@ impl Contribution { #[graphql(description = "Get the work in which the contribution appears")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } #[graphql(description = "Get the contributor who created the contribution")] pub fn contributor(&self, context: &Context) -> FieldResult { - Contributor::from_id(&context.db, &self.contributor_id).map_err(|e| e.into()) + Contributor::from_id(&context.db, &self.contributor_id).map_err(Into::into) } #[graphql(description = "Get affiliations linked to this contribution")] @@ -3455,7 +3383,7 @@ impl Contribution { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3522,7 +3450,7 @@ impl Series { #[graphql(description = "Get the imprint linked to this series")] pub fn imprint(&self, context: &Context) -> FieldResult { - Imprint::from_id(&context.db, &self.imprint_id).map_err(|e| e.into()) + Imprint::from_id(&context.db, &self.imprint_id).map_err(Into::into) } #[graphql(description = "Get issues linked to this series")] @@ -3550,7 +3478,7 @@ impl Series { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3590,12 +3518,12 @@ impl Issue { #[graphql(description = "Get the series to which the issue belongs")] pub fn series(&self, context: &Context) -> FieldResult { - Series::from_id(&context.db, &self.series_id).map_err(|e| e.into()) + Series::from_id(&context.db, &self.series_id).map_err(Into::into) } #[graphql(description = "Get the work represented by the issue")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } @@ -3640,7 +3568,7 @@ impl Language { #[graphql(description = "Get the work which has this language")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } @@ -3690,7 +3618,7 @@ impl Location { #[graphql(description = "Get the publication linked to this location")] pub fn publication(&self, context: &Context) -> FieldResult { - Publication::from_id(&context.db, &self.publication_id).map_err(|e| e.into()) + Publication::from_id(&context.db, &self.publication_id).map_err(Into::into) } } @@ -3730,7 +3658,7 @@ impl Price { #[graphql(description = "Get the publication linked to this price")] pub fn publication(&self, context: &Context) -> FieldResult { - Publication::from_id(&context.db, &self.publication_id).map_err(|e| e.into()) + Publication::from_id(&context.db, &self.publication_id).map_err(Into::into) } } @@ -3775,7 +3703,7 @@ impl Subject { #[graphql(description = "Get the work to which the subject is linked")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } @@ -3847,7 +3775,7 @@ impl Institution { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } #[graphql(description = "Get affiliations linked to this institution")] @@ -3875,7 +3803,7 @@ impl Institution { vec![], None, ) - .map_err(|e| e.into()) + .map_err(Into::into) } } @@ -3933,12 +3861,12 @@ impl Funding { #[graphql(description = "Get the funded work")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } #[graphql(description = "Get the funding institution")] pub fn institution(&self, context: &Context) -> FieldResult { - Institution::from_id(&context.db, &self.institution_id).map_err(|e| e.into()) + Institution::from_id(&context.db, &self.institution_id).map_err(Into::into) } } @@ -3985,12 +3913,12 @@ impl Affiliation { #[graphql(description = "Get the institution linked to this affiliation")] pub fn institution(&self, context: &Context) -> FieldResult { - Institution::from_id(&context.db, &self.institution_id).map_err(|e| e.into()) + Institution::from_id(&context.db, &self.institution_id).map_err(Into::into) } #[graphql(description = "Get the contribution linked to this affiliation")] pub fn contribution(&self, context: &Context) -> FieldResult { - Contribution::from_id(&context.db, &self.contribution_id).map_err(|e| e.into()) + Contribution::from_id(&context.db, &self.contribution_id).map_err(Into::into) } } @@ -4035,7 +3963,7 @@ impl WorkRelation { #[graphql(description = "Get the other work in the relationship")] pub fn related_work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.related_work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.related_work_id).map_err(Into::into) } } @@ -4185,7 +4113,7 @@ impl Reference { #[graphql(description = "The citing work.")] pub fn work(&self, context: &Context) -> FieldResult { - Work::from_id(&context.db, &self.work_id).map_err(|e| e.into()) + Work::from_id(&context.db, &self.work_id).map_err(Into::into) } } @@ -4195,24 +4123,18 @@ pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, MutationRoot {}, EmptySubscription::new()) } -fn publisher_id_from_imprint_id(db: &crate::db::PgPool, imprint_id: Uuid) -> ThothResult { - Ok(Imprint::from_id(db, &imprint_id)?.publisher_id) +fn publisher_id_from_imprint_id(db: &PgPool, imprint_id: &Uuid) -> ThothResult { + Ok(Imprint::from_id(db, imprint_id)?.publisher_id) } -fn publisher_id_from_work_id(db: &crate::db::PgPool, work_id: Uuid) -> ThothResult { - Work::from_id(db, &work_id)?.publisher_id(db) +fn publisher_id_from_work_id(db: &PgPool, work_id: &Uuid) -> ThothResult { + Work::from_id(db, work_id)?.publisher_id(db) } -fn publisher_id_from_publication_id( - db: &crate::db::PgPool, - publication_id: Uuid, -) -> ThothResult { - Publication::from_id(db, &publication_id)?.publisher_id(db) +fn publisher_id_from_publication_id(db: &PgPool, publication_id: &Uuid) -> ThothResult { + Publication::from_id(db, publication_id)?.publisher_id(db) } -fn publisher_id_from_contribution_id( - db: &crate::db::PgPool, - contribution_id: Uuid, -) -> ThothResult { - Contribution::from_id(db, &contribution_id)?.publisher_id(db) +fn publisher_id_from_contribution_id(db: &PgPool, contribution_id: &Uuid) -> ThothResult { + Contribution::from_id(db, contribution_id)?.publisher_id(db) } diff --git a/thoth-api/src/model/affiliation/crud.rs b/thoth-api/src/model/affiliation/crud.rs index 3aee12fb3..09630b8d7 100644 --- a/thoth-api/src/model/affiliation/crud.rs +++ b/thoth-api/src/model/affiliation/crud.rs @@ -124,10 +124,10 @@ impl Crud for Affiliation { 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()), } } @@ -152,13 +152,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 d3ad6b392..b57904a33 100644 --- a/thoth-api/src/model/affiliation/mod.rs +++ b/thoth-api/src/model/affiliation/mod.rs @@ -86,7 +86,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, } @@ -98,7 +98,7 @@ pub struct AffiliationHistory { )] pub struct NewAffiliationHistory { pub affiliation_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/contribution/crud.rs b/thoth-api/src/model/contribution/crud.rs index 4f40e7e8f..e4b4cd881 100644 --- a/thoth-api/src/model/contribution/crud.rs +++ b/thoth-api/src/model/contribution/crud.rs @@ -148,10 +148,10 @@ impl Crud for Contribution { 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()), } } @@ -176,13 +176,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 bf8265d99..cdc6d9bcd 100644 --- a/thoth-api/src/model/contribution/mod.rs +++ b/thoth-api/src/model/contribution/mod.rs @@ -191,7 +191,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, } @@ -203,7 +203,7 @@ pub struct ContributionHistory { )] pub struct NewContributionHistory { pub contribution_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index d3c960379..4305e0215 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -133,10 +133,10 @@ impl Crud for 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()), } } @@ -185,13 +185,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..79702d670 100644 --- a/thoth-api/src/model/contributor/mod.rs +++ b/thoth-api/src/model/contributor/mod.rs @@ -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, } diff --git a/thoth-api/src/model/funding/crud.rs b/thoth-api/src/model/funding/crud.rs index 0b14cfc90..81231542e 100644 --- a/thoth-api/src/model/funding/crud.rs +++ b/thoth-api/src/model/funding/crud.rs @@ -130,10 +130,10 @@ impl Crud for Funding { 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()), } } @@ -158,10 +158,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 d976ecdf3..a64b6baae 100644 --- a/thoth-api/src/model/funding/mod.rs +++ b/thoth-api/src/model/funding/mod.rs @@ -100,7 +100,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, } @@ -112,7 +112,7 @@ pub struct FundingHistory { )] pub struct NewFundingHistory { pub funding_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/imprint/crud.rs b/thoth-api/src/model/imprint/crud.rs index 49816b101..a59a33431 100644 --- a/thoth-api/src/model/imprint/crud.rs +++ b/thoth-api/src/model/imprint/crud.rs @@ -130,10 +130,10 @@ impl Crud for Imprint { 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()), } } @@ -158,10 +158,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 7333925d4..f2291e389 100644 --- a/thoth-api/src/model/imprint/mod.rs +++ b/thoth-api/src/model/imprint/mod.rs @@ -89,7 +89,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, } @@ -101,7 +101,7 @@ pub struct ImprintHistory { )] pub struct NewImprintHistory { pub imprint_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 1b0a6a062..8b23c0720 100644 --- a/thoth-api/src/model/institution/crud.rs +++ b/thoth-api/src/model/institution/crud.rs @@ -129,10 +129,10 @@ impl Crud for Institution { 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()), } } @@ -192,13 +192,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..535b5537b 100644 --- a/thoth-api/src/model/institution/mod.rs +++ b/thoth-api/src/model/institution/mod.rs @@ -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, } diff --git a/thoth-api/src/model/issue/crud.rs b/thoth-api/src/model/issue/crud.rs index e502c1b7d..ec0b0dd73 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -114,10 +114,10 @@ impl Crud for Issue { 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()), } } @@ -175,10 +175,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 4d933380a..757a704c6 100644 --- a/thoth-api/src/model/issue/mod.rs +++ b/thoth-api/src/model/issue/mod.rs @@ -73,7 +73,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, } @@ -81,7 +81,7 @@ 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, } diff --git a/thoth-api/src/model/language/crud.rs b/thoth-api/src/model/language/crud.rs index 66f7a7ed6..8b87a6864 100644 --- a/thoth-api/src/model/language/crud.rs +++ b/thoth-api/src/model/language/crud.rs @@ -130,10 +130,10 @@ impl Crud for Language { 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()), } } @@ -158,10 +158,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..22b6630ea 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, } diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index 39739c9b1..c7de79742 100644 --- a/thoth-api/src/model/location/crud.rs +++ b/thoth-api/src/model/location/crud.rs @@ -148,7 +148,7 @@ impl Crud for Location { &self, db: &crate::db::PgPool, data: &PatchLocation, - account_id: &Uuid, + user_id: &str, ) -> ThothResult { let mut connection = db.get()?; connection @@ -177,7 +177,7 @@ impl Crud for Location { } }) .and_then(|location| { - self.new_history_entry(account_id) + self.new_history_entry(user_id) .insert(&mut connection) .map(|_| location) }) @@ -196,10 +196,10 @@ impl Crud for Location { 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()), } } @@ -313,10 +313,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..1f5cc0db3 100644 --- a/thoth-api/src/model/location/mod.rs +++ b/thoth-api/src/model/location/mod.rs @@ -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, } diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index b8a087479..3375c31a6 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -350,7 +350,7 @@ where &self, db: &crate::db::PgPool, data: &Self::PatchEntity, - account_id: &Uuid, + user_id: &str, ) -> ThothResult; /// Delete the record from the database and obtain the deleted instance @@ -369,7 +369,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")] @@ -435,7 +435,7 @@ macro_rules! crud_methods { &self, db: &$crate::db::PgPool, data: &Self::PatchEntity, - account_id: &Uuid, + user_id: &str, ) -> ThothResult { use diesel::{Connection, QueryDsl, RunQueryDsl}; @@ -446,7 +446,7 @@ macro_rules! crud_methods { .get_result(connection) .map_err(Into::into) .and_then(|c| { - self.new_history_entry(&account_id) + self.new_history_entry(user_id) .insert(connection) .map(|_| c) }) diff --git a/thoth-api/src/model/price/crud.rs b/thoth-api/src/model/price/crud.rs index b213b0815..e8b363a9f 100644 --- a/thoth-api/src/model/price/crud.rs +++ b/thoth-api/src/model/price/crud.rs @@ -120,10 +120,10 @@ impl Crud for Price { 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()), } } @@ -148,10 +148,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..10bfe2718 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, } diff --git a/thoth-api/src/model/publication/crud.rs b/thoth-api/src/model/publication/crud.rs index c73bb347a..4b322203e 100644 --- a/thoth-api/src/model/publication/crud.rs +++ b/thoth-api/src/model/publication/crud.rs @@ -169,10 +169,10 @@ impl Crud for Publication { 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()), } } @@ -197,13 +197,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 7b7e2013e..eb650f6bf 100644 --- a/thoth-api/src/model/publication/mod.rs +++ b/thoth-api/src/model/publication/mod.rs @@ -223,7 +223,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, } @@ -235,7 +235,7 @@ pub struct PublicationHistory { )] pub struct NewPublicationHistory { pub publication_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/publisher/crud.rs b/thoth-api/src/model/publisher/crud.rs index b2776f018..bc53dacf4 100644 --- a/thoth-api/src/model/publisher/crud.rs +++ b/thoth-api/src/model/publisher/crud.rs @@ -127,10 +127,10 @@ impl Crud for Publisher { 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()), } } @@ -155,10 +155,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 bb98f002d..37ec1171f 100644 --- a/thoth-api/src/model/publisher/mod.rs +++ b/thoth-api/src/model/publisher/mod.rs @@ -73,7 +73,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, } @@ -85,7 +85,7 @@ pub struct PublisherHistory { )] pub struct NewPublisherHistory { pub publisher_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/reference/crud.rs b/thoth-api/src/model/reference/crud.rs index 960aabca3..ed86269b8 100644 --- a/thoth-api/src/model/reference/crud.rs +++ b/thoth-api/src/model/reference/crud.rs @@ -236,10 +236,10 @@ impl Crud for Reference { 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()), } } @@ -264,10 +264,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..097583816 100644 --- a/thoth-api/src/model/reference/mod.rs +++ b/thoth-api/src/model/reference/mod.rs @@ -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, } diff --git a/thoth-api/src/model/series/crud.rs b/thoth-api/src/model/series/crud.rs index 610486fc1..04cfaf2d5 100644 --- a/thoth-api/src/model/series/crud.rs +++ b/thoth-api/src/model/series/crud.rs @@ -161,10 +161,10 @@ impl Crud for Series { 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()), } } @@ -189,10 +189,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 c41fc6554..c2961fa9a 100644 --- a/thoth-api/src/model/series/mod.rs +++ b/thoth-api/src/model/series/mod.rs @@ -138,7 +138,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, } @@ -146,7 +146,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, } diff --git a/thoth-api/src/model/subject/crud.rs b/thoth-api/src/model/subject/crud.rs index 9c63fc986..be3fc393e 100644 --- a/thoth-api/src/model/subject/crud.rs +++ b/thoth-api/src/model/subject/crud.rs @@ -130,10 +130,10 @@ impl Crud for Subject { 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()), } } @@ -158,10 +158,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 236fb8032..edb48e09b 100644 --- a/thoth-api/src/model/subject/mod.rs +++ b/thoth-api/src/model/subject/mod.rs @@ -106,7 +106,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, } @@ -118,7 +118,7 @@ pub struct SubjectHistory { )] pub struct NewSubjectHistory { pub subject_id: Uuid, - pub account_id: Uuid, + pub user_id: String, pub data: serde_json::Value, } diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index 45009d8aa..03f2d554e 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -388,10 +388,10 @@ impl Crud for Work { 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()), } } @@ -416,10 +416,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 bcbe4b85e..1f795502a 100644 --- a/thoth-api/src/model/work/mod.rs +++ b/thoth-api/src/model/work/mod.rs @@ -369,7 +369,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, } @@ -377,7 +377,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, } diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index e9b0f3aa6..97b82b44c 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -164,7 +164,7 @@ impl Crud for WorkRelation { &self, db: &crate::db::PgPool, data: &PatchWorkRelation, - account_id: &Uuid, + user_id: &str, ) -> ThothResult { // For each Relator - Relationship - Related record we update, we must also // update the corresponding Related - InverseRelationship - Relator record. @@ -190,7 +190,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(user_id) .insert(connection) .map(|_| t) }) @@ -224,10 +224,10 @@ impl Crud for WorkRelation { 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()), } } @@ -279,13 +279,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 5959c6c76..b7ad26bd7 100644 --- a/thoth-api/src/model/work_relation/mod.rs +++ b/thoth-api/src/model/work_relation/mod.rs @@ -162,7 +162,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, } @@ -174,7 +174,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, } diff --git a/thoth-api/src/schema.rs b/thoth-api/src/schema.rs index e78c5350f..a8089e1e7 100644 --- a/thoth-api/src/schema.rs +++ b/thoth-api/src/schema.rs @@ -48,25 +48,6 @@ pub mod sql_types { pub struct RelationType; } -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, - } -} - table! { use diesel::sql_types::*; @@ -87,7 +68,7 @@ table! { affiliation_history (affiliation_history_id) { affiliation_history_id -> Uuid, affiliation_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -119,7 +100,7 @@ table! { contribution_history (contribution_history_id) { contribution_history_id -> Uuid, contribution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -146,7 +127,7 @@ table! { contributor_history (contributor_history_id) { contributor_history_id -> Uuid, contributor_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -175,7 +156,7 @@ table! { funding_history (funding_history_id) { funding_history_id -> Uuid, funding_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -201,7 +182,7 @@ table! { imprint_history (imprint_history_id) { imprint_history_id -> Uuid, imprint_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -228,7 +209,7 @@ table! { institution_history (institution_history_id) { institution_history_id -> Uuid, institution_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -253,7 +234,7 @@ table! { issue_history (issue_history_id) { issue_history_id -> Uuid, issue_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -281,7 +262,7 @@ table! { language_history (language_history_id) { language_history_id -> Uuid, language_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -309,7 +290,7 @@ table! { location_history (location_history_id) { location_history_id -> Uuid, location_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -335,7 +316,7 @@ table! { price_history (price_history_id) { price_history_id -> Uuid, price_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -369,7 +350,7 @@ table! { publication_history (publication_history_id) { publication_history_id -> Uuid, publication_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -388,25 +369,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, } @@ -450,7 +419,7 @@ table! { reference_history (reference_history_id) { reference_history_id -> Uuid, reference_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -481,7 +450,7 @@ table! { series_history (series_history_id) { series_history_id -> Uuid, series_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -508,7 +477,7 @@ table! { subject_history (subject_history_id) { subject_history_id -> Uuid, subject_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -566,7 +535,7 @@ table! { work_history (work_history_id) { work_history_id -> Uuid, work_id -> Uuid, - account_id -> Uuid, + user_id -> Text, data -> Jsonb, timestamp -> Timestamptz, } @@ -593,7 +562,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, } @@ -601,61 +570,41 @@ table! { joinable!(affiliation -> contribution (contribution_id)); joinable!(affiliation -> institution (institution_id)); -joinable!(affiliation_history -> account (account_id)); joinable!(affiliation_history -> affiliation (affiliation_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!(work -> imprint (imprint_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)); allow_tables_to_appear_in_same_query!( - account, affiliation, affiliation_history, contribution, @@ -679,7 +628,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 index 375e11a7c..cedc16da2 100644 --- a/thoth-app/src/models/work/create_work_mutation.rs +++ b/thoth-app/src/models/work/create_work_mutation.rs @@ -11,37 +11,6 @@ const CREATE_WORK_MUTATION: &str = " mutation CreateWork( $workType: WorkType!, $workStatus: WorkStatus!, - $fullTitle: String!, - $title: String!, - $subtitle: String, - $reference: String, - $edition: Int, - $imprintId: Uuid!, - $doi: Doi, - $publicationDate: Date, - $withdrawnDate: Date, - $place: String, - $pageCount: Int, - $pageBreakdown: String, - $imageCount: Int, - $tableCount: Int, - $audioCount: Int, - $videoCount: Int, - $license: String, - $copyrightHolder: String, - $landingPage: String, - $lccn: String, - $oclc: String, - $shortAbstract: String, - $longAbstract: String, - $generalNote: String, - $bibliographyNote: String, - $toc: String, - $coverUrl: String, - $coverCaption: String, - $firstPage: String, - $lastPage: String, - $pageInterval: String ) { createWork( data: { diff --git a/thoth-errors/Cargo.toml b/thoth-errors/Cargo.toml index fd9b86169..f6ea40228 100644 --- a/thoth-errors/Cargo.toml +++ b/thoth-errors/Cargo.toml @@ -27,4 +27,5 @@ juniper = "0.16.1" marc = { version = "3.1.1", features = ["xml"] } phf = { version = "0.11", features = ["macros"] } reqwest-middleware = "0.4" +tonic = "0.12.1" xml-rs = "0.8.25" diff --git a/thoth-errors/src/lib.rs b/thoth-errors/src/lib.rs index bbe92fee4..5a55256c5 100644 --- a/thoth-errors/src/lib.rs +++ b/thoth-errors/src/lib.rs @@ -309,6 +309,20 @@ impl From> for ThothError { } } +#[cfg(not(target_arch = "wasm32"))] +impl From> for ThothError { + fn from(e: Box) -> Self { + ThothError::InternalError(e.to_string()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for ThothError { + fn from(e: tonic::Status) -> Self { + ThothError::InternalError(e.to_string()) + } +} + impl From for ThothError { fn from(e: serde_json::Error) -> Self { ThothError::InternalError(e.to_string()) From 635269f450d23a1e4f14bb4d175929bffa0dd5cd Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Tue, 3 Jun 2025 15:11:21 +0100 Subject: [PATCH 02/21] Generate private key from setup --- Cargo.lock | 1 + Cargo.toml | 1 + src/bin/commands/zitadel.rs | 38 +++++++++++++++++++++++------ thoth-api/migrations/v0.14.0/up.sql | 1 + 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15adaf174..b6d154b01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4253,6 +4253,7 @@ dependencies = [ name = "thoth" version = "0.13.12" dependencies = [ + "base64 0.22.1", "clap", "dialoguer", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index 6c4d19caa..576d05b24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ thoth-api-server = { version = "=0.13.12", path = "thoth-api-server" } thoth-app-server = { version = "=0.13.12", path = "thoth-app-server" } thoth-errors = { version = "=0.13.12", path = "thoth-errors" } thoth-export-server = { version = "=0.13.12", 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" diff --git a/src/bin/commands/zitadel.rs b/src/bin/commands/zitadel.rs index c93973b25..433d83fc3 100644 --- a/src/bin/commands/zitadel.rs +++ b/src/bin/commands/zitadel.rs @@ -1,8 +1,10 @@ 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::{ + zitadel::authn::v1::KeyType, clients::ClientBuilder, zitadel::app::v1::{ ApiAuthMethodType, OidcAppType, OidcAuthMethodType, OidcGrantType, OidcResponseType, @@ -10,7 +12,7 @@ use zitadel::api::{ }, zitadel::management::v1::{ AddApiAppRequest, AddOidcAppRequest, AddProjectRequest, AddProjectRoleRequest, - AddUserGrantRequest, + AddUserGrantRequest, AddAppKeyRequest, }, zitadel::project::v1::PrivateLabelingSetting, zitadel::user::v2::{ListUsersRequest, UserFieldName}, @@ -45,9 +47,10 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { .await?; // Create Zitadel project + let project_name = "Thoth"; let project = management_client .add_project(AddProjectRequest { - name: "Thoth".to_string(), + name: project_name.to_string(), project_role_assertion: false, project_role_check: false, has_project_check: false, @@ -56,6 +59,7 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { }) .await? .into_inner(); + println!("\n✅ Created Zitadel project: {}", project_name); // Create project user roles let roles = [ @@ -72,6 +76,7 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { group: group.to_string(), }) .await?; + println!("\n✅ Added project role: {}", role_key); } // Assign SUPERUSER role to default accounts @@ -93,20 +98,36 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { role_keys: vec!["SUPERUSER".to_string()], }) .await?; + println!("\n✅ Granted SUPERUSER role to user: {}", user.username); } // Create Zitadel APPs for GraphQL API and APP - management_client + let graphql_api_name = "Thoth GraphQL API"; + let graphql_api = management_client .add_api_app(AddApiAppRequest { project_id: project.id.clone(), - name: "Thoth GraphQL API".to_string(), - auth_method_type: ApiAuthMethodType::Basic as i32, + name: graphql_api_name.to_string(), + auth_method_type: ApiAuthMethodType::PrivateKeyJwt as i32, }) - .await?; + .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, - name: "Thoth APP".to_string(), + 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], @@ -126,6 +147,7 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { login_version: None, }) .await?; + println!("\n✅ Created OIDC app: {}", app_name); Ok::<(), ThothError>(()) }) diff --git a/thoth-api/migrations/v0.14.0/up.sql b/thoth-api/migrations/v0.14.0/up.sql index b7e8d46d6..27ed7ffde 100644 --- a/thoth-api/migrations/v0.14.0/up.sql +++ b/thoth-api/migrations/v0.14.0/up.sql @@ -5,6 +5,7 @@ ALTER TABLE contributor_history DROP CONSTRAINT IF EXISTS contributor_his 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; From 4d56f8f0177b87412a9bcb05554122b07cdfb619 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Tue, 3 Jun 2025 15:11:49 +0100 Subject: [PATCH 03/21] Generate private key from setup --- src/bin/commands/zitadel.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/bin/commands/zitadel.rs b/src/bin/commands/zitadel.rs index 433d83fc3..2859ea841 100644 --- a/src/bin/commands/zitadel.rs +++ b/src/bin/commands/zitadel.rs @@ -4,15 +4,15 @@ use clap::{ArgMatches, Command}; use lazy_static::lazy_static; use thoth::errors::{ThothError, ThothResult}; use zitadel::api::{ - zitadel::authn::v1::KeyType, clients::ClientBuilder, zitadel::app::v1::{ ApiAuthMethodType, OidcAppType, OidcAuthMethodType, OidcGrantType, OidcResponseType, OidcTokenType, OidcVersion, }, + zitadel::authn::v1::KeyType, zitadel::management::v1::{ - AddApiAppRequest, AddOidcAppRequest, AddProjectRequest, AddProjectRoleRequest, - AddUserGrantRequest, AddAppKeyRequest, + AddApiAppRequest, AddAppKeyRequest, AddOidcAppRequest, AddProjectRequest, + AddProjectRoleRequest, AddUserGrantRequest, }, zitadel::project::v1::PrivateLabelingSetting, zitadel::user::v2::{ListUsersRequest, UserFieldName}, @@ -109,15 +109,19 @@ pub fn setup(arguments: &ArgMatches) -> ThothResult<()> { name: graphql_api_name.to_string(), auth_method_type: ApiAuthMethodType::PrivateKeyJwt as i32, }) - .await?.into_inner(); + .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 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"); From a52c64551a01a9bbca13e8e8f71ebd9a86654c1a Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Fri, 6 Jun 2025 11:57:16 +0100 Subject: [PATCH 04/21] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3161ccca3..f76031530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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. ## [[0.13.13]](https://github.com/thoth-pub/thoth/releases/tag/v0.13.13) - 2025-06-05 ### Changed From 7bc7b4c9c7768ce00e64538356a6cdf679c80269 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 7 Jan 2026 11:11:26 +0000 Subject: [PATCH 05/21] Rename migration --- thoth-api/migrations/{v0.14.0 => 20260107_v1.0.0}/down.sql | 0 thoth-api/migrations/{v0.14.0 => 20260107_v1.0.0}/up.sql | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename thoth-api/migrations/{v0.14.0 => 20260107_v1.0.0}/down.sql (100%) rename thoth-api/migrations/{v0.14.0 => 20260107_v1.0.0}/up.sql (100%) diff --git a/thoth-api/migrations/v0.14.0/down.sql b/thoth-api/migrations/20260107_v1.0.0/down.sql similarity index 100% rename from thoth-api/migrations/v0.14.0/down.sql rename to thoth-api/migrations/20260107_v1.0.0/down.sql diff --git a/thoth-api/migrations/v0.14.0/up.sql b/thoth-api/migrations/20260107_v1.0.0/up.sql similarity index 100% rename from thoth-api/migrations/v0.14.0/up.sql rename to thoth-api/migrations/20260107_v1.0.0/up.sql From 03c88de171adc285d0b8602542a417056f3b1589 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 7 Jan 2026 11:24:46 +0000 Subject: [PATCH 06/21] Add missing tables --- thoth-api/migrations/20260107_v1.0.0/down.sql | 12 ++++++++++++ thoth-api/migrations/20260107_v1.0.0/up.sql | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/thoth-api/migrations/20260107_v1.0.0/down.sql b/thoth-api/migrations/20260107_v1.0.0/down.sql index 554df754b..a94277fe8 100644 --- a/thoth-api/migrations/20260107_v1.0.0/down.sql +++ b/thoth-api/migrations/20260107_v1.0.0/down.sql @@ -30,7 +30,10 @@ CREATE TABLE publisher_account ( 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; @@ -45,10 +48,14 @@ ALTER TABLE publisher_history ALTER COLUMN user_id TYPE UUID USING user 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; @@ -63,11 +70,15 @@ 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); @@ -82,5 +93,6 @@ ALTER TABLE publisher_history ADD CONSTRAINT publisher_history_account_ 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 index 27ed7ffde..156921b8c 100644 --- a/thoth-api/migrations/20260107_v1.0.0/up.sql +++ b/thoth-api/migrations/20260107_v1.0.0/up.sql @@ -1,5 +1,8 @@ -- 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; @@ -15,11 +18,15 @@ ALTER TABLE publisher_history DROP CONSTRAINT IF EXISTS publisher_histo 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; @@ -34,10 +41,14 @@ 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; @@ -52,6 +63,7 @@ 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; From 2929c8d01bd781576147657e9be0e5409d25de18 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 7 Jan 2026 13:14:51 +0000 Subject: [PATCH 07/21] Fix auth bug --- thoth-api/src/graphql/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index b339ca449..076908583 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -2214,7 +2214,7 @@ impl MutationRoot { ) -> FieldResult { context.require_authentication()?; let work = Work::from_id(&context.db, &data.work_id)?; - let user = context.require_publisher(&work.work_id)?; + let user = context.require_publisher(&work.publisher_id(&context.db))?; if data.imprint_id != work.imprint_id { context.require_publisher(&publisher_id_from_imprint_id( From 9bcacdb8208a332df549fae7f1dc2aab6246397e Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 7 Jan 2026 15:03:05 +0000 Subject: [PATCH 08/21] Fix auth bug --- thoth-api/src/graphql/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 076908583..b25c1bfd9 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -2214,7 +2214,7 @@ impl MutationRoot { ) -> FieldResult { context.require_authentication()?; let work = Work::from_id(&context.db, &data.work_id)?; - let user = context.require_publisher(&work.publisher_id(&context.db))?; + let user = context.require_publisher(&work.publisher_id(&context.db)?)?; if data.imprint_id != work.imprint_id { context.require_publisher(&publisher_id_from_imprint_id( From f2cf91b3688fde65a3c7e504382e8590b735a7b9 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 7 Jan 2026 17:22:23 +0000 Subject: [PATCH 09/21] Abstract publisher_id --- thoth-api/src/graphql/model.rs | 118 +++++++--------------- thoth-api/src/model/abstract/crud.rs | 12 +-- thoth-api/src/model/affiliation/crud.rs | 9 +- thoth-api/src/model/biography/crud.rs | 15 ++- thoth-api/src/model/contact/crud.rs | 8 +- thoth-api/src/model/contribution/crud.rs | 8 +- thoth-api/src/model/contributor/crud.rs | 9 +- thoth-api/src/model/funding/crud.rs | 8 +- thoth-api/src/model/imprint/crud.rs | 8 +- thoth-api/src/model/institution/crud.rs | 6 -- thoth-api/src/model/issue/crud.rs | 8 +- thoth-api/src/model/language/crud.rs | 8 +- thoth-api/src/model/location/crud.rs | 8 +- thoth-api/src/model/mod.rs | 69 ++++++++++++- thoth-api/src/model/price/crud.rs | 8 +- thoth-api/src/model/publication/crud.rs | 8 +- thoth-api/src/model/publisher/crud.rs | 17 +++- thoth-api/src/model/reference/crud.rs | 7 +- thoth-api/src/model/series/crud.rs | 12 +-- thoth-api/src/model/subject/crud.rs | 8 +- thoth-api/src/model/title/crud.rs | 12 +-- thoth-api/src/model/work/crud.rs | 12 +-- thoth-api/src/model/work_relation/crud.rs | 6 -- 23 files changed, 195 insertions(+), 189 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index b25c1bfd9..3eecc390e 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -40,8 +40,8 @@ use crate::model::{ work_relation::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, }, - ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, Reorder, Ror, - Timestamp, WeightUnit, + ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, PublisherId, + Reorder, Ror, Timestamp, WeightUnit, }; use thoth_errors::{ThothError, ThothResult}; @@ -1886,10 +1886,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work to be created")] data: NewWork, ) -> FieldResult { - context.require_publisher(&publisher_id_from_imprint_id( - &context.db, - &data.imprint_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; data.validate()?; Work::create(&context.db, &data).map_err(Into::into) } @@ -1926,7 +1923,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contribution to be created")] data: NewContribution, ) -> FieldResult { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Contribution::create(&context.db, &data).map_err(Into::into) } @@ -1935,7 +1932,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publication to be created")] data: NewPublication, ) -> FieldResult { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; data.validate(&context.db)?; Publication::create(&context.db, &data).map_err(Into::into) } @@ -1945,10 +1942,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for series to be created")] data: NewSeries, ) -> FieldResult { - context.require_publisher(&publisher_id_from_imprint_id( - &context.db, - &data.imprint_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Series::create(&context.db, &data).map_err(Into::into) } @@ -1957,7 +1951,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for issue to be created")] data: NewIssue, ) -> FieldResult { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; data.imprints_match(&context.db)?; Issue::create(&context.db, &data).map_err(Into::into) } @@ -1967,7 +1961,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for language to be created")] data: NewLanguage, ) -> FieldResult { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Language::create(&context.db, &data).map_err(Into::into) } @@ -1979,7 +1973,7 @@ impl MutationRoot { >, #[graphql(description = "Values for title to be created")] data: NewTitle, ) -> FieldResult { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; let has_canonical_title = Work::from_id(&context.db, &data.work_id)? .title(context) @@ -2012,7 +2006,7 @@ impl MutationRoot { >, #[graphql(description = "Values for abstract to be created")] data: NewAbstract, ) -> FieldResult<Abstract> { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; let has_canonical_abstract = Abstract::all( &context.db, @@ -2056,10 +2050,7 @@ impl MutationRoot { >, #[graphql(description = "Values for biography to be created")] data: NewBiography, ) -> FieldResult<Biography> { - context.require_publisher(&publisher_id_from_contribution_id( - &context.db, - &data.contribution_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; let has_canonical_biography = Biography::all( &context.db, @@ -2103,7 +2094,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for funding to be created")] data: NewFunding, ) -> FieldResult<Funding> { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Funding::create(&context.db, &data).map_err(Into::into) } @@ -2112,10 +2103,8 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for location to be created")] data: NewLocation, ) -> FieldResult<Location> { - let user = context.require_publisher(&publisher_id_from_publication_id( - &context.db, - &data.publication_id, - )?)?; + let user = context.require_publisher(&data.publisher_id(&context.db)?)?; + // Only superusers can create new locations where Location Platform is Thoth if !user.is_superuser() && data.location_platform == LocationPlatform::Thoth { return Err(ThothError::ThothLocationError.into()); @@ -2135,10 +2124,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for price to be created")] data: NewPrice, ) -> FieldResult<Price> { - context.require_publisher(&publisher_id_from_publication_id( - &context.db, - &data.publication_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; if data.unit_price <= 0.0 { // Prices must be non-zero (and non-negative). @@ -2153,7 +2139,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for subject to be created")] data: NewSubject, ) -> FieldResult<Subject> { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; check_subject(&data.subject_type, &data.subject_code)?; Subject::create(&context.db, &data).map_err(Into::into) } @@ -2163,10 +2149,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, ) -> FieldResult<Affiliation> { - context.require_publisher(&publisher_id_from_contribution_id( - &context.db, - &data.contribution_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Affiliation::create(&context.db, &data).map_err(Into::into) } @@ -2194,7 +2177,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for reference to be created")] data: NewReference, ) -> FieldResult<Reference> { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; Reference::create(&context.db, &data).map_err(Into::into) } @@ -2217,10 +2200,7 @@ impl MutationRoot { let user = context.require_publisher(&work.publisher_id(&context.db)?)?; if data.imprint_id != work.imprint_id { - context.require_publisher(&publisher_id_from_imprint_id( - &context.db, - &data.imprint_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; work.can_update_imprint(&context.db)?; } @@ -2312,7 +2292,7 @@ impl MutationRoot { let user = context.require_publisher(&contribution.publisher_id(&context.db)?)?; if data.work_id != contribution.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } contribution .update(&context.db, &data, &user.user_id) @@ -2326,13 +2306,10 @@ impl MutationRoot { ) -> FieldResult<Publication> { context.require_authentication()?; let publication = Publication::from_id(&context.db, &data.publication_id)?; - let user = context.require_publisher(&publisher_id_from_publication_id( - &context.db, - &data.publication_id, - )?)?; + let user = context.require_publisher(&data.publisher_id(&context.db)?)?; if data.work_id != publication.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } data.validate(&context.db)?; @@ -2352,10 +2329,7 @@ impl MutationRoot { let user = context.require_publisher(&series.publisher_id(&context.db)?)?; if data.imprint_id != series.imprint_id { - context.require_publisher(&publisher_id_from_imprint_id( - &context.db, - &data.imprint_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } series .update(&context.db, &data, &user.user_id) @@ -2374,7 +2348,7 @@ impl MutationRoot { data.imprints_match(&context.db)?; if data.work_id != issue.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } issue .update(&context.db, &data, &user.user_id) @@ -2391,7 +2365,7 @@ impl MutationRoot { let user = context.require_publisher(&language.publisher_id(&context.db)?)?; if data.work_id != language.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } language @@ -2420,7 +2394,7 @@ impl MutationRoot { let user = context.require_publisher(&funding.publisher_id(&context.db)?)?; if data.work_id != funding.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } funding @@ -2458,10 +2432,7 @@ impl MutationRoot { } if data.publication_id != current_location.publication_id { - context.require_publisher(&publisher_id_from_publication_id( - &context.db, - &data.publication_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } if data.canonical { @@ -2483,10 +2454,7 @@ impl MutationRoot { let user = context.require_publisher(&price.publisher_id(&context.db)?)?; if data.publication_id != price.publication_id { - context.require_publisher(&publisher_id_from_publication_id( - &context.db, - &data.publication_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } if data.unit_price <= 0.0 { @@ -2509,7 +2477,7 @@ impl MutationRoot { let user = context.require_publisher(&subject.publisher_id(&context.db)?)?; if data.work_id != subject.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } check_subject(&data.subject_type, &data.subject_code)?; @@ -2529,10 +2497,7 @@ impl MutationRoot { let user = context.require_publisher(&affiliation.publisher_id(&context.db)?)?; if data.contribution_id != affiliation.contribution_id { - context.require_publisher(&publisher_id_from_contribution_id( - &context.db, - &data.contribution_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } affiliation @@ -2587,7 +2552,7 @@ impl MutationRoot { let user = context.require_publisher(&reference.publisher_id(&context.db)?)?; if data.work_id != reference.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } reference @@ -2626,7 +2591,7 @@ impl MutationRoot { let user = context.require_publisher(&title.publisher_id(&context.db)?)?; if data.work_id != title.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } let mut data = data.clone(); @@ -2658,7 +2623,7 @@ impl MutationRoot { let user = context.require_publisher(&r#abstract.publisher_id(&context.db)?)?; if data.work_id != r#abstract.work_id { - context.require_publisher(&publisher_id_from_work_id(&context.db, &data.work_id)?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } let mut data = data.clone(); @@ -2690,10 +2655,7 @@ impl MutationRoot { // If contribution changes, ensure permission on the new work via contribution if data.contribution_id != biography.contribution_id { - context.require_publisher(&publisher_id_from_contribution_id( - &context.db, - &data.contribution_id, - )?)?; + context.require_publisher(&data.publisher_id(&context.db)?)?; } let mut data = data.clone(); @@ -5371,18 +5333,6 @@ 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/model/abstract/crud.rs b/thoth-api/src/model/abstract/crud.rs index f45224276..143a0212e 100644 --- a/thoth-api/src/model/abstract/crud.rs +++ b/thoth-api/src/model/abstract/crud.rs @@ -4,7 +4,7 @@ use super::{ NewAbstractHistory, PatchAbstract, }; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +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,14 +146,14 @@ 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; diff --git a/thoth-api/src/model/affiliation/crud.rs b/thoth-api/src/model/affiliation/crud.rs index 3472af489..a88ad2f15 100644 --- a/thoth-api/src/model/affiliation/crud.rs +++ b/thoth-api/src/model/affiliation/crud.rs @@ -115,14 +115,13 @@ 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; diff --git a/thoth-api/src/model/biography/crud.rs b/thoth-api/src/model/biography/crud.rs index 945919bf8..1f49431cc 100644 --- a/thoth-api/src/model/biography/crud.rs +++ b/thoth-api/src/model/biography/crud.rs @@ -4,7 +4,7 @@ use super::{ NewBiographyHistory, PatchBiography, }; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{biography, biography_history}; use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; use thoth_errors::ThothResult; @@ -130,16 +130,15 @@ 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; diff --git a/thoth-api/src/model/contact/crud.rs b/thoth-api/src/model/contact/crud.rs index 946022cb9..663e0128a 100644 --- a/thoth-api/src/model/contact/crud.rs +++ b/thoth-api/src/model/contact/crud.rs @@ -112,13 +112,13 @@ 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; diff --git a/thoth-api/src/model/contribution/crud.rs b/thoth-api/src/model/contribution/crud.rs index b91ebd8d8..3d2f9e4d9 100644 --- a/thoth-api/src/model/contribution/crud.rs +++ b/thoth-api/src/model/contribution/crud.rs @@ -145,13 +145,13 @@ 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; diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index be055101c..d7f7628e9 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -8,7 +8,7 @@ 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,13 +122,6 @@ impl Crud for Contributor { .map(|t| t.to_string().parse::<i32>().unwrap()) .map_err(Into::into) } - - fn publisher_id(&self, _db: &crate::db::PgPool) -> ThothResult<Uuid> { - Err(ThothError::InternalError( - "Method publisher_id() is not supported for Contributor objects".to_string(), - )) - } - crud_methods!(contributor::table, contributor::dsl::contributor); } diff --git a/thoth-api/src/model/funding/crud.rs b/thoth-api/src/model/funding/crud.rs index cb6c35fe6..193c7fd5a 100644 --- a/thoth-api/src/model/funding/crud.rs +++ b/thoth-api/src/model/funding/crud.rs @@ -122,13 +122,13 @@ 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; diff --git a/thoth-api/src/model/imprint/crud.rs b/thoth-api/src/model/imprint/crud.rs index 104de6651..827bd9202 100644 --- a/thoth-api/src/model/imprint/crud.rs +++ b/thoth-api/src/model/imprint/crud.rs @@ -122,13 +122,13 @@ 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; diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 8284b442a..73041548f 100644 --- a/thoth-api/src/model/institution/crud.rs +++ b/thoth-api/src/model/institution/crud.rs @@ -119,12 +119,6 @@ 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); } diff --git a/thoth-api/src/model/issue/crud.rs b/thoth-api/src/model/issue/crud.rs index e7c136949..969cedda5 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -106,13 +106,13 @@ 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; diff --git a/thoth-api/src/model/language/crud.rs b/thoth-api/src/model/language/crud.rs index 43c57c2dd..644e21897 100644 --- a/thoth-api/src/model/language/crud.rs +++ b/thoth-api/src/model/language/crud.rs @@ -122,13 +122,13 @@ 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; diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index e48949bc4..d7f837ec5 100644 --- a/thoth-api/src/model/location/crud.rs +++ b/thoth-api/src/model/location/crud.rs @@ -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. @@ -195,6 +191,10 @@ 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; diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index f15bc9b60..6e05272cd 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -371,11 +371,78 @@ where /// 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>; } +/// 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 + } + } + }; +} + #[cfg(feature = "backend")] /// Common functionality to store history pub trait HistoryEntry diff --git a/thoth-api/src/model/price/crud.rs b/thoth-api/src/model/price/crud.rs index 6e844460d..d073c92c0 100644 --- a/thoth-api/src/model/price/crud.rs +++ b/thoth-api/src/model/price/crud.rs @@ -112,13 +112,13 @@ 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; diff --git a/thoth-api/src/model/publication/crud.rs b/thoth-api/src/model/publication/crud.rs index a246f7bbc..e6296236c 100644 --- a/thoth-api/src/model/publication/crud.rs +++ b/thoth-api/src/model/publication/crud.rs @@ -177,13 +177,13 @@ 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; diff --git a/thoth-api/src/model/publisher/crud.rs b/thoth-api/src/model/publisher/crud.rs index 06cdb7373..6d1229893 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::db::PgPool; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{publisher, publisher_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -127,11 +128,19 @@ 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 { diff --git a/thoth-api/src/model/reference/crud.rs b/thoth-api/src/model/reference/crud.rs index a09a2487a..6dc8a2394 100644 --- a/thoth-api/src/model/reference/crud.rs +++ b/thoth-api/src/model/reference/crud.rs @@ -230,12 +230,13 @@ 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; diff --git a/thoth-api/src/model/series/crud.rs b/thoth-api/src/model/series/crud.rs index 7f5f5c6a9..05f2c148f 100644 --- a/thoth-api/src/model/series/crud.rs +++ b/thoth-api/src/model/series/crud.rs @@ -3,7 +3,7 @@ use super::{ SeriesType, }; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{series, series_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -152,14 +152,14 @@ 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; diff --git a/thoth-api/src/model/subject/crud.rs b/thoth-api/src/model/subject/crud.rs index b4fa8cc78..9222d4427 100644 --- a/thoth-api/src/model/subject/crud.rs +++ b/thoth-api/src/model/subject/crud.rs @@ -125,13 +125,13 @@ 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; diff --git a/thoth-api/src/model/title/crud.rs b/thoth-api/src/model/title/crud.rs index 001f6a015..f3e246af4 100644 --- a/thoth-api/src/model/title/crud.rs +++ b/thoth-api/src/model/title/crud.rs @@ -3,7 +3,7 @@ use super::{ TitleOrderBy, }; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{title_history, work_title}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -144,14 +144,14 @@ 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; diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index 9a7843448..1fa034aef 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -5,7 +5,7 @@ use super::{ use crate::graphql::model::TimeExpression; use crate::graphql::utils::{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,14 +447,14 @@ 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; diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index c64944dc1..3706ca66c 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -230,12 +230,6 @@ 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(), - )) - } } impl HistoryEntry for WorkRelation { From b24632ac55b19711453ee1d05eb07c9c89b8585b Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Thu, 8 Jan 2026 07:13:46 +0000 Subject: [PATCH 10/21] Abstract authorisation --- thoth-api/src/graphql/model.rs | 244 +++++++++------------- thoth-api/src/model/contributor/crud.rs | 42 ++-- thoth-api/src/model/institution/crud.rs | 69 +++--- thoth-api/src/model/mod.rs | 78 +++++++ thoth-api/src/model/work_relation/crud.rs | 11 +- 5 files changed, 241 insertions(+), 203 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 3eecc390e..8f8cb9c7e 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -41,7 +41,7 @@ use crate::model::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, }, ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, PublisherId, - Reorder, Ror, Timestamp, WeightUnit, + PublisherIds, Reorder, Ror, Timestamp, WeightUnit, }; use thoth_errors::{ThothError, ThothResult}; @@ -133,6 +133,25 @@ impl Context { user.can_edit(publisher_id)?; Ok(user) } + + /// Authorise the current user against the publisher derived from the given value. + fn require_publisher_for<T: PublisherId>(&self, value: &T) -> ThothResult<&IntrospectedUser> { + let publisher_id = value.publisher_id(&self.db)?; + self.require_publisher(&publisher_id) + } + + /// 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 mut user: Option<&IntrospectedUser> = None; + for publisher_id in value.publisher_ids(&self.db)? { + user = Some(self.require_publisher(&publisher_id)?); + } + // If there are no publisher IDs, fall back to requiring authentication. + // This should be unusual, but prevents silently authorising unauthenticated users. + Ok(user.unwrap_or(self.require_authentication()?)) + } } #[derive(juniper::GraphQLInputObject)] @@ -1886,7 +1905,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work to be created")] data: NewWork, ) -> FieldResult<Work> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; data.validate()?; Work::create(&context.db, &data).map_err(Into::into) } @@ -1905,7 +1924,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for imprint to be created")] data: NewImprint, ) -> FieldResult<Imprint> { - context.require_publisher(&data.publisher_id)?; + context.require_publisher_for(&data)?; Imprint::create(&context.db, &data).map_err(Into::into) } @@ -1923,7 +1942,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contribution to be created")] data: NewContribution, ) -> FieldResult<Contribution> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Contribution::create(&context.db, &data).map_err(Into::into) } @@ -1932,7 +1951,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publication to be created")] data: NewPublication, ) -> FieldResult<Publication> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; data.validate(&context.db)?; Publication::create(&context.db, &data).map_err(Into::into) } @@ -1942,7 +1961,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for series to be created")] data: NewSeries, ) -> FieldResult<Series> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Series::create(&context.db, &data).map_err(Into::into) } @@ -1951,7 +1970,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for issue to be created")] data: NewIssue, ) -> FieldResult<Issue> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; data.imprints_match(&context.db)?; Issue::create(&context.db, &data).map_err(Into::into) } @@ -1961,7 +1980,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for language to be created")] data: NewLanguage, ) -> FieldResult<Language> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Language::create(&context.db, &data).map_err(Into::into) } @@ -1973,7 +1992,7 @@ impl MutationRoot { >, #[graphql(description = "Values for title to be created")] data: NewTitle, ) -> FieldResult<Title> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; let has_canonical_title = Work::from_id(&context.db, &data.work_id)? .title(context) @@ -2006,7 +2025,7 @@ impl MutationRoot { >, #[graphql(description = "Values for abstract to be created")] data: NewAbstract, ) -> FieldResult<Abstract> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; let has_canonical_abstract = Abstract::all( &context.db, @@ -2050,7 +2069,7 @@ impl MutationRoot { >, #[graphql(description = "Values for biography to be created")] data: NewBiography, ) -> FieldResult<Biography> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; let has_canonical_biography = Biography::all( &context.db, @@ -2094,7 +2113,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for funding to be created")] data: NewFunding, ) -> FieldResult<Funding> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Funding::create(&context.db, &data).map_err(Into::into) } @@ -2103,7 +2122,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for location to be created")] data: NewLocation, ) -> FieldResult<Location> { - let user = context.require_publisher(&data.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&data)?; // Only superusers can create new locations where Location Platform is Thoth if !user.is_superuser() && data.location_platform == LocationPlatform::Thoth { @@ -2124,7 +2143,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for price to be created")] data: NewPrice, ) -> FieldResult<Price> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; if data.unit_price <= 0.0 { // Prices must be non-zero (and non-negative). @@ -2139,7 +2158,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for subject to be created")] data: NewSubject, ) -> FieldResult<Subject> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; check_subject(&data.subject_type, &data.subject_code)?; Subject::create(&context.db, &data).map_err(Into::into) } @@ -2149,7 +2168,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, ) -> FieldResult<Affiliation> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Affiliation::create(&context.db, &data).map_err(Into::into) } @@ -2158,17 +2177,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, ) -> FieldResult<WorkRelation> { - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &data.relator_work_id, - )?)?; - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &data.related_work_id, - )?)?; - + context.require_publishers_for(&data)?; WorkRelation::create(&context.db, &data).map_err(Into::into) } @@ -2177,7 +2186,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for reference to be created")] data: NewReference, ) -> FieldResult<Reference> { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; Reference::create(&context.db, &data).map_err(Into::into) } @@ -2186,7 +2195,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contact to be created")] data: NewContact, ) -> FieldResult<Contact> { - context.require_publisher(&data.publisher_id)?; + context.require_publisher_for(&data)?; Contact::create(&context.db, &data).map_err(Into::into) } @@ -2197,10 +2206,10 @@ impl MutationRoot { ) -> FieldResult<Work> { context.require_authentication()?; let work = Work::from_id(&context.db, &data.work_id)?; - let user = context.require_publisher(&work.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&work)?; if data.imprint_id != work.imprint_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; work.can_update_imprint(&context.db)?; } @@ -2243,10 +2252,10 @@ impl MutationRoot { ) -> FieldResult<Publisher> { context.require_authentication()?; let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; - let user = context.require_publisher(&publisher.publisher_id)?; + let user = context.require_publisher_for(&publisher)?; if data.publisher_id != publisher.publisher_id { - context.require_publisher(&data.publisher_id)?; + context.require_publisher_for(&data)?; } publisher .update(&context.db, &data, &user.user_id) @@ -2260,10 +2269,10 @@ impl MutationRoot { ) -> FieldResult<Imprint> { context.require_authentication()?; let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; - let user = context.require_publisher(&imprint.publisher_id())?; + let user = context.require_publisher_for(&imprint)?; if data.publisher_id != imprint.publisher_id { - context.require_publisher(&data.publisher_id)?; + context.require_publisher_for(&data)?; } imprint .update(&context.db, &data, &user.user_id) @@ -2289,10 +2298,10 @@ impl MutationRoot { ) -> FieldResult<Contribution> { context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; - let user = context.require_publisher(&contribution.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&contribution)?; if data.work_id != contribution.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } contribution .update(&context.db, &data, &user.user_id) @@ -2306,10 +2315,10 @@ impl MutationRoot { ) -> FieldResult<Publication> { context.require_authentication()?; let publication = Publication::from_id(&context.db, &data.publication_id)?; - let user = context.require_publisher(&data.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&data)?; if data.work_id != publication.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } data.validate(&context.db)?; @@ -2326,10 +2335,10 @@ impl MutationRoot { ) -> FieldResult<Series> { context.require_authentication()?; let series = Series::from_id(&context.db, &data.series_id)?; - let user = context.require_publisher(&series.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&series)?; if data.imprint_id != series.imprint_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } series .update(&context.db, &data, &user.user_id) @@ -2343,12 +2352,12 @@ impl MutationRoot { ) -> FieldResult<Issue> { context.require_authentication()?; let issue = Issue::from_id(&context.db, &data.issue_id)?; - let user = context.require_publisher(&issue.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&issue)?; data.imprints_match(&context.db)?; if data.work_id != issue.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } issue .update(&context.db, &data, &user.user_id) @@ -2362,10 +2371,10 @@ impl MutationRoot { ) -> FieldResult<Language> { context.require_authentication()?; let language = Language::from_id(&context.db, &data.language_id)?; - let user = context.require_publisher(&language.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&language)?; if data.work_id != language.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } language @@ -2391,10 +2400,10 @@ impl MutationRoot { ) -> FieldResult<Funding> { context.require_authentication()?; let funding = Funding::from_id(&context.db, &data.funding_id)?; - let user = context.require_publisher(&funding.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&funding)?; if data.work_id != funding.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } funding @@ -2409,7 +2418,7 @@ impl MutationRoot { ) -> FieldResult<Location> { context.require_authentication()?; let current_location = Location::from_id(&context.db, &data.location_id)?; - let user = context.require_publisher(¤t_location.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(¤t_location)?; let has_canonical_thoth_location = Publication::from_id(&context.db, &data.publication_id)? .locations( @@ -2432,7 +2441,7 @@ impl MutationRoot { } if data.publication_id != current_location.publication_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } if data.canonical { @@ -2451,10 +2460,10 @@ impl MutationRoot { ) -> FieldResult<Price> { context.require_authentication()?; let price = Price::from_id(&context.db, &data.price_id)?; - let user = context.require_publisher(&price.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&price)?; if data.publication_id != price.publication_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } if data.unit_price <= 0.0 { @@ -2474,10 +2483,10 @@ impl MutationRoot { ) -> FieldResult<Subject> { context.require_authentication()?; let subject = Subject::from_id(&context.db, &data.subject_id)?; - let user = context.require_publisher(&subject.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&subject)?; if data.work_id != subject.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } check_subject(&data.subject_type, &data.subject_code)?; @@ -2494,10 +2503,10 @@ impl MutationRoot { ) -> FieldResult<Affiliation> { context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; - let user = context.require_publisher(&affiliation.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&affiliation)?; if data.contribution_id != affiliation.contribution_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } affiliation @@ -2513,29 +2522,8 @@ impl MutationRoot { ) -> FieldResult<WorkRelation> { let user = context.require_authentication()?; 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.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.relator_work_id, - )?)?; - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.related_work_id, - )?)?; - - if data.relator_work_id != work_relation.relator_work_id { - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &data.relator_work_id, - )?)?; - } - if data.related_work_id != work_relation.related_work_id { - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &data.related_work_id, - )?)?; - } + context.require_publishers_for(&work_relation)?; + context.require_publishers_for(&data)?; work_relation .update(&context.db, &data, &user.user_id) @@ -2549,10 +2537,10 @@ impl MutationRoot { ) -> FieldResult<Reference> { context.require_authentication()?; let reference = Reference::from_id(&context.db, &data.reference_id)?; - let user = context.require_publisher(&reference.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&reference)?; if data.work_id != reference.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } reference @@ -2567,10 +2555,10 @@ impl MutationRoot { ) -> FieldResult<Contact> { context.require_authentication()?; let contact = Contact::from_id(&context.db, &data.contact_id)?; - let user = context.require_publisher(&contact.publisher_id)?; + let user = context.require_publisher_for(&contact)?; if data.publisher_id != contact.publisher_id { - context.require_publisher(&data.publisher_id)?; + context.require_publisher_for(&data)?; } contact @@ -2588,10 +2576,10 @@ impl MutationRoot { ) -> FieldResult<Title> { context.require_authentication()?; let title = Title::from_id(&context.db, &data.title_id)?; - let user = context.require_publisher(&title.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&title)?; if data.work_id != title.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } let mut data = data.clone(); @@ -2620,10 +2608,10 @@ impl MutationRoot { ) -> FieldResult<Abstract> { context.require_authentication()?; let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; - let user = context.require_publisher(&r#abstract.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&r#abstract)?; if data.work_id != r#abstract.work_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } let mut data = data.clone(); @@ -2651,11 +2639,11 @@ impl MutationRoot { ) -> FieldResult<Biography> { context.require_authentication()?; let biography = Biography::from_id(&context.db, &data.biography_id)?; - let user = context.require_publisher(&biography.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&biography)?; // If contribution changes, ensure permission on the new work via contribution if data.contribution_id != biography.contribution_id { - context.require_publisher(&data.publisher_id(&context.db)?)?; + context.require_publisher_for(&data)?; } let mut data = data.clone(); @@ -2674,7 +2662,7 @@ impl MutationRoot { ) -> FieldResult<Work> { context.require_authentication()?; let work = Work::from_id(&context.db, &work_id)?; - let user = context.require_publisher(&work.publisher_id(&context.db)?)?; + let user = context.require_publisher_for(&work)?; if work.is_published() && !user.is_superuser() { return Err(ThothError::ThothDeleteWorkError.into()); @@ -2690,7 +2678,7 @@ impl MutationRoot { ) -> FieldResult<Publisher> { context.require_authentication()?; let publisher = Publisher::from_id(&context.db, &publisher_id)?; - context.require_publisher(&publisher_id)?; + context.require_publisher_for(&publisher)?; publisher.delete(&context.db).map_err(Into::into) } @@ -2702,7 +2690,7 @@ impl MutationRoot { ) -> FieldResult<Imprint> { context.require_authentication()?; let imprint = Imprint::from_id(&context.db, &imprint_id)?; - context.require_publisher(&imprint.publisher_id())?; + context.require_publisher_for(&imprint)?; imprint.delete(&context.db).map_err(Into::into) } @@ -2714,9 +2702,7 @@ impl MutationRoot { ) -> FieldResult<Contributor> { context.require_authentication()?; let contributor = Contributor::from_id(&context.db, &contributor_id)?; - for linked_publisher_id in contributor.linked_publisher_ids(&context.db)? { - context.require_publisher(&linked_publisher_id)?; - } + context.require_publishers_for(&contributor)?; contributor.delete(&context.db).map_err(Into::into) } @@ -2728,7 +2714,7 @@ impl MutationRoot { ) -> FieldResult<Contribution> { context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &contribution_id)?; - context.require_publisher(&contribution.publisher_id(&context.db)?)?; + context.require_publisher_for(&contribution)?; contribution.delete(&context.db).map_err(Into::into) } @@ -2740,7 +2726,7 @@ impl MutationRoot { ) -> FieldResult<Publication> { context.require_authentication()?; let publication = Publication::from_id(&context.db, &publication_id)?; - context.require_publisher(&publication.publisher_id(&context.db)?)?; + context.require_publisher_for(&publication)?; publication.delete(&context.db).map_err(Into::into) } @@ -2752,7 +2738,7 @@ impl MutationRoot { ) -> FieldResult<Series> { context.require_authentication()?; let series = Series::from_id(&context.db, &series_id)?; - context.require_publisher(&series.publisher_id(&context.db)?)?; + context.require_publisher_for(&series)?; series.delete(&context.db).map_err(Into::into) } @@ -2764,7 +2750,7 @@ impl MutationRoot { ) -> FieldResult<Issue> { context.require_authentication()?; let issue = Issue::from_id(&context.db, &issue_id)?; - context.require_publisher(&issue.publisher_id(&context.db)?)?; + context.require_publisher_for(&issue)?; issue.delete(&context.db).map_err(Into::into) } @@ -2776,7 +2762,7 @@ impl MutationRoot { ) -> FieldResult<Language> { context.require_authentication()?; let language = Language::from_id(&context.db, &language_id)?; - context.require_publisher(&language.publisher_id(&context.db)?)?; + context.require_publisher_for(&language)?; language.delete(&context.db).map_err(Into::into) } @@ -2788,7 +2774,7 @@ impl MutationRoot { ) -> FieldResult<Title> { context.require_authentication()?; let title = Title::from_id(&context.db, &title_id)?; - context.require_publisher(&title.publisher_id(&context.db)?)?; + context.require_publisher_for(&title)?; title.delete(&context.db).map_err(Into::into) } @@ -2800,9 +2786,7 @@ impl MutationRoot { ) -> FieldResult<Institution> { context.require_authentication()?; let institution = Institution::from_id(&context.db, &institution_id)?; - for linked_publisher_id in institution.linked_publisher_ids(&context.db)? { - context.require_publisher(&linked_publisher_id)?; - } + context.require_publishers_for(&institution)?; institution.delete(&context.db).map_err(Into::into) } @@ -2814,7 +2798,7 @@ impl MutationRoot { ) -> FieldResult<Funding> { context.require_authentication()?; let funding = Funding::from_id(&context.db, &funding_id)?; - context.require_publisher(&funding.publisher_id(&context.db)?)?; + context.require_publisher_for(&funding)?; funding.delete(&context.db).map_err(Into::into) } @@ -2830,7 +2814,7 @@ impl MutationRoot { if !user.is_superuser() && location.location_platform == LocationPlatform::Thoth { return Err(ThothError::ThothLocationError.into()); } - context.require_publisher(&location.publisher_id(&context.db)?)?; + context.require_publisher_for(&location)?; location.delete(&context.db).map_err(Into::into) } @@ -2842,7 +2826,7 @@ impl MutationRoot { ) -> FieldResult<Price> { context.require_authentication()?; let price = Price::from_id(&context.db, &price_id)?; - context.require_publisher(&price.publisher_id(&context.db)?)?; + context.require_publisher_for(&price)?; price.delete(&context.db).map_err(Into::into) } @@ -2854,7 +2838,7 @@ impl MutationRoot { ) -> FieldResult<Subject> { context.require_authentication()?; let subject = Subject::from_id(&context.db, &subject_id)?; - context.require_publisher(&subject.publisher_id(&context.db)?)?; + context.require_publisher_for(&subject)?; subject.delete(&context.db).map_err(Into::into) } @@ -2866,7 +2850,7 @@ impl MutationRoot { ) -> FieldResult<Affiliation> { context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; - context.require_publisher(&affiliation.publisher_id(&context.db)?)?; + context.require_publisher_for(&affiliation)?; affiliation.delete(&context.db).map_err(Into::into) } @@ -2878,16 +2862,7 @@ impl MutationRoot { ) -> FieldResult<WorkRelation> { context.require_authentication()?; 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.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.relator_work_id, - )?)?; - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.related_work_id, - )?)?; + context.require_publishers_for(&work_relation)?; work_relation.delete(&context.db).map_err(Into::into) } @@ -2899,7 +2874,7 @@ impl MutationRoot { ) -> FieldResult<Reference> { context.require_authentication()?; let reference = Reference::from_id(&context.db, &reference_id)?; - context.require_publisher(&reference.publisher_id(&context.db)?)?; + context.require_publisher_for(&reference)?; reference.delete(&context.db).map_err(Into::into) } @@ -2911,7 +2886,7 @@ impl MutationRoot { ) -> FieldResult<Abstract> { context.require_authentication()?; let r#abstract = Abstract::from_id(&context.db, &abstract_id)?; - context.require_publisher(&r#abstract.publisher_id(&context.db)?)?; + context.require_publisher_for(&r#abstract)?; r#abstract.delete(&context.db).map_err(Into::into) } @@ -2923,7 +2898,7 @@ impl MutationRoot { ) -> FieldResult<Biography> { context.require_authentication()?; let biography = Biography::from_id(&context.db, &biography_id)?; - context.require_publisher(&biography.publisher_id(&context.db)?)?; + context.require_publisher_for(&biography)?; biography.delete(&context.db).map_err(Into::into) } @@ -2945,7 +2920,7 @@ impl MutationRoot { return Ok(affiliation); } - context.require_publisher(&affiliation.publisher_id(&context.db)?)?; + context.require_publisher_for(&affiliation)?; affiliation .change_ordinal( @@ -2974,7 +2949,7 @@ impl MutationRoot { return Ok(contribution); } - context.require_publisher(&contribution.publisher_id(&context.db)?)?; + context.require_publisher_for(&contribution)?; contribution .change_ordinal( @@ -3001,7 +2976,7 @@ impl MutationRoot { return Ok(issue); } - context.require_publisher(&issue.publisher_id(&context.db)?)?; + context.require_publisher_for(&issue)?; issue .change_ordinal(&context.db, issue.issue_ordinal, new_ordinal, &user.user_id) @@ -3025,7 +3000,7 @@ impl MutationRoot { return Ok(reference); } - context.require_publisher(&reference.publisher_id(&context.db)?)?; + context.require_publisher_for(&reference)?; reference .change_ordinal( @@ -3052,7 +3027,7 @@ impl MutationRoot { return Ok(subject); } - context.require_publisher(&subject.publisher_id(&context.db)?)?; + context.require_publisher_for(&subject)?; subject .change_ordinal( @@ -3080,16 +3055,7 @@ impl MutationRoot { return Ok(work_relation); } - // Work relations may link works from different publishers. - // User must have permissions for all relevant publishers. - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.relator_work_id, - )?)?; - context.require_publisher(&publisher_id_from_work_id( - &context.db, - &work_relation.related_work_id, - )?)?; + context.require_publishers_for(&work_relation)?; work_relation .change_ordinal( @@ -3108,7 +3074,7 @@ impl MutationRoot { ) -> FieldResult<Contact> { context.require_authentication()?; let contact = Contact::from_id(&context.db, &contact_id)?; - context.require_publisher(&contact.publisher_id)?; + context.require_publisher_for(&contact)?; contact.delete(&context.db).map_err(Into::into) } @@ -5332,7 +5298,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_work_id(db: &PgPool, work_id: &Uuid) -> ThothResult<Uuid> { - Work::from_id(db, work_id)?.publisher_id(db) -} diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index d7f7628e9..c9d42bdfe 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -2,8 +2,9 @@ use super::{ Contributor, ContributorField, ContributorHistory, ContributorOrderBy, NewContributor, NewContributorHistory, PatchContributor, }; +use crate::db::PgPool; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{contributor, contributor_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -125,6 +126,21 @@ impl Crud for Contributor { crud_methods!(contributor::table, contributor::dsl::contributor); } +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) + } +} + impl HistoryEntry for Contributor { type NewHistoryEntity = NewContributorHistory; @@ -143,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::*; diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 73041548f..890c047ca 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::db::PgPool; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry}; +use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{institution, institution_history}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl, @@ -122,6 +123,37 @@ impl Crud for Institution { 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; @@ -140,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::*; diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 6e05272cd..9f2d98f05 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -382,6 +382,18 @@ where 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** @@ -443,6 +455,72 @@ macro_rules! publisher_id_impls { }; } +/// 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 diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index 3706ca66c..c762ba76a 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -3,7 +3,7 @@ use super::{ WorkRelationField, WorkRelationHistory, WorkRelationOrderBy, }; use crate::graphql::utils::Direction; -use crate::model::{Crud, DbInsert, HistoryEntry, Reorder}; +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, @@ -232,6 +232,15 @@ impl Crud for WorkRelation { } } +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; From 0ae56025cefb8f0ec796bceef06f6227d1b2a58f Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Thu, 8 Jan 2026 09:04:39 +0000 Subject: [PATCH 11/21] Abstract policy --- thoth-api/src/graphql/model.rs | 106 ++++----------------------------- thoth-api/src/lib.rs | 2 + thoth-api/src/policy.rs | 101 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 96 deletions(-) create mode 100644 thoth-api/src/policy.rs diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 8f8cb9c7e..2c11a6421 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -40,10 +40,11 @@ use crate::model::{ work_relation::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, }, - ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, PublisherId, - PublisherIds, Reorder, Ror, Timestamp, WeightUnit, + ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, Reorder, Ror, + Timestamp, WeightUnit, }; -use thoth_errors::{ThothError, ThothResult}; +use crate::policy::{PolicyContext, UserAccess}; +use thoth_errors::ThothError; impl juniper::Context for Context {} @@ -52,105 +53,18 @@ pub struct Context { pub user: Option<IntrospectedUser>, } -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) - } -} - impl Context { pub fn new(pool: Arc<PgPool>, user: Option<IntrospectedUser>) -> Self { Self { db: pool, user } } +} - fn require_authentication(&self) -> ThothResult<&IntrospectedUser> { - self.user.as_ref().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) - } - } - - fn require_publisher(&self, publisher_id: &Uuid) -> ThothResult<&IntrospectedUser> { - let user = self.require_authentication()?; - user.can_edit(publisher_id)?; - Ok(user) - } - - /// Authorise the current user against the publisher derived from the given value. - fn require_publisher_for<T: PublisherId>(&self, value: &T) -> ThothResult<&IntrospectedUser> { - let publisher_id = value.publisher_id(&self.db)?; - self.require_publisher(&publisher_id) +impl PolicyContext for Context { + fn db(&self) -> &PgPool { + &self.db } - - /// 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 mut user: Option<&IntrospectedUser> = None; - for publisher_id in value.publisher_ids(&self.db)? { - user = Some(self.require_publisher(&publisher_id)?); - } - // If there are no publisher IDs, fall back to requiring authentication. - // This should be unusual, but prevents silently authorising unauthenticated users. - Ok(user.unwrap_or(self.require_authentication()?)) + fn user(&self) -> Option<&IntrospectedUser> { + self.user.as_ref() } } diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index 3965e644a..5d6ddd57f 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -25,6 +25,8 @@ pub mod model; pub mod redis; #[cfg(feature = "backend")] mod schema; +#[cfg(feature = "backend")] +pub(crate) mod policy; macro_rules! apis { ($($name:ident => $content:expr,)*) => ( diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs new file mode 100644 index 000000000..d6f5b8419 --- /dev/null +++ b/thoth-api/src/policy.rs @@ -0,0 +1,101 @@ +use uuid::Uuid; +use zitadel::actix::introspection::IntrospectedUser; + +use crate::model::{PublisherId, PublisherIds}; +use crate::db::PgPool; +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 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) + } +} From e988c69584db19eed11208aafb3266af6cee13f7 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 12:57:37 +0000 Subject: [PATCH 12/21] Add authorization policies --- thoth-api/src/graphql/model.rs | 471 ++++++-------------- thoth-api/src/graphql/utils.rs | 2 - thoth-api/src/lib.rs | 4 +- thoth-api/src/model/abstract/mod.rs | 4 + thoth-api/src/model/abstract/policy.rs | 85 ++++ thoth-api/src/model/affiliation/mod.rs | 4 + thoth-api/src/model/affiliation/policy.rs | 49 ++ thoth-api/src/model/biography/mod.rs | 4 + thoth-api/src/model/biography/policy.rs | 70 +++ thoth-api/src/model/contact/mod.rs | 4 + thoth-api/src/model/contact/policy.rs | 37 ++ thoth-api/src/model/contribution/mod.rs | 4 + thoth-api/src/model/contribution/policy.rs | 52 +++ thoth-api/src/model/contributor/mod.rs | 4 + thoth-api/src/model/contributor/policy.rs | 41 ++ thoth-api/src/model/funding/mod.rs | 4 + thoth-api/src/model/funding/policy.rs | 38 ++ thoth-api/src/model/imprint/mod.rs | 4 + thoth-api/src/model/imprint/policy.rs | 37 ++ thoth-api/src/model/institution/mod.rs | 4 + thoth-api/src/model/institution/policy.rs | 40 ++ thoth-api/src/model/issue/crud.rs | 35 +- thoth-api/src/model/issue/mod.rs | 4 + thoth-api/src/model/issue/policy.rs | 67 +++ thoth-api/src/model/language/mod.rs | 4 + thoth-api/src/model/language/policy.rs | 37 ++ thoth-api/src/model/location/mod.rs | 4 + thoth-api/src/model/location/policy.rs | 97 ++++ thoth-api/src/model/price/mod.rs | 4 + thoth-api/src/model/price/policy.rs | 48 ++ thoth-api/src/model/publication/mod.rs | 4 + thoth-api/src/model/publication/policy.rs | 44 ++ thoth-api/src/model/publisher/mod.rs | 4 + thoth-api/src/model/publisher/policy.rs | 36 ++ thoth-api/src/model/reference/mod.rs | 4 + thoth-api/src/model/reference/policy.rs | 45 ++ thoth-api/src/model/series/mod.rs | 4 + thoth-api/src/model/series/policy.rs | 38 ++ thoth-api/src/model/subject/mod.rs | 35 +- thoth-api/src/model/subject/policy.rs | 71 +++ thoth-api/src/model/title/mod.rs | 3 + thoth-api/src/model/title/policy.rs | 70 +++ thoth-api/src/model/work/mod.rs | 4 + thoth-api/src/model/work/policy.rs | 51 +++ thoth-api/src/model/work_relation/mod.rs | 4 + thoth-api/src/model/work_relation/policy.rs | 50 +++ thoth-api/src/policy.rs | 32 +- 47 files changed, 1362 insertions(+), 399 deletions(-) create mode 100644 thoth-api/src/model/abstract/policy.rs create mode 100644 thoth-api/src/model/affiliation/policy.rs create mode 100644 thoth-api/src/model/biography/policy.rs create mode 100644 thoth-api/src/model/contact/policy.rs create mode 100644 thoth-api/src/model/contribution/policy.rs create mode 100644 thoth-api/src/model/contributor/policy.rs create mode 100644 thoth-api/src/model/funding/policy.rs create mode 100644 thoth-api/src/model/imprint/policy.rs create mode 100644 thoth-api/src/model/institution/policy.rs create mode 100644 thoth-api/src/model/issue/policy.rs create mode 100644 thoth-api/src/model/language/policy.rs create mode 100644 thoth-api/src/model/location/policy.rs create mode 100644 thoth-api/src/model/price/policy.rs create mode 100644 thoth-api/src/model/publication/policy.rs create mode 100644 thoth-api/src/model/publisher/policy.rs create mode 100644 thoth-api/src/model/reference/policy.rs create mode 100644 thoth-api/src/model/series/policy.rs create mode 100644 thoth-api/src/model/subject/policy.rs create mode 100644 thoth-api/src/model/title/policy.rs create mode 100644 thoth-api/src/model/work/policy.rs create mode 100644 thoth-api/src/model/work_relation/policy.rs diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 2c11a6421..91d5ff26c 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -5,45 +5,59 @@ 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 super::utils::{Direction, Expression}; use crate::db::PgPool; 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, ContributionField, ContributionPolicy, ContributionType, NewContribution, + PatchContribution, + }, + contributor::{ + Contributor, ContributorOrderBy, ContributorPolicy, NewContributor, PatchContributor, }, - 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}, + funding::{Funding, FundingField, FundingPolicy, NewFunding, PatchFunding}, + imprint::{Imprint, ImprintField, ImprintOrderBy, ImprintPolicy, NewImprint, PatchImprint}, + institution::{ + CountryCode, Institution, InstitutionOrderBy, InstitutionPolicy, NewInstitution, + PatchInstitution, + }, + issue::{Issue, IssueField, IssuePolicy, NewIssue, PatchIssue}, language::{ - Language, LanguageCode, LanguageField, LanguageRelation, NewLanguage, PatchLanguage, + Language, LanguageCode, LanguageField, 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, PriceField, 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, SubjectField, SubjectPolicy, SubjectType}, + title::{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, }; -use crate::policy::{PolicyContext, UserAccess}; +use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; use thoth_errors::ThothError; impl juniper::Context for Context {} @@ -1819,8 +1833,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work to be created")] data: NewWork, ) -> FieldResult<Work> { - context.require_publisher_for(&data)?; - data.validate()?; + WorkPolicy::can_create(context, &data, ())?; Work::create(&context.db, &data).map_err(Into::into) } @@ -1829,7 +1842,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publisher to be created")] data: NewPublisher, ) -> FieldResult<Publisher> { - context.require_superuser()?; + PublisherPolicy::can_create(context, &data, ())?; Publisher::create(&context.db, &data).map_err(Into::into) } @@ -1838,7 +1851,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for imprint to be created")] data: NewImprint, ) -> FieldResult<Imprint> { - context.require_publisher_for(&data)?; + ImprintPolicy::can_create(context, &data, ())?; Imprint::create(&context.db, &data).map_err(Into::into) } @@ -1847,7 +1860,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contributor to be created")] data: NewContributor, ) -> FieldResult<Contributor> { - context.require_authentication()?; + ContributorPolicy::can_create(context, &data, ())?; Contributor::create(&context.db, &data).map_err(Into::into) } @@ -1856,7 +1869,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contribution to be created")] data: NewContribution, ) -> FieldResult<Contribution> { - context.require_publisher_for(&data)?; + ContributionPolicy::can_create(context, &data, ())?; Contribution::create(&context.db, &data).map_err(Into::into) } @@ -1865,8 +1878,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for publication to be created")] data: NewPublication, ) -> FieldResult<Publication> { - context.require_publisher_for(&data)?; - data.validate(&context.db)?; + PublicationPolicy::can_create(context, &data, ())?; Publication::create(&context.db, &data).map_err(Into::into) } @@ -1875,7 +1887,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for series to be created")] data: NewSeries, ) -> FieldResult<Series> { - context.require_publisher_for(&data)?; + SeriesPolicy::can_create(context, &data, ())?; Series::create(&context.db, &data).map_err(Into::into) } @@ -1884,8 +1896,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for issue to be created")] data: NewIssue, ) -> FieldResult<Issue> { - context.require_publisher_for(&data)?; - data.imprints_match(&context.db)?; + IssuePolicy::can_create(context, &data, ())?; Issue::create(&context.db, &data).map_err(Into::into) } @@ -1894,7 +1905,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for language to be created")] data: NewLanguage, ) -> FieldResult<Language> { - context.require_publisher_for(&data)?; + LanguagePolicy::can_create(context, &data, ())?; Language::create(&context.db, &data).map_err(Into::into) } @@ -1906,19 +1917,11 @@ impl MutationRoot { >, #[graphql(description = "Values for title to be created")] data: NewTitle, ) -> FieldResult<Title> { - context.require_publisher_for(&data)?; - - let has_canonical_title = Work::from_id(&context.db, &data.work_id)? - .title(context) - .is_ok(); - - if has_canonical_title && data.canonical { - return Err(ThothError::CanonicalTitleExistsError.into()); - } + TitlePolicy::can_create(context, &data, markup_format)?; let mut data = data.clone(); - - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; data.subtitle = data .subtitle @@ -1939,39 +1942,13 @@ impl MutationRoot { >, #[graphql(description = "Values for abstract to be created")] data: NewAbstract, ) -> FieldResult<Abstract> { - context.require_publisher_for(&data)?; - - 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)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); 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) } @@ -1983,31 +1960,11 @@ impl MutationRoot { >, #[graphql(description = "Values for biography to be created")] data: NewBiography, ) -> FieldResult<Biography> { - context.require_publisher_for(&data)?; - - 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)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; Biography::create(&context.db, &data).map_err(Into::into) @@ -2018,7 +1975,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for institution to be created")] data: NewInstitution, ) -> FieldResult<Institution> { - context.require_authentication()?; + InstitutionPolicy::can_create(context, &data, ())?; Institution::create(&context.db, &data).map_err(Into::into) } @@ -2027,7 +1984,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for funding to be created")] data: NewFunding, ) -> FieldResult<Funding> { - context.require_publisher_for(&data)?; + FundingPolicy::can_create(context, &data, ())?; Funding::create(&context.db, &data).map_err(Into::into) } @@ -2036,19 +1993,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for location to be created")] data: NewLocation, ) -> FieldResult<Location> { - let user = context.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.into()); - } - - 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) } @@ -2057,13 +2002,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for price to be created")] data: NewPrice, ) -> FieldResult<Price> { - context.require_publisher_for(&data)?; - - 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) } @@ -2072,8 +2011,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for subject to be created")] data: NewSubject, ) -> FieldResult<Subject> { - context.require_publisher_for(&data)?; - check_subject(&data.subject_type, &data.subject_code)?; + SubjectPolicy::can_create(context, &data, ())?; Subject::create(&context.db, &data).map_err(Into::into) } @@ -2082,7 +2020,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for affiliation to be created")] data: NewAffiliation, ) -> FieldResult<Affiliation> { - context.require_publisher_for(&data)?; + AffiliationPolicy::can_create(context, &data, ())?; Affiliation::create(&context.db, &data).map_err(Into::into) } @@ -2091,7 +2029,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for work relation to be created")] data: NewWorkRelation, ) -> FieldResult<WorkRelation> { - context.require_publishers_for(&data)?; + WorkRelationPolicy::can_create(context, &data, ())?; WorkRelation::create(&context.db, &data).map_err(Into::into) } @@ -2100,7 +2038,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for reference to be created")] data: NewReference, ) -> FieldResult<Reference> { - context.require_publisher_for(&data)?; + ReferencePolicy::can_create(context, &data, ())?; Reference::create(&context.db, &data).map_err(Into::into) } @@ -2109,7 +2047,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values for contact to be created")] data: NewContact, ) -> FieldResult<Contact> { - context.require_publisher_for(&data)?; + ContactPolicy::can_create(context, &data, ())?; Contact::create(&context.db, &data).map_err(Into::into) } @@ -2118,24 +2056,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing work")] data: PatchWork, ) -> FieldResult<Work> { - context.require_authentication()?; + let user = context.require_authentication()?; let work = Work::from_id(&context.db, &data.work_id)?; - let user = context.require_publisher_for(&work)?; - - if data.imprint_id != work.imprint_id { - context.require_publisher_for(&data)?; - 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() && !user.is_superuser() { - return Err(ThothError::ThothSetWorkStatusError.into()); - } + WorkPolicy::can_update(context, &work, &data, ())?; // update the work and, if it succeeds, synchronise its children statuses and pub. date match work.update(&context.db, &data, &user.user_id) { @@ -2164,13 +2087,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, ) -> FieldResult<Publisher> { - context.require_authentication()?; + let user = context.require_authentication()?; let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; - let user = context.require_publisher_for(&publisher)?; + PublisherPolicy::can_update(context, &publisher, &data, ())?; - if data.publisher_id != publisher.publisher_id { - context.require_publisher_for(&data)?; - } publisher .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2181,13 +2101,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, ) -> FieldResult<Imprint> { - context.require_authentication()?; + let user = context.require_authentication()?; let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; - let user = context.require_publisher_for(&imprint)?; + ImprintPolicy::can_update(context, &imprint, &data, ())?; - if data.publisher_id != imprint.publisher_id { - context.require_publisher_for(&data)?; - } imprint .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2199,7 +2116,10 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, ) -> FieldResult<Contributor> { let user = context.require_authentication()?; - Contributor::from_id(&context.db, &data.contributor_id)? + let contributor = Contributor::from_id(&context.db, &data.contributor_id)?; + ContributorPolicy::can_update(context, &contributor, &data, ())?; + + contributor .update(&context.db, &data, &user.user_id) .map_err(Into::into) } @@ -2210,13 +2130,10 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contribution")] data: PatchContribution, ) -> FieldResult<Contribution> { - context.require_authentication()?; + let user = context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; - let user = context.require_publisher_for(&contribution)?; + ContributionPolicy::can_update(context, &contribution, &data, ())?; - if data.work_id != contribution.work_id { - context.require_publisher_for(&data)?; - } contribution .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2227,15 +2144,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, ) -> FieldResult<Publication> { - context.require_authentication()?; + let user = context.require_authentication()?; let publication = Publication::from_id(&context.db, &data.publication_id)?; - let user = context.require_publisher_for(&data)?; - - if data.work_id != publication.work_id { - context.require_publisher_for(&data)?; - } - - data.validate(&context.db)?; + PublicationPolicy::can_update(context, &publication, &data, ())?; publication .update(&context.db, &data, &user.user_id) @@ -2247,13 +2158,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing series")] data: PatchSeries, ) -> FieldResult<Series> { - context.require_authentication()?; + let user = context.require_authentication()?; let series = Series::from_id(&context.db, &data.series_id)?; - let user = context.require_publisher_for(&series)?; + SeriesPolicy::can_update(context, &series, &data, ())?; - if data.imprint_id != series.imprint_id { - context.require_publisher_for(&data)?; - } series .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2264,15 +2172,10 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, ) -> FieldResult<Issue> { - context.require_authentication()?; + let user = context.require_authentication()?; let issue = Issue::from_id(&context.db, &data.issue_id)?; - let user = context.require_publisher_for(&issue)?; + IssuePolicy::can_update(context, &issue, &data, ())?; - data.imprints_match(&context.db)?; - - if data.work_id != issue.work_id { - context.require_publisher_for(&data)?; - } issue .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2283,13 +2186,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, ) -> FieldResult<Language> { - context.require_authentication()?; + let user = context.require_authentication()?; let language = Language::from_id(&context.db, &data.language_id)?; - let user = context.require_publisher_for(&language)?; - - if data.work_id != language.work_id { - context.require_publisher_for(&data)?; - } + LanguagePolicy::can_update(context, &language, &data, ())?; language .update(&context.db, &data, &user.user_id) @@ -2302,7 +2201,10 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, ) -> FieldResult<Institution> { let user = context.require_authentication()?; - Institution::from_id(&context.db, &data.institution_id)? + let institution = Institution::from_id(&context.db, &data.institution_id)?; + InstitutionPolicy::can_update(context, &institution, &data, ())?; + + institution .update(&context.db, &data, &user.user_id) .map_err(Into::into) } @@ -2312,13 +2214,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, ) -> FieldResult<Funding> { - context.require_authentication()?; + let user = context.require_authentication()?; let funding = Funding::from_id(&context.db, &data.funding_id)?; - let user = context.require_publisher_for(&funding)?; - - if data.work_id != funding.work_id { - context.require_publisher_for(&data)?; - } + FundingPolicy::can_update(context, &funding, &data, ())?; funding .update(&context.db, &data, &user.user_id) @@ -2330,37 +2228,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing location")] data: PatchLocation, ) -> FieldResult<Location> { - context.require_authentication()?; + let user = context.require_authentication()?; let current_location = Location::from_id(&context.db, &data.location_id)?; - let user = context.require_publisher_for(¤t_location)?; - - 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 && !user.is_superuser() { - return Err(ThothError::ThothUpdateCanonicalError.into()); - } - - // Only superusers can edit locations where Location Platform is Thoth - if !user.is_superuser() && current_location.location_platform == LocationPlatform::Thoth { - return Err(ThothError::ThothLocationError.into()); - } - - if data.publication_id != current_location.publication_id { - context.require_publisher_for(&data)?; - } - - if data.canonical { - data.canonical_record_complete(&context.db)?; - } + LocationPolicy::can_update(context, ¤t_location, &data, ())?; current_location .update(&context.db, &data, &user.user_id) @@ -2372,18 +2242,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing price")] data: PatchPrice, ) -> FieldResult<Price> { - context.require_authentication()?; + let user = context.require_authentication()?; let price = Price::from_id(&context.db, &data.price_id)?; - let user = context.require_publisher_for(&price)?; - - if data.publication_id != price.publication_id { - context.require_publisher_for(&data)?; - } - - if data.unit_price <= 0.0 { - // Prices must be non-zero (and non-negative). - return Err(ThothError::PriceZeroError.into()); - } + PricePolicy::can_update(context, &price, &data, ())?; price .update(&context.db, &data, &user.user_id) @@ -2395,15 +2256,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, ) -> FieldResult<Subject> { - context.require_authentication()?; + let user = context.require_authentication()?; let subject = Subject::from_id(&context.db, &data.subject_id)?; - let user = context.require_publisher_for(&subject)?; - - if data.work_id != subject.work_id { - context.require_publisher_for(&data)?; - } - - check_subject(&data.subject_type, &data.subject_code)?; + SubjectPolicy::can_update(context, &subject, &data, ())?; subject .update(&context.db, &data, &user.user_id) @@ -2415,13 +2270,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, ) -> FieldResult<Affiliation> { - context.require_authentication()?; + let user = context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; - let user = context.require_publisher_for(&affiliation)?; - - if data.contribution_id != affiliation.contribution_id { - context.require_publisher_for(&data)?; - } + AffiliationPolicy::can_update(context, &affiliation, &data, ())?; affiliation .update(&context.db, &data, &user.user_id) @@ -2436,8 +2287,7 @@ impl MutationRoot { ) -> FieldResult<WorkRelation> { let user = context.require_authentication()?; let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id)?; - context.require_publishers_for(&work_relation)?; - context.require_publishers_for(&data)?; + WorkRelationPolicy::can_update(context, &work_relation, &data, ())?; work_relation .update(&context.db, &data, &user.user_id) @@ -2449,13 +2299,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing reference")] data: PatchReference, ) -> FieldResult<Reference> { - context.require_authentication()?; + let user = context.require_authentication()?; let reference = Reference::from_id(&context.db, &data.reference_id)?; - let user = context.require_publisher_for(&reference)?; - - if data.work_id != reference.work_id { - context.require_publisher_for(&data)?; - } + ReferencePolicy::can_update(context, &reference, &data, ())?; reference .update(&context.db, &data, &user.user_id) @@ -2467,13 +2313,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contact")] data: PatchContact, ) -> FieldResult<Contact> { - context.require_authentication()?; + let user = context.require_authentication()?; let contact = Contact::from_id(&context.db, &data.contact_id)?; - let user = context.require_publisher_for(&contact)?; - - if data.publisher_id != contact.publisher_id { - context.require_publisher_for(&data)?; - } + ContactPolicy::can_update(context, &contact, &data, ())?; contact .update(&context.db, &data, &user.user_id) @@ -2488,16 +2330,13 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing title")] data: PatchTitle, ) -> FieldResult<Title> { - context.require_authentication()?; + let user = context.require_authentication()?; let title = Title::from_id(&context.db, &data.title_id)?; - let user = context.require_publisher_for(&title)?; - - if data.work_id != title.work_id { - context.require_publisher_for(&data)?; - } + TitlePolicy::can_update(context, &title, &data, markup_format)?; let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; data.subtitle = data .subtitle @@ -2520,24 +2359,15 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing abstract")] data: PatchAbstract, ) -> FieldResult<Abstract> { - context.require_authentication()?; + let user = context.require_authentication()?; let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; - let user = context.require_publisher_for(&r#abstract)?; - - if data.work_id != r#abstract.work_id { - context.require_publisher_for(&data)?; - } + AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); 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()); - } - r#abstract .update(&context.db, &data, &user.user_id) .map_err(Into::into) @@ -2551,17 +2381,13 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing biography")] data: PatchBiography, ) -> FieldResult<Biography> { - context.require_authentication()?; + let user = context.require_authentication()?; let biography = Biography::from_id(&context.db, &data.biography_id)?; - let user = context.require_publisher_for(&biography)?; - - // If contribution changes, ensure permission on the new work via contribution - if data.contribution_id != biography.contribution_id { - context.require_publisher_for(&data)?; - } + BiographyPolicy::can_update(context, &biography, &data, markup_format)?; + // Safe to unwrap after policy check. + let markup = markup_format.unwrap(); let mut data = data.clone(); - let markup = markup_format.ok_or(ThothError::MissingMarkupFormat)?; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; biography @@ -2576,11 +2402,7 @@ impl MutationRoot { ) -> FieldResult<Work> { context.require_authentication()?; let work = Work::from_id(&context.db, &work_id)?; - let user = context.require_publisher_for(&work)?; - - if work.is_published() && !user.is_superuser() { - return Err(ThothError::ThothDeleteWorkError.into()); - } + WorkPolicy::can_delete(context, &work)?; work.delete(&context.db).map_err(Into::into) } @@ -2592,7 +2414,7 @@ impl MutationRoot { ) -> FieldResult<Publisher> { context.require_authentication()?; let publisher = Publisher::from_id(&context.db, &publisher_id)?; - context.require_publisher_for(&publisher)?; + PublisherPolicy::can_delete(context, &publisher)?; publisher.delete(&context.db).map_err(Into::into) } @@ -2604,7 +2426,7 @@ impl MutationRoot { ) -> FieldResult<Imprint> { context.require_authentication()?; let imprint = Imprint::from_id(&context.db, &imprint_id)?; - context.require_publisher_for(&imprint)?; + ImprintPolicy::can_delete(context, &imprint)?; imprint.delete(&context.db).map_err(Into::into) } @@ -2616,7 +2438,7 @@ impl MutationRoot { ) -> FieldResult<Contributor> { context.require_authentication()?; let contributor = Contributor::from_id(&context.db, &contributor_id)?; - context.require_publishers_for(&contributor)?; + ContributorPolicy::can_delete(context, &contributor)?; contributor.delete(&context.db).map_err(Into::into) } @@ -2628,7 +2450,7 @@ impl MutationRoot { ) -> FieldResult<Contribution> { context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &contribution_id)?; - context.require_publisher_for(&contribution)?; + ContributionPolicy::can_delete(context, &contribution)?; contribution.delete(&context.db).map_err(Into::into) } @@ -2640,7 +2462,7 @@ impl MutationRoot { ) -> FieldResult<Publication> { context.require_authentication()?; let publication = Publication::from_id(&context.db, &publication_id)?; - context.require_publisher_for(&publication)?; + PublicationPolicy::can_delete(context, &publication)?; publication.delete(&context.db).map_err(Into::into) } @@ -2652,7 +2474,7 @@ impl MutationRoot { ) -> FieldResult<Series> { context.require_authentication()?; let series = Series::from_id(&context.db, &series_id)?; - context.require_publisher_for(&series)?; + SeriesPolicy::can_delete(context, &series)?; series.delete(&context.db).map_err(Into::into) } @@ -2676,7 +2498,7 @@ impl MutationRoot { ) -> FieldResult<Language> { context.require_authentication()?; let language = Language::from_id(&context.db, &language_id)?; - context.require_publisher_for(&language)?; + LanguagePolicy::can_delete(context, &language)?; language.delete(&context.db).map_err(Into::into) } @@ -2688,7 +2510,7 @@ impl MutationRoot { ) -> FieldResult<Title> { context.require_authentication()?; let title = Title::from_id(&context.db, &title_id)?; - context.require_publisher_for(&title)?; + TitlePolicy::can_delete(context, &title)?; title.delete(&context.db).map_err(Into::into) } @@ -2700,7 +2522,7 @@ impl MutationRoot { ) -> FieldResult<Institution> { context.require_authentication()?; let institution = Institution::from_id(&context.db, &institution_id)?; - context.require_publishers_for(&institution)?; + InstitutionPolicy::can_delete(context, &institution)?; institution.delete(&context.db).map_err(Into::into) } @@ -2712,7 +2534,7 @@ impl MutationRoot { ) -> FieldResult<Funding> { context.require_authentication()?; let funding = Funding::from_id(&context.db, &funding_id)?; - context.require_publisher_for(&funding)?; + FundingPolicy::can_delete(context, &funding)?; funding.delete(&context.db).map_err(Into::into) } @@ -2722,13 +2544,9 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, ) -> FieldResult<Location> { - let user = context.require_authentication()?; + context.require_authentication()?; let location = Location::from_id(&context.db, &location_id)?; - // Only superusers can delete locations where Location Platform is Thoth - if !user.is_superuser() && location.location_platform == LocationPlatform::Thoth { - return Err(ThothError::ThothLocationError.into()); - } - context.require_publisher_for(&location)?; + LocationPolicy::can_delete(context, &location)?; location.delete(&context.db).map_err(Into::into) } @@ -2740,7 +2558,7 @@ impl MutationRoot { ) -> FieldResult<Price> { context.require_authentication()?; let price = Price::from_id(&context.db, &price_id)?; - context.require_publisher_for(&price)?; + PricePolicy::can_delete(context, &price)?; price.delete(&context.db).map_err(Into::into) } @@ -2752,7 +2570,7 @@ impl MutationRoot { ) -> FieldResult<Subject> { context.require_authentication()?; let subject = Subject::from_id(&context.db, &subject_id)?; - context.require_publisher_for(&subject)?; + SubjectPolicy::can_delete(context, &subject)?; subject.delete(&context.db).map_err(Into::into) } @@ -2764,7 +2582,7 @@ impl MutationRoot { ) -> FieldResult<Affiliation> { context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; - context.require_publisher_for(&affiliation)?; + AffiliationPolicy::can_delete(context, &affiliation)?; affiliation.delete(&context.db).map_err(Into::into) } @@ -2776,7 +2594,7 @@ impl MutationRoot { ) -> FieldResult<WorkRelation> { context.require_authentication()?; let work_relation = WorkRelation::from_id(&context.db, &work_relation_id)?; - context.require_publishers_for(&work_relation)?; + WorkRelationPolicy::can_delete(context, &work_relation)?; work_relation.delete(&context.db).map_err(Into::into) } @@ -2788,7 +2606,7 @@ impl MutationRoot { ) -> FieldResult<Reference> { context.require_authentication()?; let reference = Reference::from_id(&context.db, &reference_id)?; - context.require_publisher_for(&reference)?; + ReferencePolicy::can_delete(context, &reference)?; reference.delete(&context.db).map_err(Into::into) } @@ -2800,7 +2618,7 @@ impl MutationRoot { ) -> FieldResult<Abstract> { context.require_authentication()?; let r#abstract = Abstract::from_id(&context.db, &abstract_id)?; - context.require_publisher_for(&r#abstract)?; + AbstractPolicy::can_delete(context, &r#abstract)?; r#abstract.delete(&context.db).map_err(Into::into) } @@ -2812,7 +2630,7 @@ impl MutationRoot { ) -> FieldResult<Biography> { context.require_authentication()?; let biography = Biography::from_id(&context.db, &biography_id)?; - context.require_publisher_for(&biography)?; + BiographyPolicy::can_delete(context, &biography)?; biography.delete(&context.db).map_err(Into::into) } @@ -2828,14 +2646,13 @@ impl MutationRoot { ) -> FieldResult<Affiliation> { let user = context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; + AffiliationPolicy::can_move(context, &affiliation)?; if new_ordinal == affiliation.affiliation_ordinal { // No action required return Ok(affiliation); } - context.require_publisher_for(&affiliation)?; - affiliation .change_ordinal( &context.db, @@ -2857,14 +2674,13 @@ impl MutationRoot { ) -> FieldResult<Contribution> { let user = context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &contribution_id)?; + ContributionPolicy::can_move(context, &contribution)?; if new_ordinal == contribution.contribution_ordinal { // No action required return Ok(contribution); } - context.require_publisher_for(&contribution)?; - contribution .change_ordinal( &context.db, @@ -2884,14 +2700,13 @@ impl MutationRoot { ) -> FieldResult<Issue> { let user = context.require_authentication()?; let issue = Issue::from_id(&context.db, &issue_id)?; + IssuePolicy::can_move(context, &issue)?; if new_ordinal == issue.issue_ordinal { // No action required return Ok(issue); } - context.require_publisher_for(&issue)?; - issue .change_ordinal(&context.db, issue.issue_ordinal, new_ordinal, &user.user_id) .map_err(Into::into) @@ -2908,14 +2723,13 @@ impl MutationRoot { ) -> FieldResult<Reference> { let user = context.require_authentication()?; let reference = Reference::from_id(&context.db, &reference_id)?; + ReferencePolicy::can_move(context, &reference)?; if new_ordinal == reference.reference_ordinal { // No action required return Ok(reference); } - context.require_publisher_for(&reference)?; - reference .change_ordinal( &context.db, @@ -2935,14 +2749,13 @@ impl MutationRoot { ) -> FieldResult<Subject> { let user = context.require_authentication()?; let subject = Subject::from_id(&context.db, &subject_id)?; + SubjectPolicy::can_move(context, &subject)?; if new_ordinal == subject.subject_ordinal { // No action required return Ok(subject); } - context.require_publisher_for(&subject)?; - subject .change_ordinal( &context.db, @@ -2964,13 +2777,13 @@ impl MutationRoot { ) -> FieldResult<WorkRelation> { let user = context.require_authentication()?; let work_relation = WorkRelation::from_id(&context.db, &work_relation_id)?; + WorkRelationPolicy::can_move(context, &work_relation)?; + if new_ordinal == work_relation.relation_ordinal { // No action required return Ok(work_relation); } - context.require_publishers_for(&work_relation)?; - work_relation .change_ordinal( &context.db, @@ -2988,7 +2801,7 @@ impl MutationRoot { ) -> FieldResult<Contact> { context.require_authentication()?; let contact = Contact::from_id(&context.db, &contact_id)?; - context.require_publisher_for(&contact)?; + ContactPolicy::can_delete(context, &contact)?; contact.delete(&context.db).map_err(Into::into) } diff --git a/thoth-api/src/graphql/utils.rs b/thoth-api/src/graphql/utils.rs index 1f4e033ea..95440fea7 100644 --- a/thoth-api/src/graphql/utils.rs +++ b/thoth-api/src/graphql/utils.rs @@ -1,8 +1,6 @@ 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")] diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index 5d6ddd57f..d76c54c60 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -22,11 +22,11 @@ pub mod graphql; #[macro_use] pub mod model; #[cfg(feature = "backend")] +pub(crate) mod policy; +#[cfg(feature = "backend")] pub mod redis; #[cfg(feature = "backend")] mod schema; -#[cfg(feature = "backend")] -pub(crate) mod policy; macro_rules! apis { ($($name:ident => $content:expr,)*) => ( diff --git a/thoth-api/src/model/abstract/mod.rs b/thoth-api/src/model/abstract/mod.rs index 734ee4a3e..77a311ffe 100644 --- a/thoth-api/src/model/abstract/mod.rs +++ b/thoth-api/src/model/abstract/mod.rs @@ -138,3 +138,7 @@ pub struct AbstractHistory { #[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..a9b2981cf --- /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::model::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/mod.rs b/thoth-api/src/model/affiliation/mod.rs index 63d03cf38..b65a052e9 100644 --- a/thoth-api/src/model/affiliation/mod.rs +++ b/thoth-api/src/model/affiliation/mod.rs @@ -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/mod.rs b/thoth-api/src/model/biography/mod.rs index 8bf062dbc..35a496779 100644 --- a/thoth-api/src/model/biography/mod.rs +++ b/thoth-api/src/model/biography/mod.rs @@ -102,3 +102,7 @@ pub struct BiographyHistory { #[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..4a347d5c4 --- /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::model::biography::{Biography, NewBiography, PatchBiography}; +use crate::model::MarkupFormat; +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/mod.rs b/thoth-api/src/model/contact/mod.rs index e4fcb5dd4..4894947ca 100644 --- a/thoth-api/src/model/contact/mod.rs +++ b/thoth-api/src/model/contact/mod.rs @@ -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/mod.rs b/thoth-api/src/model/contribution/mod.rs index 766120757..2347ab503 100644 --- a/thoth-api/src/model/contribution/mod.rs +++ b/thoth-api/src/model/contribution/mod.rs @@ -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/mod.rs b/thoth-api/src/model/contributor/mod.rs index 79702d670..de0e21f9c 100644 --- a/thoth-api/src/model/contributor/mod.rs +++ b/thoth-api/src/model/contributor/mod.rs @@ -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/mod.rs b/thoth-api/src/model/funding/mod.rs index 9d4f5c020..99491035a 100644 --- a/thoth-api/src/model/funding/mod.rs +++ b/thoth-api/src/model/funding/mod.rs @@ -96,3 +96,7 @@ pub struct NewFundingHistory { #[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/mod.rs b/thoth-api/src/model/imprint/mod.rs index 8009309cf..9d190c91e 100644 --- a/thoth-api/src/model/imprint/mod.rs +++ b/thoth-api/src/model/imprint/mod.rs @@ -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/mod.rs b/thoth-api/src/model/institution/mod.rs index 535b5537b..904f3e1bf 100644 --- a/thoth-api/src/model/institution/mod.rs +++ b/thoth-api/src/model/institution/mod.rs @@ -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 969cedda5..cb3c9389e 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -4,7 +4,7 @@ use crate::graphql::utils::Direction; 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 { @@ -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::*; diff --git a/thoth-api/src/model/issue/mod.rs b/thoth-api/src/model/issue/mod.rs index 36fb29a00..31e16b419 100644 --- a/thoth-api/src/model/issue/mod.rs +++ b/thoth-api/src/model/issue/mod.rs @@ -76,3 +76,7 @@ pub struct NewIssueHistory { #[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/mod.rs b/thoth-api/src/model/language/mod.rs index 22b6630ea..c70fad8dd 100644 --- a/thoth-api/src/model/language/mod.rs +++ b/thoth-api/src/model/language/mod.rs @@ -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/mod.rs b/thoth-api/src/model/location/mod.rs index 1f5cc0db3..95cbd618b 100644 --- a/thoth-api/src/model/location/mod.rs +++ b/thoth-api/src/model/location/mod.rs @@ -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/price/mod.rs b/thoth-api/src/model/price/mod.rs index 10bfe2718..e171d3085 100644 --- a/thoth-api/src/model/price/mod.rs +++ b/thoth-api/src/model/price/mod.rs @@ -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/mod.rs b/thoth-api/src/model/publication/mod.rs index 172c0c1e1..ea9928b11 100644 --- a/thoth-api/src/model/publication/mod.rs +++ b/thoth-api/src/model/publication/mod.rs @@ -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/mod.rs b/thoth-api/src/model/publisher/mod.rs index 5cedd8df4..a1524680f 100644 --- a/thoth-api/src/model/publisher/mod.rs +++ b/thoth-api/src/model/publisher/mod.rs @@ -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/mod.rs b/thoth-api/src/model/reference/mod.rs index 097583816..361cb07a6 100644 --- a/thoth-api/src/model/reference/mod.rs +++ b/thoth-api/src/model/reference/mod.rs @@ -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/mod.rs b/thoth-api/src/model/series/mod.rs index 7443d3096..38be97024 100644 --- a/thoth-api/src/model/series/mod.rs +++ b/thoth-api/src/model/series/mod.rs @@ -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/mod.rs b/thoth-api/src/model/subject/mod.rs index 930ccc118..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", @@ -120,18 +118,6 @@ pub struct NewSubjectHistory { 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/mod.rs b/thoth-api/src/model/title/mod.rs index 647928a67..f10307bf4 100644 --- a/thoth-api/src/model/title/mod.rs +++ b/thoth-api/src/model/title/mod.rs @@ -162,3 +162,6 @@ title_properties!(PatchTitle); #[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..03a0946a6 --- /dev/null +++ b/thoth-api/src/model/title/policy.rs @@ -0,0 +1,70 @@ +use crate::model::title::{NewTitle, PatchTitle, Title}; +use crate::model::MarkupFormat; +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/mod.rs b/thoth-api/src/model/work/mod.rs index edfe0f577..e66b371dc 100644 --- a/thoth-api/src/model/work/mod.rs +++ b/thoth-api/src/model/work/mod.rs @@ -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/mod.rs b/thoth-api/src/model/work_relation/mod.rs index 3d5fddc18..01aa49506 100644 --- a/thoth-api/src/model/work_relation/mod.rs +++ b/thoth-api/src/model/work_relation/mod.rs @@ -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 index d6f5b8419..37025a2f0 100644 --- a/thoth-api/src/policy.rs +++ b/thoth-api/src/policy.rs @@ -1,8 +1,8 @@ use uuid::Uuid; use zitadel::actix::introspection::IntrospectedUser; -use crate::model::{PublisherId, PublisherIds}; use crate::db::PgPool; +use crate::model::{PublisherId, PublisherIds}; use thoth_errors::{ThothError, ThothResult}; pub(crate) trait UserAccess { @@ -99,3 +99,33 @@ pub(crate) trait PolicyContext { Ok(user) } } + +/// 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<()>; +} From c132a246326841cb2af96d2a26cc63c73a9f36ba Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 13:27:22 +0000 Subject: [PATCH 13/21] Add authorization policies --- thoth-api/src/graphql/model.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 91d5ff26c..9f05fd9f0 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -1919,9 +1919,9 @@ impl MutationRoot { ) -> FieldResult<Title> { TitlePolicy::can_create(context, &data, markup_format)?; - let mut data = data.clone(); + let mut data = data; // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); + let markup = markup_format.expect("Validated by policy"); data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; data.subtitle = data .subtitle @@ -1944,9 +1944,9 @@ impl MutationRoot { ) -> FieldResult<Abstract> { AbstractPolicy::can_create(context, &data, markup_format)?; - let mut data = data.clone(); + let mut data = data; // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; Abstract::create(&context.db, &data).map_err(Into::into) @@ -1963,8 +1963,8 @@ impl MutationRoot { BiographyPolicy::can_create(context, &data, markup_format)?; // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); - let mut data = data.clone(); + let markup = markup_format.expect("Validated by policy"); + let mut data = data; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; Biography::create(&context.db, &data).map_err(Into::into) @@ -2334,9 +2334,8 @@ impl MutationRoot { let title = Title::from_id(&context.db, &data.title_id)?; TitlePolicy::can_update(context, &title, &data, markup_format)?; - let mut data = data.clone(); - // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); + let mut data = data; + let markup = markup_format.expect("Validated by policy"); data.title = convert_to_jats(data.title, markup, ConversionLimit::Title)?; data.subtitle = data .subtitle @@ -2363,9 +2362,9 @@ impl MutationRoot { let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; - let mut data = data.clone(); + let mut data = data; // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); + let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; r#abstract @@ -2386,8 +2385,8 @@ impl MutationRoot { BiographyPolicy::can_update(context, &biography, &data, markup_format)?; // Safe to unwrap after policy check. - let markup = markup_format.unwrap(); - let mut data = data.clone(); + let markup = markup_format.expect("Validated by policy"); + let mut data = data; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; biography @@ -2486,7 +2485,7 @@ impl MutationRoot { ) -> FieldResult<Issue> { context.require_authentication()?; let issue = Issue::from_id(&context.db, &issue_id)?; - context.require_publisher_for(&issue)?; + IssuePolicy::can_delete(context, &issue)?; issue.delete(&context.db).map_err(Into::into) } From ba0b8dac1720ae605ff556a81fe3fee0f92a0194 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 16:04:38 +0000 Subject: [PATCH 14/21] Add authorization policies --- thoth-api/src/graphql/model.rs | 175 +++++++--------------- thoth-api/src/model/location/crud.rs | 11 +- thoth-api/src/model/mod.rs | 35 ++--- thoth-api/src/model/work_relation/crud.rs | 11 +- thoth-api/src/policy.rs | 6 + 5 files changed, 85 insertions(+), 153 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 9f05fd9f0..ec41128f6 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -2056,12 +2056,12 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing work")] data: PatchWork, ) -> FieldResult<Work> { - let user = context.require_authentication()?; + context.require_authentication()?; let work = Work::from_id(&context.db, &data.work_id)?; WorkPolicy::can_update(context, &work, &data, ())?; // update the work and, if it succeeds, synchronise its children statuses and pub. date - match work.update(&context.db, &data, &user.user_id) { + match work.update(context, &data) { 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)? { @@ -2073,7 +2073,7 @@ impl MutationRoot { data.publication_date = w.publication_date; data.withdrawn_date = w.withdrawn_date; data.work_status = w.work_status; - child.update(&context.db, &data, &user.user_id)?; + child.update(context, &data)?; } } Ok(w) @@ -2087,13 +2087,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, ) -> FieldResult<Publisher> { - let user = context.require_authentication()?; + context.require_authentication()?; let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; PublisherPolicy::can_update(context, &publisher, &data, ())?; - publisher - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + publisher.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing imprint with the specified values")] @@ -2101,13 +2099,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, ) -> FieldResult<Imprint> { - let user = context.require_authentication()?; + context.require_authentication()?; let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; ImprintPolicy::can_update(context, &imprint, &data, ())?; - imprint - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + imprint.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contributor with the specified values")] @@ -2115,13 +2111,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, ) -> FieldResult<Contributor> { - let user = context.require_authentication()?; + context.require_authentication()?; let contributor = Contributor::from_id(&context.db, &data.contributor_id)?; ContributorPolicy::can_update(context, &contributor, &data, ())?; - contributor - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + contributor.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contribution with the specified values")] @@ -2130,13 +2124,11 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contribution")] data: PatchContribution, ) -> FieldResult<Contribution> { - let user = context.require_authentication()?; + context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; ContributionPolicy::can_update(context, &contribution, &data, ())?; - contribution - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + contribution.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing publication with the specified values")] @@ -2144,13 +2136,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, ) -> FieldResult<Publication> { - let user = context.require_authentication()?; + context.require_authentication()?; let publication = Publication::from_id(&context.db, &data.publication_id)?; PublicationPolicy::can_update(context, &publication, &data, ())?; - publication - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + publication.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing series with the specified values")] @@ -2158,13 +2148,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing series")] data: PatchSeries, ) -> FieldResult<Series> { - let user = context.require_authentication()?; + context.require_authentication()?; let series = Series::from_id(&context.db, &data.series_id)?; SeriesPolicy::can_update(context, &series, &data, ())?; - series - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + series.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing issue with the specified values")] @@ -2172,13 +2160,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, ) -> FieldResult<Issue> { - let user = context.require_authentication()?; + context.require_authentication()?; let issue = Issue::from_id(&context.db, &data.issue_id)?; IssuePolicy::can_update(context, &issue, &data, ())?; - issue - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + issue.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing language with the specified values")] @@ -2186,13 +2172,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, ) -> FieldResult<Language> { - let user = context.require_authentication()?; + context.require_authentication()?; let language = Language::from_id(&context.db, &data.language_id)?; LanguagePolicy::can_update(context, &language, &data, ())?; - language - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + language.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing institution with the specified values")] @@ -2200,13 +2184,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, ) -> FieldResult<Institution> { - let user = context.require_authentication()?; + context.require_authentication()?; let institution = Institution::from_id(&context.db, &data.institution_id)?; InstitutionPolicy::can_update(context, &institution, &data, ())?; - institution - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + institution.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing funding with the specified values")] @@ -2214,13 +2196,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, ) -> FieldResult<Funding> { - let user = context.require_authentication()?; + context.require_authentication()?; let funding = Funding::from_id(&context.db, &data.funding_id)?; FundingPolicy::can_update(context, &funding, &data, ())?; - funding - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + funding.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing location with the specified values")] @@ -2228,13 +2208,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing location")] data: PatchLocation, ) -> FieldResult<Location> { - let user = context.require_authentication()?; + context.require_authentication()?; let current_location = Location::from_id(&context.db, &data.location_id)?; LocationPolicy::can_update(context, ¤t_location, &data, ())?; - current_location - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + current_location.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing price with the specified values")] @@ -2242,13 +2220,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing price")] data: PatchPrice, ) -> FieldResult<Price> { - let user = context.require_authentication()?; + context.require_authentication()?; let price = Price::from_id(&context.db, &data.price_id)?; PricePolicy::can_update(context, &price, &data, ())?; - price - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + price.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing subject with the specified values")] @@ -2256,13 +2232,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, ) -> FieldResult<Subject> { - let user = context.require_authentication()?; + context.require_authentication()?; let subject = Subject::from_id(&context.db, &data.subject_id)?; SubjectPolicy::can_update(context, &subject, &data, ())?; - subject - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + subject.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing affiliation with the specified values")] @@ -2270,13 +2244,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, ) -> FieldResult<Affiliation> { - let user = context.require_authentication()?; + context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; AffiliationPolicy::can_update(context, &affiliation, &data, ())?; - affiliation - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + affiliation.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing work relation with the specified values")] @@ -2285,13 +2257,11 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing work relation")] data: PatchWorkRelation, ) -> FieldResult<WorkRelation> { - let user = context.require_authentication()?; + context.require_authentication()?; let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id)?; WorkRelationPolicy::can_update(context, &work_relation, &data, ())?; - work_relation - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + work_relation.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing reference with the specified values")] @@ -2299,13 +2269,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing reference")] data: PatchReference, ) -> FieldResult<Reference> { - let user = context.require_authentication()?; + context.require_authentication()?; let reference = Reference::from_id(&context.db, &data.reference_id)?; ReferencePolicy::can_update(context, &reference, &data, ())?; - reference - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + reference.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing contact with the specified values")] @@ -2313,13 +2281,11 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contact")] data: PatchContact, ) -> FieldResult<Contact> { - let user = context.require_authentication()?; + context.require_authentication()?; let contact = Contact::from_id(&context.db, &data.contact_id)?; ContactPolicy::can_update(context, &contact, &data, ())?; - contact - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + contact.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing title with the specified values")] @@ -2330,7 +2296,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing title")] data: PatchTitle, ) -> FieldResult<Title> { - let user = context.require_authentication()?; + context.require_authentication()?; let title = Title::from_id(&context.db, &data.title_id)?; TitlePolicy::can_update(context, &title, &data, markup_format)?; @@ -2345,9 +2311,7 @@ impl MutationRoot { .transpose()?; data.full_title = convert_to_jats(data.full_title, markup, ConversionLimit::Title)?; - title - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + title.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing abstract with the specified values")] @@ -2358,7 +2322,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing abstract")] data: PatchAbstract, ) -> FieldResult<Abstract> { - let user = context.require_authentication()?; + context.require_authentication()?; let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; @@ -2367,9 +2331,7 @@ impl MutationRoot { let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; - r#abstract - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + r#abstract.update(context, &data).map_err(Into::into) } #[graphql(description = "Update an existing biography with the specified values")] @@ -2380,7 +2342,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing biography")] data: PatchBiography, ) -> FieldResult<Biography> { - let user = context.require_authentication()?; + context.require_authentication()?; let biography = Biography::from_id(&context.db, &data.biography_id)?; BiographyPolicy::can_update(context, &biography, &data, markup_format)?; @@ -2389,9 +2351,7 @@ impl MutationRoot { let mut data = data; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; - biography - .update(&context.db, &data, &user.user_id) - .map_err(Into::into) + biography.update(context, &data).map_err(Into::into) } #[graphql(description = "Delete a single work using its ID")] @@ -2643,7 +2603,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Affiliation> { - let user = context.require_authentication()?; + context.require_authentication()?; let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; AffiliationPolicy::can_move(context, &affiliation)?; @@ -2653,12 +2613,7 @@ impl MutationRoot { } affiliation - .change_ordinal( - &context.db, - affiliation.affiliation_ordinal, - new_ordinal, - &user.user_id, - ) + .change_ordinal(context, affiliation.affiliation_ordinal, new_ordinal) .map_err(Into::into) } @@ -2671,7 +2626,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Contribution> { - let user = context.require_authentication()?; + context.require_authentication()?; let contribution = Contribution::from_id(&context.db, &contribution_id)?; ContributionPolicy::can_move(context, &contribution)?; @@ -2681,12 +2636,7 @@ impl MutationRoot { } contribution - .change_ordinal( - &context.db, - contribution.contribution_ordinal, - new_ordinal, - &user.user_id, - ) + .change_ordinal(context, contribution.contribution_ordinal, new_ordinal) .map_err(Into::into) } @@ -2697,7 +2647,7 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which issue should be moved")] new_ordinal: i32, ) -> FieldResult<Issue> { - let user = context.require_authentication()?; + context.require_authentication()?; let issue = Issue::from_id(&context.db, &issue_id)?; IssuePolicy::can_move(context, &issue)?; @@ -2707,7 +2657,7 @@ impl MutationRoot { } issue - .change_ordinal(&context.db, issue.issue_ordinal, new_ordinal, &user.user_id) + .change_ordinal(context, issue.issue_ordinal, new_ordinal) .map_err(Into::into) } @@ -2720,7 +2670,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Reference> { - let user = context.require_authentication()?; + context.require_authentication()?; let reference = Reference::from_id(&context.db, &reference_id)?; ReferencePolicy::can_move(context, &reference)?; @@ -2730,12 +2680,7 @@ impl MutationRoot { } reference - .change_ordinal( - &context.db, - reference.reference_ordinal, - new_ordinal, - &user.user_id, - ) + .change_ordinal(context, reference.reference_ordinal, new_ordinal) .map_err(Into::into) } @@ -2746,7 +2691,7 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which subject should be moved")] new_ordinal: i32, ) -> FieldResult<Subject> { - let user = context.require_authentication()?; + context.require_authentication()?; let subject = Subject::from_id(&context.db, &subject_id)?; SubjectPolicy::can_move(context, &subject)?; @@ -2756,12 +2701,7 @@ impl MutationRoot { } subject - .change_ordinal( - &context.db, - subject.subject_ordinal, - new_ordinal, - &user.user_id, - ) + .change_ordinal(context, subject.subject_ordinal, new_ordinal) .map_err(Into::into) } @@ -2774,7 +2714,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<WorkRelation> { - let user = context.require_authentication()?; + context.require_authentication()?; let work_relation = WorkRelation::from_id(&context.db, &work_relation_id)?; WorkRelationPolicy::can_move(context, &work_relation)?; @@ -2784,12 +2724,7 @@ impl MutationRoot { } work_relation - .change_ordinal( - &context.db, - work_relation.relation_ordinal, - new_ordinal, - &user.user_id, - ) + .change_ordinal(context, work_relation.relation_ordinal, new_ordinal) .map_err(Into::into) } diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index d7f837ec5..763e21db4 100644 --- a/thoth-api/src/model/location/crud.rs +++ b/thoth-api/src/model/location/crud.rs @@ -142,13 +142,12 @@ impl Crud for Location { }) } - fn update( + fn update<C: crate::policy::PolicyContext>( &self, - db: &crate::db::PgPool, + ctx: &C, data: &PatchLocation, - user_id: &str, ) -> ThothResult<Self> { - let mut connection = db.get()?; + let mut connection = ctx.db().get()?; connection .transaction(|connection| { if data.canonical == self.canonical { @@ -163,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) @@ -175,7 +174,7 @@ impl Crud for Location { } }) .and_then(|location| { - self.new_history_entry(user_id) + self.new_history_entry(ctx.user_id()?) .insert(&mut connection) .map(|_| location) }) diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 9f2d98f05..4d43fe07d 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -3,6 +3,7 @@ use crate::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}; @@ -300,7 +301,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,12 +363,7 @@ 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, - user_id: &str, - ) -> 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>; @@ -547,16 +543,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, - user_id: &str, ) -> ThothResult<Self>; fn get_other_objects( @@ -613,22 +608,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, - user_id: &str, ) -> 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(user_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| c) }) @@ -746,14 +740,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, - user_id: &str, ) -> 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| { @@ -793,7 +786,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(user_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| t) }) diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index c762ba76a..f7ae45754 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -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, - user_id: &str, ) -> 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(user_id) + self.new_history_entry(ctx.user_id()?) .insert(connection) .map(|_| t) }) diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs index 37025a2f0..11817cae4 100644 --- a/thoth-api/src/policy.rs +++ b/thoth-api/src/policy.rs @@ -71,6 +71,12 @@ pub(crate) trait PolicyContext { 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() { From 2b14c0a8208920ae4d8a74714f1b31a0aaf8779e Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 16:45:51 +0000 Subject: [PATCH 15/21] Add authorization policies --- thoth-api/src/graphql/model.rs | 144 +++++++++++---------------------- thoth-api/src/policy.rs | 8 +- 2 files changed, 55 insertions(+), 97 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index ec41128f6..ad13c3b77 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -2056,8 +2056,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing work")] data: PatchWork, ) -> FieldResult<Work> { - context.require_authentication()?; - let work = Work::from_id(&context.db, &data.work_id)?; + let work = context.load_current(&data.work_id)?; WorkPolicy::can_update(context, &work, &data, ())?; // update the work and, if it succeeds, synchronise its children statuses and pub. date @@ -2087,8 +2086,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publisher")] data: PatchPublisher, ) -> FieldResult<Publisher> { - context.require_authentication()?; - let publisher = Publisher::from_id(&context.db, &data.publisher_id)?; + let publisher = context.load_current(&data.publisher_id)?; PublisherPolicy::can_update(context, &publisher, &data, ())?; publisher.update(context, &data).map_err(Into::into) @@ -2099,8 +2097,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing imprint")] data: PatchImprint, ) -> FieldResult<Imprint> { - context.require_authentication()?; - let imprint = Imprint::from_id(&context.db, &data.imprint_id)?; + let imprint = context.load_current(&data.imprint_id)?; ImprintPolicy::can_update(context, &imprint, &data, ())?; imprint.update(context, &data).map_err(Into::into) @@ -2111,8 +2108,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contributor")] data: PatchContributor, ) -> FieldResult<Contributor> { - context.require_authentication()?; - let contributor = Contributor::from_id(&context.db, &data.contributor_id)?; + let contributor = context.load_current(&data.contributor_id)?; ContributorPolicy::can_update(context, &contributor, &data, ())?; contributor.update(context, &data).map_err(Into::into) @@ -2124,8 +2120,7 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing contribution")] data: PatchContribution, ) -> FieldResult<Contribution> { - context.require_authentication()?; - let contribution = Contribution::from_id(&context.db, &data.contribution_id)?; + let contribution = context.load_current(&data.contribution_id)?; ContributionPolicy::can_update(context, &contribution, &data, ())?; contribution.update(context, &data).map_err(Into::into) @@ -2136,8 +2131,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing publication")] data: PatchPublication, ) -> FieldResult<Publication> { - context.require_authentication()?; - let publication = Publication::from_id(&context.db, &data.publication_id)?; + let publication = context.load_current(&data.publication_id)?; PublicationPolicy::can_update(context, &publication, &data, ())?; publication.update(context, &data).map_err(Into::into) @@ -2148,8 +2142,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing series")] data: PatchSeries, ) -> FieldResult<Series> { - context.require_authentication()?; - let series = Series::from_id(&context.db, &data.series_id)?; + let series = context.load_current(&data.series_id)?; SeriesPolicy::can_update(context, &series, &data, ())?; series.update(context, &data).map_err(Into::into) @@ -2160,8 +2153,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing issue")] data: PatchIssue, ) -> FieldResult<Issue> { - context.require_authentication()?; - let issue = Issue::from_id(&context.db, &data.issue_id)?; + let issue = context.load_current(&data.issue_id)?; IssuePolicy::can_update(context, &issue, &data, ())?; issue.update(context, &data).map_err(Into::into) @@ -2172,8 +2164,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing language")] data: PatchLanguage, ) -> FieldResult<Language> { - context.require_authentication()?; - let language = Language::from_id(&context.db, &data.language_id)?; + let language = context.load_current(&data.language_id)?; LanguagePolicy::can_update(context, &language, &data, ())?; language.update(context, &data).map_err(Into::into) @@ -2184,8 +2175,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing institution")] data: PatchInstitution, ) -> FieldResult<Institution> { - context.require_authentication()?; - let institution = Institution::from_id(&context.db, &data.institution_id)?; + let institution = context.load_current(&data.institution_id)?; InstitutionPolicy::can_update(context, &institution, &data, ())?; institution.update(context, &data).map_err(Into::into) @@ -2196,8 +2186,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing funding")] data: PatchFunding, ) -> FieldResult<Funding> { - context.require_authentication()?; - let funding = Funding::from_id(&context.db, &data.funding_id)?; + let funding = context.load_current(&data.funding_id)?; FundingPolicy::can_update(context, &funding, &data, ())?; funding.update(context, &data).map_err(Into::into) @@ -2208,8 +2197,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing location")] data: PatchLocation, ) -> FieldResult<Location> { - context.require_authentication()?; - let current_location = Location::from_id(&context.db, &data.location_id)?; + let current_location = context.load_current(&data.location_id)?; LocationPolicy::can_update(context, ¤t_location, &data, ())?; current_location.update(context, &data).map_err(Into::into) @@ -2220,8 +2208,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing price")] data: PatchPrice, ) -> FieldResult<Price> { - context.require_authentication()?; - let price = Price::from_id(&context.db, &data.price_id)?; + let price = context.load_current(&data.price_id)?; PricePolicy::can_update(context, &price, &data, ())?; price.update(context, &data).map_err(Into::into) @@ -2232,8 +2219,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing subject")] data: PatchSubject, ) -> FieldResult<Subject> { - context.require_authentication()?; - let subject = Subject::from_id(&context.db, &data.subject_id)?; + let subject = context.load_current(&data.subject_id)?; SubjectPolicy::can_update(context, &subject, &data, ())?; subject.update(context, &data).map_err(Into::into) @@ -2244,8 +2230,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing affiliation")] data: PatchAffiliation, ) -> FieldResult<Affiliation> { - context.require_authentication()?; - let affiliation = Affiliation::from_id(&context.db, &data.affiliation_id)?; + let affiliation = context.load_current(&data.affiliation_id)?; AffiliationPolicy::can_update(context, &affiliation, &data, ())?; affiliation.update(context, &data).map_err(Into::into) @@ -2257,8 +2242,7 @@ impl MutationRoot { #[graphql(description = "Values to apply to existing work relation")] data: PatchWorkRelation, ) -> FieldResult<WorkRelation> { - context.require_authentication()?; - let work_relation = WorkRelation::from_id(&context.db, &data.work_relation_id)?; + let work_relation = context.load_current(&data.work_relation_id)?; WorkRelationPolicy::can_update(context, &work_relation, &data, ())?; work_relation.update(context, &data).map_err(Into::into) @@ -2269,8 +2253,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing reference")] data: PatchReference, ) -> FieldResult<Reference> { - context.require_authentication()?; - let reference = Reference::from_id(&context.db, &data.reference_id)?; + let reference = context.load_current(&data.reference_id)?; ReferencePolicy::can_update(context, &reference, &data, ())?; reference.update(context, &data).map_err(Into::into) @@ -2281,8 +2264,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Values to apply to existing contact")] data: PatchContact, ) -> FieldResult<Contact> { - context.require_authentication()?; - let contact = Contact::from_id(&context.db, &data.contact_id)?; + let contact = context.load_current(&data.contact_id)?; ContactPolicy::can_update(context, &contact, &data, ())?; contact.update(context, &data).map_err(Into::into) @@ -2296,8 +2278,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing title")] data: PatchTitle, ) -> FieldResult<Title> { - context.require_authentication()?; - let title = Title::from_id(&context.db, &data.title_id)?; + let title = context.load_current(&data.title_id)?; TitlePolicy::can_update(context, &title, &data, markup_format)?; let mut data = data; @@ -2322,8 +2303,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing abstract")] data: PatchAbstract, ) -> FieldResult<Abstract> { - context.require_authentication()?; - let r#abstract = Abstract::from_id(&context.db, &data.abstract_id)?; + let r#abstract = context.load_current(&data.abstract_id)?; AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; let mut data = data; @@ -2342,8 +2322,7 @@ impl MutationRoot { >, #[graphql(description = "Values to apply to existing biography")] data: PatchBiography, ) -> FieldResult<Biography> { - context.require_authentication()?; - let biography = Biography::from_id(&context.db, &data.biography_id)?; + let biography = context.load_current(&data.biography_id)?; BiographyPolicy::can_update(context, &biography, &data, markup_format)?; // Safe to unwrap after policy check. @@ -2359,8 +2338,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work to be deleted")] work_id: Uuid, ) -> FieldResult<Work> { - context.require_authentication()?; - let work = Work::from_id(&context.db, &work_id)?; + let work = context.load_current(&work_id)?; WorkPolicy::can_delete(context, &work)?; work.delete(&context.db).map_err(Into::into) @@ -2371,8 +2349,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publisher to be deleted")] publisher_id: Uuid, ) -> FieldResult<Publisher> { - context.require_authentication()?; - let publisher = Publisher::from_id(&context.db, &publisher_id)?; + let publisher = context.load_current(&publisher_id)?; PublisherPolicy::can_delete(context, &publisher)?; publisher.delete(&context.db).map_err(Into::into) @@ -2383,8 +2360,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of imprint to be deleted")] imprint_id: Uuid, ) -> FieldResult<Imprint> { - context.require_authentication()?; - let imprint = Imprint::from_id(&context.db, &imprint_id)?; + let imprint = context.load_current(&imprint_id)?; ImprintPolicy::can_delete(context, &imprint)?; imprint.delete(&context.db).map_err(Into::into) @@ -2395,8 +2371,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contributor to be deleted")] contributor_id: Uuid, ) -> FieldResult<Contributor> { - context.require_authentication()?; - let contributor = Contributor::from_id(&context.db, &contributor_id)?; + let contributor = context.load_current(&contributor_id)?; ContributorPolicy::can_delete(context, &contributor)?; contributor.delete(&context.db).map_err(Into::into) @@ -2407,8 +2382,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contribution to be deleted")] contribution_id: Uuid, ) -> FieldResult<Contribution> { - context.require_authentication()?; - let contribution = Contribution::from_id(&context.db, &contribution_id)?; + let contribution = context.load_current(&contribution_id)?; ContributionPolicy::can_delete(context, &contribution)?; contribution.delete(&context.db).map_err(Into::into) @@ -2419,8 +2393,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of publication to be deleted")] publication_id: Uuid, ) -> FieldResult<Publication> { - context.require_authentication()?; - let publication = Publication::from_id(&context.db, &publication_id)?; + let publication = context.load_current(&publication_id)?; PublicationPolicy::can_delete(context, &publication)?; publication.delete(&context.db).map_err(Into::into) @@ -2431,8 +2404,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of series to be deleted")] series_id: Uuid, ) -> FieldResult<Series> { - context.require_authentication()?; - let series = Series::from_id(&context.db, &series_id)?; + let series = context.load_current(&series_id)?; SeriesPolicy::can_delete(context, &series)?; series.delete(&context.db).map_err(Into::into) @@ -2443,8 +2415,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of issue to be deleted")] issue_id: Uuid, ) -> FieldResult<Issue> { - context.require_authentication()?; - let issue = Issue::from_id(&context.db, &issue_id)?; + let issue = context.load_current(&issue_id)?; IssuePolicy::can_delete(context, &issue)?; issue.delete(&context.db).map_err(Into::into) @@ -2455,8 +2426,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of language to be deleted")] language_id: Uuid, ) -> FieldResult<Language> { - context.require_authentication()?; - let language = Language::from_id(&context.db, &language_id)?; + let language = context.load_current(&language_id)?; LanguagePolicy::can_delete(context, &language)?; language.delete(&context.db).map_err(Into::into) @@ -2467,8 +2437,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of title to be deleted")] title_id: Uuid, ) -> FieldResult<Title> { - context.require_authentication()?; - let title = Title::from_id(&context.db, &title_id)?; + let title = context.load_current(&title_id)?; TitlePolicy::can_delete(context, &title)?; title.delete(&context.db).map_err(Into::into) @@ -2479,8 +2448,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of institution to be deleted")] institution_id: Uuid, ) -> FieldResult<Institution> { - context.require_authentication()?; - let institution = Institution::from_id(&context.db, &institution_id)?; + let institution = context.load_current(&institution_id)?; InstitutionPolicy::can_delete(context, &institution)?; institution.delete(&context.db).map_err(Into::into) @@ -2491,8 +2459,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of funding to be deleted")] funding_id: Uuid, ) -> FieldResult<Funding> { - context.require_authentication()?; - let funding = Funding::from_id(&context.db, &funding_id)?; + let funding = context.load_current(&funding_id)?; FundingPolicy::can_delete(context, &funding)?; funding.delete(&context.db).map_err(Into::into) @@ -2503,8 +2470,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of location to be deleted")] location_id: Uuid, ) -> FieldResult<Location> { - context.require_authentication()?; - let location = Location::from_id(&context.db, &location_id)?; + let location = context.load_current(&location_id)?; LocationPolicy::can_delete(context, &location)?; location.delete(&context.db).map_err(Into::into) @@ -2515,8 +2481,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of price to be deleted")] price_id: Uuid, ) -> FieldResult<Price> { - context.require_authentication()?; - let price = Price::from_id(&context.db, &price_id)?; + let price = context.load_current(&price_id)?; PricePolicy::can_delete(context, &price)?; price.delete(&context.db).map_err(Into::into) @@ -2527,8 +2492,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of subject to be deleted")] subject_id: Uuid, ) -> FieldResult<Subject> { - context.require_authentication()?; - let subject = Subject::from_id(&context.db, &subject_id)?; + let subject = context.load_current(&subject_id)?; SubjectPolicy::can_delete(context, &subject)?; subject.delete(&context.db).map_err(Into::into) @@ -2539,8 +2503,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of affiliation to be deleted")] affiliation_id: Uuid, ) -> FieldResult<Affiliation> { - context.require_authentication()?; - let affiliation = Affiliation::from_id(&context.db, &affiliation_id)?; + let affiliation = context.load_current(&affiliation_id)?; AffiliationPolicy::can_delete(context, &affiliation)?; affiliation.delete(&context.db).map_err(Into::into) @@ -2551,8 +2514,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of work relation to be deleted")] work_relation_id: Uuid, ) -> FieldResult<WorkRelation> { - context.require_authentication()?; - let work_relation = WorkRelation::from_id(&context.db, &work_relation_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) @@ -2563,8 +2525,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of reference to be deleted")] reference_id: Uuid, ) -> FieldResult<Reference> { - context.require_authentication()?; - let reference = Reference::from_id(&context.db, &reference_id)?; + let reference = context.load_current(&reference_id)?; ReferencePolicy::can_delete(context, &reference)?; reference.delete(&context.db).map_err(Into::into) @@ -2575,8 +2536,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of abstract to be deleted")] abstract_id: Uuid, ) -> FieldResult<Abstract> { - context.require_authentication()?; - let r#abstract = Abstract::from_id(&context.db, &abstract_id)?; + let r#abstract = context.load_current(&abstract_id)?; AbstractPolicy::can_delete(context, &r#abstract)?; r#abstract.delete(&context.db).map_err(Into::into) @@ -2587,8 +2547,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of biography to be deleted")] biography_id: Uuid, ) -> FieldResult<Biography> { - context.require_authentication()?; - let biography = Biography::from_id(&context.db, &biography_id)?; + let biography = context.load_current(&biography_id)?; BiographyPolicy::can_delete(context, &biography)?; biography.delete(&context.db).map_err(Into::into) @@ -2603,8 +2562,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Affiliation> { - context.require_authentication()?; - 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 { @@ -2626,8 +2584,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Contribution> { - context.require_authentication()?; - 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 { @@ -2647,8 +2604,7 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which issue should be moved")] new_ordinal: i32, ) -> FieldResult<Issue> { - context.require_authentication()?; - 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 { @@ -2670,8 +2626,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<Reference> { - context.require_authentication()?; - 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 { @@ -2691,8 +2646,7 @@ impl MutationRoot { #[graphql(description = "Ordinal representing position to which subject should be moved")] new_ordinal: i32, ) -> FieldResult<Subject> { - context.require_authentication()?; - 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 { @@ -2714,8 +2668,7 @@ impl MutationRoot { )] new_ordinal: i32, ) -> FieldResult<WorkRelation> { - context.require_authentication()?; - 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 { @@ -2733,8 +2686,7 @@ impl MutationRoot { context: &Context, #[graphql(description = "Thoth ID of contact to be deleted")] contact_id: Uuid, ) -> FieldResult<Contact> { - context.require_authentication()?; - let contact = Contact::from_id(&context.db, &contact_id)?; + let contact = context.load_current(&contact_id)?; ContactPolicy::can_delete(context, &contact)?; contact.delete(&context.db).map_err(Into::into) diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs index 11817cae4..ba58b238e 100644 --- a/thoth-api/src/policy.rs +++ b/thoth-api/src/policy.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use zitadel::actix::introspection::IntrospectedUser; use crate::db::PgPool; -use crate::model::{PublisherId, PublisherIds}; +use crate::model::{Crud, PublisherId, PublisherIds}; use thoth_errors::{ThothError, ThothResult}; pub(crate) trait UserAccess { @@ -104,6 +104,12 @@ pub(crate) trait PolicyContext { } 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. From 7aac8c4ca2eb3ff402debc109278fa6c9b506c83 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 17:22:45 +0000 Subject: [PATCH 16/21] Refactor graphql inputs --- thoth-api/src/graphql/inputs.rs | 158 ++++++++++++++++++++++ thoth-api/src/graphql/mod.rs | 2 +- thoth-api/src/graphql/model.rs | 153 +++------------------ thoth-api/src/graphql/utils.rs | 46 ------- thoth-api/src/model/abstract/crud.rs | 2 +- thoth-api/src/model/abstract/mod.rs | 2 +- thoth-api/src/model/affiliation/crud.rs | 2 +- thoth-api/src/model/affiliation/mod.rs | 2 +- thoth-api/src/model/biography/crud.rs | 2 +- thoth-api/src/model/biography/mod.rs | 2 +- thoth-api/src/model/contact/crud.rs | 2 +- thoth-api/src/model/contact/mod.rs | 2 +- thoth-api/src/model/contribution/crud.rs | 4 +- thoth-api/src/model/contributor/crud.rs | 2 +- thoth-api/src/model/contributor/mod.rs | 2 +- thoth-api/src/model/funding/crud.rs | 4 +- thoth-api/src/model/imprint/crud.rs | 2 +- thoth-api/src/model/imprint/mod.rs | 2 +- thoth-api/src/model/institution/crud.rs | 2 +- thoth-api/src/model/institution/mod.rs | 2 +- thoth-api/src/model/issue/crud.rs | 4 +- thoth-api/src/model/language/crud.rs | 4 +- thoth-api/src/model/location/crud.rs | 2 +- thoth-api/src/model/location/mod.rs | 2 +- thoth-api/src/model/price/crud.rs | 4 +- thoth-api/src/model/publication/crud.rs | 2 +- thoth-api/src/model/publication/mod.rs | 2 +- thoth-api/src/model/publisher/crud.rs | 2 +- thoth-api/src/model/publisher/mod.rs | 2 +- thoth-api/src/model/reference/crud.rs | 2 +- thoth-api/src/model/reference/mod.rs | 2 +- thoth-api/src/model/series/crud.rs | 2 +- thoth-api/src/model/series/mod.rs | 2 +- thoth-api/src/model/subject/crud.rs | 4 +- thoth-api/src/model/title/crud.rs | 2 +- thoth-api/src/model/title/mod.rs | 2 +- thoth-api/src/model/work/crud.rs | 4 +- thoth-api/src/model/work/mod.rs | 2 +- thoth-api/src/model/work_relation/crud.rs | 2 +- thoth-api/src/model/work_relation/mod.rs | 2 +- 40 files changed, 224 insertions(+), 221 deletions(-) create mode 100644 thoth-api/src/graphql/inputs.rs delete mode 100644 thoth-api/src/graphql/utils.rs diff --git a/thoth-api/src/graphql/inputs.rs b/thoth-api/src/graphql/inputs.rs new file mode 100644 index 000000000..914c7331a --- /dev/null +++ b/thoth-api/src/graphql/inputs.rs @@ -0,0 +1,158 @@ +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; + +#[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, +} diff --git a/thoth-api/src/graphql/mod.rs b/thoth-api/src/graphql/mod.rs index 51e333633..745d35bfa 100644 --- a/thoth-api/src/graphql/mod.rs +++ b/thoth-api/src/graphql/mod.rs @@ -1,6 +1,6 @@ +pub mod inputs; #[cfg(feature = "backend")] 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 ad13c3b77..c505854d1 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -5,7 +5,10 @@ use juniper::{EmptySubscription, FieldError, FieldResult, RootNode}; use uuid::Uuid; use zitadel::actix::introspection::IntrospectedUser; -use super::utils::{Direction, Expression}; +use super::inputs::{ + ContributionOrderBy, Direction, FundingOrderBy, IssueOrderBy, LanguageOrderBy, PriceOrderBy, + SubjectOrderBy, TimeExpression, +}; use crate::db::PgPool; use crate::model::{ affiliation::{ @@ -14,29 +17,27 @@ use crate::model::{ biography::{Biography, BiographyOrderBy, BiographyPolicy, NewBiography, PatchBiography}, contact::{Contact, ContactOrderBy, ContactPolicy, ContactType, NewContact, PatchContact}, contribution::{ - Contribution, ContributionField, ContributionPolicy, ContributionType, NewContribution, - PatchContribution, + Contribution, ContributionPolicy, ContributionType, NewContribution, PatchContribution, }, contributor::{ Contributor, ContributorOrderBy, ContributorPolicy, NewContributor, PatchContributor, }, convert_from_jats, convert_to_jats, - funding::{Funding, FundingField, FundingPolicy, NewFunding, PatchFunding}, + funding::{Funding, FundingPolicy, NewFunding, PatchFunding}, imprint::{Imprint, ImprintField, ImprintOrderBy, ImprintPolicy, NewImprint, PatchImprint}, institution::{ CountryCode, Institution, InstitutionOrderBy, InstitutionPolicy, NewInstitution, PatchInstitution, }, - issue::{Issue, IssueField, IssuePolicy, NewIssue, PatchIssue}, + issue::{Issue, IssuePolicy, NewIssue, PatchIssue}, language::{ - Language, LanguageCode, LanguageField, LanguagePolicy, LanguageRelation, NewLanguage, - PatchLanguage, + Language, LanguageCode, LanguagePolicy, LanguageRelation, NewLanguage, PatchLanguage, }, locale::LocaleCode, location::{ Location, LocationOrderBy, LocationPlatform, LocationPolicy, NewLocation, PatchLocation, }, - price::{CurrencyCode, NewPrice, PatchPrice, Price, PriceField, PricePolicy}, + price::{CurrencyCode, NewPrice, PatchPrice, Price, PricePolicy}, publication::{ AccessibilityException, AccessibilityStandard, NewPublication, PatchPublication, Publication, PublicationOrderBy, PublicationPolicy, PublicationType, @@ -47,7 +48,7 @@ use crate::model::{ }, reference::{NewReference, PatchReference, Reference, ReferenceOrderBy, ReferencePolicy}, series::{NewSeries, PatchSeries, Series, SeriesOrderBy, SeriesPolicy, SeriesType}, - subject::{NewSubject, PatchSubject, Subject, SubjectField, SubjectPolicy, SubjectType}, + subject::{NewSubject, PatchSubject, Subject, SubjectPolicy, SubjectType}, title::{NewTitle, PatchTitle, Title, TitleOrderBy, TitlePolicy}, work::{NewWork, PatchWork, Work, WorkOrderBy, WorkPolicy, WorkStatus, WorkType}, work_relation::{ @@ -82,111 +83,6 @@ impl PolicyContext for Context { } } -#[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, -} - pub struct QueryRoot; #[juniper::graphql_object(Context = Context)] @@ -2060,25 +1956,20 @@ impl MutationRoot { WorkPolicy::can_update(context, &work, &data, ())?; // update the work and, if it succeeds, synchronise its children statuses and pub. date - match work.update(context, &data) { - 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, &data)?; - } - } - 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")] diff --git a/thoth-api/src/graphql/utils.rs b/thoth-api/src/graphql/utils.rs deleted file mode 100644 index 95440fea7..000000000 --- a/thoth-api/src/graphql/utils.rs +++ /dev/null @@ -1,46 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -#[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/model/abstract/crud.rs b/thoth-api/src/model/abstract/crud.rs index 143a0212e..e044830ea 100644 --- a/thoth-api/src/model/abstract/crud.rs +++ b/thoth-api/src/model/abstract/crud.rs @@ -3,7 +3,7 @@ use super::{ Abstract, AbstractField, AbstractHistory, AbstractOrderBy, AbstractType, NewAbstract, NewAbstractHistory, PatchAbstract, }; -use crate::graphql::utils::Direction; +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}; diff --git a/thoth-api/src/model/abstract/mod.rs b/thoth-api/src/model/abstract/mod.rs index 77a311ffe..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; diff --git a/thoth-api/src/model/affiliation/crud.rs b/thoth-api/src/model/affiliation/crud.rs index a88ad2f15..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}; diff --git a/thoth-api/src/model/affiliation/mod.rs b/thoth-api/src/model/affiliation/mod.rs index b65a052e9..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; diff --git a/thoth-api/src/model/biography/crud.rs b/thoth-api/src/model/biography/crud.rs index 1f49431cc..a3260d9ed 100644 --- a/thoth-api/src/model/biography/crud.rs +++ b/thoth-api/src/model/biography/crud.rs @@ -3,7 +3,7 @@ use super::{ Biography, BiographyField, BiographyHistory, BiographyOrderBy, NewBiography, NewBiographyHistory, PatchBiography, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{biography, biography_history}; use diesel::{ExpressionMethods, PgTextExpressionMethods, QueryDsl, RunQueryDsl}; diff --git a/thoth-api/src/model/biography/mod.rs b/thoth-api/src/model/biography/mod.rs index 35a496779..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; diff --git a/thoth-api/src/model/contact/crud.rs b/thoth-api/src/model/contact/crud.rs index 663e0128a..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}; diff --git a/thoth-api/src/model/contact/mod.rs b/thoth-api/src/model/contact/mod.rs index 4894947ca..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; diff --git a/thoth-api/src/model/contribution/crud.rs b/thoth-api/src/model/contribution/crud.rs index 3d2f9e4d9..1c571e432 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}; diff --git a/thoth-api/src/model/contributor/crud.rs b/thoth-api/src/model/contributor/crud.rs index c9d42bdfe..09696be20 100644 --- a/thoth-api/src/model/contributor/crud.rs +++ b/thoth-api/src/model/contributor/crud.rs @@ -3,7 +3,7 @@ use super::{ NewContributorHistory, PatchContributor, }; use crate::db::PgPool; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{contributor, contributor_history}; use diesel::{ diff --git a/thoth-api/src/model/contributor/mod.rs b/thoth-api/src/model/contributor/mod.rs index de0e21f9c..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")] diff --git a/thoth-api/src/model/funding/crud.rs b/thoth-api/src/model/funding/crud.rs index 193c7fd5a..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}; diff --git a/thoth-api/src/model/imprint/crud.rs b/thoth-api/src/model/imprint/crud.rs index 827bd9202..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::{ diff --git a/thoth-api/src/model/imprint/mod.rs b/thoth-api/src/model/imprint/mod.rs index 9d190c91e..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; diff --git a/thoth-api/src/model/institution/crud.rs b/thoth-api/src/model/institution/crud.rs index 890c047ca..43f764fe7 100644 --- a/thoth-api/src/model/institution/crud.rs +++ b/thoth-api/src/model/institution/crud.rs @@ -3,7 +3,7 @@ use super::{ NewInstitutionHistory, PatchInstitution, }; use crate::db::PgPool; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherIds}; use crate::schema::{institution, institution_history}; use diesel::{ diff --git a/thoth-api/src/model/institution/mod.rs b/thoth-api/src/model/institution/mod.rs index 904f3e1bf..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; diff --git a/thoth-api/src/model/issue/crud.rs b/thoth-api/src/model/issue/crud.rs index cb3c9389e..a21b723b9 100644 --- a/thoth-api/src/model/issue/crud.rs +++ b/thoth-api/src/model/issue/crud.rs @@ -1,6 +1,6 @@ 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}; diff --git a/thoth-api/src/model/language/crud.rs b/thoth-api/src/model/language/crud.rs index 644e21897..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}; diff --git a/thoth-api/src/model/location/crud.rs b/thoth-api/src/model/location/crud.rs index 763e21db4..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}; diff --git a/thoth-api/src/model/location/mod.rs b/thoth-api/src/model/location/mod.rs index 95cbd618b..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; diff --git a/thoth-api/src/model/price/crud.rs b/thoth-api/src/model/price/crud.rs index d073c92c0..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}; diff --git a/thoth-api/src/model/publication/crud.rs b/thoth-api/src/model/publication/crud.rs index e6296236c..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}; diff --git a/thoth-api/src/model/publication/mod.rs b/thoth-api/src/model/publication/mod.rs index ea9928b11..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")] diff --git a/thoth-api/src/model/publisher/crud.rs b/thoth-api/src/model/publisher/crud.rs index 6d1229893..6dd297620 100644 --- a/thoth-api/src/model/publisher/crud.rs +++ b/thoth-api/src/model/publisher/crud.rs @@ -3,7 +3,7 @@ use super::{ PublisherOrderBy, }; use crate::db::PgPool; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{publisher, publisher_history}; use diesel::{ diff --git a/thoth-api/src/model/publisher/mod.rs b/thoth-api/src/model/publisher/mod.rs index a1524680f..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; diff --git a/thoth-api/src/model/reference/crud.rs b/thoth-api/src/model/reference/crud.rs index 6dc8a2394..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::{ diff --git a/thoth-api/src/model/reference/mod.rs b/thoth-api/src/model/reference/mod.rs index 361cb07a6..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; diff --git a/thoth-api/src/model/series/crud.rs b/thoth-api/src/model/series/crud.rs index 05f2c148f..0ef584967 100644 --- a/thoth-api/src/model/series/crud.rs +++ b/thoth-api/src/model/series/crud.rs @@ -2,7 +2,7 @@ use super::{ NewSeries, NewSeriesHistory, PatchSeries, Series, SeriesField, SeriesHistory, SeriesOrderBy, SeriesType, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{series, series_history}; use diesel::{ diff --git a/thoth-api/src/model/series/mod.rs b/thoth-api/src/model/series/mod.rs index 38be97024..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; diff --git a/thoth-api/src/model/subject/crud.rs b/thoth-api/src/model/subject/crud.rs index 9222d4427..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::{ diff --git a/thoth-api/src/model/title/crud.rs b/thoth-api/src/model/title/crud.rs index f3e246af4..cab1849ec 100644 --- a/thoth-api/src/model/title/crud.rs +++ b/thoth-api/src/model/title/crud.rs @@ -2,7 +2,7 @@ use super::{ LocaleCode, NewTitle, NewTitleHistory, PatchTitle, Title, TitleField, TitleHistory, TitleOrderBy, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId}; use crate::schema::{title_history, work_title}; use diesel::{ diff --git a/thoth-api/src/model/title/mod.rs b/thoth-api/src/model/title/mod.rs index f10307bf4..9046126a7 100644 --- a/thoth-api/src/model/title/mod.rs +++ b/thoth-api/src/model/title/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::title_history; diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index 1fa034aef..2d8b71d4e 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -2,8 +2,8 @@ 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, PublisherId}; use crate::schema::{work, work_abstract, work_history, work_title}; diff --git a/thoth-api/src/model/work/mod.rs b/thoth-api/src/model/work/mod.rs index e66b371dc..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")] diff --git a/thoth-api/src/model/work_relation/crud.rs b/thoth-api/src/model/work_relation/crud.rs index f7ae45754..b57026ceb 100644 --- a/thoth-api/src/model/work_relation/crud.rs +++ b/thoth-api/src/model/work_relation/crud.rs @@ -2,7 +2,7 @@ use super::{ NewWorkRelation, NewWorkRelationHistory, PatchWorkRelation, RelationType, WorkRelation, WorkRelationField, WorkRelationHistory, WorkRelationOrderBy, }; -use crate::graphql::utils::Direction; +use crate::graphql::inputs::Direction; use crate::model::{Crud, DbInsert, HistoryEntry, PublisherId, Reorder}; use crate::schema::{work_relation, work_relation_history}; use diesel::{ diff --git a/thoth-api/src/model/work_relation/mod.rs b/thoth-api/src/model/work_relation/mod.rs index 01aa49506..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; From 56090960986d14bd8236b8e2378c8734ebd4881a Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Fri, 9 Jan 2026 18:00:17 +0000 Subject: [PATCH 17/21] Abstract title jats conversion --- thoth-api/src/graphql/model.rs | 41 +++++++------------------------- thoth-api/src/model/title/mod.rs | 36 +++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index c505854d1..bf2a9fe86 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -49,7 +49,7 @@ use crate::model::{ reference::{NewReference, PatchReference, Reference, ReferenceOrderBy, ReferencePolicy}, series::{NewSeries, PatchSeries, Series, SeriesOrderBy, SeriesPolicy, SeriesType}, subject::{NewSubject, PatchSubject, Subject, SubjectPolicy, SubjectType}, - title::{NewTitle, PatchTitle, Title, TitleOrderBy, TitlePolicy}, + 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, @@ -1811,21 +1811,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<Title> { TitlePolicy::can_create(context, &data, markup_format)?; - let mut data = data; - // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); - 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)?; + convert_title_to_jats(&mut data, markup)?; Title::create(&context.db, &data).map_err(Into::into) } @@ -1836,11 +1827,10 @@ 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> { AbstractPolicy::can_create(context, &data, markup_format)?; - let mut data = data; // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; @@ -1854,13 +1844,12 @@ 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> { BiographyPolicy::can_create(context, &data, markup_format)?; // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); - let mut data = data; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; Biography::create(&context.db, &data).map_err(Into::into) @@ -2167,21 +2156,13 @@ 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> { let title = context.load_current(&data.title_id)?; TitlePolicy::can_update(context, &title, &data, markup_format)?; - let mut data = data; let markup = markup_format.expect("Validated by policy"); - 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)?; + convert_title_to_jats(&mut data, markup)?; title.update(context, &data).map_err(Into::into) } @@ -2192,13 +2173,11 @@ 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> { let r#abstract = context.load_current(&data.abstract_id)?; AbstractPolicy::can_update(context, &r#abstract, &data, markup_format)?; - let mut data = data; - // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; @@ -2211,14 +2190,12 @@ 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> { let biography = context.load_current(&data.biography_id)?; BiographyPolicy::can_update(context, &biography, &data, markup_format)?; - // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); - let mut data = data; data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; biography.update(context, &data).map_err(Into::into) diff --git a/thoth-api/src/model/title/mod.rs b/thoth-api/src/model/title/mod.rs index 9046126a7..8201abaf9 100644 --- a/thoth-api/src/model/title/mod.rs +++ b/thoth-api/src/model/title/mod.rs @@ -1,5 +1,6 @@ -use crate::model::locale::LocaleCode; +use crate::model::{convert_to_jats, locale::LocaleCode, ConversionLimit, MarkupFormat}; use serde::{Deserialize, Serialize}; +use thoth_errors::ThothResult; use uuid::Uuid; use crate::graphql::inputs::Direction; @@ -111,6 +112,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 +137,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 +151,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,6 +177,23 @@ 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; From 1cc31506d891dab5727a60f8ce5bb47f9aa0d571 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Sat, 10 Jan 2026 07:00:03 +0000 Subject: [PATCH 18/21] Remove account --- thoth-api/src/account/mod.rs | 1 - thoth-api/src/account/model.rs | 124 --------------------------------- thoth-api/src/graphql/model.rs | 2 - thoth-api/src/lib.rs | 1 - 4 files changed, 128 deletions(-) delete mode 100644 thoth-api/src/account/mod.rs delete mode 100644 thoth-api/src/account/model.rs diff --git a/thoth-api/src/account/mod.rs b/thoth-api/src/account/mod.rs deleted file mode 100644 index 65880be0e..000000000 --- a/thoth-api/src/account/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod model; diff --git a/thoth-api/src/account/model.rs b/thoth-api/src/account/model.rs deleted file mode 100644 index b3a2be5bd..000000000 --- a/thoth-api/src/account/model.rs +++ /dev/null @@ -1,124 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::model::Timestamp; -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<u8>, - 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<String>, -} - -#[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, -} - -#[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<LinkedPublisher>, -} - -#[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<String>, - pub created_at: Timestamp, - pub updated_at: Timestamp, - pub resource_access: AccountAccess, -} - -#[derive(Debug, Clone)] -pub struct DecodedToken { - pub jwt: Option<Token>, -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] -pub struct LoginCredentials { - pub email: String, - pub password: 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<Vec<String>> { - 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/graphql/model.rs b/thoth-api/src/graphql/model.rs index bf2a9fe86..5ee7b2bd6 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -1831,7 +1831,6 @@ impl MutationRoot { ) -> FieldResult<Abstract> { AbstractPolicy::can_create(context, &data, markup_format)?; - // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Abstract)?; @@ -1848,7 +1847,6 @@ impl MutationRoot { ) -> FieldResult<Biography> { BiographyPolicy::can_create(context, &data, markup_format)?; - // Safe to unwrap after policy check. let markup = markup_format.expect("Validated by policy"); data.content = convert_to_jats(data.content, markup, ConversionLimit::Biography)?; diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index d76c54c60..cd6960d20 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -14,7 +14,6 @@ extern crate diesel_migrations; extern crate dotenv; extern crate juniper; -pub mod account; pub mod ast; #[cfg(feature = "backend")] pub mod db; From efa3cd6e05c6f091ba9b1919aa35918b98f51b26 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Sat, 10 Jan 2026 07:30:00 +0000 Subject: [PATCH 19/21] Move markup logic to module --- thoth-api/src/graphql/model.rs | 5 +- thoth-api/src/lib.rs | 2 +- thoth-api/src/{ast/mod.rs => markup/ast.rs} | 2 +- thoth-api/src/markup/mod.rs | 490 ++++++++++++++++++ thoth-api/src/model/abstract/policy.rs | 2 +- thoth-api/src/model/biography/policy.rs | 2 +- thoth-api/src/model/mod.rs | 486 ----------------- thoth-api/src/model/title/mod.rs | 3 +- thoth-api/src/model/title/policy.rs | 2 +- .../src/bibtex/bibtex_thoth.rs | 2 +- thoth-export-server/src/csv/csv_thoth.rs | 2 +- .../src/marc21/marc21record_thoth.rs | 2 +- 12 files changed, 502 insertions(+), 498 deletions(-) rename thoth-api/src/{ast/mod.rs => markup/ast.rs} (99%) create mode 100644 thoth-api/src/markup/mod.rs diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 5ee7b2bd6..8b60e1f7d 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -10,6 +10,7 @@ use super::inputs::{ SubjectOrderBy, TimeExpression, }; use crate::db::PgPool; +use crate::markup::{convert_from_jats, convert_to_jats, ConversionLimit, MarkupFormat}; use crate::model::{ affiliation::{ Affiliation, AffiliationOrderBy, AffiliationPolicy, NewAffiliation, PatchAffiliation, @@ -22,7 +23,6 @@ use crate::model::{ contributor::{ Contributor, ContributorOrderBy, ContributorPolicy, NewContributor, PatchContributor, }, - convert_from_jats, convert_to_jats, funding::{Funding, FundingPolicy, NewFunding, PatchFunding}, imprint::{Imprint, ImprintField, ImprintOrderBy, ImprintPolicy, NewImprint, PatchImprint}, institution::{ @@ -55,8 +55,7 @@ use crate::model::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, WorkRelationPolicy, }, - ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, Reorder, Ror, - Timestamp, WeightUnit, + Convert, Crud, Doi, Isbn, LengthUnit, Orcid, Reorder, Ror, Timestamp, WeightUnit, }; use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; use thoth_errors::ThothError; diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index cd6960d20..f5b5adfaa 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -14,10 +14,10 @@ extern crate diesel_migrations; extern crate dotenv; extern crate juniper; -pub mod ast; #[cfg(feature = "backend")] pub mod db; pub mod graphql; +pub mod markup; #[macro_use] pub mod model; #[cfg(feature = "backend")] 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/policy.rs b/thoth-api/src/model/abstract/policy.rs index a9b2981cf..009cf78c7 100644 --- a/thoth-api/src/model/abstract/policy.rs +++ b/thoth-api/src/model/abstract/policy.rs @@ -3,7 +3,7 @@ use diesel::prelude::*; use uuid::Uuid; use super::{Abstract, AbstractType, NewAbstract, PatchAbstract}; -use crate::model::MarkupFormat; +use crate::markup::MarkupFormat; use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; use crate::schema::work_abstract; use thoth_errors::{ThothError, ThothResult}; diff --git a/thoth-api/src/model/biography/policy.rs b/thoth-api/src/model/biography/policy.rs index 4a347d5c4..f2b2306da 100644 --- a/thoth-api/src/model/biography/policy.rs +++ b/thoth-api/src/model/biography/policy.rs @@ -2,8 +2,8 @@ use diesel::dsl::{exists, select}; use diesel::prelude::*; use uuid::Uuid; +use crate::markup::MarkupFormat; use crate::model::biography::{Biography, NewBiography, PatchBiography}; -use crate::model::MarkupFormat; use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; use crate::schema::biography; use thoth_errors::{ThothError, ThothResult}; diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 4d43fe07d..85cecf075 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -1,8 +1,3 @@ -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; @@ -879,491 +874,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(); diff --git a/thoth-api/src/model/title/mod.rs b/thoth-api/src/model/title/mod.rs index 8201abaf9..66c87e0e1 100644 --- a/thoth-api/src/model/title/mod.rs +++ b/thoth-api/src/model/title/mod.rs @@ -1,4 +1,5 @@ -use crate::model::{convert_to_jats, locale::LocaleCode, ConversionLimit, MarkupFormat}; +use crate::markup::{convert_to_jats, ConversionLimit, MarkupFormat}; +use crate::model::locale::LocaleCode; use serde::{Deserialize, Serialize}; use thoth_errors::ThothResult; use uuid::Uuid; diff --git a/thoth-api/src/model/title/policy.rs b/thoth-api/src/model/title/policy.rs index 03a0946a6..46ee42867 100644 --- a/thoth-api/src/model/title/policy.rs +++ b/thoth-api/src/model/title/policy.rs @@ -1,5 +1,5 @@ +use crate::markup::MarkupFormat; use crate::model::title::{NewTitle, PatchTitle, Title}; -use crate::model::MarkupFormat; use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy}; use crate::schema::work_title; 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; From 97b091c95ceef28bb2de851cb6671e3119cdfd86 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Sat, 10 Jan 2026 07:47:36 +0000 Subject: [PATCH 20/21] Move units to inputs --- thoth-api/src/graphql/inputs.rs | 267 ++++++++++++++++++++++++++++++++ thoth-api/src/graphql/mod.rs | 2 - thoth-api/src/graphql/model.rs | 6 +- thoth-api/src/lib.rs | 1 + thoth-api/src/model/mod.rs | 254 ------------------------------ 5 files changed, 271 insertions(+), 259 deletions(-) diff --git a/thoth-api/src/graphql/inputs.rs b/thoth-api/src/graphql/inputs.rs index 914c7331a..f562e02b1 100644 --- a/thoth-api/src/graphql/inputs.rs +++ b/thoth-api/src/graphql/inputs.rs @@ -7,6 +7,7 @@ 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")] @@ -156,3 +157,269 @@ 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 745d35bfa..70dcd0b1e 100644 --- a/thoth-api/src/graphql/mod.rs +++ b/thoth-api/src/graphql/mod.rs @@ -1,6 +1,4 @@ pub mod inputs; -#[cfg(feature = "backend")] pub mod model; -#[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 8b60e1f7d..1bb20b6e1 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -6,8 +6,8 @@ use uuid::Uuid; use zitadel::actix::introspection::IntrospectedUser; use super::inputs::{ - ContributionOrderBy, Direction, FundingOrderBy, IssueOrderBy, LanguageOrderBy, PriceOrderBy, - SubjectOrderBy, TimeExpression, + 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}; @@ -55,7 +55,7 @@ use crate::model::{ NewWorkRelation, PatchWorkRelation, RelationType, WorkRelation, WorkRelationOrderBy, WorkRelationPolicy, }, - Convert, Crud, Doi, Isbn, LengthUnit, Orcid, Reorder, Ror, Timestamp, WeightUnit, + Crud, Doi, Isbn, Orcid, Reorder, Ror, Timestamp, }; use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; use thoth_errors::ThothError; diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index f5b5adfaa..8e65138bc 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -16,6 +16,7 @@ extern crate juniper; #[cfg(feature = "backend")] pub mod db; +#[cfg(feature = "backend")] pub mod graphql; pub mod markup; #[macro_use] diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 85cecf075..fb0bebe7a 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -4,8 +4,6 @@ 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; @@ -14,44 +12,6 @@ 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), @@ -790,53 +750,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; @@ -1151,173 +1064,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"; From f51c51b2d7846929832a7eee0f310f527d870f36 Mon Sep 17 00:00:00 2001 From: Javier Arias <javier@jarias.org> Date: Sat, 10 Jan 2026 08:00:42 +0000 Subject: [PATCH 21/21] Move units to inputs --- thoth-api/src/model/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index fb0bebe7a..9309b88a0 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; use thoth_errors::{ThothError, ThothResult}; -#[cfg(feature = "backend")] use uuid::Uuid; pub const DOI_DOMAIN: &str = "https://doi.org/";