diff --git a/.gitignore b/.gitignore index acce4fb..17e8b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /comet-cli/target +**/target/ diff --git a/Cargo.lock b/Cargo.lock index f0a9a2b..4825845 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "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 0.2.17", +] + +[[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.4" @@ -11,18 +46,113 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -77,6 +207,48 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "binascii" version = "0.1.4" @@ -89,6 +261,24 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -101,6 +291,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -120,6 +316,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -167,6 +365,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -174,6 +382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -182,8 +391,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -192,6 +415,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "comet" version = "0.1.0" @@ -206,11 +450,67 @@ dependencies = [ "send_wrapper", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "trybuild", "worker", ] +[[package]] +name = "comet-auth" +version = "0.1.0" +dependencies = [ + "base64ct", + "comet", + "comet-auth-macros", + "crypto-bigint", + "getrandom 0.4.3", + "js-sys", + "p256", + "rocket", + "rsa", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "url", + "worker", +] + +[[package]] +name = "comet-auth-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "comet-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "comet", + "quote", + "serde_json", + "syn", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "comet-cloudflare-worker-example" +version = "0.1.0" +dependencies = [ + "comet", + "comet-auth", + "console_error_panic_hook", + "rocket", + "wasm-bindgen", + "worker", +] + [[package]] name = "comet-macros" version = "0.1.0" @@ -220,17 +520,63 @@ dependencies = [ "syn", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64", + "hkdf", "percent-encoding", + "rand 0.8.6", + "sha2 0.10.9", + "subtle", "time", "version_check", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -299,85 +645,294 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "deranged" -version = "0.5.8" +name = "crypto-bigint" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] [[package]] -name = "devise" -version = "0.4.2" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "devise_codegen", - "devise_core", + "generic-array", + "rand_core 0.6.4", + "typenum", ] [[package]] -name = "devise_codegen" -version = "0.4.2" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "devise_core", - "quote", + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", ] [[package]] -name = "devise_core" -version = "0.4.2" +name = "crypto-primes" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ - "bitflags", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", + "crypto-bigint", + "rand_core 0.10.1", ] [[package]] -name = "displaydoc" -version = "0.2.6" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cipher", ] [[package]] -name = "either" -version = "1.16.0" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "cuckoofilter" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "b810a8449931679f64cd7eef1bbd0fa315801b6d5d9cdc1ace2804d6529eee18" dependencies = [ - "cfg-if", + "byteorder", + "fnv", + "rand 0.7.3", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "figment" -version = "0.10.19" +name = "der" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "atomic", + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", "pear", "serde", + "tempfile", "toml 0.8.23", "uncased", "version_check", @@ -389,6 +944,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -398,6 +965,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -487,6 +1060,38 @@ dependencies = [ "windows", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -496,17 +1101,85 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[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 = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h3" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7675a0963b47a6d12fe44c279918b4ffb19baee838ac37f48d2722ad5bc6ab" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "tokio", +] + [[package]] name = "half" version = "2.7.1" @@ -518,6 +1191,23 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash_hasher" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4b9ebce26001bad2e6366295f64e381c1e9c479109202149b9e15e154973e9" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -536,6 +1226,39 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.4.2" @@ -568,6 +1291,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.10.1" @@ -578,6 +1312,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -712,7 +1447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -723,6 +1458,24 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -731,9 +1484,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -750,7 +1509,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "js-sys" +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" @@ -772,6 +1541,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -829,12 +1610,38 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + [[package]] name = "multer" version = "3.1.0" @@ -846,6 +1653,7 @@ dependencies = [ "futures-util", "http", "httparse", + "log", "memchr", "mime", "spin", @@ -854,13 +1662,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -869,6 +1697,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -876,6 +1723,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", ] [[package]] @@ -884,12 +1751,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -913,6 +1805,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pear" version = "0.2.9" @@ -936,6 +1834,15 @@ dependencies = [ "syn", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -968,6 +1875,32 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plotters" version = "0.3.7" @@ -996,6 +1929,18 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1020,6 +1965,43 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1057,14 +2039,64 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1074,7 +2106,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1083,7 +2133,22 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -1170,6 +2235,52 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "rocket" version = "0.6.0-dev" @@ -1182,7 +2293,7 @@ dependencies = [ "either", "figment", "futures", - "getrandom", + "getrandom 0.3.4", "http", "hyper", "hyper-util", @@ -1190,72 +2301,309 @@ dependencies = [ "libc", "memchr", "multer", + "num_cpus", "parking_lot", "pin-project-lite", - "rand", + "pretty_assertions", + "rand 0.9.4", "ref-cast", "ref-swap", + "rmp-serde", "rocket_codegen", "rocket_http", + "rustls", + "rustls-pemfile", + "s2n-quic", + "s2n-quic-h3", "send_wrapper", "serde", "serde_json", "state", + "tempfile", + "thread_local", "time", + "tinyvec", "tokio", + "tokio-rustls", "tokio-stream", "tokio-util", "tracing", + "tracing-subscriber", "ubyte", + "uuid", "version_check", + "x509-parser", "yansi", ] [[package]] -name = "rocket_codegen" -version = "0.6.0-dev" +name = "rocket_codegen" +version = "0.6.0-dev" +dependencies = [ + "devise", + "glob", + "indexmap", + "pretty_assertions", + "proc-macro2", + "quote", + "rocket", + "rocket_http", + "syn", + "time", + "trybuild", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_http" +version = "0.6.0-dev" +dependencies = [ + "cookie", + "either", + "indexmap", + "memchr", + "pear", + "percent-encoding", + "ref-cast", + "rocket", + "serde", + "stable-pattern", + "state", + "time", + "tinyvec", + "uncased", + "uuid", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "s2n-codec" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af95d6ab40673cfd762227ba56e9aa9d15db73088916db4c6845a807bbbd013" +dependencies = [ + "byteorder", + "bytes", + "zerocopy", +] + +[[package]] +name = "s2n-quic" +version = "1.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3016ee30eca88de3a8971aaf419343190f11125b6a09808736de295ffd442b" +dependencies = [ + "bytes", + "cfg-if", + "cuckoofilter", + "futures", + "hash_hasher", + "rand 0.9.4", + "rand_chacha 0.9.0", + "s2n-codec", + "s2n-quic-core", + "s2n-quic-crypto", + "s2n-quic-platform", + "s2n-quic-rustls", + "s2n-quic-transport", + "tokio", + "zerocopy", + "zeroize", +] + +[[package]] +name = "s2n-quic-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e4053072e13d24dc26d06e70531bac2e52de4163cc7c8ca0f5429acb4dda5f" +dependencies = [ + "atomic-waker", + "byteorder", + "bytes", + "cfg-if", + "crossbeam-utils", + "hex-literal", + "num-rational", + "num-traits", + "once_cell", + "pin-project-lite", + "s2n-codec", + "subtle", + "tracing", + "zerocopy", +] + +[[package]] +name = "s2n-quic-crypto" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91a03d399ade6bf71199dca58f344abd07bef7d65092fe8abc6254351586923a" +dependencies = [ + "aws-lc-rs", + "cfg-if", + "lazy_static", + "s2n-codec", + "s2n-quic-core", + "zeroize", +] + +[[package]] +name = "s2n-quic-h3" +version = "0.1.0" +source = "git+https://github.com/SergioBenitez/s2n-quic-h3.git?rev=f832471#f83247128132c968d57a99fa5e76ac7f1528ea10" dependencies = [ - "devise", - "glob", - "indexmap", - "proc-macro2", - "quote", - "rocket_http", - "syn", - "unicode-xid", - "version_check", + "bytes", + "futures", + "h3", + "s2n-quic", + "tracing", ] [[package]] -name = "rocket_http" -version = "0.6.0-dev" +name = "s2n-quic-platform" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "432e21d80884de01c55780d1078c8a842f15b6ae134dcb94328bbe7aea1c766e" dependencies = [ - "cookie", - "either", - "indexmap", - "memchr", - "pear", - "percent-encoding", - "ref-cast", - "serde", - "stable-pattern", - "state", - "time", - "tinyvec", - "uncased", + "cfg-if", + "futures", + "lazy_static", + "libc", + "s2n-quic-core", + "socket2 0.5.10", + "tokio", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "s2n-quic-rustls" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "c0d5be186b0966834caac0ba630623f3dd41bcaaeec91fbdcf3316d25b84c954" +dependencies = [ + "bytes", + "rustls", + "rustls-pemfile", + "s2n-codec", + "s2n-quic-core", + "s2n-quic-crypto", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "s2n-quic-transport" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "f2c483a98b94ba4fd2c6581ce6c7580a125e611711f84dbb21e7971a9a42933b" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "hashbrown 0.15.5", + "intrusive-collections", + "once_cell", + "s2n-codec", + "s2n-quic-core", + "siphasher", + "smallvec", +] [[package]] name = "same-file" @@ -1278,6 +2626,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "send_wrapper" version = "0.6.0" @@ -1371,6 +2733,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1386,6 +2780,32 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1398,12 +2818,42 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable-pattern" version = "0.1.0" @@ -1428,6 +2878,12 @@ dependencies = [ "loom", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -1449,6 +2905,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -1477,6 +2939,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1486,13 +2961,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1588,8 +3083,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", + "libc", + "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -1748,6 +3270,7 @@ dependencies = [ "matchers", "nu-ansi-term", "once_cell", + "parking_lot", "regex-automata", "sharded-slab", "smallvec", @@ -1772,6 +3295,12 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "ubyte" version = "0.10.4" @@ -1803,6 +3332,28 @@ 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 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1821,6 +3372,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1843,6 +3411,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -1936,7 +3516,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1945,7 +3525,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -1954,6 +3534,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1969,13 +3558,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1984,42 +3589,90 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -2041,6 +3694,17 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + [[package]] name = "worker" version = "0.8.5" @@ -2106,11 +3770,31 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] [[package]] name = "yoke" @@ -2176,6 +3860,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 4ce8538..5521f72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ categories = ["web-programming::http-server", "wasm"] repository = "https://github.com/viniciusamelio/comet" exclude = ["vendor/rocket/core/lib/tests", "vendor/rocket/core/lib/benches"] +[workspace] +members = ["comet-auth", "comet-auth-macros", "comet-cli", "examples/cloudflare-worker"] +resolver = "3" + [features] default = ["native-client"] cloudflare = ["dep:rocket", "dep:worker", "dep:send_wrapper", "dep:async-stream", "dep:futures-channel"] diff --git a/comet-auth-macros/Cargo.toml b/comet-auth-macros/Cargo.toml new file mode 100644 index 0000000..50a4e4f --- /dev/null +++ b/comet-auth-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "comet-auth-macros" +version = "0.1.0" +edition = "2024" +description = "Proc macros for comet-auth route ergonomics." +license = "MIT OR Apache-2.0" +repository = "https://github.com/viniciusamelio/comet" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "parsing", "printing", "proc-macro"] } diff --git a/comet-auth-macros/src/lib.rs b/comet-auth-macros/src/lib.rs new file mode 100644 index 0000000..2a93b7d --- /dev/null +++ b/comet-auth-macros/src/lib.rs @@ -0,0 +1,346 @@ +use proc_macro::TokenStream; + +use proc_macro2::Span; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{FnArg, Ident, ItemFn, LitStr, Pat, Result, Token, Type, parenthesized}; +use syn::{parse_macro_input, parse_quote}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GuardKind { + Required, + Optional, + Authorized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PolicyMode { + All, + Any, +} + +#[derive(Debug)] +struct PolicyGroup { + mode: PolicyMode, + roles: Vec, + permissions: Vec, + scopes: Vec, +} + +impl PolicyGroup { + fn all() -> Self { + Self { + mode: PolicyMode::All, + roles: Vec::new(), + permissions: Vec::new(), + scopes: Vec::new(), + } + } + + fn is_empty(&self) -> bool { + self.roles.is_empty() && self.permissions.is_empty() && self.scopes.is_empty() + } + + fn push(&mut self, claim: ClaimArg) { + match claim { + ClaimArg::Role(value) => self.roles.push(value), + ClaimArg::Permission(value) => self.permissions.push(value), + ClaimArg::Scope(value) => self.scopes.push(value), + } + } +} + +#[derive(Debug)] +struct RequiresAuthArgs { + optional: bool, + resource: Option, + group: PolicyGroup, +} + +impl Default for RequiresAuthArgs { + fn default() -> Self { + Self { + optional: false, + resource: None, + group: PolicyGroup::all(), + } + } +} + +impl RequiresAuthArgs { + fn has_authorization_policy(&self) -> bool { + !self.group.is_empty() + } +} + +enum RequiresAuthArg { + Optional, + Resource(LitStr), + Claim(ClaimArg), + Group(PolicyGroup), +} + +enum ClaimArg { + Role(LitStr), + Permission(LitStr), + Scope(LitStr), +} + +impl Parse for ClaimArg { + fn parse(input: ParseStream<'_>) -> Result { + let name: Ident = input.parse()?; + input.parse::()?; + match name.to_string().as_str() { + "role" => Ok(Self::Role(input.parse()?)), + "permission" => Ok(Self::Permission(input.parse()?)), + "scope" => Ok(Self::Scope(input.parse()?)), + other => Err(syn::Error::new( + name.span(), + format!("unsupported authorization claim `{other}`"), + )), + } + } +} + +impl Parse for RequiresAuthArg { + fn parse(input: ParseStream<'_>) -> Result { + let name: Ident = input.parse()?; + match name.to_string().as_str() { + "optional" => Ok(Self::Optional), + "resource" => { + input.parse::()?; + Ok(Self::Resource(input.parse()?)) + } + "role" | "permission" | "scope" => { + input.parse::()?; + let value: LitStr = input.parse()?; + let claim = match name.to_string().as_str() { + "role" => ClaimArg::Role(value), + "permission" => ClaimArg::Permission(value), + "scope" => ClaimArg::Scope(value), + _ => unreachable!(), + }; + Ok(Self::Claim(claim)) + } + "any" | "all" => { + let mode = if name == "any" { + PolicyMode::Any + } else { + PolicyMode::All + }; + let content; + parenthesized!(content in input); + let claims = Punctuated::::parse_terminated(&content)?; + let mut group = PolicyGroup { + mode, + roles: Vec::new(), + permissions: Vec::new(), + scopes: Vec::new(), + }; + for claim in claims { + group.push(claim); + } + Ok(Self::Group(group)) + } + other => Err(syn::Error::new( + name.span(), + format!("unsupported requires_auth policy `{other}`"), + )), + } + } +} + +impl Parse for RequiresAuthArgs { + fn parse(input: ParseStream<'_>) -> Result { + if input.is_empty() { + return Ok(Self::default()); + } + + let mut args = Self::default(); + let mut saw_group = false; + let parsed = Punctuated::::parse_terminated(input)?; + for arg in parsed { + match arg { + RequiresAuthArg::Optional => args.optional = true, + RequiresAuthArg::Resource(resource) => { + if args.resource.replace(resource).is_some() { + return Err(syn::Error::new( + Span::call_site(), + "`resource` can only be declared once", + )); + } + } + RequiresAuthArg::Claim(claim) => { + if saw_group { + return Err(syn::Error::new( + Span::call_site(), + "top-level claims cannot be combined with any(...)/all(...)", + )); + } + args.group.push(claim); + } + RequiresAuthArg::Group(group) => { + if saw_group || !args.group.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "only one any(...), all(...), or top-level claim group is supported", + )); + } + args.group = group; + saw_group = true; + } + } + } + + if args.optional && args.has_authorization_policy() { + return Err(syn::Error::new( + Span::call_site(), + "`optional` cannot be combined with authorization policies", + )); + } + + if args.resource.is_some() && !args.has_authorization_policy() { + return Err(syn::Error::new( + Span::call_site(), + "`resource` requires role, permission, scope, any(...), or all(...)", + )); + } + + Ok(args) + } +} + +#[proc_macro_attribute] +pub fn requires_auth(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as RequiresAuthArgs); + let mut item = parse_macro_input!(input as ItemFn); + + match expand_requires_auth(args, &mut item) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +fn expand_requires_auth( + args: RequiresAuthArgs, + item: &mut ItemFn, +) -> Result { + let desired = if args.optional { + GuardKind::Optional + } else if args.has_authorization_policy() { + GuardKind::Authorized + } else { + GuardKind::Required + }; + + let mut existing = None; + for input in &item.sig.inputs { + match input { + FnArg::Receiver(receiver) => { + return Err(syn::Error::new( + receiver.self_token.span, + "`requires_auth` can only be applied to free route handlers", + )); + } + FnArg::Typed(pat_type) => { + if let Some(kind) = auth_guard_kind(&pat_type.ty) { + existing = Some(kind); + } + } + } + } + + match (desired, existing) { + (GuardKind::Required | GuardKind::Authorized, Some(GuardKind::Optional)) => { + return Err(syn::Error::new( + item.sig.ident.span(), + "`requires_auth` requires an authenticated session, but this route already takes `OptionalAuthSession`", + )); + } + (GuardKind::Optional, Some(GuardKind::Required | GuardKind::Authorized)) => { + return Err(syn::Error::new( + item.sig.ident.span(), + "`requires_auth(optional)` requires `OptionalAuthSession`, but this route already takes a required auth guard", + )); + } + (_, Some(_)) => {} + (GuardKind::Required, None) => { + item.sig.inputs.insert(0, auth_session_arg()); + } + (GuardKind::Optional, None) => { + item.sig.inputs.insert(0, optional_auth_session_arg()); + } + (GuardKind::Authorized, None) => {} + } + + let policy = if args.has_authorization_policy() { + let policy_ident = format_ident!("__CometAuthPolicyFor{}", item.sig.ident); + let mode = match args.group.mode { + PolicyMode::All => quote!(::comet_auth::AuthorizationMode::All), + PolicyMode::Any => quote!(::comet_auth::AuthorizationMode::Any), + }; + let roles = args.group.roles; + let permissions = args.group.permissions; + let scopes = args.group.scopes; + let resource = match args.resource { + Some(resource) => quote!(Some(#resource)), + None => quote!(None), + }; + item.sig + .inputs + .insert(0, authorized_session_arg(&policy_ident)); + + quote! { + #[allow(non_camel_case_types)] + pub struct #policy_ident; + + impl ::comet_auth::RequiredAuthorization for #policy_ident { + const REQUIREMENT: ::comet_auth::AuthorizationRequirement = + ::comet_auth::AuthorizationRequirement::with_mode_and_resource( + #mode, + &[#(#roles),*], + &[#(#permissions),*], + &[#(#scopes),*], + #resource, + ); + } + } + } else { + quote!() + }; + + Ok(quote! { + #policy + #item + }) +} + +fn auth_guard_kind(ty: &Type) -> Option { + let Type::Path(type_path) = ty else { + return None; + }; + + let ident = &type_path.path.segments.last()?.ident; + match ident.to_string().as_str() { + "AuthSession" => Some(GuardKind::Required), + "OptionalAuthSession" => Some(GuardKind::Optional), + "AuthorizedSession" => Some(GuardKind::Authorized), + _ => None, + } +} + +fn auth_session_arg() -> FnArg { + let pat: Pat = parse_quote!(_comet_auth_session); + parse_quote!(#pat: ::comet_auth::AuthSession) +} + +fn optional_auth_session_arg() -> FnArg { + let pat: Pat = parse_quote!(_comet_auth_session); + parse_quote!(#pat: ::comet_auth::OptionalAuthSession) +} + +fn authorized_session_arg(policy_ident: &Ident) -> FnArg { + let pat: Pat = parse_quote!(_comet_auth_authorized_session); + parse_quote!(#pat: ::comet_auth::AuthorizedSession<#policy_ident>) +} diff --git a/comet-auth/Cargo.toml b/comet-auth/Cargo.toml new file mode 100644 index 0000000..ae083e2 --- /dev/null +++ b/comet-auth/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "comet-auth" +version = "0.1.0" +edition = "2024" +description = "Authentication runtime for Comet routes on Cloudflare Workers." +license = "MIT OR Apache-2.0" +repository = "https://github.com/viniciusamelio/comet" + +[features] +default = ["native-client", "macros"] +cloudflare = ["dep:comet", "dep:worker"] +macros = ["dep:comet-auth-macros"] +native-client = [] +nebula = ["dep:comet", "comet/nebula"] + +[dependencies] +base64ct = { version = "1", features = ["alloc"] } +comet = { path = "..", default-features = false, features = ["cloudflare-d1", "cloudflare-kv"], optional = true } +comet-auth-macros = { path = "../comet-auth-macros", optional = true } +crypto-bigint = "0.7" +getrandom = { version = "0.4", features = ["wasm_js"] } +js-sys = "0.3" +p256 = { version = "0.14.0-pre.2", features = ["ecdsa", "pem", "pkcs8"] } +rsa = { version = "0.10.0-rc.18", features = ["sha2"] } +rocket = { path = "../vendor/rocket/core/lib", default-features = false, features = ["json", "worker"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" +url = "2" +worker = { version = "0.8.5", optional = true, features = ["d1"] } + +[dev-dependencies] +serde_json = "1" diff --git a/comet-auth/migrations/0001_comet_auth.sql b/comet-auth/migrations/0001_comet_auth.sql new file mode 100644 index 0000000..2a72012 --- /dev/null +++ b/comet-auth/migrations/0001_comet_auth.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS comet_auth_users ( + id TEXT PRIMARY KEY, + primary_email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + name TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS comet_auth_users_primary_email_unique + ON comet_auth_users(primary_email) + WHERE primary_email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS comet_auth_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + name TEXT, + avatar_url TEXT, + raw_profile_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(provider, provider_account_id) +); + +CREATE INDEX IF NOT EXISTS comet_auth_accounts_user_id_idx + ON comet_auth_accounts(user_id); + +CREATE TABLE IF NOT EXISTS comet_auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + user_agent_hash TEXT, + ip_hash TEXT +); + +CREATE INDEX IF NOT EXISTS comet_auth_sessions_user_id_idx + ON comet_auth_sessions(user_id); + +CREATE INDEX IF NOT EXISTS comet_auth_sessions_expires_at_idx + ON comet_auth_sessions(expires_at); + +CREATE TABLE IF NOT EXISTS comet_auth_oauth_states ( + state_hash TEXT PRIMARY KEY, + provider TEXT NOT NULL, + code_verifier TEXT NOT NULL, + nonce TEXT, + redirect_after TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS comet_auth_oauth_states_expires_at_idx + ON comet_auth_oauth_states(expires_at); diff --git a/comet-auth/migrations/0002_comet_auth_rbac.sql b/comet-auth/migrations/0002_comet_auth_rbac.sql new file mode 100644 index 0000000..d04b006 --- /dev/null +++ b/comet-auth/migrations/0002_comet_auth_rbac.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS comet_auth_roles ( + name TEXT PRIMARY KEY, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS comet_auth_permissions ( + name TEXT PRIMARY KEY, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS comet_auth_user_roles ( + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + role_name TEXT NOT NULL REFERENCES comet_auth_roles(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, role_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_user_roles_user_id_idx + ON comet_auth_user_roles(user_id); + +CREATE INDEX IF NOT EXISTS comet_auth_user_roles_role_name_idx + ON comet_auth_user_roles(role_name); + +CREATE TABLE IF NOT EXISTS comet_auth_role_permissions ( + role_name TEXT NOT NULL REFERENCES comet_auth_roles(name) ON DELETE CASCADE, + permission_name TEXT NOT NULL REFERENCES comet_auth_permissions(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (role_name, permission_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_role_permissions_role_name_idx + ON comet_auth_role_permissions(role_name); + +CREATE TABLE IF NOT EXISTS comet_auth_user_permissions ( + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + permission_name TEXT NOT NULL REFERENCES comet_auth_permissions(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, permission_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_user_permissions_user_id_idx + ON comet_auth_user_permissions(user_id); + +CREATE INDEX IF NOT EXISTS comet_auth_user_permissions_permission_name_idx + ON comet_auth_user_permissions(permission_name); diff --git a/comet-auth/src/authz.rs b/comet-auth/src/authz.rs new file mode 100644 index 0000000..a0d33bf --- /dev/null +++ b/comet-auth/src/authz.rs @@ -0,0 +1,367 @@ +use std::marker::PhantomData; + +use rocket::request::{FromRequest, Outcome}; +use rocket::{Request, async_trait}; +use serde::{Deserialize, Serialize}; + +use crate::{AuthError, AuthSession}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationRequirement { + pub mode: AuthorizationMode, + pub roles: &'static [&'static str], + pub permissions: &'static [&'static str], + pub scopes: &'static [&'static str], + pub resource: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationMode { + All, + Any, +} + +impl AuthorizationRequirement { + pub const fn new( + roles: &'static [&'static str], + permissions: &'static [&'static str], + scopes: &'static [&'static str], + ) -> Self { + Self::with_mode_and_resource(AuthorizationMode::All, roles, permissions, scopes, None) + } + + pub const fn with_mode_and_resource( + mode: AuthorizationMode, + roles: &'static [&'static str], + permissions: &'static [&'static str], + scopes: &'static [&'static str], + resource: Option<&'static str>, + ) -> Self { + Self { + mode, + roles, + permissions, + scopes, + resource, + } + } + + pub fn is_empty(&self) -> bool { + self.roles.is_empty() && self.permissions.is_empty() && self.scopes.is_empty() + } +} + +pub trait RequiredAuthorization { + const REQUIREMENT: AuthorizationRequirement; +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorizationClaims { + pub roles: Vec, + pub permissions: Vec, +} + +impl AuthorizationClaims { + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|claim| claim == role) + } + + pub fn has_permission(&self, permission: &str) -> bool { + self.permissions.iter().any(|claim| claim == permission) + } + + pub fn satisfies(&self, requirement: &AuthorizationRequirement) -> bool { + if requirement.is_empty() { + return true; + } + + let role_matches = requirement.roles.iter().map(|role| self.has_role(role)); + let permission_matches = requirement + .permissions + .iter() + .map(|permission| self.has_permission(permission)); + let scope_matches = requirement + .scopes + .iter() + .map(|scope| self.has_permission(scope)); + let mut matches = role_matches.chain(permission_matches).chain(scope_matches); + + match requirement.mode { + AuthorizationMode::All => matches.all(|matched| matched), + AuthorizationMode::Any => matches.any(|matched| matched), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorizedSession

{ + pub session: AuthSession, + pub claims: AuthorizationClaims, + #[serde(skip)] + _policy: PhantomData

, +} + +impl

AuthorizedSession

{ + pub fn session(&self) -> &AuthSession { + &self.session + } + + pub fn claims(&self) -> &AuthorizationClaims { + &self.claims + } + + #[allow(dead_code)] + fn new(session: AuthSession, claims: AuthorizationClaims) -> Self { + Self { + session, + claims, + _policy: PhantomData, + } + } +} + +#[async_trait] +impl<'r, P> FromRequest<'r> for AuthorizedSession

+where + P: RequiredAuthorization + Send + Sync + 'static, +{ + type Error = AuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match load_authorized_session::

(request).await { + Ok(session) => Outcome::Success(session), + Err(error) => Outcome::Error((error.status(), error)), + } + } +} + +#[cfg(feature = "cloudflare")] +async fn load_authorized_session

( + request: &Request<'_>, +) -> Result, AuthError> +where + P: RequiredAuthorization + Send + Sync + 'static, +{ + comet::cloudflare::local(load_authorized_session_inner::

(request)).await +} + +#[cfg(feature = "cloudflare")] +async fn load_authorized_session_inner

( + request: &Request<'_>, +) -> Result, AuthError> +where + P: RequiredAuthorization + Send + Sync + 'static, +{ + let Some(session) = crate::routes::load_session_guard(request).await? else { + return Err(AuthError::MissingSession); + }; + + if P::REQUIREMENT.is_empty() { + return Ok(AuthorizedSession::new( + session, + AuthorizationClaims::default(), + )); + } + + let state = request + .rocket() + .state::() + .ok_or(AuthError::MissingConfig)?; + let env = request + .rocket() + .state::() + .ok_or(AuthError::MissingEnv)?; + let resource = P::REQUIREMENT.resource.unwrap_or(""); + let cache_key = claims_cache_key(&session.user.id, resource); + let claims = if state.config.authorization_claims_cache_ttl_seconds > 0 { + if let Ok(kv) = env.kv(state.kv_binding) { + if let Some(claims) = load_claims_from_cache(&kv, &cache_key).await? { + claims + } else { + let db = env.d1(state.db_binding)?; + let claims = D1AuthorizationStore::new(db) + .claims_for_user(&session.user.id, P::REQUIREMENT.resource) + .await?; + cache_claims( + &kv, + &cache_key, + &claims, + state.config.authorization_claims_cache_ttl_seconds, + ) + .await?; + claims + } + } else { + let db = env.d1(state.db_binding)?; + D1AuthorizationStore::new(db) + .claims_for_user(&session.user.id, P::REQUIREMENT.resource) + .await? + } + } else { + let db = env.d1(state.db_binding)?; + D1AuthorizationStore::new(db) + .claims_for_user(&session.user.id, P::REQUIREMENT.resource) + .await? + }; + + if claims.satisfies(&P::REQUIREMENT) { + Ok(AuthorizedSession::new(session, claims)) + } else { + Err(AuthError::Forbidden) + } +} + +#[cfg(not(feature = "cloudflare"))] +async fn load_authorized_session

( + _request: &Request<'_>, +) -> Result, AuthError> +where + P: RequiredAuthorization + Send + Sync + 'static, +{ + Err(AuthError::MissingSession) +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug)] +pub struct D1AuthorizationStore { + db: worker::D1Database, +} + +#[cfg(feature = "cloudflare")] +impl D1AuthorizationStore { + pub fn new(db: worker::D1Database) -> Self { + Self { db } + } + + pub async fn claims_for_user( + &self, + user_id: &str, + resource: Option<&str>, + ) -> Result { + #[derive(Debug, Deserialize)] + struct ClaimRow { + name: String, + } + + let role_rows = self + .db + .prepare( + "SELECT role_name AS name \ + FROM comet_auth_user_roles \ + WHERE user_id = ?1 AND (resource = '' OR resource = ?2) \ + ORDER BY role_name", + ) + .bind(&[js(user_id), js(resource.unwrap_or(""))])? + .all() + .await? + .results::()?; + + let permission_rows = self + .db + .prepare( + "SELECT permission_name AS name \ + FROM comet_auth_user_permissions \ + WHERE user_id = ?1 AND (resource = '' OR resource = ?2) \ + UNION \ + SELECT rp.permission_name AS name \ + FROM comet_auth_user_roles ur \ + JOIN comet_auth_role_permissions rp ON rp.role_name = ur.role_name \ + WHERE ur.user_id = ?1 \ + AND (ur.resource = '' OR ur.resource = ?2) \ + AND (rp.resource = '' OR rp.resource = ?2) \ + ORDER BY name", + ) + .bind(&[js(user_id), js(resource.unwrap_or(""))])? + .all() + .await? + .results::()?; + + Ok(AuthorizationClaims { + roles: role_rows.into_iter().map(|row| row.name).collect(), + permissions: permission_rows.into_iter().map(|row| row.name).collect(), + }) + } +} + +#[cfg(feature = "cloudflare")] +async fn load_claims_from_cache( + kv: &worker::kv::KvStore, + key: &str, +) -> Result, AuthError> { + let Some(value) = kv.get(key).text().await? else { + return Ok(None); + }; + Ok(Some(serde_json::from_str(&value)?)) +} + +#[cfg(feature = "cloudflare")] +async fn cache_claims( + kv: &worker::kv::KvStore, + key: &str, + claims: &AuthorizationClaims, + ttl_seconds: u64, +) -> Result<(), AuthError> { + kv.put(key, serde_json::to_string(claims)?)? + .expiration_ttl(ttl_seconds) + .execute() + .await?; + Ok(()) +} + +#[cfg(feature = "cloudflare")] +fn claims_cache_key(user_id: &str, resource: &str) -> String { + format!("authz:{user_id}:{resource}") +} + +#[cfg(not(feature = "cloudflare"))] +#[derive(Debug)] +pub struct D1AuthorizationStore; + +#[cfg(feature = "cloudflare")] +fn js(value: impl Into) -> worker::wasm_bindgen::JsValue { + value.into() +} + +#[cfg(test)] +mod tests { + use super::{AuthorizationClaims, AuthorizationMode, AuthorizationRequirement}; + + #[test] + fn scopes_are_permissions_for_enforcement() { + let claims = AuthorizationClaims { + roles: vec!["admin".to_owned()], + permissions: vec!["boards:write".to_owned()], + }; + let requirement = AuthorizationRequirement::new(&["admin"], &[], &["boards:write"]); + + assert!(claims.satisfies(&requirement)); + } + + #[test] + fn missing_claim_fails_requirement() { + let claims = AuthorizationClaims { + roles: vec!["member".to_owned()], + permissions: Vec::new(), + }; + let requirement = AuthorizationRequirement::new(&["admin"], &[], &[]); + + assert!(!claims.satisfies(&requirement)); + } + + #[test] + fn any_mode_allows_one_matching_claim() { + let claims = AuthorizationClaims { + roles: vec!["member".to_owned()], + permissions: vec!["boards:read".to_owned()], + }; + let requirement = AuthorizationRequirement::with_mode_and_resource( + AuthorizationMode::Any, + &["admin"], + &["boards:read"], + &[], + None, + ); + + assert!(claims.satisfies(&requirement)); + } +} diff --git a/comet-auth/src/config.rs b/comet-auth/src/config.rs new file mode 100644 index 0000000..f9b5847 --- /dev/null +++ b/comet-auth/src/config.rs @@ -0,0 +1,238 @@ +use crate::{DEFAULT_SESSION_COOKIE, DEFAULT_SESSION_TTL_SECONDS}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CookieSameSite { + Strict, + Lax, + None, +} + +impl From for rocket::http::SameSite { + fn from(value: CookieSameSite) -> Self { + match value { + CookieSameSite::Strict => rocket::http::SameSite::Strict, + CookieSameSite::Lax => rocket::http::SameSite::Lax, + CookieSameSite::None => rocket::http::SameSite::None, + } + } +} + +#[derive(Debug, Clone)] +pub struct AuthConfig { + pub base_url: Option, + pub session_cookie: String, + pub session_ttl_seconds: u64, + pub authorization_claims_cache_ttl_seconds: u64, + pub same_site: CookieSameSite, + pub secure_cookies: bool, + pub token_pepper_env: Option, + pub providers: Vec, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + base_url: None, + session_cookie: DEFAULT_SESSION_COOKIE.to_owned(), + session_ttl_seconds: DEFAULT_SESSION_TTL_SECONDS, + authorization_claims_cache_ttl_seconds: 60, + same_site: CookieSameSite::Lax, + secure_cookies: true, + token_pepper_env: Some("COMET_AUTH_TOKEN_PEPPER".to_owned()), + providers: Vec::new(), + } + } +} + +impl AuthConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn from_env() -> Self { + Self::default() + } + + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = Some(base_url.into()); + self + } + + pub fn session_cookie(mut self, name: impl Into) -> Self { + self.session_cookie = name.into(); + self + } + + pub fn session_ttl_seconds(mut self, ttl: u64) -> Self { + self.session_ttl_seconds = ttl; + self + } + + pub fn authorization_claims_cache_ttl_seconds(mut self, ttl: u64) -> Self { + self.authorization_claims_cache_ttl_seconds = ttl; + self + } + + pub fn same_site(mut self, same_site: CookieSameSite) -> Self { + self.same_site = same_site; + self + } + + pub fn secure_cookies(mut self, secure: bool) -> Self { + self.secure_cookies = secure; + self + } + + pub fn token_pepper_env(mut self, name: impl Into) -> Self { + self.token_pepper_env = Some(name.into()); + self + } + + pub fn provider(mut self, provider: impl Into) -> Self { + self.providers.push(provider.into()); + self + } + + pub fn provider_config(&self, provider: &str) -> Option<&ProviderConfig> { + self.providers + .iter() + .find(|config| config.provider_id() == Some(provider)) + } +} + +#[derive(Debug, Clone)] +pub enum ProviderConfig { + Google(GoogleProviderConfig), + Apple(AppleProviderConfig), + GitHub(GitHubProviderConfig), +} + +impl ProviderConfig { + pub fn provider_id(&self) -> Option<&'static str> { + match self { + Self::Google(_) => Some("google"), + Self::Apple(_) => Some("apple"), + Self::GitHub(_) => Some("github"), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct GoogleProviderConfig { + pub web_client_id_env: Option, + pub web_client_secret_env: Option, + pub native_client_id_envs: Vec, +} + +impl GoogleProviderConfig { + pub fn from_env() -> Self { + Self::default() + } + + pub fn web_client_id_env(mut self, name: impl Into) -> Self { + self.web_client_id_env = Some(name.into()); + self + } + + pub fn web_client_secret_env(mut self, name: impl Into) -> Self { + self.web_client_secret_env = Some(name.into()); + self + } + + pub fn native_client_id_env(mut self, name: impl Into) -> Self { + self.native_client_id_envs.push(name.into()); + self + } +} + +impl From for ProviderConfig { + fn from(value: GoogleProviderConfig) -> Self { + Self::Google(value) + } +} + +#[derive(Debug, Clone, Default)] +pub struct AppleProviderConfig { + pub service_id_env: Option, + pub team_id_env: Option, + pub key_id_env: Option, + pub private_key_pkcs8_pem_env: Option, + pub client_secret_env: Option, + pub native_audience_envs: Vec, +} + +impl AppleProviderConfig { + pub fn from_env() -> Self { + Self::default() + } + + pub fn service_id_env(mut self, name: impl Into) -> Self { + self.service_id_env = Some(name.into()); + self + } + + pub fn team_id_env(mut self, name: impl Into) -> Self { + self.team_id_env = Some(name.into()); + self + } + + pub fn key_id_env(mut self, name: impl Into) -> Self { + self.key_id_env = Some(name.into()); + self + } + + pub fn private_key_pkcs8_pem_env(mut self, name: impl Into) -> Self { + self.private_key_pkcs8_pem_env = Some(name.into()); + self + } + + pub fn client_secret_env(mut self, name: impl Into) -> Self { + self.client_secret_env = Some(name.into()); + self + } + + pub fn native_audience_env(mut self, name: impl Into) -> Self { + self.native_audience_envs.push(name.into()); + self + } +} + +impl From for ProviderConfig { + fn from(value: AppleProviderConfig) -> Self { + Self::Apple(value) + } +} + +#[derive(Debug, Clone, Default)] +pub struct GitHubProviderConfig { + pub client_id_env: Option, + pub client_secret_env: Option, +} + +impl GitHubProviderConfig { + pub fn from_env() -> Self { + Self::default() + } + + pub fn client_id_env(mut self, name: impl Into) -> Self { + self.client_id_env = Some(name.into()); + self + } + + pub fn client_secret_env(mut self, name: impl Into) -> Self { + self.client_secret_env = Some(name.into()); + self + } +} + +impl From for ProviderConfig { + fn from(value: GitHubProviderConfig) -> Self { + Self::GitHub(value) + } +} + +pub mod providers { + pub type Google = super::GoogleProviderConfig; + pub type Apple = super::AppleProviderConfig; + pub type GitHub = super::GitHubProviderConfig; +} diff --git a/comet-auth/src/cookie.rs b/comet-auth/src/cookie.rs new file mode 100644 index 0000000..b5b27c5 --- /dev/null +++ b/comet-auth/src/cookie.rs @@ -0,0 +1,40 @@ +use rocket::http::{Cookie, SameSite}; +use rocket::time::Duration; + +use crate::AuthConfig; + +pub fn build_session_cookie(config: &AuthConfig, token: impl Into) -> Cookie<'static> { + Cookie::build((config.session_cookie.clone(), token.into())) + .http_only(true) + .secure(config.secure_cookies) + .same_site(SameSite::from(config.same_site)) + .path("/") + .max_age(Duration::seconds(config.session_ttl_seconds as i64)) + .build() +} + +pub fn remove_session_cookie(config: &AuthConfig) -> Cookie<'static> { + Cookie::build(config.session_cookie.clone()) + .path("/") + .secure(config.secure_cookies) + .same_site(SameSite::from(config.same_site)) + .build() +} + +#[cfg(test)] +mod tests { + use rocket::http::SameSite; + + use crate::{AuthConfig, build_session_cookie}; + + #[test] + fn session_cookie_is_http_only_secure_and_lax_by_default() { + let cookie = build_session_cookie(&AuthConfig::default(), "token"); + + assert_eq!(cookie.name(), "__Host-comet_session"); + assert_eq!(cookie.value(), "token"); + assert_eq!(cookie.http_only(), Some(true)); + assert_eq!(cookie.secure(), Some(true)); + assert_eq!(cookie.same_site(), Some(SameSite::Lax)); + } +} diff --git a/comet-auth/src/error.rs b/comet-auth/src/error.rs new file mode 100644 index 0000000..559cc38 --- /dev/null +++ b/comet-auth/src/error.rs @@ -0,0 +1,114 @@ +use rocket::http::Status; +use rocket::response::{Responder, Result as ResponseResult}; +use rocket::serde::json::Json; +use rocket::{Request, Response}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("authentication is not configured on this Rocket instance")] + MissingConfig, + #[error("worker Env is not managed by Rocket")] + MissingEnv, + #[error("session cookie is missing")] + MissingSession, + #[error("session is invalid or expired")] + InvalidSession, + #[error("authenticated session is not authorized for this route")] + Forbidden, + #[error("unsupported auth provider: {0}")] + UnsupportedProvider(String), + #[error("auth provider `{0}` is not configured")] + ProviderNotConfigured(String), + #[error("missing auth provider setting `{setting}` for `{provider}`")] + MissingProviderSetting { + provider: &'static str, + setting: &'static str, + }, + #[error("invalid callback state")] + InvalidOAuthState, + #[error("invalid post-login redirect path")] + InvalidRedirect, + #[error("native login nonce is required")] + MissingNonce, + #[error("oauth provider request failed: {0}")] + ProviderRequest(String), + #[error("identity token is invalid: {0}")] + InvalidIdentityToken(String), + #[error("random bytes unavailable: {0}")] + Random(getrandom::Error), + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), + #[cfg(feature = "cloudflare")] + #[error("worker error: {0}")] + Worker(#[from] worker::Error), + #[cfg(feature = "cloudflare")] + #[error("kv error: {0}")] + Kv(#[from] worker::kv::KvError), + #[error("storage error: {0}")] + Storage(String), +} + +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct AuthErrorBody { + pub error: &'static str, + pub message: String, +} + +impl AuthError { + pub fn status(&self) -> Status { + match self { + Self::MissingSession | Self::InvalidSession => Status::Unauthorized, + Self::Forbidden => Status::Forbidden, + Self::UnsupportedProvider(_) + | Self::ProviderNotConfigured(_) + | Self::MissingProviderSetting { .. } + | Self::InvalidOAuthState + | Self::InvalidRedirect + | Self::MissingNonce + | Self::ProviderRequest(_) + | Self::InvalidIdentityToken(_) => Status::BadRequest, + _ => Status::InternalServerError, + } + } + + pub fn code(&self) -> &'static str { + match self { + Self::MissingConfig => "missing_config", + Self::MissingEnv => "missing_env", + Self::MissingSession => "missing_session", + Self::InvalidSession => "invalid_session", + Self::Forbidden => "forbidden", + Self::UnsupportedProvider(_) => "unsupported_provider", + Self::ProviderNotConfigured(_) => "provider_not_configured", + Self::MissingProviderSetting { .. } => "missing_provider_setting", + Self::InvalidOAuthState => "invalid_oauth_state", + Self::InvalidRedirect => "invalid_redirect", + Self::MissingNonce => "missing_nonce", + Self::ProviderRequest(_) => "provider_request", + Self::InvalidIdentityToken(_) => "invalid_identity_token", + Self::Random(_) => "random", + Self::Serde(_) => "serde", + #[cfg(feature = "cloudflare")] + Self::Worker(_) => "worker", + #[cfg(feature = "cloudflare")] + Self::Kv(_) => "kv", + Self::Storage(_) => "storage", + } + } +} + +impl<'r> Responder<'r, 'static> for AuthError { + fn respond_to(self, request: &'r Request<'_>) -> ResponseResult<'static> { + let status = self.status(); + let body = AuthErrorBody { + error: self.code(), + message: self.to_string(), + }; + + Response::build_from(Json(body).respond_to(request)?) + .status(status) + .ok() + } +} diff --git a/comet-auth/src/lib.rs b/comet-auth/src/lib.rs new file mode 100644 index 0000000..1d30a08 --- /dev/null +++ b/comet-auth/src/lib.rs @@ -0,0 +1,39 @@ +mod authz; +mod config; +mod cookie; +mod error; +#[cfg(feature = "nebula")] +mod nebula; +mod oauth; +mod oidc; +mod routes; +mod session; +mod store; + +#[cfg(feature = "macros")] +pub use comet_auth_macros::requires_auth; +pub use config::{ + AppleProviderConfig, AuthConfig, CookieSameSite, GitHubProviderConfig, GoogleProviderConfig, + ProviderConfig, providers, +}; +pub use cookie::{build_session_cookie, remove_session_cookie}; +pub use error::{AuthError, AuthErrorBody}; +pub use oauth::{OAuthProviderId, OAuthStart, ProviderTokens}; +pub use routes::{Auth, routes}; +pub use session::{ + AuthSession, CachedSession, CurrentUser, IssuedSession, NewSession, OptionalAuthSession, + ProviderIdentity, StoredSession, StoredUser, +}; +pub use store::{ + D1SessionStore, KvOAuthStateStore, KvSessionCache, NoopOAuthStateStore, NoopSessionCache, + OAuthState, OAuthStateStore, SessionCache, SessionStore, +}; + +pub const DEFAULT_SESSION_COOKIE: &str = "__Host-comet_session"; +pub const DEFAULT_SESSION_TTL_SECONDS: u64 = 60 * 60 * 24 * 30; +pub use authz::{ + AuthorizationClaims, AuthorizationMode, AuthorizationRequirement, AuthorizedSession, + D1AuthorizationStore, RequiredAuthorization, +}; +#[cfg(feature = "nebula")] +pub use nebula::NebulaAccessContextExt; diff --git a/comet-auth/src/nebula.rs b/comet-auth/src/nebula.rs new file mode 100644 index 0000000..c757de4 --- /dev/null +++ b/comet-auth/src/nebula.rs @@ -0,0 +1,39 @@ +use comet::nebula::AccessContext; + +use crate::{AuthSession, AuthorizationClaims, AuthorizedSession, RequiredAuthorization}; + +pub trait NebulaAccessContextExt { + fn to_nebula_access_context(&self) -> AccessContext; +} + +impl NebulaAccessContextExt for AuthSession { + fn to_nebula_access_context(&self) -> AccessContext { + AccessContext::authenticated(self.user.id.clone()) + } +} + +impl NebulaAccessContextExt for AuthorizedSession

{ + fn to_nebula_access_context(&self) -> AccessContext { + let context = self + .session + .to_nebula_access_context() + .with_roles(self.claims.roles.clone()) + .with_permissions(self.claims.permissions.clone()) + .with_scopes(self.claims.permissions.clone()); + + if let Some(resource) = P::REQUIREMENT.resource { + context.with_resource(resource) + } else { + context + } + } +} + +impl NebulaAccessContextExt for AuthorizationClaims { + fn to_nebula_access_context(&self) -> AccessContext { + AccessContext::default() + .with_roles(self.roles.clone()) + .with_permissions(self.permissions.clone()) + .with_scopes(self.permissions.clone()) + } +} diff --git a/comet-auth/src/oauth.rs b/comet-auth/src/oauth.rs new file mode 100644 index 0000000..3b9893c --- /dev/null +++ b/comet-auth/src/oauth.rs @@ -0,0 +1,731 @@ +#![cfg_attr(not(feature = "cloudflare"), allow(dead_code))] + +use base64ct::Encoding; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; +use p256::pkcs8::DecodePrivateKey; +use serde::Deserialize; +use url::form_urlencoded; + +use crate::config::{ + AppleProviderConfig, GitHubProviderConfig, GoogleProviderConfig, ProviderConfig, +}; +#[cfg(feature = "cloudflare")] +use crate::oidc::{self, OidcValidation}; +use crate::session; +#[cfg(feature = "cloudflare")] +use crate::session::ProviderIdentity; +use crate::{AuthConfig, AuthError}; + +pub const OAUTH_STATE_TTL_SECONDS: u64 = 10 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OAuthProviderId { + Google, + Apple, + GitHub, +} + +impl OAuthProviderId { + pub fn parse(value: &str) -> Result { + match value { + "google" => Ok(Self::Google), + "apple" => Ok(Self::Apple), + "github" => Ok(Self::GitHub), + other => Err(AuthError::UnsupportedProvider(other.to_owned())), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Google => "google", + Self::Apple => "apple", + Self::GitHub => "github", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OAuthStart { + pub provider: OAuthProviderId, + pub state: String, + pub state_hash: String, + pub code_verifier: String, + pub nonce: String, + pub authorize_url: String, + pub redirect_after: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderTokens { + pub access_token: Option, + pub id_token: Option, + pub token_type: Option, + pub scope: Option, + pub expires_in: Option, +} + +#[derive(Debug, Clone)] +pub struct ProviderSecrets { + pub client_id: String, + pub client_secret: Option, +} + +pub trait EnvReader { + fn get_env(&self, name: &str) -> Result, AuthError>; +} + +#[cfg(feature = "cloudflare")] +impl EnvReader for worker::Env { + fn get_env(&self, name: &str) -> Result, AuthError> { + match self.secret(name) { + Ok(secret) => return Ok(Some(secret.to_string())), + Err(_) => {} + } + + match self.var(name) { + Ok(var) => Ok(Some(var.to_string())), + Err(_) => Ok(None), + } + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct StaticEnv<'a>(pub &'a [(&'a str, &'a str)]); + +impl EnvReader for StaticEnv<'_> { + fn get_env(&self, name: &str) -> Result, AuthError> { + Ok(self + .0 + .iter() + .find_map(|(key, value)| (*key == name).then(|| (*value).to_owned()))) + } +} + +pub fn start_oauth( + config: &AuthConfig, + env: &impl EnvReader, + provider: OAuthProviderId, + redirect_after: Option, +) -> Result { + let redirect_after = validate_redirect_after(redirect_after)?; + let state = session::generate_token()?; + let state_hash = session::hash_token(&state, None); + let code_verifier = session::generate_token()?; + let nonce = session::generate_token()?; + let code_challenge = session::hash_token(&code_verifier, None); + let redirect_uri = redirect_uri(config, provider)?; + let secrets = provider_secrets(config, env, provider)?; + let authorize_url = authorize_url( + provider, + &secrets.client_id, + &redirect_uri, + &state, + &nonce, + &code_challenge, + ); + + Ok(OAuthStart { + provider, + state, + state_hash, + code_verifier, + nonce, + authorize_url, + redirect_after, + }) +} + +pub fn redirect_uri(config: &AuthConfig, provider: OAuthProviderId) -> Result { + let base_url = config.base_url.as_deref().ok_or(AuthError::MissingConfig)?; + Ok(format!( + "{}/auth/{}/callback", + base_url.trim_end_matches('/'), + provider.as_str() + )) +} + +pub fn validate_redirect_after(value: Option) -> Result, AuthError> { + let Some(value) = value else { + return Ok(None); + }; + + let is_relative_path = value.starts_with('/') && !value.starts_with("//"); + let has_unsafe_chars = value + .chars() + .any(|character| character.is_control() || character == '\\'); + + if is_relative_path && !has_unsafe_chars { + Ok(Some(value)) + } else { + Err(AuthError::InvalidRedirect) + } +} + +pub fn provider_secrets( + config: &AuthConfig, + env: &impl EnvReader, + provider: OAuthProviderId, +) -> Result { + let provider_config = config + .provider_config(provider.as_str()) + .ok_or_else(|| AuthError::ProviderNotConfigured(provider.as_str().to_owned()))?; + + match (provider, provider_config) { + (OAuthProviderId::Google, ProviderConfig::Google(config)) => google_secrets(env, config), + (OAuthProviderId::Apple, ProviderConfig::Apple(config)) => apple_secrets(env, config), + (OAuthProviderId::GitHub, ProviderConfig::GitHub(config)) => github_secrets(env, config), + _ => Err(AuthError::ProviderNotConfigured( + provider.as_str().to_owned(), + )), + } +} + +fn google_secrets( + env: &impl EnvReader, + config: &GoogleProviderConfig, +) -> Result { + Ok(ProviderSecrets { + client_id: required_env( + env, + "google", + "web_client_id_env", + config.web_client_id_env.as_deref(), + )?, + client_secret: Some(required_env( + env, + "google", + "web_client_secret_env", + config.web_client_secret_env.as_deref(), + )?), + }) +} + +fn apple_secrets( + env: &impl EnvReader, + config: &AppleProviderConfig, +) -> Result { + let client_id = required_env( + env, + "apple", + "service_id_env", + config.service_id_env.as_deref(), + )?; + let client_secret = match config.client_secret_env.as_deref() { + Some(name) => Some(required_env(env, "apple", "client_secret_env", Some(name))?), + None => Some(generate_apple_client_secret(env, config, &client_id)?), + }; + + Ok(ProviderSecrets { + client_id, + client_secret, + }) +} + +fn generate_apple_client_secret( + env: &impl EnvReader, + config: &AppleProviderConfig, + service_id: &str, +) -> Result { + let team_id = required_env(env, "apple", "team_id_env", config.team_id_env.as_deref())?; + let key_id = required_env(env, "apple", "key_id_env", config.key_id_env.as_deref())?; + let private_key = required_env( + env, + "apple", + "private_key_pkcs8_pem_env", + config.private_key_pkcs8_pem_env.as_deref(), + )? + .replace("\\n", "\n"); + let now = session::now_unix(); + let header = serde_json::json!({ + "alg": "ES256", + "kid": key_id, + "typ": "JWT", + }); + let claims = serde_json::json!({ + "iss": team_id, + "iat": now, + "exp": now + 60 * 60 * 24 * 180, + "aud": "https://appleid.apple.com", + "sub": service_id, + }); + let signing_input = format!("{}.{}", base64_json(&header)?, base64_json(&claims)?,); + let signing_key = SigningKey::from_pkcs8_pem(&private_key).map_err(|error| { + AuthError::ProviderRequest(format!("invalid apple private key: {error}")) + })?; + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let signature = base64ct::Base64UrlUnpadded::encode_string(&signature.to_bytes()); + + Ok(format!("{signing_input}.{signature}")) +} + +fn base64_json(value: &serde_json::Value) -> Result { + Ok(base64ct::Base64UrlUnpadded::encode_string( + serde_json::to_string(value)?.as_bytes(), + )) +} + +fn github_secrets( + env: &impl EnvReader, + config: &GitHubProviderConfig, +) -> Result { + Ok(ProviderSecrets { + client_id: required_env( + env, + "github", + "client_id_env", + config.client_id_env.as_deref(), + )?, + client_secret: Some(required_env( + env, + "github", + "client_secret_env", + config.client_secret_env.as_deref(), + )?), + }) +} + +fn required_env( + env: &impl EnvReader, + provider: &'static str, + setting: &'static str, + env_name: Option<&str>, +) -> Result { + let env_name = env_name.ok_or(AuthError::MissingProviderSetting { provider, setting })?; + env.get_env(env_name)? + .ok_or(AuthError::MissingProviderSetting { provider, setting }) +} + +fn authorize_url( + provider: OAuthProviderId, + client_id: &str, + redirect_uri: &str, + state: &str, + nonce: &str, + code_challenge: &str, +) -> String { + let (base, scope) = match provider { + OAuthProviderId::Google => ( + "https://accounts.google.com/o/oauth2/v2/auth", + "openid email profile", + ), + OAuthProviderId::Apple => ( + "https://appleid.apple.com/auth/authorize", + "openid email name", + ), + OAuthProviderId::GitHub => ( + "https://github.com/login/oauth/authorize", + "read:user user:email", + ), + }; + + let mut query = form_urlencoded::Serializer::new(String::new()); + query + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", scope) + .append_pair("state", state); + + if !matches!(provider, OAuthProviderId::GitHub) { + query.append_pair("nonce", nonce); + } + query + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + + format!("{base}?{}", query.finish()) +} + +pub fn form_body(pairs: &[(&str, &str)]) -> String { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in pairs { + serializer.append_pair(key, value); + } + serializer.finish() +} + +#[cfg(feature = "cloudflare")] +pub async fn exchange_code( + config: &AuthConfig, + env: &worker::Env, + provider: OAuthProviderId, + code: &str, + code_verifier: &str, +) -> Result { + let secrets = provider_secrets(config, env, provider)?; + let redirect_uri = redirect_uri(config, provider)?; + let token_url = match provider { + OAuthProviderId::Google => "https://oauth2.googleapis.com/token", + OAuthProviderId::Apple => "https://appleid.apple.com/auth/token", + OAuthProviderId::GitHub => "https://github.com/login/oauth/access_token", + }; + + let mut pairs = vec![ + ("client_id", secrets.client_id.as_str()), + ("code", code), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri.as_str()), + ]; + pairs.push(("code_verifier", code_verifier)); + if let Some(secret) = secrets.client_secret.as_deref() { + pairs.push(("client_secret", secret)); + } + + let body = form_body(&pairs); + let mut response = post_form(token_url, &body, &[("accept", "application/json")]).await?; + if response.status_code() >= 400 { + return Err(AuthError::ProviderRequest(response.text().await?)); + } + response + .json::() + .await + .map_err(AuthError::from) +} + +#[cfg(feature = "cloudflare")] +pub async fn fetch_identity( + config: &AuthConfig, + env: &worker::Env, + provider: OAuthProviderId, + tokens: &ProviderTokens, + expected_nonce: Option<&str>, +) -> Result { + match provider { + OAuthProviderId::Google => { + validate_google_identity(config, env, tokens, expected_nonce).await + } + OAuthProviderId::Apple => { + validate_apple_identity(config, env, tokens, expected_nonce).await + } + OAuthProviderId::GitHub => fetch_github_identity(tokens).await, + } +} + +#[cfg(feature = "cloudflare")] +pub async fn validate_native_identity( + config: &AuthConfig, + env: &worker::Env, + provider: OAuthProviderId, + id_token: &str, + expected_nonce: Option<&str>, +) -> Result { + let tokens = ProviderTokens { + access_token: None, + id_token: Some(id_token.to_owned()), + token_type: None, + scope: None, + expires_in: None, + }; + + match provider { + OAuthProviderId::Google => { + validate_google_identity(config, env, &tokens, expected_nonce).await + } + OAuthProviderId::Apple => { + validate_apple_identity(config, env, &tokens, expected_nonce).await + } + OAuthProviderId::GitHub => Err(AuthError::UnsupportedProvider("github_native".into())), + } +} + +#[cfg(feature = "cloudflare")] +async fn validate_google_identity( + config: &AuthConfig, + env: &worker::Env, + tokens: &ProviderTokens, + expected_nonce: Option<&str>, +) -> Result { + let id_token = tokens + .id_token + .as_deref() + .ok_or_else(|| AuthError::ProviderRequest("google response missing id_token".into()))?; + let audiences = google_audiences(config, env)?; + let claims = oidc::validate_rs256_id_token( + "https://www.googleapis.com/oauth2/v3/certs", + id_token, + OidcValidation { + issuer: "https://accounts.google.com", + audiences: &audiences, + expected_nonce, + }, + ) + .await?; + Ok(ProviderIdentity { + provider: "google".to_owned(), + provider_account_id: claims.sub, + email: claims.email, + email_verified: claims + .email_verified + .map(|value| value.as_bool()) + .unwrap_or(false), + name: claims.name, + avatar_url: claims.picture, + raw_profile_json: None, + }) +} + +#[cfg(feature = "cloudflare")] +async fn validate_apple_identity( + config: &AuthConfig, + env: &worker::Env, + tokens: &ProviderTokens, + expected_nonce: Option<&str>, +) -> Result { + let id_token = tokens + .id_token + .as_deref() + .ok_or_else(|| AuthError::ProviderRequest("apple response missing id_token".into()))?; + let audiences = apple_audiences(config, env)?; + let claims = oidc::validate_rs256_id_token( + "https://appleid.apple.com/auth/keys", + id_token, + OidcValidation { + issuer: "https://appleid.apple.com", + audiences: &audiences, + expected_nonce, + }, + ) + .await?; + Ok(ProviderIdentity { + provider: "apple".to_owned(), + provider_account_id: claims.sub, + email: claims.email, + email_verified: claims + .email_verified + .map(|value| value.as_bool()) + .unwrap_or(false), + name: claims.name, + avatar_url: None, + raw_profile_json: None, + }) +} + +#[cfg(feature = "cloudflare")] +fn google_audiences(config: &AuthConfig, env: &worker::Env) -> Result, AuthError> { + let ProviderConfig::Google(google) = config + .provider_config("google") + .ok_or_else(|| AuthError::ProviderNotConfigured("google".into()))? + else { + return Err(AuthError::ProviderNotConfigured("google".into())); + }; + + let mut audiences = Vec::new(); + if let Some(name) = google.web_client_id_env.as_deref() { + if let Some(value) = env.get_env(name)? { + audiences.push(value); + } + } + for name in &google.native_client_id_envs { + if let Some(value) = env.get_env(name)? { + audiences.push(value); + } + } + if audiences.is_empty() { + return Err(AuthError::MissingProviderSetting { + provider: "google", + setting: "web_client_id_env/native_client_id_env", + }); + } + Ok(audiences) +} + +#[cfg(feature = "cloudflare")] +fn apple_audiences(config: &AuthConfig, env: &worker::Env) -> Result, AuthError> { + let ProviderConfig::Apple(apple) = config + .provider_config("apple") + .ok_or_else(|| AuthError::ProviderNotConfigured("apple".into()))? + else { + return Err(AuthError::ProviderNotConfigured("apple".into())); + }; + + let mut audiences = Vec::new(); + if let Some(name) = apple.service_id_env.as_deref() { + if let Some(value) = env.get_env(name)? { + audiences.push(value); + } + } + for name in &apple.native_audience_envs { + if let Some(value) = env.get_env(name)? { + audiences.push(value); + } + } + if audiences.is_empty() { + return Err(AuthError::MissingProviderSetting { + provider: "apple", + setting: "service_id_env/native_audience_env", + }); + } + Ok(audiences) +} + +#[cfg(feature = "cloudflare")] +async fn fetch_github_identity(tokens: &ProviderTokens) -> Result { + let access_token = tokens + .access_token + .as_deref() + .ok_or_else(|| AuthError::ProviderRequest("github response missing access_token".into()))?; + let auth = format!("Bearer {access_token}"); + let headers = [ + ("authorization", auth.as_str()), + ("accept", "application/vnd.github+json"), + ("user-agent", "comet-auth"), + ]; + let mut user_response = get_json("https://api.github.com/user", &headers).await?; + let user = user_response.json::().await?; + let mut emails_response = get_json("https://api.github.com/user/emails", &headers).await?; + let emails = emails_response + .json::>() + .await + .unwrap_or_default(); + let primary_email = emails + .iter() + .find(|email| email.primary && email.verified) + .or_else(|| emails.iter().find(|email| email.verified)); + + Ok(ProviderIdentity { + provider: "github".to_owned(), + provider_account_id: user.id.to_string(), + email: primary_email + .map(|email| email.email.clone()) + .or(user.email), + email_verified: primary_email.is_some(), + name: user.name.or(user.login), + avatar_url: user.avatar_url, + raw_profile_json: None, + }) +} + +#[cfg(feature = "cloudflare")] +async fn post_form( + url: &str, + body: &str, + headers: &[(&str, &str)], +) -> Result { + let mut init = worker::RequestInit::new(); + init.with_method(worker::Method::Post); + init.with_body(Some(worker::wasm_bindgen::JsValue::from_str(body))); + init.headers + .set("content-type", "application/x-www-form-urlencoded")?; + for (name, value) in headers { + init.headers.set(name, value)?; + } + let request = worker::Request::new_with_init(url, &init)?; + worker::Fetch::Request(request) + .send() + .await + .map_err(AuthError::from) +} + +#[cfg(feature = "cloudflare")] +async fn get_json(url: &str, headers: &[(&str, &str)]) -> Result { + let init = worker::RequestInit::new(); + for (name, value) in headers { + init.headers.set(name, value)?; + } + let request = worker::Request::new_with_init(url, &init)?; + let mut response = worker::Fetch::Request(request).send().await?; + if response.status_code() >= 400 { + return Err(AuthError::ProviderRequest(response.text().await?)); + } + Ok(response) +} + +#[derive(Debug, Deserialize)] +struct GitHubUser { + id: u64, + login: Option, + name: Option, + email: Option, + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubEmail { + email: String, + primary: bool, + verified: bool, +} + +#[cfg(test)] +mod tests { + use super::{OAuthProviderId, StaticEnv, form_body, start_oauth, validate_redirect_after}; + use crate::AuthConfig; + use crate::providers; + + #[test] + fn start_google_oauth_builds_authorize_url() { + let config = AuthConfig::default() + .base_url("https://api.example.com") + .provider( + providers::Google::from_env() + .web_client_id_env("GOOGLE_ID") + .web_client_secret_env("GOOGLE_SECRET"), + ); + let env = StaticEnv(&[("GOOGLE_ID", "gid"), ("GOOGLE_SECRET", "secret")]); + + let start = start_oauth(&config, &env, OAuthProviderId::Google, None).unwrap(); + + assert!( + start + .authorize_url + .starts_with("https://accounts.google.com/o/oauth2/v2/auth?") + ); + assert!(start.authorize_url.contains("client_id=gid")); + assert!(start.authorize_url.contains("code_challenge_method=S256")); + assert!( + start + .authorize_url + .contains("redirect_uri=https%3A%2F%2Fapi.example.com%2Fauth%2Fgoogle%2Fcallback") + ); + } + + #[test] + fn start_github_oauth_uses_pkce() { + let config = AuthConfig::default() + .base_url("https://api.example.com") + .provider( + providers::GitHub::from_env() + .client_id_env("GITHUB_ID") + .client_secret_env("GITHUB_SECRET"), + ); + let env = StaticEnv(&[("GITHUB_ID", "gid"), ("GITHUB_SECRET", "secret")]); + + let start = start_oauth(&config, &env, OAuthProviderId::GitHub, None).unwrap(); + + assert!(start.authorize_url.contains("code_challenge=")); + assert!(start.authorize_url.contains("code_challenge_method=S256")); + } + + #[test] + fn form_body_url_encodes_values() { + assert_eq!( + form_body(&[("scope", "openid email")]), + "scope=openid+email" + ); + } + + #[test] + fn validates_redirect_after_as_relative_path() { + assert_eq!( + validate_redirect_after(Some("/dashboard".to_owned())).unwrap(), + Some("/dashboard".to_owned()) + ); + assert!(validate_redirect_after(Some("https://evil.test".to_owned())).is_err()); + assert!(validate_redirect_after(Some("//evil.test".to_owned())).is_err()); + assert!(validate_redirect_after(Some("/\\evil".to_owned())).is_err()); + } + + #[test] + fn apple_requires_client_secret_or_signing_settings() { + let config = AuthConfig::default() + .base_url("https://api.example.com") + .provider(providers::Apple::from_env().service_id_env("APPLE_SERVICE_ID")); + let env = StaticEnv(&[("APPLE_SERVICE_ID", "app.example.web")]); + + let error = start_oauth(&config, &env, OAuthProviderId::Apple, None).unwrap_err(); + + assert!(error.to_string().contains("team_id_env")); + } +} diff --git a/comet-auth/src/oidc.rs b/comet-auth/src/oidc.rs new file mode 100644 index 0000000..9f0f777 --- /dev/null +++ b/comet-auth/src/oidc.rs @@ -0,0 +1,258 @@ +#![cfg_attr(not(feature = "cloudflare"), allow(dead_code))] + +use base64ct::{Base64UrlUnpadded, Encoding}; +use crypto_bigint::BoxedUint; +use rsa::RsaPublicKey; +use rsa::pkcs1v15::{Signature as RsaSignature, VerifyingKey}; +use rsa::signature::Verifier; +use serde::Deserialize; +use sha2::Sha256; + +use crate::AuthError; +use crate::session; + +const CLOCK_SKEW_SECONDS: i64 = 60; + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcClaims { + pub iss: String, + pub sub: String, + pub aud: Audience, + pub exp: i64, + pub iat: Option, + pub nonce: Option, + pub email: Option, + pub email_verified: Option, + pub name: Option, + pub picture: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Audience { + Single(String), + Multiple(Vec), +} + +impl Audience { + fn contains_any(&self, allowed: &[String]) -> bool { + match self { + Audience::Single(value) => allowed.iter().any(|allowed| allowed == value), + Audience::Multiple(values) => values + .iter() + .any(|value| allowed.iter().any(|allowed| allowed == value)), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum EmailVerified { + Bool(bool), + String(String), +} + +impl EmailVerified { + pub fn as_bool(&self) -> bool { + match self { + EmailVerified::Bool(value) => *value, + EmailVerified::String(value) => value == "true" || value == "1", + } + } +} + +#[derive(Debug, Deserialize)] +struct JwtHeader { + alg: String, + kid: String, +} + +#[derive(Debug, Deserialize)] +struct Jwks { + keys: Vec, +} + +#[derive(Debug, Deserialize)] +struct Jwk { + kid: String, + kty: String, + alg: Option, + n: String, + e: String, +} + +pub struct OidcValidation<'a> { + pub issuer: &'a str, + pub audiences: &'a [String], + pub expected_nonce: Option<&'a str>, +} + +#[cfg(feature = "cloudflare")] +pub async fn validate_rs256_id_token( + jwks_url: &str, + id_token: &str, + validation: OidcValidation<'_>, +) -> Result { + let (header, claims, signing_input, signature) = decode_jwt_parts(id_token)?; + if header.alg != "RS256" { + return Err(AuthError::InvalidIdentityToken(format!( + "unsupported JWT alg `{}`", + header.alg + ))); + } + + let jwks = fetch_jwks(jwks_url).await?; + let jwk = jwks + .keys + .iter() + .find(|key| key.kid == header.kid) + .ok_or_else(|| AuthError::InvalidIdentityToken("matching JWK not found".into()))?; + verify_jwk(jwk, signing_input.as_bytes(), &signature)?; + validate_claims(&claims, validation)?; + Ok(claims) +} + +#[cfg(feature = "cloudflare")] +async fn fetch_jwks(url: &str) -> Result { + let mut response = worker::Fetch::Url( + url::Url::parse(url).map_err(|error| AuthError::ProviderRequest(error.to_string()))?, + ) + .send() + .await?; + if response.status_code() >= 400 { + return Err(AuthError::ProviderRequest(response.text().await?)); + } + response.json::().await.map_err(AuthError::from) +} + +fn decode_jwt_parts(jwt: &str) -> Result<(JwtHeader, OidcClaims, String, Vec), AuthError> { + let mut parts = jwt.split('.'); + let header = parts + .next() + .ok_or_else(|| AuthError::InvalidIdentityToken("missing JWT header".into()))?; + let payload = parts + .next() + .ok_or_else(|| AuthError::InvalidIdentityToken("missing JWT payload".into()))?; + let signature = parts + .next() + .ok_or_else(|| AuthError::InvalidIdentityToken("missing JWT signature".into()))?; + if parts.next().is_some() { + return Err(AuthError::InvalidIdentityToken("too many JWT parts".into())); + } + + let header_value = decode_base64_json::(header)?; + let claims = decode_base64_json::(payload)?; + let signature = Base64UrlUnpadded::decode_vec(signature) + .map_err(|_| AuthError::InvalidIdentityToken("malformed JWT signature".into()))?; + Ok(( + header_value, + claims, + format!("{header}.{payload}"), + signature, + )) +} + +fn decode_base64_json Deserialize<'de>>(value: &str) -> Result { + let bytes = Base64UrlUnpadded::decode_vec(value) + .map_err(|_| AuthError::InvalidIdentityToken("malformed JWT base64".into()))?; + serde_json::from_slice(&bytes).map_err(AuthError::from) +} + +fn verify_jwk(jwk: &Jwk, signing_input: &[u8], signature: &[u8]) -> Result<(), AuthError> { + if jwk.kty != "RSA" || jwk.alg.as_deref().is_some_and(|alg| alg != "RS256") { + return Err(AuthError::InvalidIdentityToken( + "JWK is not an RS256 RSA key".into(), + )); + } + + let n_bytes = Base64UrlUnpadded::decode_vec(&jwk.n) + .map_err(|_| AuthError::InvalidIdentityToken("malformed JWK modulus".into()))?; + let e_bytes = Base64UrlUnpadded::decode_vec(&jwk.e) + .map_err(|_| AuthError::InvalidIdentityToken("malformed JWK exponent".into()))?; + let n = BoxedUint::from_be_slice(&n_bytes, (n_bytes.len() * 8) as u32) + .map_err(|error| AuthError::InvalidIdentityToken(error.to_string()))?; + let e = BoxedUint::from_be_slice(&e_bytes, (e_bytes.len() * 8) as u32) + .map_err(|error| AuthError::InvalidIdentityToken(error.to_string()))?; + let public_key = RsaPublicKey::new(n, e) + .map_err(|error| AuthError::InvalidIdentityToken(error.to_string()))?; + let signature = RsaSignature::try_from(signature) + .map_err(|_| AuthError::InvalidIdentityToken("malformed RS256 signature".into()))?; + VerifyingKey::::new(public_key) + .verify(signing_input, &signature) + .map_err(|_| AuthError::InvalidIdentityToken("JWT signature verification failed".into())) +} + +pub fn validate_claims( + claims: &OidcClaims, + validation: OidcValidation<'_>, +) -> Result<(), AuthError> { + if claims.iss != validation.issuer { + return Err(AuthError::InvalidIdentityToken("issuer mismatch".into())); + } + if !claims.aud.contains_any(validation.audiences) { + return Err(AuthError::InvalidIdentityToken("audience mismatch".into())); + } + if let Some(expected_nonce) = validation.expected_nonce { + if claims.nonce.as_deref() != Some(expected_nonce) { + return Err(AuthError::InvalidIdentityToken("nonce mismatch".into())); + } + } + let now = session::now_unix(); + if claims.exp + CLOCK_SKEW_SECONDS <= now { + return Err(AuthError::InvalidIdentityToken("token expired".into())); + } + if claims.iat.is_some_and(|iat| iat > now + CLOCK_SKEW_SECONDS) { + return Err(AuthError::InvalidIdentityToken( + "token issued in the future".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{Audience, OidcClaims, OidcValidation, validate_claims}; + + fn claims() -> OidcClaims { + OidcClaims { + iss: "https://accounts.google.com".into(), + sub: "sub".into(), + aud: Audience::Single("client".into()), + exp: crate::session::now_unix() + 600, + iat: Some(crate::session::now_unix()), + nonce: Some("nonce".into()), + email: None, + email_verified: None, + name: None, + picture: None, + } + } + + #[test] + fn validates_claims() { + validate_claims( + &claims(), + OidcValidation { + issuer: "https://accounts.google.com", + audiences: &[String::from("client")], + expected_nonce: Some("nonce"), + }, + ) + .unwrap(); + } + + #[test] + fn rejects_wrong_audience() { + let error = validate_claims( + &claims(), + OidcValidation { + issuer: "https://accounts.google.com", + audiences: &[String::from("other")], + expected_nonce: Some("nonce"), + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("audience")); + } +} diff --git a/comet-auth/src/routes.rs b/comet-auth/src/routes.rs new file mode 100644 index 0000000..86ef966 --- /dev/null +++ b/comet-auth/src/routes.rs @@ -0,0 +1,534 @@ +use std::marker::PhantomData; + +use rocket::fairing::AdHoc; +use rocket::http::{CookieJar, Status}; +use rocket::request::{FromRequest, Outcome}; +use rocket::response::Redirect; +use rocket::serde::json::Json; +#[cfg(feature = "cloudflare")] +use rocket::{Build, Rocket}; +use rocket::{Request, Route}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "cloudflare")] +use crate::session::{self as session_core, CachedSession}; +#[cfg(feature = "cloudflare")] +use crate::store::{OAuthStateStore, SessionCache, SessionStore}; +use crate::{ + AuthConfig, AuthError, AuthSession, CurrentUser, OptionalAuthSession, remove_session_cookie, +}; +#[cfg(feature = "cloudflare")] +use crate::{D1SessionStore, KvOAuthStateStore, KvSessionCache}; +#[cfg(feature = "cloudflare")] +use crate::{NewSession, OAuthProviderId, OAuthState, build_session_cookie}; + +#[cfg(feature = "cloudflare")] +use comet::cloudflare::BindingName; + +#[derive(Debug, Clone)] +#[cfg_attr(not(feature = "cloudflare"), allow(dead_code))] +pub struct AuthState { + pub config: AuthConfig, + pub db_binding: &'static str, + pub kv_binding: &'static str, +} + +pub struct Auth { + _bindings: PhantomData<(DB, KV)>, +} + +#[cfg(feature = "cloudflare")] +impl Auth +where + DB: BindingName + Send + Sync + 'static, + KV: BindingName + Send + Sync + 'static, +{ + pub fn fairing(config: AuthConfig) -> AdHoc { + AdHoc::on_ignite("Comet Auth", |rocket| async move { + register_auth_state::(rocket, config) + }) + } +} + +#[cfg(not(feature = "cloudflare"))] +impl Auth { + pub fn fairing(config: AuthConfig) -> AdHoc { + AdHoc::on_ignite("Comet Auth", |rocket| async move { + rocket.manage(AuthState { + config, + db_binding: "", + kv_binding: "", + }) + }) + } +} + +#[cfg(feature = "cloudflare")] +fn register_auth_state(rocket: Rocket, config: AuthConfig) -> Rocket +where + DB: BindingName + Send + Sync + 'static, + KV: BindingName + Send + Sync + 'static, +{ + rocket.manage(AuthState { + config, + db_binding: DB::NAME, + kv_binding: KV::NAME, + }) +} + +#[cfg(feature = "cloudflare")] +pub fn routes() -> Vec +where + DB: BindingName + Send + Sync + 'static, + KV: BindingName + Send + Sync + 'static, +{ + rocket::routes![session, logout, oauth_start, oauth_callback, native_login] +} + +#[cfg(not(feature = "cloudflare"))] +pub fn routes() -> Vec { + rocket::routes![session, logout, oauth_start, oauth_callback, native_login] +} + +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct SessionResponse { + pub authenticated: bool, + pub session: Option, + pub user: Option, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(not(feature = "cloudflare"), allow(dead_code))] +#[serde(crate = "rocket::serde")] +pub struct NativeLoginRequest { + pub id_token: String, + pub nonce: Option, +} + +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct NativeLoginResponse { + pub token: String, + pub session: AuthSession, + pub user: CurrentUser, +} + +#[rocket::get("/session")] +pub async fn session(optional: OptionalAuthSession) -> Json { + let session = optional.0; + let user = session.as_ref().map(|session| session.user.clone()); + Json(SessionResponse { + authenticated: session.is_some(), + session, + user, + }) +} + +#[cfg(feature = "cloudflare")] +#[rocket::get("//start?")] +pub async fn oauth_start( + provider: &str, + redirect_after: Option<&str>, + state: &rocket::State, + env: &rocket::State, +) -> Result { + let provider = OAuthProviderId::parse(provider)?; + let start = crate::oauth::start_oauth( + &state.config, + env.inner(), + provider, + redirect_after.map(str::to_owned), + )?; + let kv = env.kv(state.kv_binding)?; + let expires_at = session_core::now_unix() + crate::oauth::OAUTH_STATE_TTL_SECONDS as i64; + KvOAuthStateStore::new(kv) + .put( + &start.state_hash, + &OAuthState { + provider: provider.as_str().to_owned(), + code_verifier: start.code_verifier, + nonce: Some(start.nonce), + redirect_after: start.redirect_after, + expires_at, + }, + crate::oauth::OAUTH_STATE_TTL_SECONDS, + ) + .await?; + + Ok(Redirect::to(start.authorize_url)) +} + +#[cfg(not(feature = "cloudflare"))] +#[rocket::get("//start?")] +pub async fn oauth_start( + provider: &str, + redirect_after: Option<&str>, +) -> Result { + let _ = (provider, redirect_after); + Err(AuthError::MissingEnv) +} + +#[cfg(feature = "cloudflare")] +#[rocket::get("//callback?&&&")] +pub async fn oauth_callback( + provider: &str, + code: Option<&str>, + state: Option<&str>, + error: Option<&str>, + error_description: Option<&str>, + jar: &CookieJar<'_>, + auth_state: &rocket::State, + env: &rocket::State, +) -> Result { + if let Some(error) = error { + return Err(AuthError::ProviderRequest( + error_description.unwrap_or(error).to_owned(), + )); + } + + let provider = OAuthProviderId::parse(provider)?; + let code = code.ok_or(AuthError::InvalidOAuthState)?; + let raw_state = state.ok_or(AuthError::InvalidOAuthState)?; + let state_hash = session_core::hash_token(raw_state, None); + let kv = env.kv(auth_state.kv_binding)?; + let oauth_state = KvOAuthStateStore::new(kv) + .take(&state_hash) + .await? + .ok_or(AuthError::InvalidOAuthState)?; + + if oauth_state.provider != provider.as_str() + || oauth_state.expires_at <= session_core::now_unix() + { + return Err(AuthError::InvalidOAuthState); + } + + let tokens = crate::oauth::exchange_code( + &auth_state.config, + env.inner(), + provider, + code, + &oauth_state.code_verifier, + ) + .await?; + let identity = crate::oauth::fetch_identity( + &auth_state.config, + env.inner(), + provider, + &tokens, + oauth_state.nonce.as_deref(), + ) + .await?; + let issued = issue_session(auth_state, env, identity).await?; + + jar.add(build_session_cookie(&auth_state.config, issued.token)); + Ok(Redirect::to( + oauth_state.redirect_after.unwrap_or_else(|| "/".to_owned()), + )) +} + +#[cfg(not(feature = "cloudflare"))] +#[rocket::get("//callback?&&&")] +pub async fn oauth_callback( + provider: &str, + code: Option<&str>, + state: Option<&str>, + error: Option<&str>, + error_description: Option<&str>, +) -> Result { + let _ = (provider, code, state, error, error_description); + Err(AuthError::MissingEnv) +} + +#[cfg(feature = "cloudflare")] +#[rocket::post("/native/", data = "")] +pub async fn native_login( + provider: &str, + login: Json, + jar: &CookieJar<'_>, + auth_state: &rocket::State, + env: &rocket::State, +) -> Result, AuthError> { + let provider = OAuthProviderId::parse(provider)?; + if matches!(provider, OAuthProviderId::GitHub) { + return Err(AuthError::UnsupportedProvider("github_native".into())); + } + let nonce = login + .nonce + .as_deref() + .filter(|nonce| !nonce.trim().is_empty()) + .ok_or(AuthError::MissingNonce)?; + let identity = crate::oauth::validate_native_identity( + &auth_state.config, + env.inner(), + provider, + &login.id_token, + Some(nonce), + ) + .await?; + let issued = issue_session(auth_state, env, identity).await?; + let session = AuthSession { + id: issued.stored.id.clone(), + user: issued.stored.user.clone().into(), + expires_at: issued.stored.expires_at, + token_hash: issued.stored.token_hash.clone(), + }; + let user = session.user.clone(); + jar.add(build_session_cookie( + &auth_state.config, + issued.token.clone(), + )); + + Ok(Json(NativeLoginResponse { + token: issued.token, + session, + user, + })) +} + +#[cfg(not(feature = "cloudflare"))] +#[rocket::post("/native/", data = "")] +pub async fn native_login( + provider: &str, + login: Json, +) -> Result, AuthError> { + let _ = (provider, login); + Err(AuthError::MissingEnv) +} + +#[cfg(feature = "cloudflare")] +#[rocket::post("/logout")] +pub async fn logout( + session: Option, + jar: &CookieJar<'_>, + state: &rocket::State, + env: &rocket::State, +) -> Result { + if let Some(session) = session { + let db = env.d1(state.db_binding)?; + D1SessionStore::new(db).revoke_session(&session.id).await?; + if let Ok(kv) = env.kv(state.kv_binding) { + let _ = KvSessionCache::new(kv).delete(&session.token_hash).await; + } + } + + jar.remove(remove_session_cookie(&state.config)); + Ok(Status::NoContent) +} + +#[cfg(not(feature = "cloudflare"))] +#[rocket::post("/logout")] +pub async fn logout( + session: Option, + jar: &CookieJar<'_>, + state: &rocket::State, +) -> Result { + let _ = session; + + jar.remove(remove_session_cookie(&state.config)); + Ok(Status::NoContent) +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AuthSession { + type Error = AuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match load_session_guard(request).await { + Ok(Some(session)) => Outcome::Success(session), + Ok(None) => Outcome::Error((Status::Unauthorized, AuthError::MissingSession)), + Err(error) => Outcome::Error((error.status(), error)), + } + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for OptionalAuthSession { + type Error = AuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match load_session_guard(request).await { + Ok(session) => Outcome::Success(OptionalAuthSession(session)), + Err(AuthError::MissingSession | AuthError::InvalidSession) => { + Outcome::Success(OptionalAuthSession(None)) + } + Err(error) => Outcome::Error((error.status(), error)), + } + } +} + +#[cfg(feature = "cloudflare")] +pub(crate) async fn load_session_guard( + request: &Request<'_>, +) -> Result, AuthError> { + comet::cloudflare::local(load_session(request)).await +} + +#[cfg(not(feature = "cloudflare"))] +pub(crate) async fn load_session_guard( + request: &Request<'_>, +) -> Result, AuthError> { + load_session(request).await +} + +#[cfg(feature = "cloudflare")] +async fn load_session(request: &Request<'_>) -> Result, AuthError> { + let state = request + .rocket() + .state::() + .ok_or(AuthError::MissingConfig)?; + let token = session_token(request, &state.config).ok_or(AuthError::MissingSession)?; + let env = request + .rocket() + .state::() + .ok_or(AuthError::MissingEnv)?; + let pepper = token_pepper(env, &state.config)?; + let token_hash = session_core::hash_token(&token, pepper.as_deref()); + let now = session_core::now_unix(); + + if let Ok(kv) = env.kv(state.kv_binding) { + let cache = KvSessionCache::new(kv); + if let Some(cached) = cache.get(&token_hash).await? { + if cached.is_active_at(now) { + return Ok(Some(auth_session_from_cached(cached))); + } + cache.delete(&token_hash).await?; + } + } + + let db = env.d1(state.db_binding)?; + let store = D1SessionStore::new(db); + let Some(stored) = store.find_session(&token_hash).await? else { + return Err(AuthError::InvalidSession); + }; + + if !stored.is_active_at(now) { + return Err(AuthError::InvalidSession); + } + + if let Ok(kv) = env.kv(state.kv_binding) { + let ttl = stored.expires_at.saturating_sub(now) as u64; + if ttl > 0 { + let _ = KvSessionCache::new(kv) + .put(&CachedSession::from(&stored), ttl) + .await; + } + } + + Ok(Some(AuthSession { + id: stored.id, + user: stored.user.into(), + expires_at: stored.expires_at, + token_hash, + })) +} + +#[cfg(feature = "cloudflare")] +async fn issue_session( + auth_state: &AuthState, + env: &worker::Env, + identity: crate::ProviderIdentity, +) -> Result { + let db = env.d1(auth_state.db_binding)?; + let store = D1SessionStore::new(db); + let user = store.upsert_account(identity).await?; + let pepper = token_pepper(env, &auth_state.config)?; + let issued = store + .create_session(NewSession { + user_id: user.id, + ttl_seconds: auth_state.config.session_ttl_seconds, + token_pepper: pepper, + user_agent_hash: None, + ip_hash: None, + }) + .await?; + + if let Ok(kv) = env.kv(auth_state.kv_binding) { + let ttl = issued + .stored + .expires_at + .saturating_sub(session_core::now_unix()) as u64; + if ttl > 0 { + let _ = KvSessionCache::new(kv) + .put(&CachedSession::from(&issued.stored), ttl) + .await; + } + } + + Ok(issued) +} + +#[cfg(feature = "cloudflare")] +fn session_token(request: &Request<'_>, config: &AuthConfig) -> Option { + request + .cookies() + .get(&config.session_cookie) + .map(|cookie| cookie.value().to_owned()) + .or_else(|| { + request + .headers() + .get_one("authorization") + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_owned) + }) +} + +#[cfg(not(feature = "cloudflare"))] +async fn load_session(_request: &Request<'_>) -> Result, AuthError> { + Err(AuthError::MissingSession) +} + +#[cfg(feature = "cloudflare")] +fn auth_session_from_cached(cached: CachedSession) -> AuthSession { + AuthSession { + id: cached.id, + user: cached.user, + expires_at: cached.expires_at, + token_hash: cached.token_hash, + } +} + +#[cfg(feature = "cloudflare")] +fn token_pepper(env: &worker::Env, config: &AuthConfig) -> Result, AuthError> { + let Some(binding) = config.token_pepper_env.as_deref() else { + return Ok(None); + }; + + match env.secret(binding) { + Ok(secret) => Ok(Some(secret.to_string())), + Err(_) => match env.var(binding) { + Ok(var) => Ok(Some(var.to_string())), + Err(_) => Ok(None), + }, + } +} + +#[cfg(test)] +mod tests { + use rocket::http::{Cookie, Status}; + use rocket::local::asynchronous::Client; + + use crate::{Auth, AuthConfig, OptionalAuthSession}; + + #[rocket::get("/probe")] + async fn probe(session: OptionalAuthSession) -> &'static str { + if session.0.is_some() { "yes" } else { "no" } + } + + #[rocket::async_test] + async fn optional_session_allows_anonymous_requests() { + let app = rocket::build() + .attach(Auth::<(), ()>::fairing(AuthConfig::default())) + .mount("/", rocket::routes![probe]); + let client = Client::untracked(app).await.unwrap(); + + let response = client + .get("/probe") + .cookie(Cookie::new("__Host-comet_session", "missing")) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().await.unwrap(), "no"); + } +} diff --git a/comet-auth/src/session.rs b/comet-auth/src/session.rs new file mode 100644 index 0000000..09ff383 --- /dev/null +++ b/comet-auth/src/session.rs @@ -0,0 +1,197 @@ +use base64ct::{Base64UrlUnpadded, Encoding}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::AuthError; + +#[allow(dead_code)] +const TOKEN_BYTES: usize = 32; +#[allow(dead_code)] +const ID_BYTES: usize = 16; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CurrentUser { + pub id: String, + pub primary_email: Option, + pub email_verified: bool, + pub name: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredUser { + pub id: String, + pub primary_email: Option, + pub email_verified: bool, + pub name: Option, + pub avatar_url: Option, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for CurrentUser { + fn from(user: StoredUser) -> Self { + Self { + id: user.id, + primary_email: user.primary_email, + email_verified: user.email_verified, + name: user.name, + avatar_url: user.avatar_url, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredSession { + pub id: String, + pub user: StoredUser, + pub token_hash: String, + pub created_at: i64, + pub updated_at: i64, + pub expires_at: i64, + pub revoked_at: Option, + pub user_agent_hash: Option, + pub ip_hash: Option, +} + +impl StoredSession { + pub fn is_active_at(&self, now: i64) -> bool { + self.revoked_at.is_none() && self.expires_at > now + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedSession { + pub id: String, + pub user: CurrentUser, + pub token_hash: String, + pub expires_at: i64, +} + +impl CachedSession { + pub fn is_active_at(&self, now: i64) -> bool { + self.expires_at > now + } +} + +impl From<&StoredSession> for CachedSession { + fn from(session: &StoredSession) -> Self { + Self { + id: session.id.clone(), + user: session.user.clone().into(), + token_hash: session.token_hash.clone(), + expires_at: session.expires_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewSession { + pub user_id: String, + pub ttl_seconds: u64, + pub token_pepper: Option, + pub user_agent_hash: Option, + pub ip_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssuedSession { + pub token: String, + pub stored: StoredSession, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderIdentity { + pub provider: String, + pub provider_account_id: String, + pub email: Option, + pub email_verified: bool, + pub name: Option, + pub avatar_url: Option, + pub raw_profile_json: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthSession { + pub id: String, + pub user: CurrentUser, + pub expires_at: i64, + #[serde(skip)] + pub(crate) token_hash: String, +} + +impl AuthSession { + pub fn user(&self) -> &CurrentUser { + &self.user + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OptionalAuthSession(pub Option); + +#[allow(dead_code)] +pub fn generate_token() -> Result { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes).map_err(AuthError::Random)?; + Ok(Base64UrlUnpadded::encode_string(&bytes)) +} + +#[allow(dead_code)] +pub fn generate_id(prefix: &str) -> Result { + let mut bytes = [0u8; ID_BYTES]; + getrandom::fill(&mut bytes).map_err(AuthError::Random)?; + Ok(format!( + "{prefix}_{}", + Base64UrlUnpadded::encode_string(&bytes) + )) +} + +#[allow(dead_code)] +pub fn hash_token(token: &str, pepper: Option<&str>) -> String { + let mut hasher = Sha256::new(); + if let Some(pepper) = pepper { + hasher.update(pepper.as_bytes()); + hasher.update([0]); + } + hasher.update(token.as_bytes()); + Base64UrlUnpadded::encode_string(&hasher.finalize()) +} + +#[allow(dead_code)] +pub fn hash_optional_header(value: Option<&str>, pepper: Option<&str>) -> Option { + value.map(|value| hash_token(value, pepper)) +} + +#[allow(dead_code)] +pub fn now_unix() -> i64 { + #[cfg(target_arch = "wasm32")] + { + (js_sys::Date::now() / 1000.0) as i64 + } + + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + } +} + +#[cfg(test)] +mod tests { + use super::{hash_token, now_unix}; + + #[test] + fn token_hash_uses_pepper() { + assert_ne!( + hash_token("token", None), + hash_token("token", Some("pepper")) + ); + } + + #[test] + fn now_is_unix_seconds() { + assert!(now_unix() > 1_700_000_000); + } +} diff --git a/comet-auth/src/store.rs b/comet-auth/src/store.rs new file mode 100644 index 0000000..ccde559 --- /dev/null +++ b/comet-auth/src/store.rs @@ -0,0 +1,484 @@ +use rocket::async_trait; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "cloudflare")] +use crate::session; +use crate::session::{CachedSession, IssuedSession, NewSession, ProviderIdentity}; +use crate::{AuthError, StoredSession, StoredUser}; + +#[async_trait(?Send)] +pub trait SessionStore { + async fn create_session(&self, input: NewSession) -> Result; + async fn find_session(&self, token_hash: &str) -> Result, AuthError>; + async fn revoke_session(&self, session_id: &str) -> Result<(), AuthError>; + async fn upsert_account(&self, identity: ProviderIdentity) -> Result; +} + +#[async_trait(?Send)] +pub trait SessionCache { + async fn get(&self, token_hash: &str) -> Result, AuthError>; + async fn put(&self, session: &CachedSession, ttl_seconds: u64) -> Result<(), AuthError>; + async fn delete(&self, token_hash: &str) -> Result<(), AuthError>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OAuthState { + pub provider: String, + pub code_verifier: String, + pub nonce: Option, + pub redirect_after: Option, + pub expires_at: i64, +} + +#[async_trait(?Send)] +pub trait OAuthStateStore { + async fn put( + &self, + state_hash: &str, + state: &OAuthState, + ttl_seconds: u64, + ) -> Result<(), AuthError>; + async fn take(&self, state_hash: &str) -> Result, AuthError>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopSessionCache; + +#[async_trait(?Send)] +impl SessionCache for NoopSessionCache { + async fn get(&self, _token_hash: &str) -> Result, AuthError> { + Ok(None) + } + + async fn put(&self, _session: &CachedSession, _ttl_seconds: u64) -> Result<(), AuthError> { + Ok(()) + } + + async fn delete(&self, _token_hash: &str) -> Result<(), AuthError> { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopOAuthStateStore; + +#[async_trait(?Send)] +impl OAuthStateStore for NoopOAuthStateStore { + async fn put( + &self, + _state_hash: &str, + _state: &OAuthState, + _ttl_seconds: u64, + ) -> Result<(), AuthError> { + Ok(()) + } + + async fn take(&self, _state_hash: &str) -> Result, AuthError> { + Ok(None) + } +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug)] +pub struct D1SessionStore { + db: worker::D1Database, +} + +#[cfg(feature = "cloudflare")] +impl D1SessionStore { + pub fn new(db: worker::D1Database) -> Self { + Self { db } + } +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug, Deserialize)] +struct SessionRow { + session_id: String, + user_id: String, + token_hash: String, + session_created_at: i64, + session_updated_at: i64, + expires_at: i64, + revoked_at: Option, + user_agent_hash: Option, + ip_hash: Option, + primary_email: Option, + email_verified: i64, + name: Option, + avatar_url: Option, + user_created_at: i64, + user_updated_at: i64, +} + +#[cfg(feature = "cloudflare")] +impl From for StoredSession { + fn from(row: SessionRow) -> Self { + Self { + id: row.session_id, + user: StoredUser { + id: row.user_id, + primary_email: row.primary_email, + email_verified: row.email_verified != 0, + name: row.name, + avatar_url: row.avatar_url, + created_at: row.user_created_at, + updated_at: row.user_updated_at, + }, + token_hash: row.token_hash, + created_at: row.session_created_at, + updated_at: row.session_updated_at, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + user_agent_hash: row.user_agent_hash, + ip_hash: row.ip_hash, + } + } +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug, Deserialize)] +struct UserRow { + id: String, + primary_email: Option, + email_verified: i64, + name: Option, + avatar_url: Option, + created_at: i64, + updated_at: i64, +} + +#[cfg(feature = "cloudflare")] +impl From for StoredUser { + fn from(row: UserRow) -> Self { + Self { + id: row.id, + primary_email: row.primary_email, + email_verified: row.email_verified != 0, + name: row.name, + avatar_url: row.avatar_url, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +#[cfg(feature = "cloudflare")] +#[async_trait(?Send)] +impl SessionStore for D1SessionStore { + async fn create_session(&self, input: NewSession) -> Result { + let token = session::generate_token()?; + let token_hash = session::hash_token(&token, input.token_pepper.as_deref()); + let session_id = session::generate_id("ses")?; + let now = session::now_unix(); + let expires_at = now + input.ttl_seconds as i64; + + self.db + .prepare( + "INSERT INTO comet_auth_sessions \ + (id, user_id, token_hash, created_at, updated_at, expires_at, revoked_at, user_agent_hash, ip_hash) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8)", + ) + .bind(&[ + js(&session_id), + js(&input.user_id), + js(&token_hash), + js(now), + js(now), + js(expires_at), + js_opt(input.user_agent_hash.as_deref()), + js_opt(input.ip_hash.as_deref()), + ])? + .run() + .await?; + + let stored = self + .find_session(&token_hash) + .await? + .ok_or_else(|| AuthError::Storage("created session could not be reloaded".into()))?; + + Ok(IssuedSession { token, stored }) + } + + async fn find_session(&self, token_hash: &str) -> Result, AuthError> { + let row: Option = self + .db + .prepare( + "SELECT \ + s.id AS session_id, s.user_id, s.token_hash, \ + s.created_at AS session_created_at, s.updated_at AS session_updated_at, \ + s.expires_at, s.revoked_at, s.user_agent_hash, s.ip_hash, \ + u.primary_email, u.email_verified, u.name, u.avatar_url, \ + u.created_at AS user_created_at, u.updated_at AS user_updated_at \ + FROM comet_auth_sessions s \ + JOIN comet_auth_users u ON u.id = s.user_id \ + WHERE s.token_hash = ?1 \ + LIMIT 1", + ) + .bind(&[js(token_hash)])? + .first(None) + .await?; + + Ok(row.map(StoredSession::from)) + } + + async fn revoke_session(&self, session_id: &str) -> Result<(), AuthError> { + let now = session::now_unix(); + self.db + .prepare( + "UPDATE comet_auth_sessions \ + SET revoked_at = COALESCE(revoked_at, ?1), updated_at = ?2 \ + WHERE id = ?3", + ) + .bind(&[js(now), js(now), js(session_id)])? + .run() + .await?; + Ok(()) + } + + async fn upsert_account(&self, identity: ProviderIdentity) -> Result { + let now = session::now_unix(); + let existing_user_id: Option = self + .db + .prepare( + "SELECT user_id FROM comet_auth_accounts \ + WHERE provider = ?1 AND provider_account_id = ?2 \ + LIMIT 1", + ) + .bind(&[js(&identity.provider), js(&identity.provider_account_id)])? + .first(Some("user_id")) + .await?; + + let user_id = match existing_user_id { + Some(user_id) => { + self.db + .prepare( + "UPDATE comet_auth_users \ + SET primary_email = COALESCE(primary_email, ?1), \ + email_verified = MAX(email_verified, ?2), \ + name = COALESCE(?3, name), \ + avatar_url = COALESCE(?4, avatar_url), \ + updated_at = ?5 \ + WHERE id = ?6", + ) + .bind(&[ + js_opt(identity.email.as_deref()), + js(if identity.email_verified { 1 } else { 0 }), + js_opt(identity.name.as_deref()), + js_opt(identity.avatar_url.as_deref()), + js(now), + js(&user_id), + ])? + .run() + .await?; + user_id + } + None => { + let user_id = session::generate_id("usr")?; + self.db + .prepare( + "INSERT INTO comet_auth_users \ + (id, primary_email, email_verified, name, avatar_url, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(&[ + js(&user_id), + js_opt(identity.email.as_deref()), + js(if identity.email_verified { 1 } else { 0 }), + js_opt(identity.name.as_deref()), + js_opt(identity.avatar_url.as_deref()), + js(now), + js(now), + ])? + .run() + .await?; + user_id + } + }; + + let account_id = session::generate_id("acc")?; + self.db + .prepare( + "INSERT INTO comet_auth_accounts \ + (id, user_id, provider, provider_account_id, email, email_verified, name, avatar_url, raw_profile_json, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \ + ON CONFLICT(provider, provider_account_id) DO UPDATE SET \ + email = excluded.email, \ + email_verified = excluded.email_verified, \ + name = excluded.name, \ + avatar_url = excluded.avatar_url, \ + raw_profile_json = excluded.raw_profile_json, \ + updated_at = excluded.updated_at", + ) + .bind(&[ + js(&account_id), + js(&user_id), + js(&identity.provider), + js(&identity.provider_account_id), + js_opt(identity.email.as_deref()), + js(if identity.email_verified { 1 } else { 0 }), + js_opt(identity.name.as_deref()), + js_opt(identity.avatar_url.as_deref()), + js_opt(identity.raw_profile_json.as_deref()), + js(now), + js(now), + ])? + .run() + .await?; + + load_user(&self.db, &user_id).await + } +} + +#[cfg(feature = "cloudflare")] +async fn load_user(db: &worker::D1Database, user_id: &str) -> Result { + let row: Option = db + .prepare( + "SELECT id, primary_email, email_verified, name, avatar_url, created_at, updated_at \ + FROM comet_auth_users WHERE id = ?1 LIMIT 1", + ) + .bind(&[js(user_id)])? + .first(None) + .await?; + + row.map(StoredUser::from) + .ok_or_else(|| AuthError::Storage(format!("user `{user_id}` not found"))) +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug)] +pub struct KvSessionCache { + kv: worker::kv::KvStore, + prefix: String, +} + +#[cfg(feature = "cloudflare")] +impl KvSessionCache { + pub fn new(kv: worker::kv::KvStore) -> Self { + Self { + kv, + prefix: "session:".to_owned(), + } + } + + pub fn with_prefix(mut self, prefix: impl Into) -> Self { + self.prefix = prefix.into(); + self + } + + fn key(&self, token_hash: &str) -> String { + format!("{}{token_hash}", self.prefix) + } +} + +#[cfg(feature = "cloudflare")] +#[async_trait(?Send)] +impl SessionCache for KvSessionCache { + async fn get(&self, token_hash: &str) -> Result, AuthError> { + let Some(value) = self.kv.get(&self.key(token_hash)).text().await? else { + return Ok(None); + }; + Ok(Some(serde_json::from_str(&value)?)) + } + + async fn put(&self, session: &CachedSession, ttl_seconds: u64) -> Result<(), AuthError> { + self.kv + .put( + &self.key(&session.token_hash), + serde_json::to_string(session)?, + )? + .expiration_ttl(ttl_seconds) + .execute() + .await?; + Ok(()) + } + + async fn delete(&self, token_hash: &str) -> Result<(), AuthError> { + self.kv.delete(&self.key(token_hash)).await?; + Ok(()) + } +} + +#[cfg(feature = "cloudflare")] +#[derive(Debug)] +pub struct KvOAuthStateStore { + kv: worker::kv::KvStore, + prefix: String, +} + +#[cfg(feature = "cloudflare")] +impl KvOAuthStateStore { + pub fn new(kv: worker::kv::KvStore) -> Self { + Self { + kv, + prefix: "oauth_state:".to_owned(), + } + } + + fn key(&self, state_hash: &str) -> String { + format!("{}{state_hash}", self.prefix) + } +} + +#[cfg(feature = "cloudflare")] +#[async_trait(?Send)] +impl OAuthStateStore for KvOAuthStateStore { + async fn put( + &self, + state_hash: &str, + state: &OAuthState, + ttl_seconds: u64, + ) -> Result<(), AuthError> { + self.kv + .put(&self.key(state_hash), serde_json::to_string(state)?)? + .expiration_ttl(ttl_seconds) + .execute() + .await?; + Ok(()) + } + + async fn take(&self, state_hash: &str) -> Result, AuthError> { + let key = self.key(state_hash); + let value = self.kv.get(&key).text().await?; + self.kv.delete(&key).await?; + value + .map(|value| serde_json::from_str(&value).map_err(AuthError::from)) + .transpose() + } +} + +#[cfg(not(feature = "cloudflare"))] +#[derive(Debug)] +pub struct D1SessionStore; + +#[cfg(not(feature = "cloudflare"))] +#[derive(Debug)] +pub struct KvSessionCache; + +#[cfg(not(feature = "cloudflare"))] +#[derive(Debug)] +pub struct KvOAuthStateStore; + +#[cfg(feature = "cloudflare")] +fn js(value: impl Into) -> worker::wasm_bindgen::JsValue { + value.into() +} + +#[cfg(feature = "cloudflare")] +fn js_opt(value: Option<&str>) -> worker::wasm_bindgen::JsValue { + value + .map(worker::wasm_bindgen::JsValue::from_str) + .unwrap_or_else(worker::wasm_bindgen::JsValue::null) +} + +#[cfg(test)] +mod tests { + use rocket::async_test; + + use super::{NoopSessionCache, SessionCache}; + + #[async_test] + async fn noop_cache_misses() { + assert!(NoopSessionCache.get("missing").await.unwrap().is_none()); + } +} diff --git a/comet-auth/tests/requires_auth_macro.rs b/comet-auth/tests/requires_auth_macro.rs new file mode 100644 index 0000000..57bc09c --- /dev/null +++ b/comet-auth/tests/requires_auth_macro.rs @@ -0,0 +1,70 @@ +use comet_auth::{Auth, AuthConfig}; +use rocket::http::Status; +use rocket::local::asynchronous::Client; + +#[comet_auth::requires_auth] +#[rocket::get("/protected")] +async fn protected() -> &'static str { + "secret" +} + +#[comet_auth::requires_auth(optional)] +#[rocket::get("/optional")] +async fn optional() -> &'static str { + "ok" +} + +#[comet_auth::requires_auth(scope = "admin")] +#[rocket::get("/admin")] +async fn admin() -> &'static str { + "admin" +} + +#[comet_auth::requires_auth(role = "admin", permission = "boards:write")] +#[rocket::get("/rbac")] +async fn rbac() -> &'static str { + "rbac" +} + +#[comet_auth::requires_auth(any(role = "admin", permission = "boards:read"), resource = "org:demo")] +#[rocket::get("/rbac-any")] +async fn rbac_any() -> &'static str { + "rbac-any" +} + +#[rocket::async_test] +async fn injected_required_guard_rejects_anonymous_requests() { + let rocket = rocket::build() + .attach(Auth::<(), ()>::fairing(AuthConfig::default())) + .mount("/", rocket::routes![protected, admin, rbac, rbac_any]); + let client = Client::tracked(rocket).await.unwrap(); + + assert_eq!( + client.get("/protected").dispatch().await.status(), + Status::Unauthorized + ); + assert_eq!( + client.get("/admin").dispatch().await.status(), + Status::Unauthorized + ); + assert_eq!( + client.get("/rbac").dispatch().await.status(), + Status::Unauthorized + ); + assert_eq!( + client.get("/rbac-any").dispatch().await.status(), + Status::Unauthorized + ); +} + +#[rocket::async_test] +async fn injected_optional_guard_allows_anonymous_requests() { + let rocket = rocket::build() + .attach(Auth::<(), ()>::fairing(AuthConfig::default())) + .mount("/", rocket::routes![optional]); + let client = Client::tracked(rocket).await.unwrap(); + + let response = client.get("/optional").dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().await.as_deref(), Some("ok")); +} diff --git a/comet-cli/src/cli.rs b/comet-cli/src/cli.rs index 89781f6..cdf7ab4 100644 --- a/comet-cli/src/cli.rs +++ b/comet-cli/src/cli.rs @@ -23,11 +23,29 @@ pub enum Command { /// Generate or inspect Nebula migrations. #[command(subcommand)] Migrate(MigrateCommand), + /// Scaffold Comet Auth migrations and Cloudflare binding hints. + #[command(subcommand)] + Auth(AuthCommand), + /// Inspect Nebula RLS coverage. + #[command(subcommand)] + Rls(RlsCommand), /// Run the project's test/release gate. #[command(subcommand)] Test(TestCommand), } +#[derive(Subcommand)] +pub enum AuthCommand { + /// Add the Comet Auth runtime migration to a project. + Init(AuthInitArgs), +} + +#[derive(Subcommand)] +pub enum RlsCommand { + /// Show RLS coverage for discovered Nebula entities. + Status(RlsStatusArgs), +} + #[derive(Args)] pub struct NewArgs { /// Project name; also used as the crate name and D1 database name. @@ -118,6 +136,48 @@ pub struct MigrateStatusArgs { pub path: Option, } +#[derive(Args)] +pub struct AuthInitArgs { + /// Project directory. Defaults to the current directory. + #[arg(long)] + pub path: Option, + + /// Wrangler D1 binding used for durable auth tables. + #[arg(long, default_value = "DB")] + pub db_binding: String, + + /// Wrangler KV binding used for OAuth state and optional session cache. + #[arg(long, default_value = "AUTH_KV")] + pub kv_binding: String, + + /// Also add the RBAC authorization migration. + #[arg(long)] + pub with_rbac: bool, +} + +#[derive(Args)] +pub struct RlsStatusArgs { + /// Project directory to inspect. Defaults to the current directory. + #[arg(long)] + pub path: Option, + + /// Exit non-zero when any table has missing or incomplete RLS coverage. + #[arg(long)] + pub strict: bool, + + /// Emit machine-readable JSON. + #[arg(long)] + pub json: bool, + + /// Declare a custom predicate name available for all operations. + #[arg(long = "custom-predicate", value_name = "NAME")] + pub custom_predicates: Vec, + + /// Declare a custom predicate for specific operations, e.g. can_archive:update,delete. + #[arg(long = "custom-predicate-rule", value_name = "NAME:OPS")] + pub custom_predicate_rules: Vec, +} + #[derive(Subcommand)] pub enum TestCommand { /// `cargo fmt --check` + `cargo test --lib`. diff --git a/comet-cli/src/commands/auth.rs b/comet-cli/src/commands/auth.rs new file mode 100644 index 0000000..52e49e3 --- /dev/null +++ b/comet-cli/src/commands/auth.rs @@ -0,0 +1,175 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::cli::AuthInitArgs; +use crate::snapshot; + +const AUTH_MIGRATION_SQL: &str = include_str!("../../../comet-auth/migrations/0001_comet_auth.sql"); +const RBAC_MIGRATION_SQL: &str = + include_str!("../../../comet-auth/migrations/0002_comet_auth_rbac.sql"); + +pub fn init(args: AuthInitArgs) -> Result<()> { + let project_dir = args.path.unwrap_or_else(|| PathBuf::from(".")); + let db_binding = validate_binding(&args.db_binding, "--db-binding")?; + let kv_binding = validate_binding(&args.kv_binding, "--kv-binding")?; + let migrations_dir = project_dir.join("migrations"); + + fs::create_dir_all(&migrations_dir) + .with_context(|| format!("creating {}", migrations_dir.display()))?; + + if auth_migration_exists(&migrations_dir)? { + bail!( + "a Comet Auth migration already exists in {}; refusing to add a duplicate", + migrations_dir.display() + ); + } + + let sequence = snapshot::next_migration_sequence(&migrations_dir)?; + let path = migrations_dir.join(format!("{sequence:04}_comet_auth.sql")); + fs::write(&path, AUTH_MIGRATION_SQL).with_context(|| format!("writing {}", path.display()))?; + + println!("Wrote {}", path.display()); + if args.with_rbac { + let rbac_path = migrations_dir.join(format!("{:04}_comet_auth_rbac.sql", sequence + 1)); + fs::write(&rbac_path, RBAC_MIGRATION_SQL) + .with_context(|| format!("writing {}", rbac_path.display()))?; + println!("Wrote {}", rbac_path.display()); + } + println!(); + println!("Add the runtime dependency if it is not present:"); + println!( + " comet-auth = {{ git = \"https://github.com/viniciusamelio/comet\", default-features = false, features = [\"cloudflare\"] }}" + ); + println!(); + println!( + "Ensure wrangler.jsonc has a D1 binding named `{db_binding}` and a KV namespace named `{kv_binding}`." + ); + println!("Mount auth in Rocket with:"); + println!(" .attach(comet_auth::Auth::<{db_binding}, {kv_binding}>::fairing(auth_config))"); + println!(" .mount(\"/auth\", comet_auth::routes::<{db_binding}, {kv_binding}>())"); + if args.with_rbac { + println!(); + println!("RBAC migration included. Protect routes with:"); + println!(" #[comet_auth::requires_auth(role = \"admin\")]"); + println!(" #[comet_auth::requires_auth(permission = \"resource:action\")]"); + } + + Ok(()) +} + +fn auth_migration_exists(migrations_dir: &Path) -> Result { + if !migrations_dir.exists() { + return Ok(false); + } + + for entry in fs::read_dir(migrations_dir) + .with_context(|| format!("reading {}", migrations_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("sql") { + continue; + } + + let sql = + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + if sql.contains("comet_auth_users") || sql.contains("comet_auth_sessions") { + return Ok(true); + } + } + + Ok(false) +} + +fn validate_binding(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + bail!("{label} must not be blank"); + } + + let mut chars = trimmed.chars(); + let starts_valid = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); + let rest_valid = chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); + + if !starts_valid || !rest_valid { + bail!( + "{label} `{trimmed}` must start with an ASCII letter or `_` and contain only ASCII letters, digits, or `_`" + ); + } + + Ok(trimmed.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_auth_migration_at_next_sequence() { + let dir = tempfile::tempdir().unwrap(); + let migrations_dir = dir.path().join("migrations"); + fs::create_dir_all(&migrations_dir).unwrap(); + fs::write( + migrations_dir.join("0001_init.sql"), + "create table tasks(id integer);", + ) + .unwrap(); + + init(AuthInitArgs { + path: Some(dir.path().to_path_buf()), + db_binding: "DB".to_owned(), + kv_binding: "AUTH_KV".to_owned(), + with_rbac: false, + }) + .unwrap(); + + let migration = fs::read_to_string(migrations_dir.join("0002_comet_auth.sql")).unwrap(); + assert!(migration.contains("comet_auth_users")); + assert!(migration.contains("comet_auth_sessions")); + } + + #[test] + fn refuses_duplicate_auth_migration() { + let dir = tempfile::tempdir().unwrap(); + let migrations_dir = dir.path().join("migrations"); + fs::create_dir_all(&migrations_dir).unwrap(); + fs::write( + migrations_dir.join("0001_comet_auth.sql"), + AUTH_MIGRATION_SQL, + ) + .unwrap(); + + let error = init(AuthInitArgs { + path: Some(dir.path().to_path_buf()), + db_binding: "DB".to_owned(), + kv_binding: "AUTH_KV".to_owned(), + with_rbac: false, + }) + .unwrap_err(); + + assert!(error.to_string().contains("already exists")); + } + + #[test] + fn writes_rbac_migration_when_requested() { + let dir = tempfile::tempdir().unwrap(); + + init(AuthInitArgs { + path: Some(dir.path().to_path_buf()), + db_binding: "DB".to_owned(), + kv_binding: "AUTH_KV".to_owned(), + with_rbac: true, + }) + .unwrap(); + + let rbac = + fs::read_to_string(dir.path().join("migrations/0002_comet_auth_rbac.sql")).unwrap(); + assert!(rbac.contains("comet_auth_roles")); + assert!(rbac.contains("comet_auth_user_permissions")); + } +} diff --git a/comet-cli/src/commands/migrate.rs b/comet-cli/src/commands/migrate.rs index ecdf55b..f5248cf 100644 --- a/comet-cli/src/commands/migrate.rs +++ b/comet-cli/src/commands/migrate.rs @@ -169,5 +169,8 @@ fn describe_blocker(blocker: &MigrationBlocker) -> String { "change foreign key on `{table}` ({}) (write this migration by hand)", columns.join(", ") ), + MigrationBlocker::ChangeRls { table } => { + format!("change RLS policies on `{table}` (review the authorization change by hand)") + } } } diff --git a/comet-cli/src/commands/mod.rs b/comet-cli/src/commands/mod.rs index 9785cc2..dfefa87 100644 --- a/comet-cli/src/commands/mod.rs +++ b/comet-cli/src/commands/mod.rs @@ -1,4 +1,6 @@ +pub mod auth; pub mod generate; pub mod migrate; pub mod new; +pub mod rls; pub mod test; diff --git a/comet-cli/src/commands/rls.rs b/comet-cli/src/commands/rls.rs new file mode 100644 index 0000000..d92f54d --- /dev/null +++ b/comet-cli/src/commands/rls.rs @@ -0,0 +1,446 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use comet::nebula::schema::{OwnedRlsPolicyDef, SchemaSnapshot}; +use comet::nebula::{RlsOperation, RlsPolicyKind, SchemaLint}; +use serde_json::json; + +use crate::cli::RlsStatusArgs; +use crate::{discover, schema_dump}; + +pub fn status(args: RlsStatusArgs) -> Result<()> { + let project_dir = args.path.unwrap_or_else(|| PathBuf::from(".")); + let snapshot = dump_current_schema(&project_dir)?; + let custom_registry = + CustomRegistry::parse(&args.custom_predicates, &args.custom_predicate_rules)?; + let mut failures = 0usize; + let mut table_reports = Vec::new(); + + for table in &snapshot.tables { + let missing_operations = missing_operations(&table.rls); + let missing_custom_predicates = missing_custom_predicates(&table.rls, &custom_registry); + let table_failures = + table_failures(&table.rls) + missing_operations.len() + missing_custom_predicates.len(); + failures += table_failures; + table_reports.push(( + table.name.clone(), + table.rls.clone(), + missing_operations, + missing_custom_predicates, + table_failures, + )); + } + + let schema_lints = snapshot.clone().to_manifest().lint(); + failures += schema_lints.len(); + + if args.json { + print_json_report(&table_reports, &schema_lints, failures)?; + } else { + print_text_report(&table_reports, &schema_lints); + } + + if args.strict && failures > 0 { + bail!("RLS coverage has {failures} failure(s)"); + } + + Ok(()) +} + +fn print_text_report( + table_reports: &[( + String, + Vec, + Vec, + Vec, + usize, + )], + schema_lints: &[SchemaLint], +) { + println!("RLS coverage ({} table(s)):", table_reports.len()); + for (table, policies, missing_operations, missing_custom_predicates, table_failures) in + table_reports + { + if policies.is_empty() { + println!(" - {table}: missing"); + } else { + let status = if *table_failures == 0 { + "covered" + } else { + "incomplete" + }; + println!(" - {table}: {status}"); + for policy in policies { + println!(" {}", describe_policy(policy)); + } + } + + if !missing_operations.is_empty() { + println!( + " missing operations: {}", + missing_operations + .iter() + .map(operation_name) + .collect::>() + .join(",") + ); + } + if !missing_custom_predicates.is_empty() { + println!( + " missing custom predicates: {}", + missing_custom_predicates.join(",") + ); + } + } + + if !schema_lints.is_empty() { + println!(); + println!("RLS schema lints:"); + for lint in schema_lints { + println!(" - {}", describe_schema_lint(lint)); + } + } +} + +fn print_json_report( + table_reports: &[( + String, + Vec, + Vec, + Vec, + usize, + )], + schema_lints: &[SchemaLint], + failures: usize, +) -> Result<()> { + let tables = table_reports + .iter() + .map(|(table, policies, missing_operations, missing_custom_predicates, table_failures)| { + json!({ + "table": table, + "status": if policies.is_empty() { + "missing" + } else if *table_failures == 0 { + "covered" + } else { + "incomplete" + }, + "missing_operations": missing_operations.iter().map(operation_name).collect::>(), + "missing_custom_predicates": missing_custom_predicates, + "policies": policies.iter().map(policy_json).collect::>(), + }) + }) + .collect::>(); + + let output = json!({ + "strict_passed": failures == 0, + "failures": failures, + "tables": tables, + "schema_lints": schema_lints.iter().map(|lint| { + json!({ + "kind": format!("{lint:?}"), + "message": describe_schema_lint(lint), + }) + }).collect::>(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +#[derive(Debug, Default)] +struct CustomRegistry { + all_ops: Vec, + rules: Vec<(String, Vec)>, +} + +impl CustomRegistry { + fn parse(all_ops: &[String], rules: &[String]) -> Result { + let mut registry = Self { + all_ops: all_ops.to_vec(), + rules: Vec::new(), + }; + + for rule in rules { + let Some((name, operations)) = rule.split_once(':') else { + bail!("custom predicate rule `{rule}` must use NAME:OPS syntax"); + }; + let operations = operations + .split(',') + .map(parse_operation) + .collect::>>()?; + registry.rules.push((name.to_owned(), operations)); + } + + Ok(registry) + } + + fn covers(&self, name: &str, operations: &[RlsOperation]) -> bool { + self.all_ops.iter().any(|registered| registered == name) + || self.rules.iter().any(|(registered, registered_ops)| { + registered == name + && (registered_ops.is_empty() + || operations.is_empty() + || operations + .iter() + .all(|operation| registered_ops.contains(operation))) + }) + } +} + +fn parse_operation(value: &str) -> Result { + match value { + "select" => Ok(RlsOperation::Select), + "insert" => Ok(RlsOperation::Insert), + "update" => Ok(RlsOperation::Update), + "delete" => Ok(RlsOperation::Delete), + other => bail!("unsupported RLS operation `{other}`"), + } +} + +fn describe_schema_lint(lint: &SchemaLint) -> String { + match lint { + SchemaLint::UnindexedForeignKey { table, column } => { + format!("foreign key `{table}.{column}` is not indexed") + } + SchemaLint::MissingRls { table } => { + format!("table `{table}` has no RLS policy; add `rls(public)` if intentional") + } + SchemaLint::UnindexedRlsColumn { table, column } => { + format!("RLS column `{table}.{column}` is not indexed") + } + } +} + +fn dump_current_schema(project_dir: &Path) -> Result { + let src_dir = project_dir.join("src"); + let entities = discover::discover_entities(&src_dir) + .with_context(|| format!("discovering entities under {}", src_dir.display()))?; + schema_dump::dump_schema(project_dir, &entities) +} + +fn table_failures(policies: &[OwnedRlsPolicyDef]) -> usize { + if policies.is_empty() { + return 1; + } + + policies + .iter() + .filter(|policy| { + matches!(policy.kind, RlsPolicyKind::Owner | RlsPolicyKind::Tenant) + && policy.column.is_none() + || matches!(policy.kind, RlsPolicyKind::Custom) && policy.custom.is_none() + }) + .count() +} + +fn missing_operations(policies: &[OwnedRlsPolicyDef]) -> Vec { + if policies.is_empty() + || policies + .iter() + .any(|policy| policy.kind == RlsPolicyKind::Public && policy.operations.is_empty()) + { + return Vec::new(); + } + + [ + RlsOperation::Select, + RlsOperation::Insert, + RlsOperation::Update, + RlsOperation::Delete, + ] + .into_iter() + .filter(|operation| { + policies + .iter() + .all(|policy| !policy.operations.is_empty() && !policy.operations.contains(operation)) + }) + .collect() +} + +fn missing_custom_predicates( + policies: &[OwnedRlsPolicyDef], + registry: &CustomRegistry, +) -> Vec { + policies + .iter() + .filter(|policy| policy.kind == RlsPolicyKind::Custom) + .filter_map(|policy| { + let name = policy.custom.as_deref()?; + if registry.covers(name, &policy.operations) { + None + } else { + Some(name.to_owned()) + } + }) + .collect() +} + +fn operation_name(operation: &RlsOperation) -> &'static str { + match operation { + RlsOperation::Select => "select", + RlsOperation::Insert => "insert", + RlsOperation::Update => "update", + RlsOperation::Delete => "delete", + } +} + +fn policy_json(policy: &OwnedRlsPolicyDef) -> serde_json::Value { + json!({ + "operations": if policy.operations.is_empty() { + vec!["all".to_owned()] + } else { + policy.operations.iter().map(|operation| operation_name(operation).to_owned()).collect::>() + }, + "kind": format!("{:?}", policy.kind).to_lowercase(), + "column": policy.column.clone(), + "custom": policy.custom.clone(), + "authorization": { + "mode": format!("{:?}", policy.authorization.mode), + "roles": policy.authorization.roles.clone(), + "permissions": policy.authorization.permissions.clone(), + "scopes": policy.authorization.scopes.clone(), + "resource": policy.authorization.resource.clone(), + }, + }) +} + +fn describe_policy(policy: &OwnedRlsPolicyDef) -> String { + let operations = if policy.operations.is_empty() { + "all".to_owned() + } else { + policy + .operations + .iter() + .map(|operation| format!("{operation:?}").to_lowercase()) + .collect::>() + .join(",") + }; + + match policy.kind { + RlsPolicyKind::Public => format!("{operations}: public"), + RlsPolicyKind::Owner => format!( + "{operations}: owner column={}", + policy.column.as_deref().unwrap_or("") + ), + RlsPolicyKind::Tenant => format!( + "{operations}: tenant column={}", + policy.column.as_deref().unwrap_or("") + ), + RlsPolicyKind::Rbac => format!( + "{operations}: rbac mode={:?} roles=[{}] permissions=[{}] scopes=[{}] resource={}", + policy.authorization.mode, + policy.authorization.roles.join(","), + policy.authorization.permissions.join(","), + policy.authorization.scopes.join(","), + policy.authorization.resource.as_deref().unwrap_or("") + ), + RlsPolicyKind::Custom => format!( + "{operations}: custom name={}", + policy.custom.as_deref().unwrap_or("") + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use comet::nebula::RlsMatchMode; + use comet::nebula::schema::OwnedRlsAuthorizationDef; + + #[test] + fn custom_registry_covers_operation_specific_rules() { + let registry = CustomRegistry::parse( + &[], + &[ + "can_complete_task:update".to_owned(), + "can_read_task:select,insert".to_owned(), + ], + ) + .unwrap(); + + assert!(registry.covers("can_complete_task", &[RlsOperation::Update])); + assert!(registry.covers("can_read_task", &[RlsOperation::Select])); + assert!(registry.covers("can_read_task", &[RlsOperation::Insert])); + assert!(!registry.covers("can_complete_task", &[RlsOperation::Delete])); + assert!(!registry.covers("missing", &[RlsOperation::Update])); + } + + #[test] + fn custom_registry_supports_legacy_all_operation_names() { + let registry = CustomRegistry::parse(&["can_delete_task".to_owned()], &[]).unwrap(); + + assert!(registry.covers("can_delete_task", &[RlsOperation::Delete])); + assert!(registry.covers("can_delete_task", &[RlsOperation::Update])); + } + + #[test] + fn custom_registry_rejects_invalid_rules() { + assert!(CustomRegistry::parse(&[], &["can_update".to_owned()]).is_err()); + assert!(CustomRegistry::parse(&[], &["can_update:publish".to_owned()]).is_err()); + } + + #[test] + fn missing_custom_predicates_are_operation_aware() { + let registry = + CustomRegistry::parse(&[], &["can_complete_task:delete".to_owned()]).unwrap(); + let policies = vec![custom_policy( + "can_complete_task", + vec![RlsOperation::Update], + )]; + + assert_eq!( + missing_custom_predicates(&policies, ®istry), + vec!["can_complete_task".to_owned()] + ); + } + + #[test] + fn policy_json_includes_machine_readable_rls_shape() { + let policy = OwnedRlsPolicyDef { + operations: vec![RlsOperation::Update], + kind: RlsPolicyKind::Rbac, + column: None, + authorization: OwnedRlsAuthorizationDef { + mode: RlsMatchMode::Any, + roles: vec!["admin".to_owned()], + permissions: vec!["tasks:write".to_owned()], + scopes: vec!["org:current".to_owned()], + resource: Some("task".to_owned()), + }, + custom: None, + }; + + assert_eq!( + policy_json(&policy), + json!({ + "operations": ["update"], + "kind": "rbac", + "column": null, + "custom": null, + "authorization": { + "mode": "Any", + "roles": ["admin"], + "permissions": ["tasks:write"], + "scopes": ["org:current"], + "resource": "task", + }, + }) + ); + } + + fn custom_policy(name: &str, operations: Vec) -> OwnedRlsPolicyDef { + OwnedRlsPolicyDef { + operations, + kind: RlsPolicyKind::Custom, + column: None, + authorization: OwnedRlsAuthorizationDef { + mode: RlsMatchMode::All, + roles: Vec::new(), + permissions: Vec::new(), + scopes: Vec::new(), + resource: None, + }, + custom: Some(name.to_owned()), + } + } +} diff --git a/comet-cli/src/main.rs b/comet-cli/src/main.rs index d4947c0..7f1e259 100644 --- a/comet-cli/src/main.rs +++ b/comet-cli/src/main.rs @@ -9,7 +9,7 @@ mod schema_dump; mod snapshot; use clap::Parser; -use cli::{Command, GenerateCommand, MigrateCommand, TestCommand}; +use cli::{AuthCommand, Command, GenerateCommand, MigrateCommand, RlsCommand, TestCommand}; fn main() -> anyhow::Result<()> { let cli = cli::Cli::parse(); @@ -21,6 +21,8 @@ fn main() -> anyhow::Result<()> { Command::Migrate(MigrateCommand::Init(args)) => commands::migrate::init(args), Command::Migrate(MigrateCommand::Generate(args)) => commands::migrate::generate(args), Command::Migrate(MigrateCommand::Status(args)) => commands::migrate::status(args), + Command::Auth(AuthCommand::Init(args)) => commands::auth::init(args), + Command::Rls(RlsCommand::Status(args)) => commands::rls::status(args), Command::Test(TestCommand::Unit(args)) => commands::test::unit(args), Command::Test(TestCommand::Integration(args)) => commands::test::integration(args), Command::Test(TestCommand::Perf(args)) => commands::test::perf(args), diff --git a/comet-cli/src/snapshot.rs b/comet-cli/src/snapshot.rs index 4f4139e..fb650ad 100644 --- a/comet-cli/src/snapshot.rs +++ b/comet-cli/src/snapshot.rs @@ -89,6 +89,7 @@ mod tests { columns: Vec::new(), indexes: Vec::new(), foreign_keys: Vec::new(), + rls: Vec::new(), }], }; diff --git a/comet-cli/tests/e2e.sh b/comet-cli/tests/e2e.sh index 90dc039..6c1f0f1 100755 --- a/comet-cli/tests/e2e.sh +++ b/comet-cli/tests/e2e.sh @@ -23,7 +23,8 @@ trap cleanup EXIT echo "Building comet-cli..." cargo build --manifest-path "$CLI_DIR/Cargo.toml" --quiet -COMET="$CLI_DIR/target/debug/comet" +TARGET_DIR="$(cargo metadata --manifest-path "$CLI_DIR/Cargo.toml" --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')" +COMET="$TARGET_DIR/debug/comet" echo "Scaffolding a fixture project..." "$COMET" new fixture --path "$FIXTURE" --db-binding DB >/dev/null diff --git a/comet-macros/src/lib.rs b/comet-macros/src/lib.rs index 095fd6f..4affe6b 100644 --- a/comet-macros/src/lib.rs +++ b/comet-macros/src/lib.rs @@ -122,6 +122,13 @@ fn expand_entity(input: DeriveInput) -> Result { }); } + validate_rls_policies(&struct_options.rls, &column_names, &ident)?; + let rls_defs = struct_options + .rls + .iter() + .map(|policy| rls_policy_tokens(policy, &comet)) + .collect::>(); + if primary_key_count > 1 { return Err(syn::Error::new_spanned( ident, @@ -141,6 +148,7 @@ fn expand_entity(input: DeriveInput) -> Result { columns: &[#(#column_defs),*], indexes: &[], foreign_keys: &[#(#foreign_key_defs),*], + rls: &[#(#rls_defs),*], }; } }) @@ -150,6 +158,7 @@ fn expand_entity(input: DeriveInput) -> Result { struct StructOptions { table: Option, comet_path: Option, + rls: Vec, } impl StructOptions { @@ -160,12 +169,24 @@ impl StructOptions { attr.parse_args_with(|input: ParseStream<'_>| { while !input.is_empty() { let key: Ident = input.parse()?; - input.parse::()?; - if key == "table" { - options.table = Some(input.parse::()?.value()); - } else if key == "crate" { - options.comet_path = Some(input.parse::()?.parse()?); + if key == "rls" { + let content; + syn::parenthesized!(content in input); + options.rls.push(parse_rls_policy(&content)?); + } else if input.peek(Token![=]) { + input.parse::()?; + + if key == "table" { + options.table = Some(input.parse::()?.value()); + } else if key == "crate" { + options.comet_path = Some(input.parse::()?.parse()?); + } else { + return Err(syn::Error::new_spanned( + key, + "unsupported Nebula struct attribute", + )); + } } else { return Err(syn::Error::new_spanned( key, @@ -186,6 +207,331 @@ impl StructOptions { } } +#[derive(Debug, Clone, Copy)] +enum RlsOperationAttr { + Select, + Insert, + Update, + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RlsKindAttr { + Public, + Owner, + Tenant, + Rbac, + Custom, +} + +#[derive(Debug, Clone, Copy)] +enum RlsModeAttr { + All, + Any, +} + +#[derive(Debug, Clone)] +struct RlsPolicyAttr { + operations: Vec, + kind: Option, + column: Option, + mode: RlsModeAttr, + roles: Vec, + permissions: Vec, + scopes: Vec, + resource: Option, + custom: Option, +} + +impl Default for RlsPolicyAttr { + fn default() -> Self { + Self { + operations: Vec::new(), + kind: None, + column: None, + mode: RlsModeAttr::All, + roles: Vec::new(), + permissions: Vec::new(), + scopes: Vec::new(), + resource: None, + custom: None, + } + } +} + +fn parse_rls_policy(input: ParseStream<'_>) -> Result { + let mut policy = RlsPolicyAttr::default(); + + while !input.is_empty() { + let key: Ident = input.parse()?; + let key_name = key.to_string(); + + if let Some(operation) = parse_rls_operation(&key_name) { + policy.operations.push(operation); + } else if key_name == "public" { + set_rls_kind(&mut policy, RlsKindAttr::Public, &key)?; + } else if key_name == "any" || key_name == "all" { + let content; + syn::parenthesized!(content in input); + policy.mode = if key_name == "any" { + RlsModeAttr::Any + } else { + RlsModeAttr::All + }; + parse_rls_requirements(&content, &mut policy)?; + set_rls_kind(&mut policy, RlsKindAttr::Rbac, &key)?; + } else if input.peek(Token![=]) { + input.parse::()?; + match key_name.as_str() { + "owner" => { + policy.column = Some(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Owner, &key)?; + } + "tenant" => { + policy.column = Some(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Tenant, &key)?; + } + "role" => { + policy.roles.push(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Rbac, &key)?; + } + "permission" => { + policy.permissions.push(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Rbac, &key)?; + } + "scope" => { + policy.scopes.push(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Rbac, &key)?; + } + "resource" => { + policy.resource = Some(input.parse::()?.value()); + } + "custom" => { + policy.custom = Some(input.parse::()?.value()); + set_rls_kind(&mut policy, RlsKindAttr::Custom, &key)?; + } + _ => { + return Err(syn::Error::new_spanned( + key, + "unsupported Nebula RLS attribute", + )); + } + } + } else { + return Err(syn::Error::new_spanned( + key, + "unsupported Nebula RLS attribute", + )); + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + if policy.kind.is_none() { + return Err(input.error("Nebula `rls` requires a policy kind")); + } + + Ok(policy) +} + +fn parse_rls_requirements(input: ParseStream<'_>, policy: &mut RlsPolicyAttr) -> Result<()> { + while !input.is_empty() { + let key: Ident = input.parse()?; + let key_name = key.to_string(); + input.parse::()?; + let value = input.parse::()?.value(); + + match key_name.as_str() { + "role" => policy.roles.push(value), + "permission" => policy.permissions.push(value), + "scope" => policy.scopes.push(value), + "resource" => policy.resource = Some(value), + _ => { + return Err(syn::Error::new_spanned( + key, + "unsupported Nebula RLS authorization attribute", + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(()) +} + +fn parse_rls_operation(value: &str) -> Option { + match value { + "select" => Some(RlsOperationAttr::Select), + "insert" => Some(RlsOperationAttr::Insert), + "update" => Some(RlsOperationAttr::Update), + "delete" => Some(RlsOperationAttr::Delete), + _ => None, + } +} + +fn set_rls_kind(policy: &mut RlsPolicyAttr, kind: RlsKindAttr, key: &Ident) -> Result<()> { + if let Some(current) = policy.kind { + if current != kind { + return Err(syn::Error::new_spanned( + key, + "Nebula `rls` policy cannot combine multiple policy kinds", + )); + } + } + + policy.kind = Some(kind); + Ok(()) +} + +fn validate_rls_policies( + policies: &[RlsPolicyAttr], + column_names: &HashSet, + ident: &Ident, +) -> Result<()> { + for policy in policies { + let kind = policy.kind.expect("parsed RLS policy kind"); + if let Some(column) = &policy.column { + if !column_names.contains(column) { + return Err(syn::Error::new_spanned( + ident, + format!("Nebula `rls` references unknown column `{column}`"), + )); + } + } + + match kind { + RlsKindAttr::Public => { + if !policy.operations.is_empty() + || policy.column.is_some() + || !policy.roles.is_empty() + || !policy.permissions.is_empty() + || !policy.scopes.is_empty() + || policy.resource.is_some() + || policy.custom.is_some() + { + return Err(syn::Error::new_spanned( + ident, + "Nebula `rls(public)` cannot include operations, columns, authorization, resource, or custom predicates", + )); + } + } + RlsKindAttr::Owner | RlsKindAttr::Tenant => { + if policy.column.is_none() { + return Err(syn::Error::new_spanned( + ident, + "Nebula owner/tenant RLS requires a column", + )); + } + if !policy.roles.is_empty() + || !policy.permissions.is_empty() + || !policy.scopes.is_empty() + || policy.resource.is_some() + || policy.custom.is_some() + { + return Err(syn::Error::new_spanned( + ident, + "Nebula owner/tenant RLS cannot include authorization, resource, or custom predicates", + )); + } + } + RlsKindAttr::Rbac => { + if policy.roles.is_empty() + && policy.permissions.is_empty() + && policy.scopes.is_empty() + { + return Err(syn::Error::new_spanned( + ident, + "Nebula RBAC RLS requires at least one role, permission, or scope", + )); + } + if policy.column.is_some() || policy.custom.is_some() { + return Err(syn::Error::new_spanned( + ident, + "Nebula RBAC RLS cannot include owner/tenant columns or custom predicates", + )); + } + } + RlsKindAttr::Custom => { + if policy.custom.as_deref().is_none_or(str::is_empty) { + return Err(syn::Error::new_spanned( + ident, + "Nebula custom RLS requires a non-empty predicate name", + )); + } + if policy.column.is_some() + || !policy.roles.is_empty() + || !policy.permissions.is_empty() + || !policy.scopes.is_empty() + || policy.resource.is_some() + { + return Err(syn::Error::new_spanned( + ident, + "Nebula custom RLS cannot include owner/tenant columns or authorization", + )); + } + } + } + } + + Ok(()) +} + +fn rls_policy_tokens(policy: &RlsPolicyAttr, comet: &Path) -> proc_macro2::TokenStream { + let operations = policy.operations.iter().map(|operation| match operation { + RlsOperationAttr::Select => quote!(#comet::nebula::RlsOperation::Select), + RlsOperationAttr::Insert => quote!(#comet::nebula::RlsOperation::Insert), + RlsOperationAttr::Update => quote!(#comet::nebula::RlsOperation::Update), + RlsOperationAttr::Delete => quote!(#comet::nebula::RlsOperation::Delete), + }); + let kind = match policy.kind.expect("validated RLS policy kind") { + RlsKindAttr::Public => quote!(#comet::nebula::RlsPolicyKind::Public), + RlsKindAttr::Owner => quote!(#comet::nebula::RlsPolicyKind::Owner), + RlsKindAttr::Tenant => quote!(#comet::nebula::RlsPolicyKind::Tenant), + RlsKindAttr::Rbac => quote!(#comet::nebula::RlsPolicyKind::Rbac), + RlsKindAttr::Custom => quote!(#comet::nebula::RlsPolicyKind::Custom), + }; + let column = match &policy.column { + Some(column) => quote!(Some(#column)), + None => quote!(None), + }; + let mode = match policy.mode { + RlsModeAttr::All => quote!(#comet::nebula::RlsMatchMode::All), + RlsModeAttr::Any => quote!(#comet::nebula::RlsMatchMode::Any), + }; + let roles = policy.roles.iter(); + let permissions = policy.permissions.iter(); + let scopes = policy.scopes.iter(); + let resource = match &policy.resource { + Some(resource) => quote!(Some(#resource)), + None => quote!(None), + }; + let custom = match &policy.custom { + Some(custom) => quote!(Some(#custom)), + None => quote!(None), + }; + + quote! { + #comet::nebula::RlsPolicyDef { + operations: &[#(#operations),*], + kind: #kind, + column: #column, + authorization: #comet::nebula::RlsAuthorizationDef { + mode: #mode, + roles: &[#(#roles),*], + permissions: &[#(#permissions),*], + scopes: &[#(#scopes),*], + resource: #resource, + }, + custom: #custom, + } + } +} + #[derive(Default)] struct FieldOptions { rename: Option, diff --git a/docs-site/content/docs/auth.en.mdx b/docs-site/content/docs/auth.en.mdx new file mode 100644 index 0000000..667d118 --- /dev/null +++ b/docs-site/content/docs/auth.en.mdx @@ -0,0 +1,198 @@ +--- +title: Authentication +description: Configure comet-auth with social OAuth, D1/KV, sessions, and protected routes. +--- + +`comet-auth` adds authentication to Comet/Rocket applications on Cloudflare +Workers. It stores users, linked provider accounts, and sessions in D1, uses KV +for OAuth state/cache, and exposes Rocket guards for protected routes. + +## Bindings + +Configure one D1 database and one KV namespace in `wrangler.jsonc`: + +```jsonc +{ + "d1_databases": [{ + "binding": "DB", + "database_name": "my-app", + "database_id": "...", + "migrations_dir": "migrations" + }], + "kv_namespaces": [{ + "binding": "AUTH_KV", + "id": "..." + }] +} +``` + +Add the auth migration: + +```sh +comet auth init --db-binding DB --kv-binding AUTH_KV --with-rbac +npx wrangler d1 migrations apply DB --local +npx wrangler d1 migrations apply DB --remote +``` + +## Rocket Mount + +```rust +pub struct DB; +impl comet::cloudflare::BindingName for DB { + const NAME: &'static str = "DB"; +} + +pub struct AuthKv; +impl comet::cloudflare::BindingName for AuthKv { + const NAME: &'static str = "AUTH_KV"; +} + +let auth_config = comet_auth::AuthConfig::from_env() + .base_url("https://example.com") + .provider( + comet_auth::providers::Google::from_env() + .web_client_id_env("GOOGLE_WEB_CLIENT_ID") + .web_client_secret_env("GOOGLE_WEB_CLIENT_SECRET") + .native_client_id_env("GOOGLE_IOS_CLIENT_ID") + .native_client_id_env("GOOGLE_ANDROID_CLIENT_ID"), + ) + .provider( + comet_auth::providers::Apple::from_env() + .service_id_env("APPLE_SERVICE_ID") + .team_id_env("APPLE_TEAM_ID") + .key_id_env("APPLE_KEY_ID") + .private_key_pkcs8_pem_env("APPLE_PRIVATE_KEY_PKCS8_PEM") + .native_audience_env("APPLE_IOS_BUNDLE_ID"), + ) + .provider( + comet_auth::providers::GitHub::from_env() + .client_id_env("GITHUB_CLIENT_ID") + .client_secret_env("GITHUB_CLIENT_SECRET"), + ); + +rocket::build() + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()); +``` + +## Protected Routes + +Put `#[comet_auth::requires_auth]` above the Rocket route attribute: + +```rust +#[comet_auth::requires_auth] +#[rocket::get("/private/me")] +async fn private_me(session: comet_auth::AuthSession) -> &'static str { + "authenticated" +} +``` + +For routes that can handle anonymous visitors: + +```rust +#[comet_auth::requires_auth(optional)] +#[rocket::get("/maybe")] +async fn maybe(session: comet_auth::OptionalAuthSession) -> &'static str { + if session.0.is_some() { "signed in" } else { "anonymous" } +} +``` + +Authorization policies are enforced with D1-backed RBAC: + +```rust +#[comet_auth::requires_auth(role = "admin")] +#[rocket::get("/admin")] +async fn admin() -> &'static str { + "admin" +} + +#[comet_auth::requires_auth(permission = "boards:write")] +#[rocket::post("/boards")] +async fn create_board() -> &'static str { + "created" +} +``` + +Top-level policies are `all` by default. Use `any(...)` when one matching +claim is enough, and `resource = "..."` for static resource-scoped checks: + +```rust +#[comet_auth::requires_auth(any(role = "admin", permission = "tasks:review"), resource = "demo")] +#[rocket::get("/private/reviewer")] +async fn reviewer() -> &'static str { + "reviewer" +} +``` + +`scope = "..."` works as a permission alias. Missing sessions return +`401 Unauthorized`; authenticated sessions without the required role, +permission, or scope return `403 Forbidden`. + +Authorization claims are loaded from D1 and cached in KV for 60 seconds by +default. Tune or disable this with: + +```rust +comet_auth::AuthConfig::from_env() + .authorization_claims_cache_ttl_seconds(0); +``` + +To create RBAC tables when initializing auth: + +```sh +comet auth init --with-rbac +``` + +## Provider Secrets + +Set secrets with `wrangler secret put `. + +Common: + +- `COMET_AUTH_BASE_URL`: public origin used for OAuth callbacks. +- `COMET_AUTH_TOKEN_PEPPER`: extra secret material mixed into session token + hashes. + +Google: + +- `GOOGLE_WEB_CLIENT_ID` +- `GOOGLE_WEB_CLIENT_SECRET` +- `GOOGLE_IOS_CLIENT_ID`, optional native login audience +- `GOOGLE_ANDROID_CLIENT_ID`, optional native login audience + +Apple: + +- `APPLE_SERVICE_ID` +- `APPLE_TEAM_ID` +- `APPLE_KEY_ID` +- `APPLE_PRIVATE_KEY_PKCS8_PEM` +- `APPLE_IOS_BUNDLE_ID`, optional native login audience + +GitHub: + +- `GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` + +## Redirect URIs + +Register these callbacks in each provider dashboard: + +- Google: `/auth/google/callback` +- Apple: `/auth/apple/callback` +- GitHub: `/auth/github/callback` + +## Native Login + +Native clients should use the provider's official SDK to obtain an identity +token, then send it to Comet: + +```sh +curl -X POST https://example.com/auth/native/google \ + -H 'content-type: application/json' \ + -d '{"id_token":"...","nonce":"..."}' +``` + +Apple uses the same shape at `/auth/native/apple`. + +Do not use an embedded WebView for Google login. For browser login from a +mobile app, use the secure system browser, such as `ASWebAuthenticationSession`, +`SFSafariViewController`, or Chrome Custom Tabs. diff --git a/docs-site/content/docs/auth.mdx b/docs-site/content/docs/auth.mdx new file mode 100644 index 0000000..80bbcf8 --- /dev/null +++ b/docs-site/content/docs/auth.mdx @@ -0,0 +1,198 @@ +--- +title: Autenticação +description: Configurando comet-auth com OAuth social, D1/KV, sessões e rotas protegidas. +--- + +`comet-auth` adiciona autenticação a aplicações Comet/Rocket em Cloudflare +Workers. Ele mantém usuários, contas vinculadas e sessões em D1, usa KV para +estado OAuth/cache, e expõe guards Rocket para proteger rotas. + +## Bindings + +Configure um D1 e um KV no `wrangler.jsonc`: + +```jsonc +{ + "d1_databases": [{ + "binding": "DB", + "database_name": "my-app", + "database_id": "...", + "migrations_dir": "migrations" + }], + "kv_namespaces": [{ + "binding": "AUTH_KV", + "id": "..." + }] +} +``` + +Adicione a migration de auth: + +```sh +comet auth init --db-binding DB --kv-binding AUTH_KV --with-rbac +npx wrangler d1 migrations apply DB --local +npx wrangler d1 migrations apply DB --remote +``` + +## Montagem no Rocket + +```rust +pub struct DB; +impl comet::cloudflare::BindingName for DB { + const NAME: &'static str = "DB"; +} + +pub struct AuthKv; +impl comet::cloudflare::BindingName for AuthKv { + const NAME: &'static str = "AUTH_KV"; +} + +let auth_config = comet_auth::AuthConfig::from_env() + .base_url("https://example.com") + .provider( + comet_auth::providers::Google::from_env() + .web_client_id_env("GOOGLE_WEB_CLIENT_ID") + .web_client_secret_env("GOOGLE_WEB_CLIENT_SECRET") + .native_client_id_env("GOOGLE_IOS_CLIENT_ID") + .native_client_id_env("GOOGLE_ANDROID_CLIENT_ID"), + ) + .provider( + comet_auth::providers::Apple::from_env() + .service_id_env("APPLE_SERVICE_ID") + .team_id_env("APPLE_TEAM_ID") + .key_id_env("APPLE_KEY_ID") + .private_key_pkcs8_pem_env("APPLE_PRIVATE_KEY_PKCS8_PEM") + .native_audience_env("APPLE_IOS_BUNDLE_ID"), + ) + .provider( + comet_auth::providers::GitHub::from_env() + .client_id_env("GITHUB_CLIENT_ID") + .client_secret_env("GITHUB_CLIENT_SECRET"), + ); + +rocket::build() + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()); +``` + +## Rotas protegidas + +Coloque `#[comet_auth::requires_auth]` acima do atributo de rota Rocket: + +```rust +#[comet_auth::requires_auth] +#[rocket::get("/private/me")] +async fn private_me(session: comet_auth::AuthSession) -> &'static str { + "authenticated" +} +``` + +Para rotas que aceitam visitantes anônimos: + +```rust +#[comet_auth::requires_auth(optional)] +#[rocket::get("/maybe")] +async fn maybe(session: comet_auth::OptionalAuthSession) -> &'static str { + if session.0.is_some() { "signed in" } else { "anonymous" } +} +``` + +Políticas de autorização são aplicadas com RBAC em D1: + +```rust +#[comet_auth::requires_auth(role = "admin")] +#[rocket::get("/admin")] +async fn admin() -> &'static str { + "admin" +} + +#[comet_auth::requires_auth(permission = "boards:write")] +#[rocket::post("/boards")] +async fn create_board() -> &'static str { + "created" +} +``` + +Políticas top-level são `all` por padrão. Use `any(...)` quando uma claim +compatível for suficiente, e `resource = "..."` para checks estáticos por +recurso: + +```rust +#[comet_auth::requires_auth(any(role = "admin", permission = "tasks:review"), resource = "demo")] +#[rocket::get("/private/reviewer")] +async fn reviewer() -> &'static str { + "reviewer" +} +``` + +`scope = "..."` funciona como alias de permissão. Sessão ausente retorna +`401 Unauthorized`; sessão autenticada sem papel, permissão ou scope retorna +`403 Forbidden`. + +Claims de autorização são carregadas do D1 e cacheadas no KV por 60 segundos +por padrão. Ajuste ou desative com: + +```rust +comet_auth::AuthConfig::from_env() + .authorization_claims_cache_ttl_seconds(0); +``` + +Para criar as tabelas RBAC junto da auth: + +```sh +comet auth init --with-rbac +``` + +## Secrets de provider + +Configure secrets com `wrangler secret put `. + +Comuns: + +- `COMET_AUTH_BASE_URL`: origem pública usada para callbacks OAuth. +- `COMET_AUTH_TOKEN_PEPPER`: segredo extra usado no hash dos tokens de sessão. + +Google: + +- `GOOGLE_WEB_CLIENT_ID` +- `GOOGLE_WEB_CLIENT_SECRET` +- `GOOGLE_IOS_CLIENT_ID`, opcional para login nativo +- `GOOGLE_ANDROID_CLIENT_ID`, opcional para login nativo + +Apple: + +- `APPLE_SERVICE_ID` +- `APPLE_TEAM_ID` +- `APPLE_KEY_ID` +- `APPLE_PRIVATE_KEY_PKCS8_PEM` +- `APPLE_IOS_BUNDLE_ID`, opcional para login nativo + +GitHub: + +- `GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` + +## Redirect URIs + +Cadastre estes callbacks nos dashboards dos providers: + +- Google: `/auth/google/callback` +- Apple: `/auth/apple/callback` +- GitHub: `/auth/github/callback` + +## Login nativo + +Clientes nativos devem usar os SDKs oficiais do provider para obter um identity +token e então enviá-lo ao Comet: + +```sh +curl -X POST https://example.com/auth/native/google \ + -H 'content-type: application/json' \ + -d '{"id_token":"...","nonce":"..."}' +``` + +Apple usa o mesmo formato em `/auth/native/apple`. + +Não use WebView embutida para login Google. Para login browser em app mobile, +use navegador seguro do sistema, como `ASWebAuthenticationSession`, +`SFSafariViewController` ou Chrome Custom Tabs. diff --git a/docs-site/content/docs/comet-cli.en.mdx b/docs-site/content/docs/comet-cli.en.mdx index 3534840..dd4a674 100644 --- a/docs-site/content/docs/comet-cli.en.mdx +++ b/docs-site/content/docs/comet-cli.en.mdx @@ -11,6 +11,7 @@ outside the main workspace — just like `comet-macros`. It does four things: route modules to an existing project. - `comet migrate init|generate|status` — drives migration generation from the entities' real, compiled metadata. +- `comet auth init` — adds authentication migrations and optional RBAC. - `comet test unit|integration|perf|all` — runs the project's test/release gate. @@ -194,6 +195,22 @@ comet migrate generate add_notes # Updated schema snapshot at migrations/.comet-schema.json ``` +## `comet auth init` + +``` +comet auth init [--path

] [--db-binding ] [--kv-binding ] [--with-rbac] +``` + +Writes the `comet-auth` migration at the next available number in +`migrations/` and prints dependency, D1/KV, and Rocket mounting snippets. + +- `--db-binding`: D1 binding for durable auth tables (default `DB`). +- `--kv-binding`: KV binding for OAuth state/session cache (default + `AUTH_KV`). +- `--with-rbac`: also writes the roles/permissions migration used by + `#[comet_auth::requires_auth(role = "...")]` and + `#[comet_auth::requires_auth(permission = "...")]`. + ## `comet migrate status` ``` diff --git a/docs-site/content/docs/comet-cli.mdx b/docs-site/content/docs/comet-cli.mdx index 8f1f704..486bab2 100644 --- a/docs-site/content/docs/comet-cli.mdx +++ b/docs-site/content/docs/comet-cli.mdx @@ -12,6 +12,8 @@ faz quatro coisas: módulos de rotas CRUD a um projeto existente. - `comet migrate init|generate|status` — conduz a geração de migrations a partir da metadata real e compilada das entidades. +- `comet auth init` — adiciona migrations de autenticação e, opcionalmente, + RBAC. - `comet test unit|integration|perf|all` — roda o gate de testes/release do projeto. @@ -194,6 +196,22 @@ comet migrate generate add_notes # Updated schema snapshot at migrations/.comet-schema.json ``` +## `comet auth init` + +``` +comet auth init [--path ] [--db-binding ] [--kv-binding ] [--with-rbac] +``` + +Escreve a migration de `comet-auth` no próximo número disponível em +`migrations/` e imprime os snippets de dependência, D1/KV e montagem Rocket. + +- `--db-binding`: binding D1 das tabelas duráveis de auth (padrão `DB`). +- `--kv-binding`: binding KV para OAuth state/cache de sessão (padrão + `AUTH_KV`). +- `--with-rbac`: também escreve a migration de roles/permissões usada por + `#[comet_auth::requires_auth(role = "...")]` e + `#[comet_auth::requires_auth(permission = "...")]`. + ## `comet migrate status` ``` diff --git a/docs-site/content/docs/example-walkthrough.en.mdx b/docs-site/content/docs/example-walkthrough.en.mdx index a052327..716359e 100644 --- a/docs-site/content/docs/example-walkthrough.en.mdx +++ b/docs-site/content/docs/example-walkthrough.en.mdx @@ -1,12 +1,12 @@ --- title: Full example -description: A walkthrough of examples/cloudflare-worker — D1, Queues, R2, and WebSocket together. +description: A walkthrough of examples/cloudflare-worker — D1, Queues, R2, WebSocket, and auth together. --- The repository ships a complete example at `examples/cloudflare-worker`: a task API with D1, asynchronous Queue calls, R2 object streaming, a -WebSocket echo endpoint, and end-to-end unit + integration tests. This page -walks through the main pieces. +WebSocket echo endpoint, `comet-auth` mounted in the same Worker, and +end-to-end unit + integration tests. This page walks through the main pieces. ## Project layout @@ -15,13 +15,14 @@ examples/cloudflare-worker/ src/ lib.rs # pub mod app; assets; demo (private); boards; orgs; tasks; users (public) entry.rs # wasm-only glue: #[event(fetch)] and #[event(queue)] - app.rs # builds the Rocket, body limits, .manage(env) + app.rs # builds the Rocket, body limits, auth, .manage(env) tasks/ # the D1-backed task API (the example's focus) assets/ # R2 upload/download routes - demo/ # /, /echo, /stream, /ws/echo — streaming and WebSocket proofs + demo/ # /, /echo, /stream, /ws/echo, /private/me boards/ orgs/ users/ # extra Nebula entities, for relationships migrations/ 0001_init.sql + 0002_comet_auth.sql tests/ integration.sh perf.sh @@ -58,6 +59,13 @@ mentioned in [Nebula ORM](/en/docs/nebula-orm). | `POST /echo` | streamed body echo | | `GET /stream` | 3 chunks with real 400ms pauses (streaming proof) | | `GET /ws/echo` | WebSocket echo | +| `GET /auth/session` | current session state | +| `GET /auth//start` | starts Google, Apple, or GitHub OAuth | +| `POST /auth/native/google`, `POST /auth/native/apple` | native login by identity token | +| `POST /auth/logout` | revokes the current session | +| `GET /private/me` | protected route with `#[comet_auth::requires_auth]` | +| `GET /private/admin` | RBAC route with `#[comet_auth::requires_auth(role = "admin")]` | +| `GET /private/reviewer` | RBAC route with `any(...)` and `resource = "demo"` | | `GET /tasks`, `GET /tasks/` | list/fetch tasks | | `POST /tasks` | create a task (publishes an event to the queue) | | `POST /tasks//complete` | complete a task (publishes an event to the queue) | @@ -164,6 +172,37 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { } ``` +## Auth: session and protected route + +`src/app.rs` mounts auth routes in the same Rocket: + +```rust +rocket::custom(config) + .manage(env) + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()) + .mount("/", routes![index, private_me /* ... */]) +``` + +And `src/demo/routes.rs` shows the expected ergonomics for protected handlers: + +```rust +#[comet_auth::requires_auth] +#[get("/private/me")] +pub async fn private_me(session: comet_auth::AuthSession) -> Json { + Json(PrivateMeResponse { + session_id: session.id, + user_id: session.user.id, + email: session.user.primary_email, + }) +} +``` + +Public routes stay public. Without a session, `/private/me`, +`/private/admin`, and `/private/reviewer` return `401 Unauthorized`, while `/auth/session` returns +`authenticated: false`. With a valid session but without the required role or +permission, an RBAC route returns `403 Forbidden`. + ## Configuring `wrangler.jsonc` ```jsonc @@ -177,6 +216,7 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }], + "kv_namespaces": [{ "binding": "AUTH_KV", "id": "00000000000000000000000000000000" }], "r2_buckets": [{ "binding": "ASSETS", "bucket_name": "comet-cloudflare-worker-example-assets" }], "queues": { "producers": [{ "binding": "TASK_EVENTS", "queue": "task-events" }], @@ -185,13 +225,16 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { } ``` -The binding names (`DB`, `ASSETS`, `TASK_EVENTS`) need to match the +The binding names (`DB`, `AUTH_KV`, `ASSETS`, `TASK_EVENTS`) need to match the `BindingName::NAME` constants defined in the Rust code: ```rust pub struct DB; impl BindingName for DB { const NAME: &'static str = "DB"; } +pub struct AuthKv; +impl BindingName for AuthKv { const NAME: &'static str = "AUTH_KV"; } + pub struct Assets; impl BindingName for Assets { const NAME: &'static str = "ASSETS"; } @@ -203,9 +246,10 @@ impl BindingName for TaskEvents { const NAME: &'static str = "TASK_EVENTS"; } ```sh npx wrangler d1 create comet-cloudflare-worker-example +npx wrangler kv namespace create AUTH_KV npx wrangler queues create task-events npx wrangler r2 bucket create comet-cloudflare-worker-example-assets -# paste the database_id into wrangler.jsonc, in d1_databases[0] +# paste the database_id and KV id into wrangler.jsonc npx wrangler d1 migrations apply DB --local # for wrangler dev npx wrangler d1 migrations apply DB --remote # for wrangler deploy diff --git a/docs-site/content/docs/example-walkthrough.mdx b/docs-site/content/docs/example-walkthrough.mdx index 52ff9bf..a3b46ef 100644 --- a/docs-site/content/docs/example-walkthrough.mdx +++ b/docs-site/content/docs/example-walkthrough.mdx @@ -1,12 +1,13 @@ --- title: Exemplo completo -description: Um passeio pelo examples/cloudflare-worker — D1, Queues, R2 e WebSocket juntos. +description: Um passeio pelo examples/cloudflare-worker — D1, Queues, R2, WebSocket e auth juntos. --- O repositório traz um exemplo completo em `examples/cloudflare-worker`: uma API de tarefas com D1, chamadas assíncronas de Queue, streaming de objetos -R2, um endpoint WebSocket de eco, e testes unitários + de integração ponta a -ponta. Esta página passeia pelas peças principais. +R2, um endpoint WebSocket de eco, `comet-auth` montado no mesmo Worker, e +testes unitários + de integração ponta a ponta. Esta página passeia pelas +peças principais. ## Layout do projeto @@ -15,13 +16,14 @@ examples/cloudflare-worker/ src/ lib.rs # pub mod app; assets; demo (privado); boards; orgs; tasks; users (público) entry.rs # glue só-wasm: #[event(fetch)] e #[event(queue)] - app.rs # monta o Rocket, limites de corpo, .manage(env) + app.rs # monta o Rocket, limites de corpo, auth, .manage(env) tasks/ # API de tarefas backed por D1 (o foco do exemplo) assets/ # rotas de upload/download via R2 - demo/ # /, /echo, /stream, /ws/echo — provas de streaming e WebSocket + demo/ # /, /echo, /stream, /ws/echo, /private/me boards/ orgs/ users/ # entidades Nebula extras, para relacionamentos migrations/ 0001_init.sql + 0002_comet_auth.sql tests/ integration.sh perf.sh @@ -58,6 +60,13 @@ booleano" mencionada em [Nebula ORM](/pt-BR/docs/nebula-orm). | `POST /echo` | eco de corpo via streaming | | `GET /stream` | 3 blocos com pausas reais de 400ms (prova de streaming) | | `GET /ws/echo` | eco via WebSocket | +| `GET /auth/session` | estado atual da sessão | +| `GET /auth//start` | inicia OAuth Google, Apple ou GitHub | +| `POST /auth/native/google`, `POST /auth/native/apple` | login nativo por identity token | +| `POST /auth/logout` | revoga a sessão atual | +| `GET /private/me` | rota protegida com `#[comet_auth::requires_auth]` | +| `GET /private/admin` | rota RBAC com `#[comet_auth::requires_auth(role = "admin")]` | +| `GET /private/reviewer` | rota RBAC com `any(...)` e `resource = "demo"` | | `GET /tasks`, `GET /tasks/` | listar/buscar tarefas | | `POST /tasks` | criar tarefa (publica evento na queue) | | `POST /tasks//complete` | concluir tarefa (publica evento na queue) | @@ -164,6 +173,37 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { } ``` +## Auth: sessão e rota protegida + +`src/app.rs` monta as rotas de auth no mesmo Rocket: + +```rust +rocket::custom(config) + .manage(env) + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()) + .mount("/", routes![index, private_me /* ... */]) +``` + +E `src/demo/routes.rs` mostra a ergonomia esperada para proteger handlers: + +```rust +#[comet_auth::requires_auth] +#[get("/private/me")] +pub async fn private_me(session: comet_auth::AuthSession) -> Json { + Json(PrivateMeResponse { + session_id: session.id, + user_id: session.user.id, + email: session.user.primary_email, + }) +} +``` + +As rotas públicas continuam públicas. Sem sessão, `/private/me`, +`/private/admin` e `/private/reviewer` respondem `401 Unauthorized`, enquanto `/auth/session` +retorna `authenticated: false`. Com sessão válida mas sem role/permissão, uma +rota RBAC responde `403 Forbidden`. + ## Configurando o `wrangler.jsonc` ```jsonc @@ -177,6 +217,7 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }], + "kv_namespaces": [{ "binding": "AUTH_KV", "id": "00000000000000000000000000000000" }], "r2_buckets": [{ "binding": "ASSETS", "bucket_name": "comet-cloudflare-worker-example-assets" }], "queues": { "producers": [{ "binding": "TASK_EVENTS", "queue": "task-events" }], @@ -185,13 +226,16 @@ pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse { } ``` -Os nomes de binding (`DB`, `ASSETS`, `TASK_EVENTS`) precisam bater com as +Os nomes de binding (`DB`, `AUTH_KV`, `ASSETS`, `TASK_EVENTS`) precisam bater com as constantes `BindingName::NAME` definidas no código Rust: ```rust pub struct DB; impl BindingName for DB { const NAME: &'static str = "DB"; } +pub struct AuthKv; +impl BindingName for AuthKv { const NAME: &'static str = "AUTH_KV"; } + pub struct Assets; impl BindingName for Assets { const NAME: &'static str = "ASSETS"; } @@ -203,9 +247,10 @@ impl BindingName for TaskEvents { const NAME: &'static str = "TASK_EVENTS"; } ```sh npx wrangler d1 create comet-cloudflare-worker-example +npx wrangler kv namespace create AUTH_KV npx wrangler queues create task-events npx wrangler r2 bucket create comet-cloudflare-worker-example-assets -# cole o database_id em wrangler.jsonc, em d1_databases[0] +# cole database_id e id do KV em wrangler.jsonc npx wrangler d1 migrations apply DB --local # para wrangler dev npx wrangler d1 migrations apply DB --remote # para wrangler deploy diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index f37e717..a0eb0e5 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -5,6 +5,7 @@ "getting-started", "cloudflare-adapter", "nebula-orm", + "auth", "comet-cli", "example-walkthrough" ] diff --git a/docs-site/content/docs/nebula-orm.en.mdx b/docs-site/content/docs/nebula-orm.en.mdx index 6b26d2d..4947f95 100644 --- a/docs-site/content/docs/nebula-orm.en.mdx +++ b/docs-site/content/docs/nebula-orm.en.mdx @@ -177,6 +177,155 @@ Task::delete().where_(Task::ID.eq(42)).to_statement(); Bind order is always deterministic: assignment/filter binds first, then `LIMIT`/`OFFSET` at the end. +## Row-level security + +D1 uses SQLite semantics and does not provide Postgres-style native RLS. +Nebula's RLS is enforced in the ORM layer by generating additional predicates +and validating authorization before statements are executed. Raw SQL remains +available, but it is outside automatic RLS enforcement; use +`Statement::raw_unscoped(...)` so reviews and searches can identify those +manual authorization sites. + +Declare RLS on the entity: + +```rust +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(owner = "user_id"))] +#[nebula(rls(update, permission = "tasks:write"))] +#[nebula(rls(update, custom = "can_complete_task"))] +pub struct TaskRow { + #[nebula(primary_key, auto)] + pub id: i64, + #[nebula(index)] + pub user_id: String, + pub done: i32, +} +``` + +Supported first-class policy forms: + +- `#[nebula(rls(public))]` — explicitly public table. +- `#[nebula(rls(owner = "user_id"))]` — matches the current user id. +- `#[nebula(rls(tenant = "org_id"))]` — matches the current tenant id. +- `#[nebula(rls(select, permission = "boards:read"))]` — RBAC per operation. +- `#[nebula(rls(update, any(role = "admin", permission = "boards:write")))]` + — any/all RBAC matching. +- `#[nebula(rls(delete, custom = "can_delete_board"))]` — app-provided + predicate. + +Use scoped builders with an `AccessContext`: + +```rust +let context = comet::nebula::AccessContext::authenticated(user_id); +let tenant_context = comet::nebula::AccessContext::default() + .with_tenant_value(42_i64); + +let statement = TaskRow::select_scoped(&context)? + .where_(TaskRow::DONE.eq(0)) + .limit(50) + .to_statement(); +``` + +Owner and tenant values are stored as Nebula `Value`s, so text ids, integer +ids, booleans, blobs, and other supported bind types keep their original SQL +bind type. `authenticated(...)` and `with_tenant(...)` are string conveniences; +use `authenticated_value(...)`, `with_user_value(...)`, or +`with_tenant_value(...)` for non-text keys. + +Multiple policies that apply to the same operation compose with `AND`. +Use this for defense in depth, such as owner/tenant filtering plus RBAC plus a +custom lifecycle predicate. If you need `OR` semantics for row predicates, +encode that in one custom predicate. + +When `comet-auth` is built with its `nebula` feature, sessions can build an +access context directly: + +```rust +use comet_auth::NebulaAccessContextExt; + +let context = session.to_nebula_access_context(); +``` + +Custom predicates implement `CustomPredicateProvider` and return a Nebula +`Expr`: + +```rust +struct TaskPredicates; + +impl comet::nebula::CustomPredicateProvider for TaskPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: comet::nebula::RlsOperation, + _context: &comet::nebula::AccessContext, + ) -> Result { + if table == TaskRow::TABLE.name && name == "can_complete_task" { + Ok(TaskRow::DONE.eq(0)) + } else { + Err(comet::nebula::RlsError::MissingCustomPredicate { table, name }) + } + } + + fn registered_predicate_rules( + &self, + ) -> &'static [comet::nebula::CustomPredicateRegistration] { + &[comet::nebula::CustomPredicateRegistration { + name: "can_complete_task", + operations: &[comet::nebula::RlsOperation::Update], + }] + } +} +``` + +Use custom rules when ownership, tenancy, and static RBAC are not enough: + +- lifecycle rules, such as "only complete pending tasks"; +- membership rules, such as "only active project members"; +- mixed rules, such as "creator or admin"; +- feature rules, such as "only if the workspace has billing enabled". + +Keep custom predicate names stable because entity metadata stores the string. +Expose those names through `registered_predicate_rules()` to validate the +operation too (`select`, `insert`, `update`, `delete`), or use +`registered_predicates()` only when the same predicate is intentionally valid +for every operation. Validate startup or route setup with +`Entity::validate_custom_predicates_with(...)`. Test the allow path, denied +path, and missing-predicate path. A missing custom predicate is treated as a +server/configuration error. + +`select()`, `insert()`, `update()`, and `delete()` on protected tables report +`QueryLint::UnscopedRls` unless RLS was applied or +`.allow_unscoped_rls("reason")` is used explicitly. Schema lints also report missing +RLS declarations and owner/tenant columns that are not indexed. + +Use checked statement generation to fail closed on lints: + +```rust +let statement = TaskRow::select_scoped(&context)? + .limit(50) + .to_statement_checked()?; +``` + +The CLI can report entity coverage: + +```sh +comet rls status --strict +comet rls status --strict --json \ + --custom-predicate-rule can_complete_task:update +``` + +RLS metadata is part of schema snapshots. Changing RLS policies in an existing +table is reported as a migration blocker so the authorization change is +reviewed explicitly. + +Threat model: Nebula RLS protects queries built through scoped Nebula builders +when the app supplies the correct `AccessContext`. It does not protect raw SQL, +incorrect tenant context, custom predicates that are too broad, or data access +that bypasses Nebula. Treat `Statement::raw_unscoped(...)` and +`.allow_unscoped_rls("reason")` as security review points. + ## Relationships Relationships are **explicit query-builder shortcuts, not lazy loading or @@ -196,6 +345,9 @@ impl Board { (`Parent::select().where_(foreign_column.eq(local_value)).limit(1)`). - `HasMany::select_children(parent_value)` returns a `Select` — the caller still picks the limit/ordering. +- `select_parent_scoped(...)` and `select_children_scoped(...)` apply RLS on + the loaded side of the relationship with the same `AccessContext`; the + `*_scoped_with(...)` variants accept a `CustomPredicateProvider`. ```rust let statement = Task::BOARD.select_parent(42).to_statement(); @@ -205,6 +357,11 @@ let statement = Board::TASKS.select_children(7) .order_by(Task::ID.asc()) .limit(50) .to_statement(); + +let statement = Board::TASKS.select_children_scoped(7, &context)? + .order_by(Task::ID.asc()) + .limit(50) + .to_statement_checked()?; ``` ## Migration generation @@ -233,7 +390,8 @@ example `ALTER TABLE "tasks" ADD COLUMN "done" INTEGER NOT NULL DEFAULT 0`. SQL emitted): `DropTable`, `DropColumn`, `ChangeColumn`, `UnsafeAddColumn` (non-nullable column with no default), `DropIndex`/`ChangeIndex`, `AddForeignKey`/`DropForeignKey`/`ChangeForeignKey` -on existing tables. `MigrationPlan::is_safe()` is `blockers.is_empty()`. +on existing tables, and `ChangeRls` when RLS policies change on an existing +table. `MigrationPlan::is_safe()` is `blockers.is_empty()`. `MigrationPlan::migration_file_name(sequence, name)` produces Wrangler-compatible names, like `0007_add_task_done.sql`. diff --git a/docs-site/content/docs/nebula-orm.mdx b/docs-site/content/docs/nebula-orm.mdx index c892b35..310d89f 100644 --- a/docs-site/content/docs/nebula-orm.mdx +++ b/docs-site/content/docs/nebula-orm.mdx @@ -179,6 +179,154 @@ Task::delete().where_(Task::ID.eq(42)).to_statement(); A ordem dos binds é sempre determinística: binds de assignment/filtro primeiro, depois `LIMIT`/`OFFSET` no final. +## Row-level security + +D1 usa semântica SQLite e não oferece RLS nativo no estilo do Postgres. No +Nebula, RLS é aplicado na camada do ORM: o builder adiciona predicates e +valida autorização antes da execução do statement. SQL cru continua disponível, +mas fica fora da aplicação automática de RLS; use +`Statement::raw_unscoped(...)` para que revisões e buscas identifiquem esses +pontos de autorização manual. + +Declare RLS na entidade: + +```rust +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(owner = "user_id"))] +#[nebula(rls(update, permission = "tasks:write"))] +#[nebula(rls(update, custom = "can_complete_task"))] +pub struct TaskRow { + #[nebula(primary_key, auto)] + pub id: i64, + #[nebula(index)] + pub user_id: String, + pub done: i32, +} +``` + +Formas suportadas: + +- `#[nebula(rls(public))]` — tabela explicitamente pública. +- `#[nebula(rls(owner = "user_id"))]` — compara com o usuário atual. +- `#[nebula(rls(tenant = "org_id"))]` — compara com o tenant atual. +- `#[nebula(rls(select, permission = "boards:read"))]` — RBAC por operação. +- `#[nebula(rls(update, any(role = "admin", permission = "boards:write")))]` + — RBAC em modo any/all. +- `#[nebula(rls(delete, custom = "can_delete_board"))]` — predicate fornecido + pela aplicação. + +Use os builders scoped com `AccessContext`: + +```rust +let context = comet::nebula::AccessContext::authenticated(user_id); +let tenant_context = comet::nebula::AccessContext::default() + .with_tenant_value(42_i64); + +let statement = TaskRow::select_scoped(&context)? + .where_(TaskRow::DONE.eq(0)) + .limit(50) + .to_statement(); +``` + +Valores de owner e tenant são armazenados como `Value` do Nebula, então ids em +texto, inteiros, booleanos, blobs e outros tipos de bind suportados preservam o +tipo SQL original. `authenticated(...)` e `with_tenant(...)` são conveniências +para texto; use `authenticated_value(...)`, `with_user_value(...)` ou +`with_tenant_value(...)` para chaves não textuais. + +Múltiplas policies aplicáveis à mesma operação são compostas com `AND`. Use +isso para defesa em profundidade, como filtro owner/tenant mais RBAC mais um +predicate custom de ciclo de vida. Se precisar de semântica `OR` para linhas, +codifique isso em um único custom predicate. + +Com `comet-auth` compilado com a feature `nebula`, sessões podem virar +contexto diretamente: + +```rust +use comet_auth::NebulaAccessContextExt; + +let context = session.to_nebula_access_context(); +``` + +Custom predicates implementam `CustomPredicateProvider` e retornam um `Expr`: + +```rust +struct TaskPredicates; + +impl comet::nebula::CustomPredicateProvider for TaskPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: comet::nebula::RlsOperation, + _context: &comet::nebula::AccessContext, + ) -> Result { + if table == TaskRow::TABLE.name && name == "can_complete_task" { + Ok(TaskRow::DONE.eq(0)) + } else { + Err(comet::nebula::RlsError::MissingCustomPredicate { table, name }) + } + } + + fn registered_predicate_rules( + &self, + ) -> &'static [comet::nebula::CustomPredicateRegistration] { + &[comet::nebula::CustomPredicateRegistration { + name: "can_complete_task", + operations: &[comet::nebula::RlsOperation::Update], + }] + } +} +``` + +Use custom rules quando ownership, tenancy e RBAC estático não bastam: + +- regras de ciclo de vida, como "somente completar tarefas pendentes"; +- regras de associação, como "somente membros ativos do projeto"; +- regras mistas, como "criador ou admin"; +- regras de produto, como "somente se o workspace tiver billing ativo". + +Mantenha nomes de custom predicates estáveis, porque o metadado da entidade +guarda a string. Exponha esses nomes com `registered_predicate_rules()` para +validar também a operação (`select`, `insert`, `update`, `delete`) ou use +`registered_predicates()` apenas quando o mesmo predicate for intencionalmente +válido para todas as operações. Valide o startup ou a configuração da rota com +`Entity::validate_custom_predicates_with(...)`. Teste caminho permitido, +caminho negado e predicate ausente. Um custom predicate ausente é tratado como +erro de servidor/configuração. + +`select()`, `insert()`, `update()` e `delete()` em tabelas protegidas reportam +`QueryLint::UnscopedRls` quando RLS não foi aplicado. O escape hatch explícito +é `.allow_unscoped_rls("motivo")`. Os lints de schema também reportam tabelas sem +declaração RLS e colunas owner/tenant sem índice. + +Use geração checked para falhar fechado quando houver lints: + +```rust +let statement = TaskRow::select_scoped(&context)? + .limit(50) + .to_statement_checked()?; +``` + +O CLI reporta cobertura por entidade: + +```sh +comet rls status --strict +comet rls status --strict --json \ + --custom-predicate-rule can_complete_task:update +``` + +Metadados RLS fazem parte do snapshot de schema. Alterar policies RLS de uma +tabela existente aparece como blocker de migration para que a mudança de +autorização seja revisada explicitamente. + +Threat model: o RLS do Nebula protege queries feitas pelos builders scoped +quando a aplicação fornece o `AccessContext` correto. Ele não protege SQL cru, +tenant context incorreto, custom predicates amplos demais, nem acesso a dados +que bypassa o Nebula. Trate `Statement::raw_unscoped(...)` e +`.allow_unscoped_rls("motivo")` como pontos de revisão de segurança. + ## Relacionamentos Relacionamentos são **atalhos explícitos de query builder, não lazy loading @@ -199,6 +347,9 @@ impl Board { (`Parent::select().where_(foreign_column.eq(local_value)).limit(1)`). - `HasMany::select_children(parent_value)` retorna um `Select` — quem chama ainda escolhe limite/ordenação. +- `select_parent_scoped(...)` e `select_children_scoped(...)` aplicam RLS no + lado carregado da relação usando o mesmo `AccessContext`; as variantes + `*_scoped_with(...)` aceitam um `CustomPredicateProvider`. ```rust let statement = Task::BOARD.select_parent(42).to_statement(); @@ -208,6 +359,11 @@ let statement = Board::TASKS.select_children(7) .order_by(Task::ID.asc()) .limit(50) .to_statement(); + +let statement = Board::TASKS.select_children_scoped(7, &context)? + .order_by(Task::ID.asc()) + .limit(50) + .to_statement_checked()?; ``` ## Geração de migrations @@ -235,7 +391,8 @@ nullable, colunas com default, índices/índices únicos faltando — por exempl **Diffs bloqueados** (retornam um `MigrationBlocker`, exigindo revisão humana, sem SQL emitido): `DropTable`, `DropColumn`, `ChangeColumn`, `UnsafeAddColumn` (coluna não-nula sem default), `DropIndex`/`ChangeIndex`, -`AddForeignKey`/`DropForeignKey`/`ChangeForeignKey` em tabelas existentes. +`AddForeignKey`/`DropForeignKey`/`ChangeForeignKey` em tabelas existentes e +`ChangeRls` quando policies RLS mudam em tabela existente. `MigrationPlan::is_safe()` é `blockers.is_empty()`. `MigrationPlan::migration_file_name(sequence, name)` produz nomes diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..1846598 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,200 @@ +# Comet Auth Setup + +This guide covers the current `comet-auth` setup for Cloudflare Workers. +The auth runtime stores users, linked provider accounts, and sessions in D1. +KV is used for OAuth state and can be used as a fast session cache; D1 remains +the source of truth. + +## Worker Bindings + +Your Worker needs one D1 binding and one KV namespace: + +```jsonc +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-app", + "database_id": "...", + "migrations_dir": "migrations" + } + ], + "kv_namespaces": [ + { + "binding": "AUTH_KV", + "id": "..." + } + ] +} +``` + +Add the auth migration with: + +```sh +comet auth init --db-binding DB --kv-binding AUTH_KV --with-rbac +``` + +## Rocket Mount + +```rust +pub struct DB; +impl comet::cloudflare::BindingName for DB { + const NAME: &'static str = "DB"; +} + +pub struct AuthKv; +impl comet::cloudflare::BindingName for AuthKv { + const NAME: &'static str = "AUTH_KV"; +} + +let auth_config = comet_auth::AuthConfig::from_env() + .base_url("https://example.com") + .provider( + comet_auth::providers::Google::from_env() + .web_client_id_env("GOOGLE_WEB_CLIENT_ID") + .web_client_secret_env("GOOGLE_WEB_CLIENT_SECRET") + .native_client_id_env("GOOGLE_IOS_CLIENT_ID") + .native_client_id_env("GOOGLE_ANDROID_CLIENT_ID"), + ) + .provider( + comet_auth::providers::Apple::from_env() + .service_id_env("APPLE_SERVICE_ID") + .team_id_env("APPLE_TEAM_ID") + .key_id_env("APPLE_KEY_ID") + .private_key_pkcs8_pem_env("APPLE_PRIVATE_KEY_PKCS8_PEM") + .native_audience_env("APPLE_IOS_BUNDLE_ID"), + ) + .provider( + comet_auth::providers::GitHub::from_env() + .client_id_env("GITHUB_CLIENT_ID") + .client_secret_env("GITHUB_CLIENT_SECRET"), + ); + +rocket::build() + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()); +``` + +## Protected Routes + +Put `#[comet_auth::requires_auth]` above the Rocket route attribute: + +```rust +#[comet_auth::requires_auth] +#[rocket::get("/private/me")] +async fn private_me(session: comet_auth::AuthSession) -> &'static str { + "authenticated" +} +``` + +For anonymous-aware routes: + +```rust +#[comet_auth::requires_auth(optional)] +#[rocket::get("/maybe")] +async fn maybe(session: comet_auth::OptionalAuthSession) -> &'static str { + if session.0.is_some() { "signed in" } else { "anonymous" } +} +``` + +Authorization policies are enforced with D1-backed RBAC: + +```rust +#[comet_auth::requires_auth(role = "admin")] +#[rocket::get("/admin")] +async fn admin() -> &'static str { + "admin" +} + +#[comet_auth::requires_auth(permission = "boards:write")] +#[rocket::post("/boards")] +async fn create_board() -> &'static str { + "created" +} +``` + +Top-level policies are `all` by default. Use `any(...)` when one matching +claim is enough, and `resource = "..."` for static resource-scoped checks: + +```rust +#[comet_auth::requires_auth(any(role = "admin", permission = "tasks:review"), resource = "demo")] +#[rocket::get("/private/reviewer")] +async fn reviewer() -> &'static str { + "reviewer" +} +``` + +`scope = "..."` is treated as a permission alias. Missing sessions return +`401 Unauthorized`; authenticated sessions without the required role, +permission, or scope return `403 Forbidden`. + +Authorization claims are loaded from D1 and cached in KV for 60 seconds by +default. Tune or disable this with: + +```rust +comet_auth::AuthConfig::from_env() + .authorization_claims_cache_ttl_seconds(0); +``` + +Add RBAC tables when initializing auth: + +```sh +comet auth init --with-rbac +``` + +## Provider Secrets + +Set secrets with `wrangler secret put ` for deployed Workers. For local +development, use Wrangler's local secret flow or `.dev.vars` according to your +project policy. + +Common secrets: + +- `COMET_AUTH_BASE_URL`: public origin used to build OAuth callback URLs. +- `COMET_AUTH_TOKEN_PEPPER`: extra secret material mixed into session token + hashes. + +Google: + +- `GOOGLE_WEB_CLIENT_ID` +- `GOOGLE_WEB_CLIENT_SECRET` +- `GOOGLE_IOS_CLIENT_ID`, optional native audience +- `GOOGLE_ANDROID_CLIENT_ID`, optional native audience + +Apple: + +- `APPLE_SERVICE_ID`, used for web OAuth audience/client id +- `APPLE_TEAM_ID` +- `APPLE_KEY_ID` +- `APPLE_PRIVATE_KEY_PKCS8_PEM` +- `APPLE_IOS_BUNDLE_ID`, optional native audience + +GitHub: + +- `GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` + +## Redirect URIs + +Configure these callback URLs in each provider dashboard: + +- Google: `/auth/google/callback` +- Apple: `/auth/apple/callback` +- GitHub: `/auth/github/callback` + +## Native Login + +Native clients should use provider-native SDKs to obtain an identity token, +then send it to Comet: + +```sh +curl -X POST https://example.com/auth/native/google \ + -H 'content-type: application/json' \ + -d '{"id_token":"...","nonce":"..."}' +``` + +Apple uses the same request shape at `/auth/native/apple`. + +Do not use an embedded WebView for Google login. For browser-based login from a +mobile app, use the system browser flow such as `ASWebAuthenticationSession`, +`SFSafariViewController`, or Chrome Custom Tabs. diff --git a/examples/cloudflare-worker/Cargo.toml b/examples/cloudflare-worker/Cargo.toml index c89b6e7..b412792 100644 --- a/examples/cloudflare-worker/Cargo.toml +++ b/examples/cloudflare-worker/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] comet = { path = "../..", default-features = false, features = ["cloudflare", "cloudflare-d1", "cloudflare-queue", "cloudflare-r2", "cloudflare-websocket", "nebula", "nebula-d1"] } +comet-auth = { path = "../../comet-auth", default-features = false, features = ["cloudflare", "macros", "nebula"] } console_error_panic_hook = "0.1.7" rocket = { path = "../../vendor/rocket/core/lib", default-features = false, features = ["json", "worker"] } wasm-bindgen = "0.2" diff --git a/examples/cloudflare-worker/README.md b/examples/cloudflare-worker/README.md index ca20fe1..f61f660 100644 --- a/examples/cloudflare-worker/README.md +++ b/examples/cloudflare-worker/README.md @@ -3,12 +3,14 @@ This example is a small but real Rust Cloudflare Worker built with Rocket routes through `comet`. It's a task tracker backed by D1, with task lifecycle changes published to a Queue and consumed asynchronously. It also exercises R2 -object streaming and a Worker WebSocket route. +object streaming, a Worker WebSocket route, and `comet-auth` mounted on the +same Rocket app. It depends on `comet` with `default-features = false` and the Cloudflare features needed by the example (`cloudflare`, D1, Queue, R2, and WebSocket), so it can compile to `wasm32-unknown-unknown` without pulling Rocket's native -local client. The Worker entrypoint calls: +local client. `comet-auth` is included with `default-features = false` and the +`cloudflare`/`macros` features. The Worker entrypoint calls: ```rust comet::cloudflare::fetch(req, env, ctx, rocket).await @@ -33,6 +35,8 @@ comet::cloudflare::fetch(req, env, ctx, rocket).await validation errors into proper JSON error responses with the right HTTP status. - `migrations/0001_init.sql` — the `tasks` and `task_events` schema. +- `migrations/0002_comet_auth.sql` — the auth schema for users, linked + provider accounts, and sessions. ### Routes @@ -42,6 +46,19 @@ comet::cloudflare::fetch(req, env, ctx, rocket).await and a multi-megabyte one. - `GET /stream` — 3 chunks, a real (`worker::Delay`) 400ms gap between each. Exists purely to prove response streaming isn't buffered — see Tests below. +- `GET /auth/session` — current auth state. Anonymous visitors get + `{"authenticated":false,...}`. +- `GET /auth//start` — starts Google, Apple, or GitHub OAuth when + the corresponding provider secrets are configured. +- `GET /auth//callback` — OAuth callback endpoint. +- `POST /auth/native/google` and `POST /auth/native/apple` — exchange native + identity tokens for a Comet session. +- `POST /auth/logout` — revoke the current session. +- `GET /private/me` — protected route using `#[comet_auth::requires_auth]`. +- `GET /private/admin` — RBAC-protected route using + `#[comet_auth::requires_auth(role = "admin")]`. +- `GET /private/reviewer` — RBAC-protected route using `any(...)` and + `resource = "demo"`. - `GET /tasks` — list all tasks. - `POST /tasks` — create a task from a JSON body (`{"title": "..."}`) and publish a `created` event to the queue. @@ -124,17 +141,20 @@ calling `comet::cloudflare::fetch(req, env, ctx, rocket).await`. ## Setup -Create a D1 database and a queue, and wire them into `wrangler.jsonc`: +Create a D1 database, KV namespace, queue, and R2 bucket, and wire them into +`wrangler.jsonc`: ```sh npx wrangler d1 create comet-cloudflare-worker-example +npx wrangler kv namespace create AUTH_KV npx wrangler queues create task-events npx wrangler r2 bucket create comet-cloudflare-worker-example-assets ``` Copy the `database_id` from the first command's output into the -`d1_databases[0].database_id` field in `wrangler.jsonc` (it currently has a -placeholder). Then apply migrations: +`d1_databases[0].database_id` field in `wrangler.jsonc` and the KV namespace +`id` into `kv_namespaces[0].id` (both currently have placeholders). Then apply +migrations: ```sh # local (used by `wrangler dev`) @@ -144,6 +164,21 @@ npx wrangler d1 migrations apply DB --local npx wrangler d1 migrations apply DB --remote ``` +For local development, set the public callback base URL and a token pepper: + +```sh +npx wrangler secret put COMET_AUTH_TOKEN_PEPPER +npx wrangler secret put COMET_AUTH_BASE_URL +``` + +Use `http://localhost:8787` for `COMET_AUTH_BASE_URL` when running +`npm run dev`. For OAuth providers, configure the provider-specific secrets +from [`../../docs/auth.md`](../../docs/auth.md). The redirect URIs are: + +- Google: `/auth/google/callback` +- Apple: `/auth/apple/callback` +- GitHub: `/auth/github/callback` + ## Run Locally ```sh @@ -165,6 +200,9 @@ curl -X POST http://localhost:8787/tasks \ curl http://localhost:8787/tasks curl -X POST http://localhost:8787/tasks/1/complete +curl http://localhost:8787/auth/session +curl -i http://localhost:8787/private/me + curl -X PUT http://localhost:8787/assets/hello.txt --data-binary 'hello from R2' curl http://localhost:8787/assets/hello.txt ``` @@ -196,7 +234,9 @@ npm run test `tests/integration.sh` drives a real `wrangler dev` instance end to end: it resets local D1 state, applies migrations, starts the worker, exercises every -route over HTTP, confirms the queue consumer actually wrote the +public route over HTTP, verifies `/auth/session`, verifies that `/private/me` +and `/private/admin` return `401` without a session, confirms provider startup +fails cleanly when local OAuth secrets are absent, confirms the queue consumer actually wrote the `task_events` audit trail, round-trips a 1MiB object through R2, verifies the `/ws/echo` WebSocket route, and proves request/response bodies are genuinely streamed rather than buffered — a 1MiB `/echo` body round-trips diff --git a/examples/cloudflare-worker/migrations/0001_init.sql b/examples/cloudflare-worker/migrations/0001_init.sql index 46cc04e..925426c 100644 --- a/examples/cloudflare-worker/migrations/0001_init.sql +++ b/examples/cloudflare-worker/migrations/0001_init.sql @@ -1,10 +1,14 @@ CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE INDEX IF NOT EXISTS idx_tasks_user_id + ON tasks(user_id); + CREATE TABLE IF NOT EXISTS task_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, diff --git a/examples/cloudflare-worker/migrations/0002_comet_auth.sql b/examples/cloudflare-worker/migrations/0002_comet_auth.sql new file mode 100644 index 0000000..a8e9cdf --- /dev/null +++ b/examples/cloudflare-worker/migrations/0002_comet_auth.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS comet_auth_users ( + id TEXT PRIMARY KEY, + primary_email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + name TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS comet_auth_accounts ( + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + name TEXT, + avatar_url TEXT, + raw_profile_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (provider, provider_account_id), + FOREIGN KEY (user_id) REFERENCES comet_auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_comet_auth_accounts_user_id + ON comet_auth_accounts(user_id); + +CREATE TABLE IF NOT EXISTS comet_auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + user_agent_hash TEXT, + ip_hash TEXT, + FOREIGN KEY (user_id) REFERENCES comet_auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_comet_auth_sessions_user_id + ON comet_auth_sessions(user_id); + +CREATE INDEX IF NOT EXISTS idx_comet_auth_sessions_token_hash + ON comet_auth_sessions(token_hash); + +CREATE INDEX IF NOT EXISTS idx_comet_auth_sessions_expires_at + ON comet_auth_sessions(expires_at); diff --git a/examples/cloudflare-worker/migrations/0003_comet_auth_rbac.sql b/examples/cloudflare-worker/migrations/0003_comet_auth_rbac.sql new file mode 100644 index 0000000..d04b006 --- /dev/null +++ b/examples/cloudflare-worker/migrations/0003_comet_auth_rbac.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS comet_auth_roles ( + name TEXT PRIMARY KEY, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS comet_auth_permissions ( + name TEXT PRIMARY KEY, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS comet_auth_user_roles ( + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + role_name TEXT NOT NULL REFERENCES comet_auth_roles(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, role_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_user_roles_user_id_idx + ON comet_auth_user_roles(user_id); + +CREATE INDEX IF NOT EXISTS comet_auth_user_roles_role_name_idx + ON comet_auth_user_roles(role_name); + +CREATE TABLE IF NOT EXISTS comet_auth_role_permissions ( + role_name TEXT NOT NULL REFERENCES comet_auth_roles(name) ON DELETE CASCADE, + permission_name TEXT NOT NULL REFERENCES comet_auth_permissions(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (role_name, permission_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_role_permissions_role_name_idx + ON comet_auth_role_permissions(role_name); + +CREATE TABLE IF NOT EXISTS comet_auth_user_permissions ( + user_id TEXT NOT NULL REFERENCES comet_auth_users(id) ON DELETE CASCADE, + permission_name TEXT NOT NULL REFERENCES comet_auth_permissions(name) ON DELETE CASCADE, + resource TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, permission_name, resource) +); + +CREATE INDEX IF NOT EXISTS comet_auth_user_permissions_user_id_idx + ON comet_auth_user_permissions(user_id); + +CREATE INDEX IF NOT EXISTS comet_auth_user_permissions_permission_name_idx + ON comet_auth_user_permissions(permission_name); diff --git a/examples/cloudflare-worker/src/app.rs b/examples/cloudflare-worker/src/app.rs index d12692a..4235d9b 100644 --- a/examples/cloudflare-worker/src/app.rs +++ b/examples/cloudflare-worker/src/app.rs @@ -2,9 +2,19 @@ use rocket::{Build, Rocket}; use worker::{Context, Env}; use crate::assets::routes::{get_asset, put_asset}; -use crate::demo::routes::{echo, index, stream_demo, websocket_echo}; -use crate::tasks::routes::{complete_task, create_task, get_task, list_tasks}; +use crate::boards::routes::list_org_boards; +use crate::demo::routes::{ + echo, index, private_admin, private_me, private_reviewer, stream_demo, websocket_echo, +}; +use crate::tasks::routes::{complete_task, create_task, get_task, list_tasks, DB}; +struct AuthKv; + +impl comet::cloudflare::BindingName for AuthKv { + const NAME: &'static str = "AUTH_KV"; +} + +#[allow(unused)] pub fn rocket(env: Env, _ctx: Context) -> Rocket { use rocket::data::{Limits, ToByteUnit}; @@ -15,20 +25,58 @@ pub fn rocket(env: Env, _ctx: Context) -> Rocket { limits, ..rocket::Config::default() }; + let auth_base_url = env + .var("COMET_AUTH_BASE_URL") + .map(|value| value.to_string()) + .or_else(|_| { + env.secret("COMET_AUTH_BASE_URL") + .map(|value| value.to_string()) + }) + .unwrap_or_else(|_| "http://localhost:8787".to_owned()); + let auth_config = comet_auth::AuthConfig::from_env() + .base_url(auth_base_url) + .provider( + comet_auth::providers::Google::from_env() + .web_client_id_env("GOOGLE_WEB_CLIENT_ID") + .web_client_secret_env("GOOGLE_WEB_CLIENT_SECRET") + .native_client_id_env("GOOGLE_IOS_CLIENT_ID") + .native_client_id_env("GOOGLE_ANDROID_CLIENT_ID"), + ) + .provider( + comet_auth::providers::Apple::from_env() + .service_id_env("APPLE_SERVICE_ID") + .team_id_env("APPLE_TEAM_ID") + .key_id_env("APPLE_KEY_ID") + .private_key_pkcs8_pem_env("APPLE_PRIVATE_KEY_PKCS8_PEM") + .native_audience_env("APPLE_IOS_BUNDLE_ID"), + ) + .provider( + comet_auth::providers::GitHub::from_env() + .client_id_env("GITHUB_CLIENT_ID") + .client_secret_env("GITHUB_CLIENT_SECRET"), + ); - rocket::custom(config).manage(env).mount( - "/", - routes![ - index, - echo, - stream_demo, - websocket_echo, - put_asset, - get_asset, - list_tasks, - get_task, - create_task, - complete_task - ], - ) + rocket::custom(config) + .manage(env) + .attach(comet_auth::Auth::::fairing(auth_config)) + .mount("/auth", comet_auth::routes::()) + .mount( + "/", + routes![ + index, + echo, + stream_demo, + websocket_echo, + put_asset, + get_asset, + list_org_boards, + list_tasks, + get_task, + create_task, + complete_task, + private_admin, + private_me, + private_reviewer + ], + ) } diff --git a/examples/cloudflare-worker/src/boards/board_task.rs b/examples/cloudflare-worker/src/boards/board_task.rs index ae89fc7..66ca7ce 100644 --- a/examples/cloudflare-worker/src/boards/board_task.rs +++ b/examples/cloudflare-worker/src/boards/board_task.rs @@ -12,6 +12,7 @@ use crate::users::model::UserRow; /// property of any single one of the three tables it references. #[derive(Debug, Clone, Deserialize, comet::nebula::Entity)] #[nebula(table = "board_tasks")] +#[nebula(rls(public))] #[serde(crate = "rocket::serde")] pub struct BoardTaskRow { #[nebula(primary_key, auto, unique, index)] diff --git a/examples/cloudflare-worker/src/boards/mod.rs b/examples/cloudflare-worker/src/boards/mod.rs index 8e2f750..d248da7 100644 --- a/examples/cloudflare-worker/src/boards/mod.rs +++ b/examples/cloudflare-worker/src/boards/mod.rs @@ -1,2 +1,3 @@ pub mod board_task; pub mod model; +pub mod routes; diff --git a/examples/cloudflare-worker/src/boards/model.rs b/examples/cloudflare-worker/src/boards/model.rs index c71499e..30e239c 100644 --- a/examples/cloudflare-worker/src/boards/model.rs +++ b/examples/cloudflare-worker/src/boards/model.rs @@ -1,11 +1,12 @@ use comet::nebula::{belongs_to, has_many, BelongsTo, HasMany}; -use rocket::serde::Deserialize; +use rocket::serde::{Deserialize, Serialize}; use crate::boards::board_task::BoardTaskRow; use crate::orgs::model::OrgRow; -#[derive(Debug, Clone, Deserialize, comet::nebula::Entity)] +#[derive(Debug, Clone, Deserialize, Serialize, comet::nebula::Entity)] #[nebula(table = "boards")] +#[nebula(rls(tenant = "org_id"))] #[serde(crate = "rocket::serde")] pub struct BoardRow { #[nebula(primary_key, auto, unique, index)] diff --git a/examples/cloudflare-worker/src/boards/routes.rs b/examples/cloudflare-worker/src/boards/routes.rs new file mode 100644 index 0000000..37ec51b --- /dev/null +++ b/examples/cloudflare-worker/src/boards/routes.rs @@ -0,0 +1,29 @@ +use comet::cloudflare::D1; +use comet::nebula::Entity; +use comet_auth::{AuthSession, NebulaAccessContextExt}; +use rocket::serde::json::Json; + +use crate::boards::model::BoardRow; +use crate::tasks::error::{ApiError, ApiResult}; +use crate::tasks::routes::DB; + +#[get("/orgs//boards")] +pub async fn list_org_boards( + org_id: i32, + session: AuthSession, + db: D1, +) -> ApiResult>> { + let context = session.to_nebula_access_context().with_tenant_value(org_id); + let rows = BoardRow::select_scoped(&context) + .map_err(ApiError::from)? + .order_by(BoardRow::ID.asc()) + .limit(100) + .to_statement() + .fetch_all_d1(&db) + .await + .map_err(ApiError::from)? + .results::() + .map_err(ApiError::from)?; + + Ok(Json(rows)) +} diff --git a/examples/cloudflare-worker/src/demo/routes.rs b/examples/cloudflare-worker/src/demo/routes.rs index 6cb1e38..8d8a86a 100644 --- a/examples/cloudflare-worker/src/demo/routes.rs +++ b/examples/cloudflare-worker/src/demo/routes.rs @@ -1,5 +1,8 @@ use comet::cloudflare::{WebSocketResponse, WebSocketUpgrade}; +use comet_auth::AuthSession; use rocket::futures::StreamExt; +use rocket::serde::json::Json; +use rocket::serde::Serialize; use worker::WebsocketEvent; #[get("/")] @@ -7,6 +10,36 @@ pub fn index() -> &'static str { "hello from Rocket on Cloudflare Workers\n" } +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct PrivateMeResponse { + pub session_id: String, + pub user_id: String, + pub email: Option, +} + +#[comet_auth::requires_auth] +#[get("/private/me")] +pub async fn private_me(session: AuthSession) -> Json { + Json(PrivateMeResponse { + session_id: session.id, + user_id: session.user.id, + email: session.user.primary_email, + }) +} + +#[comet_auth::requires_auth(role = "admin")] +#[get("/private/admin")] +pub async fn private_admin() -> &'static str { + "admin\n" +} + +#[comet_auth::requires_auth(any(role = "admin", permission = "tasks:review"), resource = "demo")] +#[get("/private/reviewer")] +pub async fn private_reviewer() -> &'static str { + "reviewer\n" +} + #[post("/echo", data = "")] pub fn echo(body: String) -> String { body diff --git a/examples/cloudflare-worker/src/lib.rs b/examples/cloudflare-worker/src/lib.rs index 0cc5591..11e58fc 100644 --- a/examples/cloudflare-worker/src/lib.rs +++ b/examples/cloudflare-worker/src/lib.rs @@ -14,5 +14,4 @@ pub mod orgs; pub mod tasks; pub mod users; -#[cfg(target_arch = "wasm32")] mod entry; diff --git a/examples/cloudflare-worker/src/orgs/model.rs b/examples/cloudflare-worker/src/orgs/model.rs index 75c2ae4..3705efa 100644 --- a/examples/cloudflare-worker/src/orgs/model.rs +++ b/examples/cloudflare-worker/src/orgs/model.rs @@ -2,6 +2,7 @@ use rocket::serde::Deserialize; #[derive(Debug, Clone, Deserialize, comet::nebula::Entity)] #[nebula(table = "orgs")] +#[nebula(rls(public))] #[serde(crate = "rocket::serde")] pub struct OrgRow { #[nebula(primary_key, auto, unique, index)] diff --git a/examples/cloudflare-worker/src/tasks/error.rs b/examples/cloudflare-worker/src/tasks/error.rs index 5650aa3..37c33f7 100644 --- a/examples/cloudflare-worker/src/tasks/error.rs +++ b/examples/cloudflare-worker/src/tasks/error.rs @@ -4,10 +4,13 @@ use rocket::serde::json::Json; use rocket::serde::Serialize; use rocket::Request; +use comet::nebula::RlsError; + #[derive(Debug)] pub enum ApiError { NotFound, BadRequest(String), + Rls(RlsError), Worker(worker::Error), } @@ -17,6 +20,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(error: RlsError) -> Self { + ApiError::Rls(error) + } +} + #[derive(Serialize)] #[serde(crate = "rocket::serde")] struct ErrorBody { @@ -25,11 +34,7 @@ struct ErrorBody { impl<'r> Responder<'r, 'static> for ApiError { fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> { - let (status, message) = match self { - ApiError::NotFound => (Status::NotFound, "task not found".to_string()), - ApiError::BadRequest(message) => (Status::BadRequest, message), - ApiError::Worker(error) => (Status::InternalServerError, error.to_string()), - }; + let (status, message) = self.status_and_message(); Json(ErrorBody { error: message }) .respond_to(request) @@ -40,4 +45,118 @@ impl<'r> Responder<'r, 'static> for ApiError { } } +impl ApiError { + fn status_and_message(self) -> (Status, String) { + match self { + ApiError::NotFound => (Status::NotFound, "task not found".to_string()), + ApiError::BadRequest(message) => (Status::BadRequest, message), + ApiError::Rls(RlsError::MissingUser { .. } | RlsError::MissingTenant { .. }) => ( + Status::Unauthorized, + "missing RLS access context".to_string(), + ), + ApiError::Rls(RlsError::Forbidden { .. }) => { + (Status::Forbidden, "RLS policy denied access".to_string()) + } + ApiError::Rls(RlsError::TypeMismatch { + table, + column, + expected, + }) => ( + Status::InternalServerError, + format!( + "RLS value for `{table}.{column}` does not match expected {}", + expected.name() + ), + ), + ApiError::Rls(RlsError::MissingCustomPredicate { table, name }) => ( + Status::InternalServerError, + format!("missing RLS custom predicate `{name}` for table `{table}`"), + ), + ApiError::Worker(error) => (Status::InternalServerError, error.to_string()), + } + } +} + pub type ApiResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use comet::nebula::SqlType; + use rocket::local::asynchronous::Client; + + #[test] + fn rls_errors_map_to_http_statuses() { + assert_eq!( + ApiError::from(RlsError::MissingUser { table: "tasks" }) + .status_and_message() + .0, + Status::Unauthorized + ); + assert_eq!( + ApiError::from(RlsError::Forbidden { table: "tasks" }) + .status_and_message() + .0, + Status::Forbidden + ); + assert_eq!( + ApiError::from(RlsError::MissingCustomPredicate { + table: "tasks", + name: "can_update", + }) + .status_and_message() + .0, + Status::InternalServerError + ); + assert_eq!( + ApiError::from(RlsError::TypeMismatch { + table: "boards", + column: "org_id", + expected: SqlType::Integer, + }) + .status_and_message() + .0, + Status::InternalServerError + ); + } + + #[rocket::get("/missing-context")] + fn missing_context() -> Result<&'static str, ApiError> { + Err(ApiError::from(RlsError::MissingUser { table: "tasks" })) + } + + #[rocket::get("/forbidden")] + fn forbidden() -> Result<&'static str, ApiError> { + Err(ApiError::from(RlsError::Forbidden { table: "tasks" })) + } + + #[rocket::get("/missing-custom")] + fn missing_custom() -> Result<&'static str, ApiError> { + Err(ApiError::from(RlsError::MissingCustomPredicate { + table: "tasks", + name: "can_complete_task", + })) + } + + #[rocket::async_test] + async fn rls_responder_sets_http_statuses() { + let rocket = rocket::build().mount( + "/", + rocket::routes![missing_context, forbidden, missing_custom], + ); + let client = Client::tracked(rocket).await.unwrap(); + + assert_eq!( + client.get("/missing-context").dispatch().await.status(), + Status::Unauthorized + ); + assert_eq!( + client.get("/forbidden").dispatch().await.status(), + Status::Forbidden + ); + assert_eq!( + client.get("/missing-custom").dispatch().await.status(), + Status::InternalServerError + ); + } +} diff --git a/examples/cloudflare-worker/src/tasks/model.rs b/examples/cloudflare-worker/src/tasks/model.rs index 6f18e64..1068305 100644 --- a/examples/cloudflare-worker/src/tasks/model.rs +++ b/examples/cloudflare-worker/src/tasks/model.rs @@ -17,10 +17,15 @@ pub struct Task { /// actually serve to clients. #[derive(Debug, Clone, Deserialize, comet::nebula::Entity)] #[nebula(table = "tasks")] +#[nebula(rls(owner = "user_id"))] +#[nebula(rls(update, permission = "tasks:write"))] +#[nebula(rls(update, custom = "can_complete_task"))] #[serde(crate = "rocket::serde")] pub struct TaskRow { #[nebula(primary_key, auto, unique, index)] pub id: i32, + #[nebula(index)] + pub user_id: String, pub title: String, #[nebula(default = "0")] pub done: i32, @@ -93,6 +98,7 @@ mod tests { fn task_row_maps_integer_done_to_bool() { let row = TaskRow { id: 1, + user_id: "user_1".into(), title: "write tests".into(), done: 1, created_at: "2026-07-01T00:00:00Z".into(), @@ -109,6 +115,7 @@ mod tests { fn task_row_zero_is_not_done() { let row = TaskRow { id: 2, + user_id: "user_1".into(), title: "pending".into(), done: 0, created_at: "2026-07-01T00:00:00Z".into(), diff --git a/examples/cloudflare-worker/src/tasks/routes.rs b/examples/cloudflare-worker/src/tasks/routes.rs index 1ae53f2..68c6939 100644 --- a/examples/cloudflare-worker/src/tasks/routes.rs +++ b/examples/cloudflare-worker/src/tasks/routes.rs @@ -1,11 +1,18 @@ use comet::cloudflare::{BindingName, QueueBinding, D1}; -use comet::nebula::Entity; +use comet::nebula::{ + AccessContext, CustomPredicateProvider, CustomPredicateRegistration, Entity, Expr, RlsError, + RlsOperation, +}; +use comet_auth::{ + AuthSession, AuthorizationMode, AuthorizationRequirement, AuthorizedSession, + NebulaAccessContextExt, RequiredAuthorization, +}; use rocket::serde::json::Json; use crate::tasks::error::{ApiError, ApiResult}; use crate::tasks::model::{NewTask, Task, TaskEvent, TaskEventKind, TaskRow}; -const TASK_COLUMNS: &[&str] = &["id", "title", "done", "created_at"]; +const TASK_COLUMNS: &[&str] = &["id", "user_id", "title", "done", "created_at"]; pub struct DB; @@ -19,6 +26,43 @@ impl BindingName for TaskEvents { const NAME: &'static str = "TASK_EVENTS"; } +pub struct TaskWritePolicy; + +impl RequiredAuthorization for TaskWritePolicy { + const REQUIREMENT: AuthorizationRequirement = AuthorizationRequirement::with_mode_and_resource( + AuthorizationMode::All, + &[], + &["tasks:write"], + &[], + None, + ); +} + +struct CompleteTaskPredicates; + +impl CustomPredicateProvider for CompleteTaskPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: RlsOperation, + _context: &AccessContext, + ) -> Result { + if table == TaskRow::TABLE.name && name == "can_complete_task" { + Ok(TaskRow::DONE.eq(0)) + } else { + Err(RlsError::MissingCustomPredicate { table, name }) + } + } + + fn registered_predicate_rules(&self) -> &'static [CustomPredicateRegistration] { + &[CustomPredicateRegistration { + name: "can_complete_task", + operations: &[RlsOperation::Update], + }] + } +} + async fn publish_task_event( queue: &QueueBinding, task_id: i32, @@ -31,9 +75,12 @@ async fn publish_task_event( } #[get("/tasks")] -pub async fn list_tasks(db: D1) -> ApiResult>> { - let rows = TaskRow::select() +pub async fn list_tasks(session: AuthSession, db: D1) -> ApiResult>> { + let context = session.to_nebula_access_context(); + let rows = TaskRow::select_scoped(&context) + .map_err(ApiError::from)? .order_by(TaskRow::ID.asc()) + .limit(100) .to_statement() .fetch_all_d1(&db) .await @@ -45,9 +92,12 @@ pub async fn list_tasks(db: D1) -> ApiResult>> { } #[get("/tasks/")] -pub async fn get_task(id: i32, db: D1) -> ApiResult> { - let row = TaskRow::select() +pub async fn get_task(id: i32, session: AuthSession, db: D1) -> ApiResult> { + let context = session.to_nebula_access_context(); + let row = TaskRow::select_scoped(&context) + .map_err(ApiError::from)? .where_(TaskRow::ID.eq(id)) + .limit(1) .to_statement() .fetch_optional_d1::(&db) .await @@ -60,14 +110,17 @@ pub async fn get_task(id: i32, db: D1) -> ApiResult> { #[post("/tasks", data = "")] pub async fn create_task( new_task: Json, + session: AuthSession, db: D1, queue: QueueBinding, ) -> ApiResult> { + let context = session.to_nebula_access_context(); let title = new_task .validated_title() .map_err(|message| ApiError::BadRequest(message.to_string()))?; - let row = TaskRow::insert() + let row = TaskRow::insert_scoped(&context) + .map_err(ApiError::from)? .set(TaskRow::TITLE, title) .returning(TASK_COLUMNS.iter().copied()) .to_statement() @@ -84,10 +137,14 @@ pub async fn create_task( #[post("/tasks//complete")] pub async fn complete_task( id: i32, + session: AuthorizedSession, db: D1, queue: QueueBinding, ) -> ApiResult> { - let row = TaskRow::update() + let context = session.to_nebula_access_context(); + TaskRow::validate_custom_predicates_with(&CompleteTaskPredicates).map_err(ApiError::from)?; + let row = TaskRow::update_scoped_with(&context, &CompleteTaskPredicates) + .map_err(ApiError::from)? .set(TaskRow::DONE, 1) .where_(TaskRow::ID.eq(id)) .returning(TASK_COLUMNS.iter().copied()) diff --git a/examples/cloudflare-worker/src/users/model.rs b/examples/cloudflare-worker/src/users/model.rs index c038bb5..617d671 100644 --- a/examples/cloudflare-worker/src/users/model.rs +++ b/examples/cloudflare-worker/src/users/model.rs @@ -5,6 +5,7 @@ use crate::orgs::model::OrgRow; #[derive(Debug, Clone, Deserialize, comet::nebula::Entity)] #[nebula(table = "users")] +#[nebula(rls(tenant = "org_id"))] #[serde(crate = "rocket::serde")] pub struct UserRow { #[nebula(primary_key, auto, unique, index)] diff --git a/examples/cloudflare-worker/tests/integration.sh b/examples/cloudflare-worker/tests/integration.sh index d837ad5..caee1de 100755 --- a/examples/cloudflare-worker/tests/integration.sh +++ b/examples/cloudflare-worker/tests/integration.sh @@ -62,6 +62,24 @@ check() { INDEX=$(curl -s "$BASE/") check "index route returns greeting" "hello from Rocket on Cloudflare Workers" "$INDEX" +AUTH_SESSION=$(curl -s "$BASE/auth/session") +check "auth session route reports anonymous visitor" "false" "$(echo "$AUTH_SESSION" | jq -r .authenticated)" + +PRIVATE_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/private/me") +check "protected auth demo route rejects anonymous visitor" "401" "$PRIVATE_STATUS" + +PRIVATE_ADMIN_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/private/admin") +check "rbac auth demo route rejects anonymous visitor" "401" "$PRIVATE_ADMIN_STATUS" + +PRIVATE_REVIEWER_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/private/reviewer") +check "resource any-rbac demo route rejects anonymous visitor" "401" "$PRIVATE_REVIEWER_STATUS" + +GOOGLE_START_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/auth/google/start") +check "configured auth provider route fails cleanly without local secrets" "400" "$GOOGLE_START_STATUS" + +OPEN_REDIRECT_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/auth/google/start?redirect_after=https://evil.test") +check "auth start rejects external redirect_after" "400" "$OPEN_REDIRECT_STATUS" + ECHO=$(curl -s -X POST "$BASE/echo" -d 'ping') check "echo route returns the request body" "ping" "$ECHO" diff --git a/examples/cloudflare-worker/wrangler.jsonc b/examples/cloudflare-worker/wrangler.jsonc index 52553ee..f43fca5 100644 --- a/examples/cloudflare-worker/wrangler.jsonc +++ b/examples/cloudflare-worker/wrangler.jsonc @@ -19,6 +19,12 @@ "migrations_dir": "migrations" } ], + "kv_namespaces": [ + { + "binding": "AUTH_KV", + "id": "00000000000000000000000000000000" + } + ], "r2_buckets": [ { "binding": "ASSETS", diff --git a/src/nebula/migration.rs b/src/nebula/migration.rs index 5c80f78..78c7126 100644 --- a/src/nebula/migration.rs +++ b/src/nebula/migration.rs @@ -1,5 +1,5 @@ use super::column::quote_ident; -use super::{ColumnDef, ForeignKeyDef, TableDef}; +use super::{ColumnDef, ForeignKeyDef, RlsPolicyKind, TableDef}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SchemaManifest { @@ -75,7 +75,8 @@ impl SchemaManifest { impl TableDef { pub fn lint(&self) -> Vec { - self.foreign_keys + let mut lints = self + .foreign_keys .iter() .flat_map(|foreign_key| foreign_key.columns.iter()) .filter(|column| !is_indexed_in_table(*self, column)) @@ -83,7 +84,26 @@ impl TableDef { table: self.name, column, }) - .collect() + .collect::>(); + + if self.rls.is_empty() { + lints.push(SchemaLint::MissingRls { table: self.name }); + } + + for policy in self.rls { + if matches!(policy.kind, RlsPolicyKind::Owner | RlsPolicyKind::Tenant) { + if let Some(column) = policy.column { + if !is_indexed_in_table(*self, column) { + lints.push(SchemaLint::UnindexedRlsColumn { + table: self.name, + column, + }); + } + } + } + } + + lints } } @@ -175,6 +195,7 @@ pub enum MigrationBlocker { AddForeignKey { table: String, columns: Vec }, DropForeignKey { table: String, columns: Vec }, ChangeForeignKey { table: String, columns: Vec }, + ChangeRls { table: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -183,6 +204,13 @@ pub enum SchemaLint { table: &'static str, column: &'static str, }, + MissingRls { + table: &'static str, + }, + UnindexedRlsColumn { + table: &'static str, + column: &'static str, + }, } pub(crate) fn is_indexed_in_table(table: TableDef, column: &str) -> bool { @@ -351,6 +379,11 @@ fn diff_table( diff_indexes(current, desired, statements, blockers); diff_foreign_keys(current, desired, blockers); + if current.rls != desired.rls { + blockers.push(MigrationBlocker::ChangeRls { + table: current.name.to_owned(), + }); + } } fn diff_indexes( diff --git a/src/nebula/mod.rs b/src/nebula/mod.rs index d670178..9856f8e 100644 --- a/src/nebula/mod.rs +++ b/src/nebula/mod.rs @@ -13,6 +13,18 @@ pub enum SqlType { Boolean, } +impl SqlType { + pub const fn name(self) -> &'static str { + match self { + SqlType::Integer => "INTEGER", + SqlType::Real => "REAL", + SqlType::Text => "TEXT", + SqlType::Blob => "BLOB", + SqlType::Boolean => "BOOLEAN", + } + } +} + impl SqlType { pub const fn as_sql(self) -> &'static str { match self { @@ -66,12 +78,90 @@ pub struct ForeignKeyDef { pub references_columns: &'static [&'static str], } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + feature = "nebula-schema", + derive(serde::Serialize, serde::Deserialize) +)] +pub enum RlsOperation { + Select, + Insert, + Update, + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + feature = "nebula-schema", + derive(serde::Serialize, serde::Deserialize) +)] +pub enum RlsPolicyKind { + Public, + Owner, + Tenant, + Rbac, + Custom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + feature = "nebula-schema", + derive(serde::Serialize, serde::Deserialize) +)] +pub enum RlsMatchMode { + All, + Any, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RlsAuthorizationDef { + pub mode: RlsMatchMode, + pub roles: &'static [&'static str], + pub permissions: &'static [&'static str], + pub scopes: &'static [&'static str], + pub resource: Option<&'static str>, +} + +impl RlsAuthorizationDef { + pub const fn empty() -> Self { + Self { + mode: RlsMatchMode::All, + roles: &[], + permissions: &[], + scopes: &[], + resource: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RlsPolicyDef { + pub operations: &'static [RlsOperation], + pub kind: RlsPolicyKind, + pub column: Option<&'static str>, + pub authorization: RlsAuthorizationDef, + pub custom: Option<&'static str>, +} + +impl RlsPolicyDef { + pub const fn public() -> Self { + Self { + operations: &[], + kind: RlsPolicyKind::Public, + column: None, + authorization: RlsAuthorizationDef::empty(), + custom: None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TableDef { pub name: &'static str, pub columns: &'static [ColumnDef], pub indexes: &'static [IndexDef], pub foreign_keys: &'static [ForeignKeyDef], + pub rls: &'static [RlsPolicyDef], } pub trait Entity { @@ -104,20 +194,104 @@ pub trait Entity { { Delete::new() } + + fn validate_custom_predicates_with( + predicates: &impl CustomPredicateProvider, + ) -> Result<(), RlsError> + where + Self: Sized, + { + rls::validate_custom_predicates(Self::TABLE, predicates) + } + + fn select_scoped(context: &AccessContext) -> Result, RlsError> + where + Self: Sized, + { + Self::select().apply_rls(context, &NoCustomPredicates) + } + + fn select_scoped_with( + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + Self: Sized, + { + Self::select().apply_rls(context, predicates) + } + + fn insert_scoped(context: &AccessContext) -> Result, RlsError> + where + Self: Sized, + { + Self::insert().apply_rls(context, &NoCustomPredicates) + } + + fn insert_scoped_with( + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + Self: Sized, + { + Self::insert().apply_rls(context, predicates) + } + + fn update_scoped(context: &AccessContext) -> Result, RlsError> + where + Self: Sized, + { + Self::update().apply_rls(context, &NoCustomPredicates) + } + + fn update_scoped_with( + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + Self: Sized, + { + Self::update().apply_rls(context, predicates) + } + + fn delete_scoped(context: &AccessContext) -> Result, RlsError> + where + Self: Sized, + { + Self::delete().apply_rls(context, &NoCustomPredicates) + } + + fn delete_scoped_with( + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + Self: Sized, + { + Self::delete().apply_rls(context, predicates) + } } mod column; mod migration; mod query; mod relationships; +mod rls; mod value; pub use column::{Column, ColumnRef, Direction, Expr, Ordering}; pub use migration::{ MigrationBlocker, MigrationPlan, MigrationWriteError, SchemaLint, SchemaManifest, }; -pub use query::{Delete, Insert, QueryLint, Select, Statement, Update}; +pub use query::{ + Delete, Insert, QueryCheckError, QueryLint, QueryLintSeverity, Select, Statement, Update, +}; pub use relationships::{BelongsTo, HasMany, belongs_to, has_many}; +pub use rls::{ + AccessContext, CustomPredicateProvider, CustomPredicateRegistration, NoCustomPredicates, + RlsError, +}; pub use value::Value; #[cfg(feature = "nebula-d1")] diff --git a/src/nebula/query.rs b/src/nebula/query.rs index 4b1d0bb..8a1d5f6 100644 --- a/src/nebula/query.rs +++ b/src/nebula/query.rs @@ -3,7 +3,12 @@ use std::marker::PhantomData; use super::Entity; use super::column::{Column, ColumnRef, Expr, Ordering, format_ordering, quote_ident}; use super::migration::is_indexed_in_table; +use super::rls::{ + AccessContext, CustomPredicateProvider, RlsError, authorize_policy, policy_applies, + require_rls_operation_coverage, table_has_protected_rls, validate_policy_value_type, +}; use super::value::Value; +use super::{RlsOperation, RlsPolicyKind}; #[derive(Debug, Clone, PartialEq)] pub struct Statement { @@ -11,15 +16,53 @@ pub struct Statement { pub binds: Vec, } +impl Statement { + /// Builds a raw statement that bypasses Nebula's query builders and RLS. + /// + /// Use this only when the caller applies equivalent authorization checks + /// manually or when the statement intentionally targets public data. + pub fn raw_unscoped(sql: impl Into, binds: impl IntoIterator) -> Self { + Self { + sql: sql.into(), + binds: binds.into_iter().collect(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueryLint { MissingLimit, UnindexedFilter { column: ColumnRef }, UnindexedOrdering { column: ColumnRef }, + UnscopedRls { table: &'static str }, BroadUpdate, BroadDelete, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryLintSeverity { + Warning, + Error, +} + +impl QueryLint { + pub const fn severity(self) -> QueryLintSeverity { + match self { + QueryLint::UnscopedRls { .. } | QueryLint::BroadUpdate | QueryLint::BroadDelete => { + QueryLintSeverity::Error + } + QueryLint::MissingLimit + | QueryLint::UnindexedFilter { .. } + | QueryLint::UnindexedOrdering { .. } => QueryLintSeverity::Warning, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryCheckError { + pub lints: Vec, +} + #[derive(Debug, Clone)] pub struct Select { columns: Vec<&'static str>, @@ -29,6 +72,8 @@ pub struct Select { offset: Option, allow_full_table_scan: bool, allow_unbounded_select: bool, + rls_applied: bool, + unscoped_rls_reason: Option<&'static str>, _entity: PhantomData, } @@ -42,6 +87,8 @@ impl Select { offset: None, allow_full_table_scan: false, allow_unbounded_select: false, + rls_applied: false, + unscoped_rls_reason: None, _entity: PhantomData, } } @@ -52,7 +99,10 @@ impl Select { } pub fn where_(mut self, filter: Expr) -> Self { - self.filter = Some(filter); + self.filter = Some(match self.filter { + Some(current) => current.and(filter), + None => filter, + }); self } @@ -89,6 +139,34 @@ impl Select { self } + pub fn allow_unscoped_rls(mut self, reason: &'static str) -> Self { + self.unscoped_rls_reason = Some(reason); + self + } + + pub fn apply_rls( + mut self, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result { + require_rls_operation_coverage(E::TABLE, RlsOperation::Select)?; + for policy in E::TABLE + .rls + .iter() + .filter(|policy| policy_applies(policy, RlsOperation::Select)) + { + authorize_policy(E::TABLE.name, policy, context)?; + if let Some(predicate) = + predicate_for_policy::(policy, RlsOperation::Select, context, predicates)? + { + self = self.and_where(predicate); + } + } + + self.rls_applied = true; + Ok(self) + } + pub fn lint(&self) -> Vec { let mut lints = Vec::new(); @@ -96,6 +174,8 @@ impl Select { lints.push(QueryLint::MissingLimit); } + push_unscoped_rls_lint::(&mut lints, self.rls_applied, self.unscoped_rls_reason); + if !self.allow_full_table_scan { if let Some(filter) = &self.filter { push_unindexed_filter_lints::(&mut lints, &filter.columns); @@ -154,6 +234,18 @@ impl Select { Statement { sql, binds } } + + pub fn to_statement_checked(self) -> Result { + let lints = self.lint(); + if lints + .iter() + .all(|lint| lint.severity() == QueryLintSeverity::Warning) + { + Ok(self.to_statement()) + } else { + Err(QueryCheckError { lints }) + } + } } #[derive(Debug, Clone)] @@ -161,6 +253,8 @@ pub struct Insert { columns: Vec<&'static str>, values: Vec, returning: Vec<&'static str>, + rls_applied: bool, + unscoped_rls_reason: Option<&'static str>, _entity: PhantomData, } @@ -170,6 +264,8 @@ impl Insert { columns: Vec::new(), values: Vec::new(), returning: Vec::new(), + rls_applied: false, + unscoped_rls_reason: None, _entity: PhantomData, } } @@ -188,6 +284,80 @@ impl Insert { self } + pub fn allow_unscoped_rls(mut self, reason: &'static str) -> Self { + self.unscoped_rls_reason = Some(reason); + self + } + + pub fn apply_rls( + mut self, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result { + require_rls_operation_coverage(E::TABLE, RlsOperation::Insert)?; + for policy in E::TABLE + .rls + .iter() + .filter(|policy| policy_applies(policy, RlsOperation::Insert)) + { + authorize_policy(E::TABLE.name, policy, context)?; + match policy.kind { + RlsPolicyKind::Owner => { + let user_id = context.user_id.clone().ok_or(RlsError::MissingUser { + table: E::TABLE.name, + })?; + self = self.set_or_validate_policy_column(policy.column, user_id)?; + } + RlsPolicyKind::Tenant => { + let tenant_id = context.tenant_id.clone().ok_or(RlsError::MissingTenant { + table: E::TABLE.name, + })?; + self = self.set_or_validate_policy_column(policy.column, tenant_id)?; + } + RlsPolicyKind::Custom => { + if let Some(name) = policy.custom { + predicates.predicate(E::TABLE.name, name, RlsOperation::Insert, context)?; + } + } + RlsPolicyKind::Public | RlsPolicyKind::Rbac => {} + } + } + + self.rls_applied = true; + Ok(self) + } + + fn set_or_validate_policy_column( + mut self, + column: Option<&'static str>, + value: Value, + ) -> Result { + let Some(column) = column else { + return Ok(self); + }; + validate_policy_value_type(E::TABLE, column, &value)?; + + if let Some(position) = self.columns.iter().position(|existing| *existing == column) { + if self.values[position] == value { + Ok(self) + } else { + Err(RlsError::Forbidden { + table: E::TABLE.name, + }) + } + } else { + self.columns.push(column); + self.values.push(value); + Ok(self) + } + } + + pub fn lint(&self) -> Vec { + let mut lints = Vec::new(); + push_unscoped_rls_lint::(&mut lints, self.rls_applied, self.unscoped_rls_reason); + lints + } + pub fn to_statement(self) -> Statement { let columns = self .columns @@ -207,6 +377,18 @@ impl Insert { binds: self.values, } } + + pub fn to_statement_checked(self) -> Result { + let lints = self.lint(); + if lints + .iter() + .all(|lint| lint.severity() == QueryLintSeverity::Warning) + { + Ok(self.to_statement()) + } else { + Err(QueryCheckError { lints }) + } + } } #[derive(Debug, Clone)] @@ -216,6 +398,8 @@ pub struct Update { returning: Vec<&'static str>, allow_full_table_scan: bool, allow_broad_write: bool, + rls_applied: bool, + unscoped_rls_reason: Option<&'static str>, _entity: PhantomData, } @@ -227,6 +411,8 @@ impl Update { returning: Vec::new(), allow_full_table_scan: false, allow_broad_write: false, + rls_applied: false, + unscoped_rls_reason: None, _entity: PhantomData, } } @@ -240,7 +426,18 @@ impl Update { } pub fn where_(mut self, filter: Expr) -> Self { - self.filter = Some(filter); + self.filter = Some(match self.filter { + Some(current) => current.and(filter), + None => filter, + }); + self + } + + pub fn and_where(mut self, filter: Expr) -> Self { + self.filter = Some(match self.filter { + Some(current) => current.and(filter), + None => filter, + }); self } @@ -259,9 +456,52 @@ impl Update { self } + pub fn allow_unscoped_rls(mut self, reason: &'static str) -> Self { + self.unscoped_rls_reason = Some(reason); + self + } + + pub fn apply_rls( + mut self, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result { + require_rls_operation_coverage(E::TABLE, RlsOperation::Update)?; + for policy in E::TABLE + .rls + .iter() + .filter(|policy| policy_applies(policy, RlsOperation::Update)) + { + authorize_policy(E::TABLE.name, policy, context)?; + if matches!(policy.kind, RlsPolicyKind::Owner | RlsPolicyKind::Tenant) { + if let Some(column) = policy.column { + if self + .assignments + .iter() + .any(|(assigned, _)| *assigned == column) + { + return Err(RlsError::Forbidden { + table: E::TABLE.name, + }); + } + } + } + if let Some(predicate) = + predicate_for_policy::(policy, RlsOperation::Update, context, predicates)? + { + self = self.and_where(predicate); + } + } + + self.rls_applied = true; + Ok(self) + } + pub fn lint(&self) -> Vec { let mut lints = Vec::new(); + push_unscoped_rls_lint::(&mut lints, self.rls_applied, self.unscoped_rls_reason); + match &self.filter { Some(filter) if !self.allow_full_table_scan => { push_unindexed_filter_lints::(&mut lints, &filter.columns); @@ -297,6 +537,18 @@ impl Update { Statement { sql, binds } } + + pub fn to_statement_checked(self) -> Result { + let lints = self.lint(); + if lints + .iter() + .all(|lint| lint.severity() == QueryLintSeverity::Warning) + { + Ok(self.to_statement()) + } else { + Err(QueryCheckError { lints }) + } + } } #[derive(Debug, Clone)] @@ -304,6 +556,8 @@ pub struct Delete { filter: Option, allow_full_table_scan: bool, allow_broad_write: bool, + rls_applied: bool, + unscoped_rls_reason: Option<&'static str>, _entity: PhantomData, } @@ -313,12 +567,25 @@ impl Delete { filter: None, allow_full_table_scan: false, allow_broad_write: false, + rls_applied: false, + unscoped_rls_reason: None, _entity: PhantomData, } } pub fn where_(mut self, filter: Expr) -> Self { - self.filter = Some(filter); + self.filter = Some(match self.filter { + Some(current) => current.and(filter), + None => filter, + }); + self + } + + pub fn and_where(mut self, filter: Expr) -> Self { + self.filter = Some(match self.filter { + Some(current) => current.and(filter), + None => filter, + }); self } @@ -332,9 +599,39 @@ impl Delete { self } + pub fn allow_unscoped_rls(mut self, reason: &'static str) -> Self { + self.unscoped_rls_reason = Some(reason); + self + } + + pub fn apply_rls( + mut self, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result { + require_rls_operation_coverage(E::TABLE, RlsOperation::Delete)?; + for policy in E::TABLE + .rls + .iter() + .filter(|policy| policy_applies(policy, RlsOperation::Delete)) + { + authorize_policy(E::TABLE.name, policy, context)?; + if let Some(predicate) = + predicate_for_policy::(policy, RlsOperation::Delete, context, predicates)? + { + self = self.and_where(predicate); + } + } + + self.rls_applied = true; + Ok(self) + } + pub fn lint(&self) -> Vec { let mut lints = Vec::new(); + push_unscoped_rls_lint::(&mut lints, self.rls_applied, self.unscoped_rls_reason); + match &self.filter { Some(filter) if !self.allow_full_table_scan => { push_unindexed_filter_lints::(&mut lints, &filter.columns); @@ -358,6 +655,18 @@ impl Delete { Statement { sql, binds } } + + pub fn to_statement_checked(self) -> Result { + let lints = self.lint(); + if lints + .iter() + .all(|lint| lint.severity() == QueryLintSeverity::Warning) + { + Ok(self.to_statement()) + } else { + Err(QueryCheckError { lints }) + } + } } fn append_returning(mut sql: String, returning: Vec<&'static str>) -> String { @@ -388,6 +697,18 @@ fn push_unindexed_ordering_lint(lints: &mut Vec, column: C } } +fn push_unscoped_rls_lint( + lints: &mut Vec, + rls_applied: bool, + unscoped_rls_reason: Option<&'static str>, +) { + if table_has_protected_rls(E::TABLE) && !rls_applied && unscoped_rls_reason.is_none() { + lints.push(QueryLint::UnscopedRls { + table: E::TABLE.name, + }); + } +} + fn push_unique_lint(lints: &mut Vec, lint: QueryLint) { if !lints.contains(&lint) { lints.push(lint); @@ -401,3 +722,66 @@ fn is_indexed(column: ColumnRef) -> bool { is_indexed_in_table(E::TABLE, column.name) } + +fn predicate_for_policy( + policy: &super::RlsPolicyDef, + operation: RlsOperation, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, +) -> Result, RlsError> { + match policy.kind { + RlsPolicyKind::Owner => { + let user_id = context.user_id.clone().ok_or(RlsError::MissingUser { + table: E::TABLE.name, + })?; + if let Some(column) = policy.column { + validate_policy_value_type(E::TABLE, column, &user_id)?; + Ok(Some(policy_column_predicate( + E::TABLE.name, + column, + user_id, + ))) + } else { + Ok(None) + } + } + RlsPolicyKind::Tenant => { + let tenant_id = context.tenant_id.clone().ok_or(RlsError::MissingTenant { + table: E::TABLE.name, + })?; + if let Some(column) = policy.column { + validate_policy_value_type(E::TABLE, column, &tenant_id)?; + Ok(Some(policy_column_predicate( + E::TABLE.name, + column, + tenant_id, + ))) + } else { + Ok(None) + } + } + RlsPolicyKind::Custom => { + let Some(name) = policy.custom else { + return Ok(None); + }; + Ok(Some(predicates.predicate( + E::TABLE.name, + name, + operation, + context, + )?)) + } + RlsPolicyKind::Public | RlsPolicyKind::Rbac => Ok(None), + } +} + +fn policy_column_predicate(table: &'static str, column: &'static str, value: Value) -> Expr { + Expr { + sql: format!("{} = ?", super::column::qualified_column(table, column)), + binds: vec![value], + columns: vec![ColumnRef { + table, + name: column, + }], + } +} diff --git a/src/nebula/relationships.rs b/src/nebula/relationships.rs index eb3b6c8..1e4bddd 100644 --- a/src/nebula/relationships.rs +++ b/src/nebula/relationships.rs @@ -3,6 +3,7 @@ use std::marker::PhantomData; use super::Entity; use super::column::Column; use super::query::Select; +use super::rls::{AccessContext, CustomPredicateProvider, NoCustomPredicates, RlsError}; use super::value::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,6 +45,30 @@ where .where_(self.foreign_column().eq(local_value)) .limit(1) } + + pub fn select_parent_scoped( + &self, + local_value: V, + context: &AccessContext, + ) -> Result, RlsError> + where + V: Into, + { + self.select_parent_scoped_with(local_value, context, &NoCustomPredicates) + } + + pub fn select_parent_scoped_with( + &self, + local_value: V, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + V: Into, + { + self.select_parent(local_value) + .apply_rls(context, predicates) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -83,6 +108,30 @@ where { Child::select().where_(self.child_column().eq(parent_value)) } + + pub fn select_children_scoped( + &self, + parent_value: V, + context: &AccessContext, + ) -> Result, RlsError> + where + V: Into, + { + self.select_children_scoped_with(parent_value, context, &NoCustomPredicates) + } + + pub fn select_children_scoped_with( + &self, + parent_value: V, + context: &AccessContext, + predicates: &impl CustomPredicateProvider, + ) -> Result, RlsError> + where + V: Into, + { + self.select_children(parent_value) + .apply_rls(context, predicates) + } } pub const fn belongs_to( diff --git a/src/nebula/rls.rs b/src/nebula/rls.rs new file mode 100644 index 0000000..fe94bc1 --- /dev/null +++ b/src/nebula/rls.rs @@ -0,0 +1,274 @@ +use super::{ + Expr, RlsAuthorizationDef, RlsMatchMode, RlsOperation, RlsPolicyDef, RlsPolicyKind, SqlType, + TableDef, Value, +}; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AccessContext { + pub user_id: Option, + pub tenant_id: Option, + pub roles: Vec, + pub permissions: Vec, + pub scopes: Vec, + pub resource: Option, +} + +impl AccessContext { + pub fn authenticated(user_id: impl Into) -> Self { + Self::authenticated_value(user_id.into()) + } + + pub fn authenticated_value(user_id: impl Into) -> Self { + Self { + user_id: Some(user_id.into()), + ..Self::default() + } + } + + pub fn with_tenant(mut self, tenant_id: impl Into) -> Self { + self.tenant_id = Some(Value::Text(tenant_id.into())); + self + } + + pub fn with_user_value(mut self, user_id: impl Into) -> Self { + self.user_id = Some(user_id.into()); + self + } + + pub fn with_tenant_value(mut self, tenant_id: impl Into) -> Self { + self.tenant_id = Some(tenant_id.into()); + self + } + + pub fn with_roles(mut self, roles: impl IntoIterator>) -> Self { + self.roles = roles.into_iter().map(Into::into).collect(); + self + } + + pub fn with_permissions( + mut self, + permissions: impl IntoIterator>, + ) -> Self { + self.permissions = permissions.into_iter().map(Into::into).collect(); + self + } + + pub fn with_scopes(mut self, scopes: impl IntoIterator>) -> Self { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + pub fn with_resource(mut self, resource: impl Into) -> Self { + self.resource = Some(resource.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RlsError { + MissingUser { + table: &'static str, + }, + MissingTenant { + table: &'static str, + }, + Forbidden { + table: &'static str, + }, + TypeMismatch { + table: &'static str, + column: &'static str, + expected: SqlType, + }, + MissingCustomPredicate { + table: &'static str, + name: &'static str, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CustomPredicateRegistration { + pub name: &'static str, + pub operations: &'static [RlsOperation], +} + +pub trait CustomPredicateProvider { + fn predicate( + &self, + table: &'static str, + name: &'static str, + operation: RlsOperation, + context: &AccessContext, + ) -> Result; + + fn registered_predicates(&self) -> &'static [&'static str] { + &[] + } + + fn registered_predicate_rules(&self) -> &'static [CustomPredicateRegistration] { + &[] + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct NoCustomPredicates; + +impl CustomPredicateProvider for NoCustomPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: RlsOperation, + _context: &AccessContext, + ) -> Result { + Err(RlsError::MissingCustomPredicate { table, name }) + } +} + +pub(crate) fn validate_policy_value_type( + table: TableDef, + column: &'static str, + value: &Value, +) -> Result<(), RlsError> { + let Some(column_def) = table + .columns + .iter() + .find(|candidate| candidate.name == column) + else { + return Ok(()); + }; + + let matches = matches!( + (column_def.sql_type, value), + (SqlType::Integer, Value::Integer(_)) + | (SqlType::Real, Value::Real(_)) + | (SqlType::Text, Value::Text(_)) + | (SqlType::Blob, Value::Blob(_)) + | (SqlType::Boolean, Value::Bool(_)) + | (SqlType::Boolean, Value::Integer(0 | 1)) + | (_, Value::Null) + ); + + if matches { + Ok(()) + } else { + Err(RlsError::TypeMismatch { + table: table.name, + column, + expected: column_def.sql_type, + }) + } +} + +pub(crate) fn policy_applies(policy: &RlsPolicyDef, operation: RlsOperation) -> bool { + policy.operations.is_empty() || policy.operations.contains(&operation) +} + +pub(crate) fn table_has_protected_rls(table: TableDef) -> bool { + table + .rls + .iter() + .any(|policy| policy.kind != RlsPolicyKind::Public) +} + +pub(crate) fn require_rls_operation_coverage( + table: TableDef, + operation: RlsOperation, +) -> Result<(), RlsError> { + if table_has_protected_rls(table) + && !table + .rls + .iter() + .any(|policy| policy_applies(policy, operation)) + { + return Err(RlsError::Forbidden { table: table.name }); + } + + Ok(()) +} + +pub(crate) fn validate_custom_predicates( + table: TableDef, + predicates: &impl CustomPredicateProvider, +) -> Result<(), RlsError> { + let registered = predicates.registered_predicates(); + let registered_rules = predicates.registered_predicate_rules(); + + for policy in table + .rls + .iter() + .filter(|policy| policy.kind == RlsPolicyKind::Custom) + { + let Some(name) = policy.custom else { + continue; + }; + let registered_for_operation = policy.operations.iter().all(|operation| { + registered_rules.iter().any(|rule| { + rule.name == name + && (rule.operations.is_empty() || rule.operations.contains(operation)) + }) || registered.contains(&name) + }); + let registered_for_all_operations = policy.operations.is_empty() + && (registered.contains(&name) + || registered_rules + .iter() + .any(|rule| rule.name == name && rule.operations.is_empty())); + + if !(registered_for_operation || registered_for_all_operations) { + return Err(RlsError::MissingCustomPredicate { + table: table.name, + name, + }); + } + } + + Ok(()) +} + +pub(crate) fn authorize_policy( + table: &'static str, + policy: &RlsPolicyDef, + context: &AccessContext, +) -> Result<(), RlsError> { + match policy.kind { + RlsPolicyKind::Rbac => authorize_rbac(table, &policy.authorization, context), + _ => Ok(()), + } +} + +fn authorize_rbac( + table: &'static str, + authorization: &RlsAuthorizationDef, + context: &AccessContext, +) -> Result<(), RlsError> { + if let Some(resource) = authorization.resource { + if context.resource.as_deref() != Some(resource) { + return Err(RlsError::Forbidden { table }); + } + } + + let role_matches = authorization + .roles + .iter() + .map(|role| context.roles.iter().any(|claim| claim == role)); + let permission_matches = authorization + .permissions + .iter() + .map(|permission| context.permissions.iter().any(|claim| claim == permission)); + let scope_matches = authorization + .scopes + .iter() + .map(|scope| context.scopes.iter().any(|claim| claim == scope)); + let mut matches = role_matches.chain(permission_matches).chain(scope_matches); + + let authorized = match authorization.mode { + RlsMatchMode::All => matches.all(|matched| matched), + RlsMatchMode::Any => matches.any(|matched| matched), + }; + + if authorized { + Ok(()) + } else { + Err(RlsError::Forbidden { table }) + } +} diff --git a/src/nebula/schema.rs b/src/nebula/schema.rs index b39fdee..ea70f53 100644 --- a/src/nebula/schema.rs +++ b/src/nebula/schema.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; -use super::{ColumnDef, ForeignKeyDef, IndexDef, SchemaManifest, SqlType, TableDef}; +use super::{ + ColumnDef, ForeignKeyDef, IndexDef, RlsAuthorizationDef, RlsMatchMode, RlsOperation, + RlsPolicyDef, RlsPolicyKind, SchemaManifest, SqlType, TableDef, +}; fn leak_str(value: String) -> &'static str { Box::leak(value.into_boxed_str()) @@ -126,6 +129,8 @@ pub struct OwnedTableDef { pub columns: Vec, pub indexes: Vec, pub foreign_keys: Vec, + #[serde(default)] + pub rls: Vec, } impl From<&TableDef> for OwnedTableDef { @@ -139,6 +144,7 @@ impl From<&TableDef> for OwnedTableDef { .iter() .map(OwnedForeignKeyDef::from) .collect(), + rls: table.rls.iter().map(OwnedRlsPolicyDef::from).collect(), } } } @@ -155,6 +161,85 @@ impl OwnedTableDef { .map(OwnedForeignKeyDef::leak) .collect(), ), + rls: leak_slice(self.rls.into_iter().map(OwnedRlsPolicyDef::leak).collect()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnedRlsAuthorizationDef { + pub mode: RlsMatchMode, + pub roles: Vec, + pub permissions: Vec, + pub scopes: Vec, + pub resource: Option, +} + +impl From<&RlsAuthorizationDef> for OwnedRlsAuthorizationDef { + fn from(authorization: &RlsAuthorizationDef) -> Self { + Self { + mode: authorization.mode, + roles: authorization + .roles + .iter() + .map(|role| (*role).to_owned()) + .collect(), + permissions: authorization + .permissions + .iter() + .map(|permission| (*permission).to_owned()) + .collect(), + scopes: authorization + .scopes + .iter() + .map(|scope| (*scope).to_owned()) + .collect(), + resource: authorization.resource.map(str::to_owned), + } + } +} + +impl OwnedRlsAuthorizationDef { + fn leak(self) -> RlsAuthorizationDef { + RlsAuthorizationDef { + mode: self.mode, + roles: leak_slice(self.roles.into_iter().map(leak_str).collect()), + permissions: leak_slice(self.permissions.into_iter().map(leak_str).collect()), + scopes: leak_slice(self.scopes.into_iter().map(leak_str).collect()), + resource: self.resource.map(leak_str), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnedRlsPolicyDef { + pub operations: Vec, + pub kind: RlsPolicyKind, + pub column: Option, + pub authorization: OwnedRlsAuthorizationDef, + pub custom: Option, +} + +impl From<&RlsPolicyDef> for OwnedRlsPolicyDef { + fn from(policy: &RlsPolicyDef) -> Self { + Self { + operations: policy.operations.to_vec(), + kind: policy.kind, + column: policy.column.map(str::to_owned), + authorization: OwnedRlsAuthorizationDef::from(&policy.authorization), + custom: policy.custom.map(str::to_owned), + } + } +} + +impl OwnedRlsPolicyDef { + fn leak(self) -> RlsPolicyDef { + RlsPolicyDef { + operations: leak_slice(self.operations), + kind: self.kind, + column: self.column.map(leak_str), + authorization: self.authorization.leak(), + custom: self.custom.map(leak_str), } } } @@ -215,6 +300,7 @@ mod tests { columns: COLUMNS, indexes: &[], foreign_keys: FOREIGN_KEYS, + rls: &[RlsPolicyDef::public()], }; #[test] @@ -268,6 +354,7 @@ mod tests { columns: DESIRED_COLUMNS, indexes: &[], foreign_keys: FOREIGN_KEYS, + rls: &[RlsPolicyDef::public()], }; let current = SchemaSnapshot::from_manifest(&SchemaManifest::new([TABLE])); diff --git a/src/nebula/tests.rs b/src/nebula/tests.rs index 8bdb531..0aa7f96 100644 --- a/src/nebula/tests.rs +++ b/src/nebula/tests.rs @@ -1,6 +1,8 @@ use super::{ - BelongsTo, Column, ColumnDef, ColumnRef, Entity, Expr, ForeignKeyDef, HasMany, IndexDef, - MigrationBlocker, MigrationPlan, MigrationWriteError, QueryLint, SchemaLint, SchemaManifest, + AccessContext, BelongsTo, Column, ColumnDef, ColumnRef, CustomPredicateProvider, + CustomPredicateRegistration, Entity, Expr, ForeignKeyDef, HasMany, IndexDef, MigrationBlocker, + MigrationPlan, MigrationWriteError, QueryLint, QueryLintSeverity, RlsAuthorizationDef, + RlsError, RlsMatchMode, RlsOperation, RlsPolicyDef, RlsPolicyKind, SchemaLint, SchemaManifest, SqlType, TableDef, Value, belongs_to, has_many, }; @@ -35,12 +37,15 @@ const TASK_INDEXES: &[IndexDef] = &[IndexDef { unique: false, }]; +const PUBLIC_RLS: &[RlsPolicyDef] = &[RlsPolicyDef::public()]; + impl Entity for Task { const TABLE: TableDef = TableDef { name: "tasks", columns: TASK_COLUMNS, indexes: TASK_INDEXES, foreign_keys: &[], + rls: PUBLIC_RLS, }; } @@ -62,6 +67,7 @@ impl Entity for Board { columns: BOARD_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }; } @@ -69,6 +75,306 @@ impl Task { const BOARD: BelongsTo = belongs_to(Self::ID, Board::ID); } +#[derive(Debug)] +struct SecureDoc; + +impl SecureDoc { + const ID: Column = Column::new("secure_docs", "id"); + const USER_ID: Column = Column::new("secure_docs", "user_id"); + const STATUS: Column = Column::new("secure_docs", "status"); +} + +const SECURE_DOC_COLUMNS: &[ColumnDef] = &[ + ColumnDef { + name: "id", + sql_type: SqlType::Integer, + nullable: false, + primary_key: true, + auto_increment: true, + unique: true, + indexed: true, + default_sql: None, + }, + ColumnDef { + name: "user_id", + sql_type: SqlType::Text, + nullable: false, + primary_key: false, + auto_increment: false, + unique: false, + indexed: true, + default_sql: None, + }, + ColumnDef::new("status", SqlType::Text), +]; + +const SECURE_DOC_RLS: &[RlsPolicyDef] = &[ + RlsPolicyDef { + operations: &[], + kind: RlsPolicyKind::Owner, + column: Some("user_id"), + authorization: RlsAuthorizationDef::empty(), + custom: None, + }, + RlsPolicyDef { + operations: &[RlsOperation::Update], + kind: RlsPolicyKind::Rbac, + column: None, + authorization: RlsAuthorizationDef { + mode: RlsMatchMode::Any, + roles: &["admin"], + permissions: &["docs:write"], + scopes: &[], + resource: None, + }, + custom: None, + }, + RlsPolicyDef { + operations: &[RlsOperation::Delete], + kind: RlsPolicyKind::Custom, + column: None, + authorization: RlsAuthorizationDef::empty(), + custom: Some("can_delete_doc"), + }, +]; + +impl Entity for SecureDoc { + const TABLE: TableDef = TableDef { + name: "secure_docs", + columns: SECURE_DOC_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: SECURE_DOC_RLS, + }; +} + +#[derive(Debug)] +struct UpdateOnlyDoc; + +impl UpdateOnlyDoc { + const STATUS: Column = Column::new("update_only_docs", "status"); +} + +const UPDATE_ONLY_DOC_COLUMNS: &[ColumnDef] = &[ColumnDef::new("status", SqlType::Text)]; + +const UPDATE_ONLY_DOC_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[RlsOperation::Update], + kind: RlsPolicyKind::Rbac, + column: None, + authorization: RlsAuthorizationDef { + mode: RlsMatchMode::All, + roles: &[], + permissions: &["docs:update"], + scopes: &[], + resource: None, + }, + custom: None, +}]; + +impl Entity for UpdateOnlyDoc { + const TABLE: TableDef = TableDef { + name: "update_only_docs", + columns: UPDATE_ONLY_DOC_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: UPDATE_ONLY_DOC_RLS, + }; +} + +#[derive(Debug)] +struct ResourceDoc; + +impl ResourceDoc { + const STATUS: Column = Column::new("resource_docs", "status"); +} + +const RESOURCE_DOC_COLUMNS: &[ColumnDef] = &[ColumnDef::new("status", SqlType::Text)]; + +const RESOURCE_DOC_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[RlsOperation::Update], + kind: RlsPolicyKind::Rbac, + column: None, + authorization: RlsAuthorizationDef { + mode: RlsMatchMode::All, + roles: &[], + permissions: &["docs:update"], + scopes: &[], + resource: Some("doc:7"), + }, + custom: None, +}]; + +impl Entity for ResourceDoc { + const TABLE: TableDef = TableDef { + name: "resource_docs", + columns: RESOURCE_DOC_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: RESOURCE_DOC_RLS, + }; +} + +#[derive(Debug)] +struct TenantDoc; + +impl TenantDoc { + const ID: Column = Column::new("tenant_docs", "id"); + const ORG_ID: Column = Column::new("tenant_docs", "org_id"); +} + +#[derive(Debug)] +struct BoolDoc; + +impl BoolDoc { + const ID: Column = Column::new("bool_docs", "id"); + const ACTIVE: Column = Column::new("bool_docs", "active"); +} + +const BOOL_DOC_COLUMNS: &[ColumnDef] = &[ + ColumnDef { + name: "id", + sql_type: SqlType::Integer, + nullable: false, + primary_key: true, + auto_increment: true, + unique: true, + indexed: true, + default_sql: None, + }, + ColumnDef { + name: "active", + sql_type: SqlType::Boolean, + nullable: false, + primary_key: false, + auto_increment: false, + unique: false, + indexed: true, + default_sql: None, + }, +]; + +const BOOL_DOC_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[], + kind: RlsPolicyKind::Tenant, + column: Some("active"), + authorization: RlsAuthorizationDef::empty(), + custom: None, +}]; + +impl Entity for BoolDoc { + const TABLE: TableDef = TableDef { + name: "bool_docs", + columns: BOOL_DOC_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: BOOL_DOC_RLS, + }; +} + +const TENANT_DOC_COLUMNS: &[ColumnDef] = &[ + ColumnDef { + name: "id", + sql_type: SqlType::Integer, + nullable: false, + primary_key: true, + auto_increment: true, + unique: true, + indexed: true, + default_sql: None, + }, + ColumnDef { + name: "org_id", + sql_type: SqlType::Integer, + nullable: false, + primary_key: false, + auto_increment: false, + unique: false, + indexed: true, + default_sql: None, + }, +]; + +const TENANT_DOC_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[], + kind: RlsPolicyKind::Tenant, + column: Some("org_id"), + authorization: RlsAuthorizationDef::empty(), + custom: None, +}]; + +impl Entity for TenantDoc { + const TABLE: TableDef = TableDef { + name: "tenant_docs", + columns: TENANT_DOC_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: TENANT_DOC_RLS, + }; +} + +struct TestPredicates; + +impl CustomPredicateProvider for TestPredicates { + fn predicate( + &self, + _table: &'static str, + name: &'static str, + _operation: RlsOperation, + _context: &AccessContext, + ) -> Result { + if name == "can_delete_doc" { + Ok(SecureDoc::STATUS.eq("archived")) + } else { + Err(RlsError::MissingCustomPredicate { + table: "secure_docs", + name, + }) + } + } + + fn registered_predicate_rules(&self) -> &'static [CustomPredicateRegistration] { + &[CustomPredicateRegistration { + name: "can_delete_doc", + operations: &[RlsOperation::Delete], + }] + } +} + +struct EmptyPredicates; + +impl CustomPredicateProvider for EmptyPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: RlsOperation, + _context: &AccessContext, + ) -> Result { + Err(RlsError::MissingCustomPredicate { table, name }) + } +} + +struct WrongOperationPredicates; + +impl CustomPredicateProvider for WrongOperationPredicates { + fn predicate( + &self, + table: &'static str, + name: &'static str, + _operation: RlsOperation, + _context: &AccessContext, + ) -> Result { + Err(RlsError::MissingCustomPredicate { table, name }) + } + + fn registered_predicate_rules(&self) -> &'static [CustomPredicateRegistration] { + &[CustomPredicateRegistration { + name: "can_delete_doc", + operations: &[RlsOperation::Update], + }] + } +} + #[test] fn select_statement_is_deterministic() { let statement = Task::select() @@ -89,6 +395,377 @@ fn select_statement_is_deterministic() { ); } +#[test] +fn raw_unscoped_statement_is_explicit() { + let statement = super::Statement::raw_unscoped( + "SELECT count(*) FROM secure_docs WHERE user_id = ?", + [Value::Text("user_1".into())], + ); + + assert_eq!( + statement.sql, + "SELECT count(*) FROM secure_docs WHERE user_id = ?" + ); + assert_eq!(statement.binds, vec![Value::Text("user_1".into())]); +} + +#[test] +fn scoped_select_applies_owner_predicate() { + let context = AccessContext::authenticated("user_1"); + let statement = SecureDoc::select_scoped(&context) + .unwrap() + .where_(SecureDoc::ID.eq(7)) + .order_by(SecureDoc::USER_ID.asc()) + .limit(1) + .to_statement(); + + assert_eq!( + statement.sql, + "SELECT \"id\", \"user_id\", \"status\" FROM \"secure_docs\" \ + WHERE (\"secure_docs\".\"user_id\" = ?) AND (\"secure_docs\".\"id\" = ?) \ + ORDER BY \"secure_docs\".\"user_id\" ASC LIMIT ?" + ); + assert_eq!( + statement.binds, + vec![ + Value::Text("user_1".into()), + Value::Integer(7), + Value::Integer(1) + ] + ); +} + +#[test] +fn query_lints_flag_unscoped_rls_for_protected_tables() { + assert_eq!( + SecureDoc::select().limit(10).lint(), + vec![QueryLint::UnscopedRls { + table: "secure_docs" + }] + ); + assert_eq!( + SecureDoc::update().set(SecureDoc::STATUS, "draft").lint(), + vec![ + QueryLint::UnscopedRls { + table: "secure_docs" + }, + QueryLint::BroadUpdate, + ] + ); + assert_eq!( + SecureDoc::insert().set(SecureDoc::STATUS, "draft").lint(), + vec![QueryLint::UnscopedRls { + table: "secure_docs" + }] + ); + assert_eq!( + SecureDoc::delete().lint(), + vec![ + QueryLint::UnscopedRls { + table: "secure_docs" + }, + QueryLint::BroadDelete, + ] + ); +} + +#[test] +fn query_lints_accept_scoped_or_explicitly_unscoped_rls() { + let context = AccessContext::authenticated("user_1").with_permissions(["docs:write"]); + + assert_eq!( + SecureDoc::select_scoped(&context).unwrap().limit(10).lint(), + Vec::new() + ); + assert_eq!( + SecureDoc::insert_scoped(&context) + .unwrap() + .set(SecureDoc::STATUS, "draft") + .lint(), + Vec::new() + ); + assert_eq!( + SecureDoc::select() + .allow_unscoped_rls("admin export") + .allow_unbounded_select() + .lint(), + Vec::new() + ); + assert_eq!( + SecureDoc::insert() + .allow_unscoped_rls("admin backfill") + .set(SecureDoc::STATUS, "draft") + .lint(), + Vec::new() + ); +} + +#[test] +fn scoped_insert_sets_owner_column() { + let context = AccessContext::authenticated("user_1"); + let statement = SecureDoc::insert_scoped(&context) + .unwrap() + .set(SecureDoc::STATUS, "draft") + .to_statement(); + + assert_eq!( + statement.sql, + "INSERT INTO \"secure_docs\" (\"user_id\", \"status\") VALUES (?, ?)" + ); + assert_eq!( + statement.binds, + vec![Value::Text("user_1".into()), Value::Text("draft".into())] + ); +} + +#[test] +fn scoped_tenant_predicate_preserves_value_type() { + let context = AccessContext::default().with_tenant_value(42_i64); + let statement = TenantDoc::select_scoped(&context) + .unwrap() + .where_(TenantDoc::ORG_ID.eq(42_i64)) + .where_(TenantDoc::ID.eq(7_i64)) + .limit(1) + .to_statement(); + + assert_eq!( + statement.sql, + "SELECT \"id\", \"org_id\" FROM \"tenant_docs\" \ + WHERE ((\"tenant_docs\".\"org_id\" = ?) AND (\"tenant_docs\".\"org_id\" = ?)) \ + AND (\"tenant_docs\".\"id\" = ?) LIMIT ?" + ); + assert_eq!( + statement.binds, + vec![ + Value::Integer(42), + Value::Integer(42), + Value::Integer(7), + Value::Integer(1) + ] + ); +} + +#[test] +fn scoped_tenant_predicate_rejects_wrong_value_type() { + let context = AccessContext::default().with_tenant("org_42"); + assert_eq!( + TenantDoc::select_scoped(&context).unwrap_err(), + RlsError::TypeMismatch { + table: "tenant_docs", + column: "org_id", + expected: SqlType::Integer, + } + ); +} + +#[test] +fn boolean_rls_accepts_bool_and_zero_one_integer_values() { + let bool_statement = BoolDoc::select_scoped(&AccessContext::default().with_tenant_value(true)) + .unwrap() + .where_(BoolDoc::ACTIVE.eq(true)) + .where_(BoolDoc::ID.eq(1_i64)) + .limit(1) + .to_statement(); + assert_eq!( + bool_statement.sql, + "SELECT \"id\", \"active\" FROM \"bool_docs\" \ + WHERE ((\"bool_docs\".\"active\" = ?) AND (\"bool_docs\".\"active\" = ?)) \ + AND (\"bool_docs\".\"id\" = ?) LIMIT ?" + ); + assert_eq!( + bool_statement.binds, + vec![ + Value::Bool(true), + Value::Bool(true), + Value::Integer(1), + Value::Integer(1) + ] + ); + + assert!(BoolDoc::select_scoped(&AccessContext::default().with_tenant_value(1_i64)).is_ok()); + assert_eq!( + BoolDoc::select_scoped(&AccessContext::default().with_tenant_value(2_i64)).unwrap_err(), + RlsError::TypeMismatch { + table: "bool_docs", + column: "active", + expected: SqlType::Boolean, + } + ); +} + +#[test] +fn scoped_update_enforces_rbac_per_operation() { + let denied = SecureDoc::update_scoped(&AccessContext::authenticated("user_1")); + assert_eq!( + denied.unwrap_err(), + RlsError::Forbidden { + table: "secure_docs" + } + ); + + let context = AccessContext::authenticated("user_1").with_permissions(["docs:write"]); + let statement = SecureDoc::update_scoped(&context) + .unwrap() + .set(SecureDoc::STATUS, "published") + .where_(SecureDoc::ID.eq(7)) + .to_statement(); + + assert_eq!( + statement.sql, + "UPDATE \"secure_docs\" SET \"status\" = ? \ + WHERE (\"secure_docs\".\"user_id\" = ?) AND (\"secure_docs\".\"id\" = ?)" + ); + assert_eq!( + statement.binds, + vec![ + Value::Text("published".into()), + Value::Text("user_1".into()), + Value::Integer(7) + ] + ); +} + +#[test] +fn scoped_builders_fail_closed_for_uncovered_operations() { + let context = AccessContext::authenticated("user_1").with_permissions(["docs:update"]); + + assert_eq!( + UpdateOnlyDoc::select_scoped(&context).unwrap_err(), + RlsError::Forbidden { + table: "update_only_docs" + } + ); + assert_eq!( + UpdateOnlyDoc::insert_scoped(&context).unwrap_err(), + RlsError::Forbidden { + table: "update_only_docs" + } + ); + assert_eq!( + UpdateOnlyDoc::delete_scoped(&context).unwrap_err(), + RlsError::Forbidden { + table: "update_only_docs" + } + ); + assert!( + UpdateOnlyDoc::update_scoped(&context) + .unwrap() + .set(UpdateOnlyDoc::STATUS, "reviewed") + .lint() + .contains(&QueryLint::BroadUpdate) + ); +} + +#[test] +fn scoped_rbac_requires_matching_resource_when_declared() { + assert_eq!( + ResourceDoc::update_scoped( + &AccessContext::authenticated("user_1").with_permissions(["docs:update"]) + ) + .unwrap_err(), + RlsError::Forbidden { + table: "resource_docs" + } + ); + assert_eq!( + ResourceDoc::update_scoped( + &AccessContext::authenticated("user_1") + .with_permissions(["docs:update"]) + .with_resource("doc:8") + ) + .unwrap_err(), + RlsError::Forbidden { + table: "resource_docs" + } + ); + assert!( + ResourceDoc::update_scoped( + &AccessContext::authenticated("user_1") + .with_permissions(["docs:update"]) + .with_resource("doc:7") + ) + .unwrap() + .set(ResourceDoc::STATUS, "reviewed") + .lint() + .contains(&QueryLint::BroadUpdate) + ); +} + +#[test] +fn checked_statement_rejects_linted_queries() { + let error = SecureDoc::select() + .limit(10) + .to_statement_checked() + .unwrap_err(); + + assert_eq!( + error.lints, + vec![QueryLint::UnscopedRls { + table: "secure_docs" + }] + ); +} + +#[test] +fn checked_statement_allows_warning_lints() { + let statement = Task::select().to_statement_checked().unwrap(); + + assert_eq!( + Task::select().lint()[0].severity(), + QueryLintSeverity::Warning + ); + assert_eq!( + statement.sql, + "SELECT \"id\", \"title\", \"done\", \"created_at\" FROM \"tasks\"" + ); +} + +#[test] +fn scoped_delete_composes_custom_predicate() { + let context = AccessContext::authenticated("user_1"); + let statement = SecureDoc::delete_scoped_with(&context, &TestPredicates) + .unwrap() + .where_(SecureDoc::ID.eq(7)) + .to_statement(); + + assert_eq!( + statement.sql, + "DELETE FROM \"secure_docs\" \ + WHERE ((\"secure_docs\".\"user_id\" = ?) AND (\"secure_docs\".\"status\" = ?)) \ + AND (\"secure_docs\".\"id\" = ?)" + ); + assert_eq!( + statement.binds, + vec![ + Value::Text("user_1".into()), + Value::Text("archived".into()), + Value::Integer(7) + ] + ); +} + +#[test] +fn custom_predicate_registry_validates_entity_policies() { + assert_eq!( + SecureDoc::validate_custom_predicates_with(&EmptyPredicates).unwrap_err(), + RlsError::MissingCustomPredicate { + table: "secure_docs", + name: "can_delete_doc", + } + ); + assert_eq!( + SecureDoc::validate_custom_predicates_with(&TestPredicates), + Ok(()) + ); + assert_eq!( + SecureDoc::validate_custom_predicates_with(&WrongOperationPredicates).unwrap_err(), + RlsError::MissingCustomPredicate { + table: "secure_docs", + name: "can_delete_doc", + } + ); +} + #[test] fn select_can_combine_filters() { let statement = Task::select() @@ -254,6 +931,23 @@ fn has_many_relation_builds_child_lookup() { assert_eq!(statement.binds, vec![Value::Integer(7), Value::Integer(50)]); } +#[test] +fn relationship_helpers_have_scoped_variants() { + let context = AccessContext::authenticated("user_1"); + let statement = Board::TASKS + .select_children_scoped(7, &context) + .unwrap() + .order_by(Task::ID.asc()) + .limit(50) + .to_statement(); + + assert_eq!( + statement.sql, + "SELECT \"id\", \"title\", \"done\", \"created_at\" FROM \"tasks\" \ + WHERE \"tasks\".\"id\" = ? ORDER BY \"tasks\".\"id\" ASC LIMIT ?" + ); +} + #[test] fn write_lints_flag_broad_writes() { assert_eq!( @@ -301,6 +995,7 @@ fn schema_manifest_string_is_deterministic() { columns: OTHER_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }; let manifest = SchemaManifest::new([Task::TABLE, other]); @@ -365,6 +1060,7 @@ fn initial_migration_generates_foreign_key_constraints() { columns: BOARD_COLUMNS, indexes: &[], foreign_keys: BOARD_FOREIGN_KEYS, + rls: PUBLIC_RLS, }]); assert_eq!( @@ -392,6 +1088,7 @@ fn schema_lints_flag_unindexed_foreign_keys() { columns: COMMENT_COLUMNS, indexes: &[], foreign_keys: COMMENT_FOREIGN_KEYS, + rls: PUBLIC_RLS, }]); assert_eq!( @@ -428,11 +1125,59 @@ fn schema_lints_accept_indexed_foreign_keys() { columns: COMMENT_COLUMNS, indexes: &[], foreign_keys: COMMENT_FOREIGN_KEYS, + rls: PUBLIC_RLS, }]); assert_eq!(manifest.lint(), Vec::new()); } +#[test] +fn schema_lints_flag_missing_rls() { + const COMMENT_COLUMNS: &[ColumnDef] = &[ColumnDef::new("id", SqlType::Integer)]; + let manifest = SchemaManifest::new([TableDef { + name: "comments", + columns: COMMENT_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: &[], + }]); + + assert_eq!( + manifest.lint(), + vec![SchemaLint::MissingRls { table: "comments" }] + ); +} + +#[test] +fn schema_lints_flag_unindexed_rls_columns() { + const COMMENT_COLUMNS: &[ColumnDef] = &[ + ColumnDef::new("id", SqlType::Integer), + ColumnDef::new("user_id", SqlType::Text), + ]; + const COMMENT_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[], + kind: RlsPolicyKind::Owner, + column: Some("user_id"), + authorization: RlsAuthorizationDef::empty(), + custom: None, + }]; + let manifest = SchemaManifest::new([TableDef { + name: "comments", + columns: COMMENT_COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: COMMENT_RLS, + }]); + + assert_eq!( + manifest.lint(), + vec![SchemaLint::UnindexedRlsColumn { + table: "comments", + column: "user_id", + }] + ); +} + #[test] fn migration_diff_generates_safe_additive_changes() { const CURRENT_COLUMNS: &[ColumnDef] = &[ @@ -486,12 +1231,14 @@ fn migration_diff_generates_safe_additive_changes() { columns: CURRENT_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }]); let desired = SchemaManifest::new([TableDef { name: "tasks", columns: DESIRED_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }]); let plan = current.diff(&desired); @@ -508,6 +1255,63 @@ fn migration_diff_generates_safe_additive_changes() { ); } +#[test] +fn migration_diff_blocks_rls_changes_for_review() { + const COLUMNS: &[ColumnDef] = &[ + ColumnDef { + name: "id", + sql_type: SqlType::Integer, + nullable: false, + primary_key: true, + auto_increment: true, + unique: true, + indexed: true, + default_sql: None, + }, + ColumnDef { + name: "user_id", + sql_type: SqlType::Text, + nullable: false, + primary_key: false, + auto_increment: false, + unique: false, + indexed: true, + default_sql: None, + }, + ]; + const OWNER_RLS: &[RlsPolicyDef] = &[RlsPolicyDef { + operations: &[], + kind: RlsPolicyKind::Owner, + column: Some("user_id"), + authorization: RlsAuthorizationDef::empty(), + custom: None, + }]; + let current = SchemaManifest::new([TableDef { + name: "tasks", + columns: COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: PUBLIC_RLS, + }]); + let desired = SchemaManifest::new([TableDef { + name: "tasks", + columns: COLUMNS, + indexes: &[], + foreign_keys: &[], + rls: OWNER_RLS, + }]); + + let plan = current.diff(&desired); + + assert_eq!( + plan.blockers, + vec![MigrationBlocker::ChangeRls { + table: "tasks".into(), + }] + ); + assert_eq!(plan.statements, Vec::::new()); +} + #[test] fn migration_diff_blocks_destructive_or_ambiguous_changes() { const CURRENT_COLUMNS: &[ColumnDef] = &[ @@ -543,12 +1347,14 @@ fn migration_diff_blocks_destructive_or_ambiguous_changes() { columns: CURRENT_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }]); let desired = SchemaManifest::new([TableDef { name: "tasks", columns: DESIRED_COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }]); let plan = current.diff(&desired); @@ -599,12 +1405,14 @@ fn migration_diff_blocks_foreign_key_changes_on_existing_tables() { columns: COLUMNS, indexes: &[], foreign_keys: &[], + rls: PUBLIC_RLS, }]); let desired = SchemaManifest::new([TableDef { name: "comments", columns: COLUMNS, indexes: &[], foreign_keys: FOREIGN_KEYS, + rls: PUBLIC_RLS, }]); let plan = current.diff(&desired); diff --git a/tests/nebula_derive.rs b/tests/nebula_derive.rs index c71cbea..a7ed924 100644 --- a/tests/nebula_derive.rs +++ b/tests/nebula_derive.rs @@ -21,6 +21,7 @@ struct TaskRow { fn derive_entity_generates_metadata_and_columns() { assert_eq!(TaskRow::TABLE.name, "tasks"); assert_eq!(TaskRow::TABLE.indexes, &[]); + assert_eq!(TaskRow::TABLE.rls, &[]); assert_eq!(TaskRow::ID.name(), "id"); assert_eq!(TaskRow::BOARD_ID.name(), "board_id"); assert_eq!(TaskRow::TITLE.name(), "title"); @@ -58,6 +59,39 @@ fn derive_entity_generates_metadata_and_columns() { ); } +#[derive(comet::nebula::Entity)] +#[nebula(table = "boards")] +#[nebula(rls(owner = "user_id"))] +#[nebula(rls(select, permission = "boards:read"))] +#[nebula(rls(update, any(role = "admin", permission = "boards:write")))] +#[nebula(rls(delete, custom = "can_delete_board"))] +#[allow(dead_code)] +struct SecuredBoard { + #[nebula(primary_key, auto)] + id: i64, + #[nebula(index)] + user_id: String, + title: String, +} + +#[test] +fn derive_entity_generates_rls_metadata() { + use comet::nebula::{RlsMatchMode, RlsOperation, RlsPolicyKind}; + + let policies = SecuredBoard::TABLE.rls; + + assert_eq!(policies.len(), 4); + assert_eq!(policies[0].kind, RlsPolicyKind::Owner); + assert_eq!(policies[0].column, Some("user_id")); + assert_eq!(policies[1].operations, &[RlsOperation::Select]); + assert_eq!(policies[1].authorization.permissions, &["boards:read"]); + assert_eq!(policies[2].operations, &[RlsOperation::Update]); + assert_eq!(policies[2].authorization.mode, RlsMatchMode::Any); + assert_eq!(policies[2].authorization.roles, &["admin"]); + assert_eq!(policies[3].kind, RlsPolicyKind::Custom); + assert_eq!(policies[3].custom, Some("can_delete_board")); +} + #[test] fn derive_entity_works_with_query_builders() { let statement = TaskRow::select() diff --git a/tests/ui/nebula_derive_rls_empty_custom.rs b/tests/ui/nebula_derive_rls_empty_custom.rs new file mode 100644 index 0000000..57aa8ba --- /dev/null +++ b/tests/ui/nebula_derive_rls_empty_custom.rs @@ -0,0 +1,9 @@ +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(delete, custom = ""))] +struct Task { + #[nebula(primary_key)] + id: i64, +} + +fn main() {} diff --git a/tests/ui/nebula_derive_rls_empty_custom.stderr b/tests/ui/nebula_derive_rls_empty_custom.stderr new file mode 100644 index 0000000..8a61fb1 --- /dev/null +++ b/tests/ui/nebula_derive_rls_empty_custom.stderr @@ -0,0 +1,5 @@ +error: Nebula custom RLS requires a non-empty predicate name + --> tests/ui/nebula_derive_rls_empty_custom.rs:4:8 + | +4 | struct Task { + | ^^^^ diff --git a/tests/ui/nebula_derive_rls_public_with_operation.rs b/tests/ui/nebula_derive_rls_public_with_operation.rs new file mode 100644 index 0000000..f94afb1 --- /dev/null +++ b/tests/ui/nebula_derive_rls_public_with_operation.rs @@ -0,0 +1,9 @@ +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(select, public))] +struct Task { + #[nebula(primary_key)] + id: i64, +} + +fn main() {} diff --git a/tests/ui/nebula_derive_rls_public_with_operation.stderr b/tests/ui/nebula_derive_rls_public_with_operation.stderr new file mode 100644 index 0000000..c804e30 --- /dev/null +++ b/tests/ui/nebula_derive_rls_public_with_operation.stderr @@ -0,0 +1,5 @@ +error: Nebula `rls(public)` cannot include operations, columns, authorization, resource, or custom predicates + --> tests/ui/nebula_derive_rls_public_with_operation.rs:4:8 + | +4 | struct Task { + | ^^^^ diff --git a/tests/ui/nebula_derive_rls_rbac_without_claim.rs b/tests/ui/nebula_derive_rls_rbac_without_claim.rs new file mode 100644 index 0000000..8665c08 --- /dev/null +++ b/tests/ui/nebula_derive_rls_rbac_without_claim.rs @@ -0,0 +1,9 @@ +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(select, any(resource = "demo")))] +struct Task { + #[nebula(primary_key)] + id: i64, +} + +fn main() {} diff --git a/tests/ui/nebula_derive_rls_rbac_without_claim.stderr b/tests/ui/nebula_derive_rls_rbac_without_claim.stderr new file mode 100644 index 0000000..17962e2 --- /dev/null +++ b/tests/ui/nebula_derive_rls_rbac_without_claim.stderr @@ -0,0 +1,5 @@ +error: Nebula RBAC RLS requires at least one role, permission, or scope + --> tests/ui/nebula_derive_rls_rbac_without_claim.rs:4:8 + | +4 | struct Task { + | ^^^^ diff --git a/tests/ui/nebula_derive_rls_unknown_column.rs b/tests/ui/nebula_derive_rls_unknown_column.rs new file mode 100644 index 0000000..fb010dd --- /dev/null +++ b/tests/ui/nebula_derive_rls_unknown_column.rs @@ -0,0 +1,10 @@ +#[derive(comet::nebula::Entity)] +#[nebula(table = "tasks")] +#[nebula(rls(owner = "missing_user_id"))] +struct Task { + #[nebula(primary_key)] + id: i64, + user_id: String, +} + +fn main() {} diff --git a/tests/ui/nebula_derive_rls_unknown_column.stderr b/tests/ui/nebula_derive_rls_unknown_column.stderr new file mode 100644 index 0000000..7640af8 --- /dev/null +++ b/tests/ui/nebula_derive_rls_unknown_column.stderr @@ -0,0 +1,5 @@ +error: Nebula `rls` references unknown column `missing_user_id` + --> tests/ui/nebula_derive_rls_unknown_column.rs:4:8 + | +4 | struct Task { + | ^^^^