diff --git a/.gitignore b/.gitignore index 1dfd0e1..40a0b00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ +/scratch target settings.toml policy.polar - +policy.cedar token-* github.pem diff --git a/Cargo.lock b/Cargo.lock index 8859f0f..ac1c535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,19 +32,34 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "ascii-canvas" -version = "3.0.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "term 0.7.0", + "term", ] [[package]] @@ -66,7 +81,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -77,7 +92,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -110,20 +125,26 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bit-set" -version = "0.5.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" @@ -143,6 +164,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -174,6 +214,69 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8c84f46d7feed570e52cd93b0f1e3ce7f57e2ac61712fba0d1318f84c96a99b" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.17", +] + +[[package]] +name = "cedar-policy-core" +version = "4.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8011d10d2ffa8ee4497d4d7234d0d4e97f52a6e6e66a1150c5c3409ba7fe1b5c" +dependencies = [ + "chrono", + "educe", + "either", + "itertools", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc_lexer", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.17", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29faad4ed540d812bc213b1228de39a34731730bca6dd51a8086796461415c0" +dependencies = [ + "cedar-policy-core", + "itertools", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -347,7 +450,41 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", ] [[package]] @@ -374,14 +511,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - [[package]] name = "digest" version = "0.10.7" @@ -403,16 +535,6 @@ dependencies = [ "dirs-sys", ] -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -421,21 +543,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -444,7 +555,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -477,14 +588,14 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap", + "indexmap 2.12.1", "multer", "openapiv3", "paste", "percent-encoding", "rustls 0.22.4", "rustls-pemfile", - "schemars", + "schemars 0.8.22", "scopeguard", "semver", "serde", @@ -512,13 +623,13 @@ version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0430a45e1a3cc8aaf1396c2ca2a4c1215ab253cbcda7ca7ed7beb107e0591bd1" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "semver", "serde", "serde_tokenstream", - "syn 2.0.111", + "syn", ] [[package]] @@ -565,6 +676,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -610,6 +733,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -676,9 +819,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" -version = "0.4.2" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "fnv" @@ -778,7 +921,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -872,13 +1015,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -914,12 +1063,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -932,6 +1075,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1204,6 +1353,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1226,14 +1381,14 @@ dependencies = [ ] [[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", + "autocfg", + "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1248,12 +1403,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" - [[package]] name = "ipnet" version = "2.11.0" @@ -1283,9 +1432,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1340,33 +1489,46 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lalrpop" -version = "0.19.12" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ "ascii-canvas", "bit-set", - "diff", "ena", - "is-terminal", "itertools", "lalrpop-util", "petgraph", + "pico-args", "regex", - "regex-syntax 0.6.29", + "regex-syntax", + "sha3", "string_cache", - "term 0.7.0", - "tiny-keccak", + "term", "unicode-xid", + "walkdir", ] [[package]] name = "lalrpop-util" -version = "0.19.12" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3c48237b9604c5a4702de6b824e02006c3214327564636aef27c1028a8fa0ed" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] [[package]] name = "lazy_static" @@ -1399,6 +1561,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1427,16 +1607,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] -name = "lru-slab" -version = "0.1.2" +name = "logos" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive", +] [[package]] -name = "maplit" -version = "1.0.2" +name = "logos-codegen" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "match_cfg" @@ -1459,6 +1667,29 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1516,6 +1747,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1553,9 +1793,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1596,21 +1836,31 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oidcx" -version = "0.3.1" +version = "0.5.0" dependencies = [ "anyhow", + "base64", "bytes", + "cedar-policy", "chrono", "config", "dropshot", "futures-util", "jsonwebtoken", - "oso", "oxide", "reqwest", - "schemars", + "schemars 0.8.22", "secrecy", "serde", "serde_json", @@ -1637,7 +1887,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde", "serde_json", ] @@ -1665,7 +1915,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1712,31 +1962,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "oso" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeabb069616e6a494420f5ab27dbad46efa8dd4b45d30a0302857a7bcdea4293" -dependencies = [ - "impl-trait-for-tuples", - "lazy_static", - "maplit", - "oso-derive", - "polar-core", - "thiserror 1.0.69", - "tracing", -] - -[[package]] -name = "oso-derive" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f5236d7c60cce1bcd76146bcbc4b2a5fb1234894fb84b1ec751863e8399e9c" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "oxide" version = "0.14.0+20251008.0.0" @@ -1751,7 +1976,7 @@ dependencies = [ "rand 0.9.2", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.17", @@ -1875,7 +2100,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1890,12 +2115,12 @@ dependencies = [ [[package]] name = "petgraph" -version = "0.6.5" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.12.1", ] [[package]] @@ -1907,6 +2132,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1946,22 +2177,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "polar-core" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3aa6f61d235de56ccffbca8627377ebe6ff0052a419f67b098f319a5f32e06d" -dependencies = [ - "indoc", - "js-sys", - "lalrpop", - "lalrpop-util", - "serde", - "serde_derive", - "strum_macros", - "wasm-bindgen", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -1992,6 +2207,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2025,6 +2251,16 @@ dependencies = [ "serde_urlencoded", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2165,24 +2401,33 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.17", + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2194,7 +2439,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax", ] [[package]] @@ -2205,15 +2450,9 @@ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.8", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.8" @@ -2351,6 +2590,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2454,6 +2702,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -2477,6 +2734,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -2486,7 +2767,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.111", + "syn", ] [[package]] @@ -2587,7 +2868,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2598,7 +2879,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2607,6 +2888,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap 2.12.1", "itoa", "memchr", "ryu", @@ -2643,7 +2925,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", + "syn", ] [[package]] @@ -2658,6 +2940,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2680,6 +2994,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2795,7 +3119,7 @@ dependencies = [ "chrono", "is-terminal", "slog", - "term 1.2.0", + "term", "thread_local", "time", ] @@ -2806,6 +3130,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.1" @@ -2838,6 +3172,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -2851,17 +3198,10 @@ dependencies = [ ] [[package]] -name = "strum_macros" -version = "0.24.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" @@ -2869,17 +3209,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.111" @@ -2908,7 +3237,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2957,17 +3286,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", -] - [[package]] name = "term" version = "1.2.0" @@ -3003,7 +3321,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3014,7 +3332,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3028,9 +3346,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -3038,22 +3356,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3118,7 +3436,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3171,7 +3489,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde_core", "serde_spanned", "toml_datetime", @@ -3195,7 +3513,7 @@ version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap", + "indexmap 2.12.1", "toml_datetime", "toml_parser", "toml_writer", @@ -3268,7 +3586,6 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3294,7 +3611,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3364,6 +3681,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -3388,12 +3711,49 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3463,6 +3823,16 @@ dependencies = [ "atomic-waker", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3532,7 +3902,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "wasm-bindgen-shared", ] @@ -3603,6 +3973,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3630,7 +4009,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3641,7 +4020,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3892,7 +4271,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -3913,7 +4292,7 @@ checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3933,7 +4312,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -3973,5 +4352,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] diff --git a/Cargo.toml b/Cargo.toml index 8ae195d..751bcea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oidcx" -version = "0.3.1" +version = "0.5.0" edition = "2024" [dependencies] @@ -11,7 +11,7 @@ config = "0.15.18" dropshot = "0.16.4" futures-util = "0.3.31" jsonwebtoken = { version = "10.1.0", features = ["rsa", "rust_crypto"] } -oso = "0.27.3" +cedar-policy = "4" oxide = "0.14.0" reqwest = { version = "0.12.23", default-features = false, features = ["http2", "json", "rustls-tls"] } schemars = "0.8.22" @@ -27,3 +27,6 @@ tracing = "0.1.41" tracing-appender = "0.2.3" tracing-slog = { git = "https://github.com/oxidecomputer/tracing-slog", default-features = false } tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } + +[dev-dependencies] +base64 = "0.22" diff --git a/policy.cedarschema b/policy.cedarschema new file mode 100644 index 0000000..fa03140 --- /dev/null +++ b/policy.cedarschema @@ -0,0 +1,62 @@ +// Authorization schema for oidcx. +// Documentation: https://github.com/oxidecomputer/oidcx#authorization-policy +// +// Principal: a GitHub Actions workflow run, carrying OIDC claims. +// Resource: a GitHub repository or an Oxide silo being accessed. +// Action: the permission being requested. + +namespace Oidcx { + + // A GitHub Actions workflow run identified by its OIDC token claims. + entity WorkflowRun = { + repository: String, + repository_owner: String, + repository_visibility: String, + event_name: String, + environment?: String, + iss: String, + }; + + // A GitHub repository that is the target of a token request. + entity Repository = { + name: String, + visibility: String, + }; + + // An Oxide silo that is the target of a deployment. + entity Silo = { + url?: String, + }; + + // GitHub repository permissions + action "contents:read" appliesTo { + principal: WorkflowRun, + resource: Repository, + }; + + action "contents:write" appliesTo { + principal: WorkflowRun, + resource: Repository, + }; + + action "pull_requests:read" appliesTo { + principal: WorkflowRun, + resource: Repository, + }; + + action "pull_requests:write" appliesTo { + principal: WorkflowRun, + resource: Repository, + }; + + action "actions:read" appliesTo { + principal: WorkflowRun, + resource: Repository, + }; + + // Oxide silo deployment + action "deploy" appliesTo { + principal: WorkflowRun, + resource: Silo, + }; +} diff --git a/src/context.rs b/src/context.rs index 089e453..6899bcf 100644 --- a/src/context.rs +++ b/src/context.rs @@ -9,16 +9,17 @@ use std::{ }; use thiserror::Error; +use cedar_policy::{Entity, PolicySet, Schema}; + use crate::{ - oidc::{OidcError, ResolvedOidcConfig}, - policy::Policy, + oidc::{Claims, OidcError, OidcProvider, ResolvedOidcConfig}, + policy::{Policy, PolicyError}, settings::Settings, token::{ github::{GitHubTokenError, GitHubTokens}, oxide::{OxideError, OxideTokens}, }, }; -use oso::OsoError; #[derive(Debug, Error)] pub enum ContextBuildError { @@ -30,13 +31,20 @@ pub enum ContextBuildError { GitHubTokens(#[from] GitHubTokenError), #[error("Encountered an error configuring OIDC providers")] Oidc(#[from] OidcError), - #[error("Failed to initialize the Oso policy")] - Oso(#[from] OsoError), + #[error("Failed to initialize the Cedar policy engine")] + Policy(#[from] PolicyError), } +/// A function that builds a Cedar principal [`Entity`] from validated OIDC +/// [`Claims`]. Each identity provider has its own builder because different +/// issuers produce tokens with different claim structures and map to different +/// Cedar entity types. +pub type PrincipalBuilder = fn(&Claims) -> Result; + #[derive(Debug)] pub struct ResolvedOidcProvider { pub config: ResolvedOidcConfig, + pub build_principal: PrincipalBuilder, } #[derive(Debug)] @@ -53,23 +61,41 @@ impl Context { let client = reqwest::Client::new(); let mut providers = HashMap::new(); - for provider in &settings.providers { - let resolved = ResolvedOidcProvider { - config: provider - .fetch_config(&client) - .await? - .resolve(&client) - .await?, - }; - let issuer = resolved.config.issuer.clone(); - providers.insert(issuer, Arc::new(RwLock::new(resolved))); + for provider_config in &settings.providers { + let config = OidcProvider::new(provider_config.url().to_string()) + .fetch_config(&client) + .await? + .resolve(&client) + .await?; + + let issuer = config.issuer.clone(); + providers.insert( + issuer, + Arc::new(RwLock::new(ResolvedOidcProvider { + config, + build_principal: provider_config.principal_builder(), + })), + ); } let github_tokens = GitHubTokens::new(&settings)?; + let base = settings.policy_path.with_extension(""); + + let schema_path = base.with_extension("cedarschema"); + let schema_src = std::fs::read_to_string(&schema_path) + .map_err(|err| PolicyError::ReadFile(schema_path, err))?; + let (schema, _warnings) = + Schema::from_cedarschema_str(&schema_src).map_err(PolicyError::InitSchema)?; + + let cedar_path = base.with_extension("cedar"); + let policy_src = std::fs::read_to_string(&cedar_path) + .map_err(|err| PolicyError::ReadFile(cedar_path, err))?; + let policy_set: PolicySet = policy_src.parse().map_err(PolicyError::InitPolicy)?; + Ok(Context { providers, - policy: Policy::new(&settings.policy_path, github_tokens.clone())?, + policy: Policy::new(schema, policy_set, github_tokens.clone()), oxide_tokens: OxideTokens::new(&settings)?, github_tokens, settings, diff --git a/src/endpoints.rs b/src/endpoints.rs index 60b5e7a..01d6b50 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -60,18 +60,23 @@ pub async fn exchange( .clone(); // Continue to the next authorization if the token does not match the required constraints - let claims = provider - .read() - .unwrap() - .config - .validate(&ctx.settings, &body.caller_identity) - .map_err(|err| { - tracing::info!(?err, "Failed to validate token"); - HttpError::for_bad_request(None, "Token validation failed".to_string()) - })?; + let principal = { + let provider = provider.read().unwrap(); + let claims = provider + .config + .validate(&ctx.settings, &body.caller_identity) + .map_err(|err| { + tracing::info!(?err, "Failed to validate token"); + HttpError::for_bad_request(None, "Token validation failed".to_string()) + })?; + (provider.build_principal)(&claims).map_err(|err| { + tracing::info!(?err, "Failed to construct principal"); + HttpError::for_bad_request(None, format!("Failed to construct principal: {err}")) + })? + }; ctx.policy - .ensure_allowed(&claims, &body.request) + .ensure_allowed(&principal, &body.request) .await .map_err(|err| { tracing::info!(?err, "Failed to match the token against the policy"); diff --git a/src/main.rs b/src/main.rs index 7052831..ef4035e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod endpoints; mod oauth; mod oidc; mod policy; +mod providers; mod server; mod settings; mod token; diff --git a/src/oidc.rs b/src/oidc.rs index 0084ad7..fe9d7b6 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -6,7 +6,6 @@ use jsonwebtoken::{ Algorithm, DecodingKey, Validation, jwk::{JwkSet, KeyAlgorithm}, }; -use oso::{PolarValue, ToPolar}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt::Debug, str::FromStr}; use thiserror::Error; @@ -119,6 +118,11 @@ impl ResolvedOidcConfig { )?); validation.set_audience(&[&settings.audience]); validation.set_issuer(&[&self.issuer]); + // Require these claims to be present. Without this, a token missing + // `aud` or `iss` could slip past the corresponding checks depending on + // the library's defaults. `aud` in particular is the cross-service + // isolation boundary, so it must always be present and validated. + validation.set_required_spec_claims(&["exp", "aud", "iss"]); Ok(Claims { claims: jsonwebtoken::decode(token, &decoding_key, &validation) @@ -136,14 +140,21 @@ pub struct Claims { claims: HashMap, } -impl ToPolar for Claims { - fn to_polar(self) -> PolarValue { - PolarValue::Map( - self.claims - .into_iter() - .map(|(k, v)| (k, v.to_polar())) - .collect(), - ) +impl Claims { + pub fn get_str(&self, key: &str) -> Option<&str> { + match self.claims.get(key)? { + ClaimValue::String(s) => Some(s.as_str()), + ClaimValue::Number(_) => None, + } + } +} + +#[cfg(test)] +impl Claims { + pub fn from_json(value: serde_json::Value) -> Self { + Self { + claims: serde_json::from_value(value).expect("invalid test claims JSON"), + } } } @@ -163,15 +174,6 @@ impl std::fmt::Debug for ClaimValue { } } -impl ToPolar for ClaimValue { - fn to_polar(self) -> PolarValue { - match self { - ClaimValue::Number(number) => PolarValue::Integer(number), - ClaimValue::String(string) => PolarValue::String(string), - } - } -} - fn key_algo_to_algo(key_algorithm: KeyAlgorithm) -> Result { Ok(match key_algorithm { KeyAlgorithm::HS256 => Algorithm::HS256, @@ -194,3 +196,288 @@ fn key_algo_to_algo(key_algorithm: KeyAlgorithm) -> Result pub struct IssuerClaim { pub iss: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_str_returns_string_value() { + let claims = Claims::from_json(serde_json::json!({ + "iss": "https://example.com", + "sub": "user123" + })); + assert_eq!(claims.get_str("iss"), Some("https://example.com")); + assert_eq!(claims.get_str("sub"), Some("user123")); + } + + #[test] + fn get_str_returns_none_for_number() { + let claims = Claims::from_json(serde_json::json!({ + "iat": 1234567890 + })); + assert_eq!(claims.get_str("iat"), None); + } + + #[test] + fn get_str_returns_none_for_missing_key() { + let claims = Claims::from_json(serde_json::json!({ + "iss": "https://example.com" + })); + assert_eq!(claims.get_str("nonexistent"), None); + } +} + +#[cfg(test)] +mod jwt_validation_tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode}; + use std::time::{SystemTime, UNIX_EPOCH}; + + // A throwaway RSA keypair generated solely for these tests. The matching + // public key parameters are embedded in the JWKS below. + const TEST_KEY_PEM: &str = include_str!("../test_fixtures/test_rsa_key.pem"); + const TEST_KID: &str = "test-key"; + const TEST_ISSUER: &str = "https://token.actions.githubusercontent.com"; + const TEST_AUDIENCE: &str = "https://oidcx.example.com"; + + // Public key (n, e) of TEST_KEY_PEM in base64url form. + const JWK_N: &str = "kXMiJsiWS_dpudfVUZfk2HC50le8P0V3PYrmYYgRFcoxIl-3tUS1KoA_DOWrVsw1-4k52hvAA24_nGnP9Wdma_s0meoWUeOMAMVrOLU3J4YKjWEG37T8uqOd--NzzUHliH9-Gg08R89IeLbKOHAGBkG38W5D1oT2oAu15FXN2azVPRokQhAYtakydLg9hJymDunnu8Jz27wxFKEfHJqjpyAGDOrb2WGhVgGH5ByP16jDKKHJd-Tq9TvzbLUlj6N2a1RkhEeaeKH18-aaMTdFLF_VARC4EMPjMdg-ZeMc_kqrvRuSPip8NTkfEHJjkG2DLL3RKNDB_2nbuiZZl5DLLQ"; + const JWK_E: &str = "AQAB"; + + fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn test_config() -> ResolvedOidcConfig { + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "RSA", + "kid": TEST_KID, + "alg": "RS256", + "use": "sig", + "n": JWK_N, + "e": JWK_E, + }] + })) + .expect("failed to build test JWKS"); + + ResolvedOidcConfig { + issuer: TEST_ISSUER.to_string(), + jwks, + subject_types_supported: vec![], + response_types_supported: vec![], + claims_supported: vec![], + id_token_signing_alg_values_supported: vec![Algorithm::RS256], + scopes_supported: vec![], + } + } + + fn test_settings(audience: &str) -> Settings { + Settings { + audience: audience.to_string(), + policy_path: std::path::PathBuf::new(), + log_directory: None, + port: None, + providers: vec![], + oxide: None, + github: None, + } + } + + fn valid_claims() -> serde_json::Value { + serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now() + 300, + "iat": now() - 10, + "jti": "test-jti", + "repository": "oxidecomputer/hubris", + }) + } + + /// Sign a token with RS256 using the test private key (the legitimate path). + fn sign_rs256(claims: serde_json::Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(TEST_KID.to_string()); + let key = EncodingKey::from_rsa_pem(TEST_KEY_PEM.as_bytes()) + .expect("failed to load test signing key"); + encode(&header, &claims, &key).expect("failed to sign test token") + } + + #[test] + fn accepts_valid_token() { + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let token = sign_rs256(valid_claims()); + let claims = config + .validate(&settings, &token) + .expect("a correctly signed token with the right aud/iss/exp should be accepted"); + assert_eq!(claims.get_str("repository"), Some("oxidecomputer/hubris")); + } + + #[test] + fn rejects_wrong_audience() { + // Token minted for this service but the service expects a different + // audience — must be rejected (cross-service replay protection). + let config = test_config(); + let settings = test_settings("https://some-other-service.example.com"); + let token = sign_rs256(valid_claims()); + assert!( + config.validate(&settings, &token).is_err(), + "token with mismatched audience must be rejected" + ); + } + + #[test] + fn rejects_missing_audience() { + // A token with no `aud` claim must not be accepted, otherwise a token + // not scoped to any service could be replayed here. + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut claims = valid_claims(); + claims.as_object_mut().unwrap().remove("aud"); + let token = sign_rs256(claims); + assert!( + config.validate(&settings, &token).is_err(), + "token without an aud claim must be rejected" + ); + } + + #[test] + fn rejects_wrong_issuer() { + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut claims = valid_claims(); + claims["iss"] = serde_json::json!("https://evil.example.com"); + let token = sign_rs256(claims); + assert!( + config.validate(&settings, &token).is_err(), + "token with mismatched issuer must be rejected" + ); + } + + #[test] + fn rejects_missing_issuer() { + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut claims = valid_claims(); + claims.as_object_mut().unwrap().remove("iss"); + let token = sign_rs256(claims); + assert!( + config.validate(&settings, &token).is_err(), + "token without an iss claim must be rejected" + ); + } + + #[test] + fn rejects_expired_token() { + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut claims = valid_claims(); + claims["exp"] = serde_json::json!(now() - 3600); + let token = sign_rs256(claims); + assert!( + config.validate(&settings, &token).is_err(), + "expired token must be rejected" + ); + } + + #[test] + fn rejects_missing_exp() { + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut claims = valid_claims(); + claims.as_object_mut().unwrap().remove("exp"); + let token = sign_rs256(claims); + assert!( + config.validate(&settings, &token).is_err(), + "token without an exp claim must be rejected" + ); + } + + #[test] + fn rejects_unknown_kid() { + // The token references a key that isn't in the JWKS. + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("some-other-key".to_string()); + let key = EncodingKey::from_rsa_pem(TEST_KEY_PEM.as_bytes()).unwrap(); + let token = encode(&header, &valid_claims(), &key).unwrap(); + let err = config.validate(&settings, &token).unwrap_err(); + assert!( + matches!(err, OidcError::UnknownKid(_)), + "expected UnknownKid, got: {err:?}" + ); + } + + #[test] + fn rejects_tampered_signature() { + // Sign a valid token, then corrupt the signature segment. Verifies the + // RS256 signature is actually checked. + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let token = sign_rs256(valid_claims()); + let mut parts: Vec<&str> = token.split('.').collect(); + let tampered_sig = if parts[2].starts_with('A') { + format!("B{}", &parts[2][1..]) + } else { + format!("A{}", &parts[2][1..]) + }; + parts[2] = &tampered_sig; + let tampered = parts.join("."); + assert!( + config.validate(&settings, &tampered).is_err(), + "token with a tampered signature must be rejected" + ); + } + + #[test] + fn rejects_hs256_key_confusion() { + // The classic RS256 → HS256 key confusion attack: an attacker signs a + // token with HS256, using the (public) RSA key material as the HMAC + // secret, hoping the verifier will treat the public key as a shared + // secret. The JWKS declares RS256, so validation must only accept + // RS256 and reject the HS256 token on algorithm grounds, before any + // signature check. + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(TEST_KID.to_string()); + // The attacker can use any secret; the public key bytes are the usual + // choice. It does not matter — the algorithm itself must be refused. + let secret = EncodingKey::from_secret(JWK_N.as_bytes()); + let token = encode(&header, &valid_claims(), &secret).unwrap(); + + let err = config.validate(&settings, &token).unwrap_err(); + assert!( + matches!(err, OidcError::InvalidToken(_)), + "HS256 token must be rejected, got: {err:?}" + ); + } + + #[test] + fn rejects_none_algorithm() { + // An `alg: none` (unsigned) token must never be accepted. + // jsonwebtoken refuses to even encode `none`, so craft the token + // manually: base64url(header).base64url(claims). with an empty sig. + use base64::Engine; + let config = test_config(); + let settings = test_settings(TEST_AUDIENCE); + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode( + serde_json::json!({ "alg": "none", "typ": "JWT", "kid": TEST_KID }).to_string(), + ); + let payload = engine.encode(valid_claims().to_string()); + let token = format!("{header}.{payload}."); + assert!( + config.validate(&settings, &token).is_err(), + "unsigned (alg: none) token must be rejected" + ); + } +} diff --git a/src/policy.rs b/src/policy.rs index d5e3005..891c15d 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -5,65 +5,123 @@ use crate::endpoints::TokenRequest; use crate::oidc::Claims; use crate::token::github::{GitHubTokenError, GitHubTokens}; +use cedar_policy::entities_errors::EntitiesError; +use cedar_policy::{ + Authorizer, CedarSchemaError, Context, Decision, Entities, Entity, EntityAttrEvaluationError, + EntityId, EntityTypeName, EntityUid, ParseErrors, PolicySet, Request, RequestValidationError, + RestrictedExpression, Schema, +}; use chrono::{DateTime, Duration, Utc}; -use oso::{Class, Oso, OsoError, PolarClass, ToPolar}; -use std::collections::HashMap; -use std::fmt::Display; -use std::path::Path; -use std::sync::{Arc, Mutex}; +use reqwest::Url; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::{Arc, LazyLock, Mutex}; + +macro_rules! cedar_entity { + ($static_name:ident, $type_name:literal, $fn_name:ident, $id_type:ty) => { + static $static_name: LazyLock = LazyLock::new(|| { + EntityTypeName::from_str($type_name) + .expect(concat!("invalid entity type name: ", $type_name)) + }); + + fn $fn_name(id: $id_type) -> EntityUid { + EntityUid::from_type_name_and_id($static_name.clone(), EntityId::new(id)) + } + }; +} + +cedar_entity!( + WORKFLOW_RUN_TYPE, + "Oidcx::WorkflowRun", + workflow_run_uid, + &str +); +cedar_entity!(REPOSITORY_TYPE, "Oidcx::Repository", repository_uid, &str); +cedar_entity!(SILO_TYPE, "Oidcx::Silo", silo_uid, &Url); +cedar_entity!(ACTION_TYPE, "Oidcx::Action", action_uid, &str); + +/// Build a Cedar principal entity for a GitHub Actions workflow run. +pub fn workflow_run_entity(claims: &Claims) -> Result { + let uid = workflow_run_uid(claims.get_str("jti").ok_or(PolicyError::NoPrincipal)?); + + let mut attrs = HashMap::from([ + ( + "repository".into(), + RestrictedExpression::new_string(claims.get_str("repository").unwrap_or("").into()), + ), + ( + "repository_owner".into(), + RestrictedExpression::new_string( + claims.get_str("repository_owner").unwrap_or("").into(), + ), + ), + ( + "repository_visibility".into(), + RestrictedExpression::new_string( + claims.get_str("repository_visibility").unwrap_or("").into(), + ), + ), + ( + "event_name".into(), + RestrictedExpression::new_string(claims.get_str("event_name").unwrap_or("").into()), + ), + ( + "iss".into(), + RestrictedExpression::new_string(claims.get_str("iss").unwrap_or("").into()), + ), + ]); + if let Some(env) = claims.get_str("environment") { + attrs.insert( + "environment".into(), + RestrictedExpression::new_string(env.into()), + ); + } + Entity::new(uid, attrs, HashSet::new()).map_err(PolicyError::EntityEvaluation) +} pub struct Policy { - oso: Oso, + schema: Schema, + policy_set: PolicySet, + authorizer: Authorizer, github_tokens: GitHubTokens, github_visibility_cache: Arc>>, } impl Policy { - pub fn new(path: &Path, github_tokens: GitHubTokens) -> Result { - let mut oso = Oso::new(); - oso.register_class(GitHubClass::get_polar_class())?; - oso.register_class(OxideClass::get_polar_class())?; - oso.register_class(create_utils_class())?; - oso.load_files(vec![path])?; - Ok(Self { - oso, + pub fn new(schema: Schema, policy_set: PolicySet, github_tokens: GitHubTokens) -> Self { + Self { + schema, + policy_set, + authorizer: Authorizer::new(), github_tokens, github_visibility_cache: Arc::new(Mutex::new(HashMap::new())), - }) + } } pub async fn ensure_allowed( &self, - claims: &Claims, + principal: &Entity, request: &TokenRequest, ) -> Result<(), PolicyError> { match request { - TokenRequest::Oxide(oxide) => self.ensure_permutation( - claims, - OxideClass { - silo: oxide.silo.clone(), - duration: oxide.duration as _, - }, - ), + TokenRequest::Oxide(oxide) => { + self.authorize(principal, "deploy", Self::silo_entity(&oxide.silo)?)?; + Ok(()) + } TokenRequest::GitHub(github) => { - // We require at least one repository and one permission to be present. If either - // is empty we would not actually run the `ensure_permutation` check which evaluates - // the policy against the repositories and permissions provided. + // Reject requests that wouldn't exercise any authorization + // check. An empty repository or permission list would pass the + // loop below vacuously, and GitHub grants a broadly-scoped + // token when the `permissions` object is empty. Fail closed. if github.repositories.is_empty() || github.permissions.is_empty() { - return Err(PolicyError::InvalidTokenRequest); + return Err(PolicyError::EmptyRequest); } for repository in &github.repositories { let repository_visibility = self.github_visibility(repository).await?; + let resource = Self::repository_entity(repository, &repository_visibility)?; for permission in &github.permissions { - self.ensure_permutation( - claims, - GitHubClass { - repository: repository.clone(), - repository_visibility: repository_visibility.clone(), - permission: permission.clone(), - }, - )?; + self.authorize(principal, permission, resource.clone())?; } } Ok(()) @@ -71,22 +129,68 @@ impl Policy { } } - fn ensure_permutation( + pub(crate) fn authorize( &self, - claims: &Claims, - permutation: T, + principal: &Entity, + action: &str, + resource: Entity, ) -> Result<(), PolicyError> { - let string_repr = permutation.to_string(); - let mut result = self - .oso - .query_rule("allow_request", (claims.clone(), permutation))?; - match result.next() { - Some(Ok(_)) => Ok(()), - Some(Err(e)) => Err(e.into()), - None => Err(PolicyError::NotMatching(string_repr)), + let principal_uid = principal.uid().clone(); + let resource_uid = resource.uid().clone(); + + let entities = Entities::from_entities([principal.clone(), resource], Some(&self.schema)) + .map_err(PolicyError::Entities)?; + + let request = Request::new( + principal_uid, + action_uid(action), + resource_uid, + Context::empty(), + Some(&self.schema), + ) + .map_err(PolicyError::Request)?; + + let response = self + .authorizer + .is_authorized(&request, &self.policy_set, &entities); + + match response.decision() { + Decision::Allow => Ok(()), + Decision::Deny => { + let errors: Vec<_> = response + .diagnostics() + .errors() + .map(|e| e.to_string()) + .collect(); + if !errors.is_empty() { + tracing::warn!(?errors, "policy evaluation errors"); + } + Err(PolicyError::NotMatching(action.to_string())) + } } } + pub(crate) fn repository_entity(name: &str, visibility: &str) -> Result { + let uid = repository_uid(name); + let attrs = HashMap::from([ + ("name".into(), RestrictedExpression::new_string(name.into())), + ( + "visibility".into(), + RestrictedExpression::new_string(visibility.into()), + ), + ]); + Entity::new(uid, attrs, HashSet::new()).map_err(PolicyError::EntityEvaluation) + } + + pub(crate) fn silo_entity(url: &Url) -> Result { + let uid = silo_uid(url); + let attrs = HashMap::from([( + "url".into(), + RestrictedExpression::new_string(url.to_string()), + )]); + Entity::new(uid, attrs, HashSet::new()).map_err(PolicyError::EntityEvaluation) + } + async fn github_visibility(&self, repo: &str) -> Result { // We are not holding the lock across the await point below. { @@ -121,65 +225,500 @@ impl std::fmt::Debug for Policy { } } -#[derive(PolarClass, Clone)] -#[polar(class_name = "Oxide")] -struct OxideClass { - #[polar(attribute)] - silo: String, - #[polar(attribute)] - duration: i64, -} - -impl std::fmt::Display for OxideClass { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "silo {}", self.silo) - } -} - -#[derive(PolarClass, Clone)] -#[polar(class_name = "GitHub")] -struct GitHubClass { - #[polar(attribute)] - repository: String, - #[polar(attribute)] - repository_visibility: String, - #[polar(attribute)] - permission: String, -} - -impl std::fmt::Display for GitHubClass { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "permission {} on repository {}", - self.permission, self.repository - ) - } -} - struct CachedVisibility { visibility: String, expires_at: DateTime, } -pub(super) fn create_utils_class() -> Class { - #[derive(Clone, PolarClass)] - #[polar(class_name = "utils")] - struct Utils; - - Utils::get_polar_class_builder() - .add_class_method("concat", |a: String, b: String| format!("{a}{b}")) - .build() -} - #[derive(Debug, thiserror::Error)] pub enum PolicyError { - #[error("Failed to evaluate the authorization policy")] - Oso(#[from] OsoError), - #[error("{0} does not match the authorization policy")] + #[error("Failed to read policy file {0}")] + ReadFile(std::path::PathBuf, #[source] std::io::Error), + #[error("Failed to parse Cedar schema")] + InitSchema(#[source] CedarSchemaError), + #[error("Failed to parse Cedar policy")] + InitPolicy(#[source] ParseErrors), + #[error("Failed to construct Cedar entities")] + Entities(#[source] EntitiesError), + #[error("Failed to evaluate Cedar entities")] + EntityEvaluation(#[source] EntityAttrEvaluationError), + #[error("Failed to construct Cedar authorization request")] + Request(#[source] RequestValidationError), + #[error("Does not match the authorization policy")] NotMatching(String), + #[error("Token request must specify at least one repository and one permission")] + EmptyRequest, + #[error("Claims do not contain an id to construct a principal from")] + NoPrincipal, #[error("failed to retrieve the repository visibility for {0}")] GetVisibility(String, #[source] GitHubTokenError), - #[error("invalid token request")] - InvalidTokenRequest, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oidc::Claims; + use cedar_policy::EvalResult; + + /// Load the static application schema from the cedarschema file. + fn load_schema() -> Schema { + let src = include_str!("../policy.cedarschema"); + let (schema, _warnings) = + Schema::from_cedarschema_str(src).expect("failed to parse application schema"); + schema + } + + /// Helper: build a Claims with typical GitHub Actions OIDC fields. + fn github_actions_claims() -> Claims { + Claims::from_json(serde_json::json!({ + "jti": "test-run-id", + "iss": "https://token.actions.githubusercontent.com", + "repository": "oxidecomputer/hubris", + "repository_owner": "oxidecomputer", + "repository_visibility": "public", + "event_name": "push", + })) + } + + #[test] + fn workflow_run_entity_has_correct_type() { + let claims = github_actions_claims(); + let entity = workflow_run_entity(&claims).unwrap(); + assert_eq!(entity.uid().type_name().to_string(), "Oidcx::WorkflowRun"); + } + + #[test] + fn workflow_run_entity_uses_jti_as_id() { + let claims = github_actions_claims(); + let entity = workflow_run_entity(&claims).unwrap(); + assert!( + entity.uid().to_string().contains("test-run-id"), + "entity uid should contain the jti value, got: {}", + entity.uid() + ); + } + + #[test] + fn workflow_run_entity_maps_required_attributes() { + let claims = github_actions_claims(); + let entity = workflow_run_entity(&claims).unwrap(); + + assert_eq!( + entity.attr("repository").unwrap().unwrap(), + EvalResult::String("oxidecomputer/hubris".into()) + ); + assert_eq!( + entity.attr("repository_owner").unwrap().unwrap(), + EvalResult::String("oxidecomputer".into()) + ); + assert_eq!( + entity.attr("repository_visibility").unwrap().unwrap(), + EvalResult::String("public".into()) + ); + assert_eq!( + entity.attr("event_name").unwrap().unwrap(), + EvalResult::String("push".into()) + ); + assert_eq!( + entity.attr("iss").unwrap().unwrap(), + EvalResult::String("https://token.actions.githubusercontent.com".into()) + ); + } + + #[test] + fn workflow_run_entity_includes_environment_when_present() { + let claims = Claims::from_json(serde_json::json!({ + "jti": "test-run-id", + "iss": "https://token.actions.githubusercontent.com", + "repository": "oxidecomputer/corp-services", + "repository_owner": "oxidecomputer", + "repository_visibility": "private", + "event_name": "push", + "environment": "staging", + })); + let entity = workflow_run_entity(&claims).unwrap(); + assert_eq!( + entity.attr("environment").unwrap().unwrap(), + EvalResult::String("staging".into()) + ); + } + + #[test] + fn workflow_run_entity_omits_environment_when_absent() { + let claims = github_actions_claims(); + let entity = workflow_run_entity(&claims).unwrap(); + assert!( + entity.attr("environment").is_none(), + "environment attribute should not be present when claim is missing" + ); + } + + #[test] + fn workflow_run_entity_requires_jti() { + let claims = Claims::from_json(serde_json::json!({ + "iss": "https://token.actions.githubusercontent.com", + "repository": "oxidecomputer/hubris", + })); + let err = workflow_run_entity(&claims).unwrap_err(); + assert!( + matches!(err, PolicyError::NoPrincipal), + "expected NoPrincipal, got: {err:?}" + ); + } + + #[test] + fn workflow_run_entity_defaults_missing_strings_to_empty() { + // Only jti is present — all other string claims are missing. + let claims = Claims::from_json(serde_json::json!({ + "jti": "minimal" + })); + let entity = workflow_run_entity(&claims).unwrap(); + assert_eq!( + entity.attr("repository").unwrap().unwrap(), + EvalResult::String("".into()) + ); + assert_eq!( + entity.attr("iss").unwrap().unwrap(), + EvalResult::String("".into()) + ); + } + + #[test] + fn workflow_run_entity_conforms_to_schema() { + let schema = load_schema(); + let claims = github_actions_claims(); + let entity = workflow_run_entity(&claims).unwrap(); + Entities::from_entities([entity], Some(&schema)) + .expect("WorkflowRun entity should conform to the schema"); + } + + #[test] + fn workflow_run_entity_with_environment_conforms_to_schema() { + let schema = load_schema(); + let claims = Claims::from_json(serde_json::json!({ + "jti": "test-run-id", + "iss": "https://token.actions.githubusercontent.com", + "repository": "oxidecomputer/corp-services", + "repository_owner": "oxidecomputer", + "repository_visibility": "private", + "event_name": "push", + "environment": "production", + })); + let entity = workflow_run_entity(&claims).unwrap(); + Entities::from_entities([entity], Some(&schema)) + .expect("WorkflowRun entity with environment should conform to the schema"); + } + + #[test] + fn repository_entity_has_correct_type() { + let entity = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + assert_eq!(entity.uid().type_name().to_string(), "Oidcx::Repository"); + } + + #[test] + fn repository_entity_has_correct_attributes() { + let entity = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + assert_eq!( + entity.attr("name").unwrap().unwrap(), + EvalResult::String("oxidecomputer/hubris".into()) + ); + assert_eq!( + entity.attr("visibility").unwrap().unwrap(), + EvalResult::String("public".into()) + ); + } + + #[test] + fn repository_entity_conforms_to_schema() { + let schema = load_schema(); + let entity = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + Entities::from_entities([entity], Some(&schema)) + .expect("Repository entity should conform to the schema"); + } + + #[test] + fn silo_entity_has_correct_attributes() { + let url: Url = "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(); + let entity = Policy::silo_entity(&url).unwrap(); + assert_eq!( + entity.attr("url").unwrap().unwrap(), + EvalResult::String("https://corp-staging.sys.r3.oxide-preview.com/".into()) + ); + } + + #[test] + fn silo_entity_conforms_to_schema() { + let schema = load_schema(); + let url: Url = "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(); + let entity = Policy::silo_entity(&url).unwrap(); + Entities::from_entities([entity], Some(&schema)) + .expect("Silo entity should conform to the schema"); + } + + #[test] + fn all_entity_types_conform_to_schema_together() { + let schema = load_schema(); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let repository = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + let url: Url = "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(); + let silo = Policy::silo_entity(&url).unwrap(); + + Entities::from_entities([principal, repository, silo], Some(&schema)) + .expect("all entity types should conform to the schema together"); + } + + /// Helper: build a Policy with the real schema, a given inline policy, and + /// no GitHub credentials (sufficient for authorize() which doesn't need them). + fn policy_with(cedar_src: &str) -> Policy { + let schema = load_schema(); + let policy_set: PolicySet = cedar_src.parse().expect("failed to parse test policy"); + let settings = crate::settings::Settings { + audience: String::new(), + policy_path: std::path::PathBuf::new(), + log_directory: None, + port: None, + providers: vec![], + oxide: None, + github: None, + }; + let github_tokens = crate::token::github::GitHubTokens::new(&settings) + .expect("failed to create dummy GitHubTokens"); + Policy::new(schema, policy_set, github_tokens) + } + + #[test] + fn authorize_allows_when_policy_permits() { + let policy = policy_with( + r#" + permit(principal, action, resource); + "#, + ); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let resource = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + + policy + .authorize(&principal, "contents:read", resource) + .unwrap(); + } + + #[test] + fn authorize_denies_when_no_policy_matches() { + // Empty policy set — default deny. + let policy = policy_with(""); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let resource = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + + let err = policy + .authorize(&principal, "contents:read", resource) + .unwrap_err(); + assert!( + matches!(err, PolicyError::NotMatching(_)), + "expected NotMatching, got: {err:?}" + ); + } + + #[test] + fn authorize_denies_when_forbid_overrides_permit() { + let policy = policy_with( + r#" + permit(principal, action, resource); + forbid(principal, action, resource); + "#, + ); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let resource = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + + let err = policy + .authorize(&principal, "contents:read", resource) + .unwrap_err(); + assert!( + matches!(err, PolicyError::NotMatching(_)), + "expected NotMatching, got: {err:?}" + ); + } + + #[test] + fn authorize_works_with_silo_entity() { + let policy = policy_with( + r#" + permit(principal, action, resource); + "#, + ); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let url: Url = "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(); + let resource = Policy::silo_entity(&url).unwrap(); + + policy.authorize(&principal, "deploy", resource).unwrap(); + } + + #[tokio::test] + async fn ensure_allowed_oxide_permit() { + let policy = policy_with( + r#" + permit(principal, action, resource); + "#, + ); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let request = + crate::endpoints::TokenRequest::Oxide(crate::token::oxide::OxideTokenRequest { + silo: "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(), + duration: 3600, + }); + + policy.ensure_allowed(&principal, &request).await.unwrap(); + } + + #[tokio::test] + async fn ensure_allowed_oxide_deny() { + let policy = policy_with(""); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let request = + crate::endpoints::TokenRequest::Oxide(crate::token::oxide::OxideTokenRequest { + silo: "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(), + duration: 3600, + }); + + let err = policy + .ensure_allowed(&principal, &request) + .await + .unwrap_err(); + assert!( + matches!(err, PolicyError::NotMatching(_)), + "expected NotMatching, got: {err:?}" + ); + } + + // These tests use a deliberately permissive allow-all policy. The point is + // that the schema (static application config) must reject type-confused or + // undefined actions BEFORE policy evaluation, so a catch-all permit can't + // be tricked into granting an action the resource doesn't support. This + // guards against a regression where `Request::new` is called without the + // schema (`None`), which would silently remove this protection. + + #[test] + fn authorize_rejects_deploy_action_on_repository() { + // A GitHub request controls the permission string. Smuggling in the + // Oxide "deploy" action against a Repository resource must be rejected, + // since the schema says `deploy` applies only to Silo. + let policy = policy_with("permit(principal, action, resource);"); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let resource = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + + let err = policy + .authorize(&principal, "deploy", resource) + .unwrap_err(); + assert!( + matches!(err, PolicyError::Request(_)), + "expected Request validation error, got: {err:?}" + ); + } + + #[test] + fn authorize_rejects_github_permission_on_silo() { + let policy = policy_with("permit(principal, action, resource);"); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let url: Url = "https://corp-staging.sys.r3.oxide-preview.com" + .parse() + .unwrap(); + let resource = Policy::silo_entity(&url).unwrap(); + + let err = policy + .authorize(&principal, "contents:read", resource) + .unwrap_err(); + assert!( + matches!(err, PolicyError::Request(_)), + "expected Request validation error, got: {err:?}" + ); + } + + #[test] + fn authorize_rejects_undefined_action() { + // An action not declared in the schema must be rejected even under an + // allow-all policy, so a request can't smuggle in an arbitrary + // permission string that a catch-all permit might otherwise match. + let policy = policy_with("permit(principal, action, resource);"); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let resource = Policy::repository_entity("oxidecomputer/hubris", "public").unwrap(); + + let err = policy + .authorize(&principal, "admin:write", resource) + .unwrap_err(); + assert!( + matches!(err, PolicyError::Request(_)), + "expected Request validation error, got: {err:?}" + ); + } + + // An empty repository or permission list would otherwise skip the + // authorization loop entirely and return Ok, while GitHub grants a + // broadly-scoped token for an empty `permissions` object. + + #[tokio::test] + async fn ensure_allowed_rejects_empty_repositories() { + // Allow-all policy: the request must still be rejected because no + // repository means no authorization check was performed. + let policy = policy_with("permit(principal, action, resource);"); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let request = + crate::endpoints::TokenRequest::GitHub(crate::token::github::GitHubTokenRequest { + repositories: vec![], + permissions: vec!["contents:read".into()], + }); + + let err = policy + .ensure_allowed(&principal, &request) + .await + .unwrap_err(); + assert!( + matches!(err, PolicyError::EmptyRequest), + "expected EmptyRequest, got: {err:?}" + ); + } + + #[tokio::test] + async fn ensure_allowed_rejects_empty_permissions() { + // Allow-all policy and a real repository, but no permissions. This must + // fail closed BEFORE any GitHub API call (no network in this test), + // preventing an empty-permissions token request. + let policy = policy_with("permit(principal, action, resource);"); + let claims = github_actions_claims(); + let principal = workflow_run_entity(&claims).unwrap(); + let request = + crate::endpoints::TokenRequest::GitHub(crate::token::github::GitHubTokenRequest { + repositories: vec!["oxidecomputer/hubris".into()], + permissions: vec![], + }); + + let err = policy + .ensure_allowed(&principal, &request) + .await + .unwrap_err(); + assert!( + matches!(err, PolicyError::EmptyRequest), + "expected EmptyRequest, got: {err:?}" + ); + } } diff --git a/src/providers.rs b/src/providers.rs new file mode 100644 index 0000000..3ab79cb --- /dev/null +++ b/src/providers.rs @@ -0,0 +1,112 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Known remote identity providers and their principal entity builders. + +use serde::Deserialize; + +use crate::context::PrincipalBuilder; +use crate::policy; + +/// Configuration for an OIDC identity provider. Each variant represents a +/// known provider type with its own claim structure and Cedar principal entity +/// type. +/// +/// ```toml +/// [[providers]] +/// type = "github_actions" +/// url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration" +/// ``` +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProviderConfig { + #[serde(rename = "github_actions")] + GitHubActions { url: String }, +} + +impl ProviderConfig { + /// The OIDC discovery URL for this provider. + pub fn url(&self) -> &str { + match self { + ProviderConfig::GitHubActions { url } => url, + } + } + + /// The [`PrincipalBuilder`] that converts validated OIDC claims into the + /// correct Cedar principal entity for this provider type. + pub fn principal_builder(&self) -> PrincipalBuilder { + match self { + ProviderConfig::GitHubActions { .. } => policy::workflow_run_entity, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_github_actions_provider() { + let config: ProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "github_actions", + "url": "https://token.actions.githubusercontent.com/.well-known/openid-configuration" + })) + .unwrap(); + + assert!( + matches!(config, ProviderConfig::GitHubActions { .. }), + "expected GitHubActions variant, got: {config:?}" + ); + assert_eq!( + config.url(), + "https://token.actions.githubusercontent.com/.well-known/openid-configuration" + ); + } + + #[test] + fn deserialize_unknown_type_is_error() { + let result = serde_json::from_value::(serde_json::json!({ + "type": "unknown_provider", + "url": "https://example.com" + })); + + assert!( + result.is_err(), + "unknown provider type should fail deserialization" + ); + } + + #[test] + fn deserialize_missing_type_is_error() { + let result = serde_json::from_value::(serde_json::json!({ + "url": "https://example.com" + })); + + assert!( + result.is_err(), + "missing type field should fail deserialization" + ); + } + + #[test] + fn principal_builder_produces_correct_entity_type() { + let config: ProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "github_actions", + "url": "https://example.com" + })) + .unwrap(); + + let builder = config.principal_builder(); + let claims = crate::oidc::Claims::from_json(serde_json::json!({ + "jti": "test-id", + "iss": "https://token.actions.githubusercontent.com", + "repository": "oxidecomputer/test", + "repository_owner": "oxidecomputer", + "repository_visibility": "public", + "event_name": "push", + })); + let entity = builder(&claims).unwrap(); + assert_eq!(entity.uid().type_name().to_string(), "Oidcx::WorkflowRun"); + } +} diff --git a/src/settings.rs b/src/settings.rs index 7d3f301..a41c120 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use config::{Config, ConfigError, File}; use serde::Deserialize; -use crate::oidc::OidcProvider; +use crate::providers::ProviderConfig; #[derive(Debug, Deserialize)] pub struct Settings { @@ -16,7 +16,7 @@ pub struct Settings { pub policy_path: PathBuf, pub log_directory: Option, pub port: Option, - pub providers: Vec, + pub providers: Vec, #[serde(default)] pub oxide: Option, #[serde(default)] diff --git a/src/token/github.rs b/src/token/github.rs index cb6171d..bc9456d 100644 --- a/src/token/github.rs +++ b/src/token/github.rs @@ -22,6 +22,90 @@ pub struct GitHubTokenRequest { pub permissions: Vec, } +/// A [`GitHubTokenRequest`] whose repository and permission strings have been +/// validated and parsed exactly once. +/// +/// The raw request carries free-form strings (`"org/name"`, `"contents:read"`). +/// Several consumers need structured pieces of that data: the org namespace to +/// locate the app installation, the bare repository names for the API call, and +/// the permission name/level pairs. Re-splitting the strings at each use site +/// would risk the parsed scope drifting from what was authorized. Instead we +/// parse once, here, and every consumer reads the already-validated fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedGitHubRequest { + namespace: String, + repository_names: Vec, + permissions: HashMap, +} + +impl ParsedGitHubRequest { + /// Validate and parse a raw request. All scope-determining parsing happens + /// here and nowhere else. + pub fn parse(request: &GitHubTokenRequest) -> Result { + // Every repository must be `namespace/name` (no extra slashes) and all + // repositories must share a single namespace, since the token is scoped + // to one app installation. + let mut namespace: Option<&str> = None; + let mut repository_names = Vec::new(); + for repo in &request.repositories { + match repo.split_once('/') { + Some((ns, name)) if !name.contains('/') => { + if let Some(existing) = namespace + && existing != ns + { + return Err(GitHubTokenError::DifferentOrgs); + } + namespace = Some(ns); + repository_names.push(name.to_string()); + } + _ => return Err(GitHubTokenError::NotAGitHubRepository(repo.clone())), + } + } + let namespace = namespace + .ok_or(GitHubTokenError::NoRepositories)? + .to_string(); + + // Each permission is `name:level`. The name must not contain a slash, + // and a name may appear only once. + let mut permissions = HashMap::new(); + for permission in &request.permissions { + match permission.split_once(':') { + Some((name, level)) if !name.contains('/') => { + if permissions + .insert(name.to_string(), level.to_string()) + .is_some() + { + return Err(GitHubTokenError::DuplicatePermission(name.into())); + } + } + _ => return Err(GitHubTokenError::NotAPermission(permission.into())), + } + } + + Ok(Self { + namespace, + repository_names, + permissions, + }) + } + + /// The single org/user namespace all repositories belong to. + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// Repository names without their namespace prefix, as the GitHub API + /// expects them. + pub fn repository_names(&self) -> &[String] { + &self.repository_names + } + + /// Requested permissions as a map of permission name to access level. + pub fn permissions(&self) -> &HashMap { + &self.permissions + } +} + #[derive(Debug)] struct State { client: Client, @@ -56,6 +140,10 @@ impl GitHubTokens { pub async fn get(&self, request: &GitHubTokenRequest) -> Result { let state = self.state.as_ref().ok_or(GitHubTokenError::NoCredentials)?; + // Validate and parse the request once. Every consumer below reads the + // parsed fields rather than re-splitting the raw request strings. + let parsed = ParsedGitHubRequest::parse(request)?; + // Generate a JWT valid for 5 minutes, used to authenticate with GitHub. let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -75,38 +163,6 @@ impl GitHubTokens { ) .map_err(GitHubTokenError::EncodeJwt)?; - // We need all repositories to belong to a single namespace (user or organization), as we - // need to assume the role of the installation of the app in that namespace. While we are - // at it, we also collect the repository names without the namespace, as the API requires. - let mut found_namespace = None; - let mut repos_without_namespace = Vec::new(); - for repo in &request.repositories { - match repo.split_once('/') { - Some((namespace, name)) if !name.contains('/') => { - if found_namespace.is_some() && found_namespace != Some(namespace) { - return Err(GitHubTokenError::DifferentOrgs); - } - found_namespace = Some(namespace); - repos_without_namespace.push(name); - } - _ => return Err(GitHubTokenError::NotAGitHubRepository(repo.clone())), - } - } - let namespace = found_namespace.ok_or(GitHubTokenError::NoRepositories)?; - - // Convert the permission:level syntax in the format GitHub expects. - let mut permissions = HashMap::new(); - for permission in &request.permissions { - match permission.split_once(':') { - Some((name, level)) if !name.contains('/') => { - if let Some(_) = permissions.insert(name, level) { - return Err(GitHubTokenError::DuplicatePermission(name.into())); - } - } - _ => return Err(GitHubTokenError::NotAPermission(permission.into())), - } - } - // Get the installation ID. We look for the namespace in both the users and the // organizations, to gracefully handle when the app is installed on a personal account // rather than an organization. @@ -116,7 +172,8 @@ impl GitHubTokens { state .client .get(format!( - "https://api.github.com/{kind}/{namespace}/installation" + "https://api.github.com/{kind}/{}/installation", + parsed.namespace() )) .bearer_auth(&jwt), ) @@ -128,7 +185,7 @@ impl GitHubTokens { } } let installation = found_installation - .ok_or_else(|| GitHubTokenError::AppNotInstalled(namespace.into()))?; + .ok_or_else(|| GitHubTokenError::AppNotInstalled(parsed.namespace().into()))?; // Request the access token from GitHub. let access_token: AccessTokenResponse = github_request( @@ -139,8 +196,8 @@ impl GitHubTokens { )) .bearer_auth(&jwt) .json(&serde_json::json!({ - "repositories": repos_without_namespace, - "permissions": permissions, + "repositories": parsed.repository_names(), + "permissions": parsed.permissions(), })), ) .await?; @@ -260,3 +317,184 @@ impl GitHubTokenError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn request(repositories: &[&str], permissions: &[&str]) -> GitHubTokenRequest { + GitHubTokenRequest { + repositories: repositories.iter().map(|s| s.to_string()).collect(), + permissions: permissions.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn parses_single_repository() { + let parsed = + ParsedGitHubRequest::parse(&request(&["oxidecomputer/hubris"], &["contents:read"])) + .unwrap(); + assert_eq!(parsed.namespace(), "oxidecomputer"); + assert_eq!(parsed.repository_names(), &["hubris".to_string()]); + } + + #[test] + fn parses_multiple_repositories_in_same_namespace() { + let parsed = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris", "oxidecomputer/bootleby"], + &["contents:read"], + )) + .unwrap(); + assert_eq!(parsed.namespace(), "oxidecomputer"); + assert_eq!( + parsed.repository_names(), + &["hubris".to_string(), "bootleby".to_string()] + ); + } + + #[test] + fn rejects_repositories_from_different_namespaces() { + let err = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris", "someoneelse/repo"], + &["contents:read"], + )) + .unwrap_err(); + assert!( + matches!(err, GitHubTokenError::DifferentOrgs), + "got: {err:?}" + ); + } + + #[test] + fn rejects_repository_without_namespace() { + let err = + ParsedGitHubRequest::parse(&request(&["justaname"], &["contents:read"])).unwrap_err(); + assert!( + matches!(err, GitHubTokenError::NotAGitHubRepository(ref r) if r == "justaname"), + "got: {err:?}" + ); + } + + #[test] + fn rejects_repository_with_extra_slashes() { + // A repo with more than one slash must be rejected, not silently + // reinterpreted, so the namespace can't be confused. + let err = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris/extra"], + &["contents:read"], + )) + .unwrap_err(); + assert!( + matches!(err, GitHubTokenError::NotAGitHubRepository(ref r) if r == "oxidecomputer/hubris/extra"), + "got: {err:?}" + ); + } + + #[test] + fn rejects_empty_repositories() { + let err = ParsedGitHubRequest::parse(&request(&[], &["contents:read"])).unwrap_err(); + assert!( + matches!(err, GitHubTokenError::NoRepositories), + "got: {err:?}" + ); + } + + #[test] + fn parses_permissions() { + let parsed = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris"], + &["contents:read", "pull_requests:write"], + )) + .unwrap(); + assert_eq!( + parsed.permissions().get("contents"), + Some(&"read".to_string()) + ); + assert_eq!( + parsed.permissions().get("pull_requests"), + Some(&"write".to_string()) + ); + assert_eq!(parsed.permissions().len(), 2); + } + + #[test] + fn rejects_permission_without_level() { + let err = ParsedGitHubRequest::parse(&request(&["oxidecomputer/hubris"], &["contents"])) + .unwrap_err(); + assert!( + matches!(err, GitHubTokenError::NotAPermission(ref p) if p == "contents"), + "got: {err:?}" + ); + } + + #[test] + fn rejects_permission_with_slash_in_name() { + // A slash in the permission name could let a crafted permission masquerade + // as a repository path; it must be rejected. + let err = + ParsedGitHubRequest::parse(&request(&["oxidecomputer/hubris"], &["org/contents:read"])) + .unwrap_err(); + assert!( + matches!(err, GitHubTokenError::NotAPermission(ref p) if p == "org/contents:read"), + "got: {err:?}" + ); + } + + #[test] + fn rejects_duplicate_permission() { + let err = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris"], + &["contents:read", "contents:write"], + )) + .unwrap_err(); + assert!( + matches!(err, GitHubTokenError::DuplicatePermission(ref p) if p == "contents"), + "got: {err:?}" + ); + } + + #[test] + fn permission_level_keeps_extra_colons() { + // `split_once(':')` only splits on the first colon, so a level may itself + // contain colons. This is benign (GitHub would reject an invalid level, + // and the policy layer rejects unknown action strings), but we pin the + // behavior so it can't change unnoticed. + let parsed = ParsedGitHubRequest::parse(&request( + &["oxidecomputer/hubris"], + &["contents:read:extra"], + )) + .unwrap(); + assert_eq!( + parsed.permissions().get("contents"), + Some(&"read:extra".to_string()) + ); + } + + #[test] + fn empty_permissions_parse_to_empty_map() { + // parse() itself permits empty permissions (the policy layer guards + // against empty requests); the map is simply empty. + let parsed = ParsedGitHubRequest::parse(&request(&["oxidecomputer/hubris"], &[])).unwrap(); + assert!(parsed.permissions().is_empty()); + } + + #[test] + fn private_key_errors_are_not_exposed() { + let err = GitHubTokenError::ReadPrivateKey( + "/secret/key.pem".into(), + std::io::Error::other("boom"), + ); + assert!( + !err.safe_to_expose(), + "private key path/IO errors must not be exposed to clients" + ); + } + + #[test] + fn request_validation_errors_are_safe_to_expose() { + assert!(GitHubTokenError::DifferentOrgs.safe_to_expose()); + assert!(GitHubTokenError::NoRepositories.safe_to_expose()); + assert!(GitHubTokenError::NotAGitHubRepository("x".into()).safe_to_expose()); + assert!(GitHubTokenError::NotAPermission("x".into()).safe_to_expose()); + } +} diff --git a/src/token/oxide.rs b/src/token/oxide.rs index 0befcee..e209567 100644 --- a/src/token/oxide.rs +++ b/src/token/oxide.rs @@ -13,7 +13,7 @@ use crate::{ endpoints::Token, oauth::{DeviceAccessTokenError, DeviceAccessTokenGrant, DeviceAuthorizationResponse}, settings::Settings, - util::{ByteStreamError, parse_bytestream}, + util::{ByteStreamError, Url, parse_bytestream}, }; static CLIENT_ID: &str = "730ae5f1-a728-4a5d-9a06-cf09b653cca6"; @@ -61,7 +61,7 @@ impl OxideError { #[derive(Clone, Debug, Deserialize, JsonSchema, Hash, PartialEq, Eq)] pub struct OxideTokenRequest { - pub silo: String, + pub silo: Url, pub duration: u32, } @@ -103,17 +103,16 @@ impl OxideTokens { return Err(OxideError::NotConfigured.into()); }; - if request.duration <= 0 && !state.allow_tokens_without_expiry { - return Err(OxideError::NoExpirationDisallowed.into()); - } - if request.duration > state.max_duration { - return Err(OxideError::TooLongExpiration(state.max_duration).into()); - } + validate_duration( + request.duration, + state.allow_tokens_without_expiry, + state.max_duration, + )?; let client = state .clients - .get(&request.silo) - .ok_or_else(|| OxideError::SiloNotConfigured(request.silo.clone()))?; + .get(&*request.silo.as_str()) + .ok_or_else(|| OxideError::SiloNotConfigured(request.silo.to_string()))?; let device_response = match client .device_auth_request() @@ -188,3 +187,81 @@ struct State { allow_tokens_without_expiry: bool, max_duration: u32, } + +/// Validate the requested token lifetime against the instance's policy. +/// +/// A duration of `0` means "no expiration" and is only permitted when the +/// instance explicitly allows it. Any duration above `max_duration` is +/// rejected. Note `duration` is a `u32`, so `0` is the only "non-positive" +/// value that can occur. +fn validate_duration( + duration: u32, + allow_tokens_without_expiry: bool, + max_duration: u32, +) -> Result<(), OxideError> { + if duration == 0 && !allow_tokens_without_expiry { + return Err(OxideError::NoExpirationDisallowed); + } + if duration > max_duration { + return Err(OxideError::TooLongExpiration(max_duration)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAX: u32 = 3600; + + #[test] + fn rejects_zero_duration_when_expiry_required() { + let err = validate_duration(0, false, MAX).unwrap_err(); + assert!( + matches!(err, OxideError::NoExpirationDisallowed), + "got: {err:?}" + ); + } + + #[test] + fn allows_zero_duration_when_configured() { + validate_duration(0, true, MAX) + .expect("a zero duration must be allowed when the instance permits it"); + } + + #[test] + fn allows_normal_duration() { + validate_duration(300, false, MAX).expect("an in-bounds duration must be allowed"); + } + + #[test] + fn allows_duration_at_max_boundary() { + validate_duration(MAX, false, MAX).expect("a duration equal to the max must be allowed"); + } + + #[test] + fn rejects_duration_exceeding_max() { + let err = validate_duration(MAX + 1, false, MAX).unwrap_err(); + assert!( + matches!(err, OxideError::TooLongExpiration(m) if m == MAX), + "got: {err:?}" + ); + } + + #[test] + fn rejects_overlong_duration_even_when_no_expiry_allowed() { + // allow_tokens_without_expiry only relaxes the zero case; it must not + // permit a duration above the maximum. + let err = validate_duration(MAX + 1, true, MAX).unwrap_err(); + assert!( + matches!(err, OxideError::TooLongExpiration(m) if m == MAX), + "got: {err:?}" + ); + } + + #[test] + fn duration_errors_are_safe_to_expose() { + assert!(OxideError::NoExpirationDisallowed.safe_to_expose()); + assert!(OxideError::TooLongExpiration(MAX).safe_to_expose()); + } +} diff --git a/src/util.rs b/src/util.rs index 02b3344..828e167 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,10 +4,72 @@ use bytes::Bytes; use futures_util::{Stream, StreamExt}; +use schemars::JsonSchema; +use schemars::schema::{InstanceType, SchemaObject, StringValidation}; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use std::pin::Pin; +use std::{fmt, ops::Deref, str::FromStr}; use thiserror::Error; +/// A newtype wrapper around [`reqwest::Url`] that implements [`JsonSchema`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Url(reqwest::Url); + +impl Url { + pub fn into_inner(self) -> reqwest::Url { + self.0 + } +} + +impl Deref for Url { + type Target = reqwest::Url; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Url { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl FromStr for Url { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + reqwest::Url::from_str(s).map(Url) + } +} + +impl From for Url { + fn from(url: reqwest::Url) -> Self { + Url(url) + } +} + +impl JsonSchema for Url { + fn schema_name() -> String { + "Url".to_string() + } + + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + format: Some("uri".to_string()), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + #[derive(Debug, Error)] pub enum ByteStreamError { #[error("Failed to read bytes from stream")] diff --git a/test_fixtures/README.md b/test_fixtures/README.md new file mode 100644 index 0000000..f48c3f3 --- /dev/null +++ b/test_fixtures/README.md @@ -0,0 +1,9 @@ +# Test fixtures + +`test_rsa_key.pem` is a **throwaway RSA keypair generated solely for unit +tests** of JWT validation (`src/oidc.rs`). It is not used by the running +service and protects nothing. Its public-key parameters (`n`, `e`) are embedded +as constants in the test module so the test can build a JWKS that matches this +signing key. + +Do not use this key for anything real. diff --git a/test_fixtures/test_rsa_key.pem b/test_fixtures/test_rsa_key.pem new file mode 100644 index 0000000..8694642 --- /dev/null +++ b/test_fixtures/test_rsa_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCRcyImyJZL92m5 +19VRl+TYcLnSV7w/RXc9iuZhiBEVyjEiX7e1RLUqgD8M5atWzDX7iTnaG8ADbj+c +ac/1Z2Zr+zSZ6hZR44wAxWs4tTcnhgqNYQbftPy6o53743PNQeWIf34aDTxHz0h4 +tso4cAYGQbfxbkPWhPagC7XkVc3ZrNU9GiRCEBi1qTJ0uD2EnKYO6ee7wnPbvDEU +oR8cmqOnIAYM6tvZYaFWAYfkHI/XqMMoocl35Or1O/NstSWPo3ZrVGSER5p4ofXz +5poxN0UsX9UBELgQw+Mx2D5l4xz+Squ9G5I+Knw1OR8QcmOQbYMsvdEo0MH/adu6 +JlmXkMstAgMBAAECggEAB4ESbcBTGsvCyWsUjy9gR/d26t6KcJQ/rO3t6XHI+eAS +7WRVWNWY6D6yIhL2ZlXGYjooATQF0dd+Ei6WY2Gok+Ejy14btTFhf4ev+r7Ev/YH +NNig72lJ0cJPzBdl2dzwv/xd7++nYMgVw96ga82BzhBVcnAfMWHuTH8WLRftNTIE +7BjSJ/VJnLQoFwFpXUdOi52xgvQZGvr/+vqFDMuyMiqCsa9JKCNwUzSe2wQb1umd +WtZ+Jm89n9dCL1T85HP1IrbNxtaIbi9K7+bEjp5FWJ9Ocuvtc0lEw1M2N8C2qWEW +SYvKNjp+rgJe1GTqayEErAfU8td3z5vOY101agqXSQKBgQDA+S6GZR5z9xsdPoAf +rZQTQA2VqemMrVYyC0Ho5HzzTDYOFad2e1v9MNWAxMObdiaBzOaBPU21tpMjMyDu +JkXxmZWe4fnLni7HSMHCikgBkRvHClovlHCEeTH+8QXJpnnrMLjYrTvCmgstatPC +mFbcVr6LWnsP+Dx9ASsHSyVgpQKBgQDA9Gx1bkutpCFWtYJhb6LjGyjuXYNEyvF1 +xeTXNw5qKXVm9poxQ7R7J8TsQN8bj1/mW0UY4HH9wsj9JyYnD/YSMqRK5QoLcarD +rFvUw8ZdEphCaiUQG1w+VBszQCyorpTByZyRZlQ+C2FdRRtIg/1Nqa/G60gmgS17 +29IYJudx6QKBgQCsPb3JN3h3CZiqAZ9apxQEGqgFbiOH7S+Ce4nY1hixENUuvSU8 +G99/npby2K2/lTXJpZoYwR8bklrCkGBUMNU9UFi79/d+RzJ5rfdELSkYPO0OO69D +yLqlg4echoUj9/PaeSKERBuTFuvjHfblOhRNlx98QsaU+n0DFofResFiAQKBgDBP +AbiE3L2kkhQ7bWEnqghiDGVhVDQs+zcZTKn1Rs6dgp88rm4XHWCYHF1qAHHN+5yY +zTvlNaTq15dJ7etpu5niw+9cWJ903eQPiw5B1bAHr8cw/AaEUxvnwseaEZRJ4USD +TYe5ru25u662VpZZNVn0WeXbkjzRqqmP55HUa63BAoGAAkFtFRtJjIUisZjrxUZT +SsqqsADSnSMJeNt5EhoiG8aM8VKvTi5I4xgsTjth4OM7EG5ELHLxJ1qIgs06sOE0 +ybFCzf+HJqgevrU/9L0oGFdEmprxKYcXwMZVU5sirVaJ9cM8hnabmiY6YdOda2qc +KE1uljm4E0dg4CbCduchbNU= +-----END PRIVATE KEY-----