diff --git a/.env.example b/.env.example index 98643b7..eaaa931 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,15 @@ TE_PROMOTE_INTERVAL_MS=500 TE_RECLAIM_INTERVAL_SEC=30 TE_STUCK_EXECUTION_TIMEOUT_SEC=300 -# Encryption -TE_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +# KMS (Secrets Manager) +TE_KMS_PROVIDER=aws +TE_KMS_AWS_REGION=us-east-1 +TE_KMS_AWS_ENDPOINT_URL=http://localhost:4566 + +# AWS credentials (dummy values for LocalStack) +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +AWS_DEFAULT_REGION=us-east-1 # API Key TE_API_KEY=dev-api-key diff --git a/Cargo.lock b/Cargo.lock index 6bbccaf..853d09c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64 0.22.1", + "base64", "bitflags", "brotli", "bytes", @@ -80,7 +80,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -198,7 +198,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -207,41 +207,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.12" @@ -359,7 +324,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -383,6 +348,48 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.1" @@ -406,10 +413,329 @@ dependencies = [ ] [[package]] -name = "base64" -version = "0.21.7" +name = "aws-runtime" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae64963d3d16d8070aaa2fb79c11cd3b13f44d2f13bba3fe8f49dcd2c42f2987" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] [[package]] name = "base64" @@ -417,6 +743,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -501,6 +837,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bytestring" version = "1.5.0" @@ -564,16 +910,6 @@ dependencies = [ "phf_codegen", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.6.0" @@ -602,10 +938,10 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -643,6 +979,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -770,19 +1115,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -835,7 +1170,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn", "unicode-xid", ] @@ -859,7 +1194,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -930,9 +1265,14 @@ dependencies = [ [[package]] name = "event-listener" -version = "2.5.3" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] [[package]] name = "fancy-regex" @@ -1149,16 +1489,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "h2" version = "0.3.27" @@ -1202,10 +1532,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -1213,6 +1539,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1224,20 +1552,11 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "heck" -version = "0.4.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "unicode-segmentation", + "hashbrown 0.15.5", ] [[package]] @@ -1300,6 +1619,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1319,7 +1649,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1335,6 +1665,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1347,7 +1701,7 @@ dependencies = [ "futures-core", "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1358,6 +1712,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1365,13 +1734,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", "rustls 0.23.37", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -1383,7 +1752,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -1397,13 +1766,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", @@ -1566,15 +1935,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1640,7 +2000,7 @@ checksum = "fa0f4bea31643be4c6a678e9aa4ae44f0db9e5609d5ca9dc9083d06eb3e9a27a" dependencies = [ "ahash", "anyhow", - "base64 0.22.1", + "base64", "bytecount", "clap", "fancy-regex", @@ -1692,9 +2052,11 @@ name = "kronos-common" version = "0.1.0" dependencies = [ "actix-web", - "aes-gcm", "anyhow", - "base64 0.22.1", + "async-trait", + "aws-config", + "aws-sdk-secretsmanager", + "base64", "chrono", "chrono-tz", "cron", @@ -1704,11 +2066,10 @@ dependencies = [ "jsonschema", "metrics", "metrics-exporter-prometheus", - "rand 0.8.5", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -1798,11 +2159,10 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.27.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -1904,17 +2264,17 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" dependencies = [ - "base64 0.22.1", + "base64", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "indexmap", "ipnet", "metrics", "metrics-util", "quanta", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -2135,7 +2495,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2150,12 +2510,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.76" @@ -2179,7 +2533,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2200,6 +2554,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2232,12 +2598,6 @@ dependencies = [ "regex", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2336,18 +2696,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -2385,7 +2733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2629,7 +2977,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-channel", @@ -2637,10 +2985,10 @@ dependencies = [ "futures-util", "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -2727,6 +3075,7 @@ version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ + "log", "ring", "rustls-webpki 0.101.7", "sct", @@ -2740,6 +3089,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.9", "subtle", @@ -2758,15 +3108,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2891,7 +3232,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3011,6 +3352,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3051,21 +3395,11 @@ dependencies = [ "der", ] -[[package]] -name = "sqlformat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" -dependencies = [ - "nom 7.1.3", - "unicode_categories", -] - [[package]] name = "sqlx" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3076,70 +3410,64 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "ahash", - "atoi", - "byteorder", + "base64", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", + "hashbrown 0.15.5", "hashlink", - "hex", "indexmap", "log", "memchr", "once_cell", - "paste", "percent-encoding", - "rustls 0.21.12", - "rustls-pemfile", + "rustls 0.23.37", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", "url", "uuid", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] name = "sqlx-macros" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 1.0.109", + "syn", ] [[package]] name = "sqlx-macros-core" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.4.1", + "heck", "hex", "once_cell", "proc-macro2", @@ -3151,20 +3479,19 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 1.0.109", - "tempfile", + "syn", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.21.7", + "base64", "bitflags", "byteorder", "bytes", @@ -3195,7 +3522,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3203,12 +3530,12 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.21.7", + "base64", "bitflags", "byteorder", "chrono", @@ -3217,7 +3544,6 @@ dependencies = [ "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -3235,7 +3561,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3243,9 +3569,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -3259,10 +3585,11 @@ dependencies = [ "log", "percent-encoding", "serde", + "serde_urlencoded", "sqlx-core", + "thiserror 2.0.18", "tracing", "url", - "urlencoding", "uuid", ] @@ -3295,17 +3622,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.117" @@ -3334,7 +3650,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3377,7 +3693,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -3388,7 +3713,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3481,7 +3817,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3494,6 +3830,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3583,7 +3929,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3623,7 +3969,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3729,22 +4075,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -3811,6 +4141,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" @@ -3896,7 +4232,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3955,9 +4291,21 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.4" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "whoami" @@ -4012,7 +4360,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4023,7 +4371,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4234,7 +4582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -4245,10 +4593,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4264,7 +4612,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4312,6 +4660,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.1" @@ -4331,7 +4685,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4352,7 +4706,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4372,7 +4726,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4412,7 +4766,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e94f76c..8e3ea0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ actix-web = "4" actix-rt = "2" # Database -sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "json"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "json"] } # Serialization serde = { version = "1", features = ["derive"] } @@ -39,8 +39,12 @@ rdkafka = { version = "0.36", features = ["tokio"] } # Redis (feature-gated) redis = { version = "0.25", features = ["tokio-comp", "aio"] } -# Crypto -aes-gcm = "0.10" +# KMS +aws-config = "1" +aws-sdk-secretsmanager = "1" +async-trait = "0.1" + +# Random (used by worker backoff) rand = "0.8" # JSON Schema validation diff --git a/cli/src/test-secrets.ts b/cli/src/test-secrets.ts new file mode 100644 index 0000000..320de32 --- /dev/null +++ b/cli/src/test-secrets.ts @@ -0,0 +1,276 @@ +/** + * Kronos CLI — Test Secrets via External KMS (LocalStack) + * + * This script tests the full secrets flow: + * 1. Seeds a secret in LocalStack's Secrets Manager + * 2. Registers the secret reference in Kronos + * 3. Creates an endpoint that uses {{secret.*}} in its spec (Authorization header) + * 4. Creates an IMMEDIATE job targeting the /echo endpoint on the mock server + * 5. Polls until execution completes + * 6. Verifies the echoed response contains the resolved secret value + * + * Prerequisites: + * - LocalStack running (docker compose --profile kms up -d) + * - Kronos API + Worker running with TE_KMS_AWS_ENDPOINT_URL=http://localhost:4566 + * - Mock server running + * - AWS env vars set: AWS_ACCESS_KEY_ID=test, AWS_SECRET_ACCESS_KEY=test + */ + +import { + KronosServiceClient, + CreateEndpointCommand, + CreateJobCommand, + DeleteEndpointCommand, + CancelJobCommand, +} from "kronos-sdk"; + +import { + KRONOS_URL, + MOCK_URL, + API_KEY, + log, + sleep, + pollExecution, + printExecutionResult, +} from "./helpers.js"; + +// ─── Config ────────────────────────────────────────────────────── + +const LOCALSTACK_URL = + process.env.LOCALSTACK_URL ?? "http://localhost:4566"; +const AWS_REGION = process.env.AWS_DEFAULT_REGION ?? "us-east-1"; + +const TEST_SECRET_NAME = `test-secret-${Date.now()}`; +const TEST_SECRET_VALUE = `sk-live-test-${Math.random().toString(36).slice(2)}`; + +// ─── LocalStack Helpers ───────────────────────────────────────── + +async function createLocalStackSecret( + name: string, + value: string, +): Promise { + const resp = await fetch(`${LOCALSTACK_URL}/`, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "secretsmanager.CreateSecret", + Authorization: + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/secretsmanager/aws4_request, SignedHeaders=content-type;host;x-amz-target, Signature=fake", + }, + body: JSON.stringify({ + Name: name, + SecretString: value, + }), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error( + `Failed to create LocalStack secret: ${resp.status} ${body}`, + ); + } + + log(`LocalStack secret created: ${name}`); +} + +async function deleteLocalStackSecret(name: string): Promise { + try { + await fetch(`${LOCALSTACK_URL}/`, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "secretsmanager.DeleteSecret", + Authorization: + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/secretsmanager/aws4_request, SignedHeaders=content-type;host;x-amz-target, Signature=fake", + }, + body: JSON.stringify({ + SecretId: name, + ForceDeleteWithoutRecovery: true, + }), + }); + } catch { + // ignore cleanup failures + } +} + +// ─── Kronos Secret API Helpers ────────────────────────────────── + +async function kronosCreateSecret( + name: string, + provider: string, + reference: string, +): Promise { + const resp = await fetch(`${KRONOS_URL}/v1/secrets`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ name, provider, reference }), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Failed to create Kronos secret: ${resp.status} ${body}`); + } + + log(`Kronos secret registered: ${name} → ${provider}:${reference}`); +} + +async function kronosDeleteSecret(name: string): Promise { + try { + await fetch(`${KRONOS_URL}/v1/secrets/${encodeURIComponent(name)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + } catch { + // ignore cleanup failures + } +} + +// ─── Main ──────────────────────────────────────────────────────── + +async function main() { + const client = new KronosServiceClient({ + endpoint: KRONOS_URL, + token: { token: API_KEY }, + }); + + const endpointName = `test-secrets-endpoint-${Date.now()}`; + let jobId: string | null = null; + + try { + // ── Step 1: Seed secret in LocalStack ─────────────────────── + log("Step 1: Creating secret in LocalStack..."); + await createLocalStackSecret(TEST_SECRET_NAME, TEST_SECRET_VALUE); + + // ── Step 2: Register secret reference in Kronos ───────────── + log("Step 2: Registering secret in Kronos..."); + await kronosCreateSecret(TEST_SECRET_NAME, "aws", TEST_SECRET_NAME); + + // ── Step 3: Create endpoint with {{secret.*}} in headers ──── + log( + `Step 3: Creating endpoint "${endpointName}" → ${MOCK_URL}/echo with secret in header`, + ); + + await client.send( + new CreateEndpointCommand({ + name: endpointName, + endpoint_type: "HTTP", + spec: { + method: "POST", + url: `${MOCK_URL}/echo`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer {{secret.${TEST_SECRET_NAME}}}`, + "X-Test-Source": "kronos-secrets-test", + }, + }, + retry_policy: { + max_attempts: 1, + backoff: "exponential", + initial_delay_ms: 500, + max_delay_ms: 5000, + }, + }), + ); + + log(`Endpoint created: ${endpointName}`); + + // ── Step 4: Create IMMEDIATE job ──────────────────────────── + log("Step 4: Creating IMMEDIATE job..."); + + const jobResp = await client.send( + new CreateJobCommand({ + endpoint: endpointName, + trigger: "IMMEDIATE", + input: { + test: "secrets-flow", + timestamp: new Date().toISOString(), + }, + }), + ); + + jobId = jobResp.data!.job_id!; + log(`Job created: ${jobId}`); + + // ── Step 5: Poll for execution completion ─────────────────── + log("Step 5: Polling for execution completion..."); + + const execution = await pollExecution(client, jobId); + + if (!execution) { + console.error("ERROR: Timed out waiting for execution"); + process.exit(1); + } + + // Print full execution details + await printExecutionResult(client, jobId, execution); + + // ── Step 6: Verify the secret was resolved ────────────────── + log("Step 6: Verifying secret resolution..."); + + if (execution.status !== "SUCCESS") { + console.error(`ERROR: Execution status is ${execution.status}, expected SUCCESS`); + process.exit(1); + } + + // The mock /echo endpoint returns the request headers in output.headers + const output = execution.output; + if (!output) { + console.error("ERROR: Execution has no output"); + process.exit(1); + } + + const echoedAuthHeader = output.headers?.authorization; + const expectedAuthHeader = `Bearer ${TEST_SECRET_VALUE}`; + + if (echoedAuthHeader === expectedAuthHeader) { + console.log("\n ✅ SECRET RESOLUTION VERIFIED!"); + console.log( + ` Authorization header: "${echoedAuthHeader}"`, + ); + console.log( + ` Matches expected: "${expectedAuthHeader}"`, + ); + } else { + console.error("\n ❌ SECRET RESOLUTION FAILED!"); + console.error(` Expected: "${expectedAuthHeader}"`); + console.error(` Got: "${echoedAuthHeader}"`); + process.exit(1); + } + + console.log("\n" + "═".repeat(60)); + console.log(" SECRETS E2E TEST PASSED"); + console.log("═".repeat(60) + "\n"); + } catch (err: any) { + console.error("\nTest failed with error:"); + console.error(` ${err.name}: ${err.message}`); + if (err.$metadata) { + console.error(` HTTP ${err.$metadata.httpStatusCode}`); + } + process.exit(1); + } finally { + // ── Cleanup ───────────────────────────────────────────────── + log("Cleaning up..."); + if (jobId) { + try { + await client.send(new CancelJobCommand({ job_id: jobId })); + } catch { + // ignore + } + } + try { + await client.send( + new DeleteEndpointCommand({ name: endpointName }), + ); + } catch { + // ignore + } + await kronosDeleteSecret(TEST_SECRET_NAME); + await deleteLocalStackSecret(TEST_SECRET_NAME); + log("Done!"); + } +} + +main(); diff --git a/crates/api/src/handlers/secrets.rs b/crates/api/src/handlers/secrets.rs index 3ac73d0..e789596 100644 --- a/crates/api/src/handlers/secrets.rs +++ b/crates/api/src/handlers/secrets.rs @@ -2,8 +2,9 @@ use crate::extractors::{AuthenticatedRequest, Workspace}; use crate::router::AppState; use actix_web::{web, HttpResponse}; use kronos_common::{ - crypto, db, + db, error::AppError, + kms::KmsProviderType, models::secret::{CreateSecret, SecretResponse, UpdateSecret}, pagination::{encode_cursor, PaginatedResponse, PaginationParams}, }; @@ -14,14 +15,25 @@ pub async fn create( ws: Workspace, body: web::Json, ) -> Result { - let encrypted = crypto::encrypt(&body.value, &state.config.encryption_key) - .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; + // Validate provider type + KmsProviderType::from_str_val(&body.provider).ok_or_else(|| { + AppError::InvalidRequest(format!( + "Unsupported KMS provider: '{}'. Supported: aws, gcp, vault", + body.provider + )) + })?; + + if body.reference.is_empty() { + return Err(AppError::InvalidRequest( + "Secret reference cannot be empty".into(), + )); + } let mut conn = kronos_common::db::scoped::scoped_connection(&state.pool, &ws.0.schema_name) .await .map_err(AppError::from)?; - let secret = db::secrets::create(&mut *conn, &body.name, &encrypted) + let secret = db::secrets::create(&mut *conn, &body.name, &body.provider, &body.reference) .await .map_err(|e| match e { sqlx::Error::Database(ref db_err) if db_err.constraint().is_some() => { @@ -31,9 +43,7 @@ pub async fn create( })?; let resp = SecretResponse::from(secret); - Ok(HttpResponse::Created().json(serde_json::json!({ "data": { - "name": resp.name, "created_at": resp.created_at, "updated_at": resp.updated_at, - }}))) + Ok(HttpResponse::Created().json(serde_json::json!({ "data": resp }))) } pub async fn list( @@ -57,14 +67,7 @@ pub async fn list( None }; - let data: Vec = items - .into_iter() - .map(|s| { - serde_json::json!({ - "name": s.name, "created_at": s.created_at, "updated_at": s.updated_at, - }) - }) - .collect(); + let data: Vec = items.into_iter().map(SecretResponse::from).collect(); Ok(HttpResponse::Ok().json(PaginatedResponse { data, @@ -86,9 +89,8 @@ pub async fn get( .await? .ok_or_else(|| AppError::SecretNotFound(name))?; - Ok(HttpResponse::Ok().json(serde_json::json!({ "data": { - "name": secret.name, "created_at": secret.created_at, "updated_at": secret.updated_at, - }}))) + let resp = SecretResponse::from(secret); + Ok(HttpResponse::Ok().json(serde_json::json!({ "data": resp }))) } pub async fn update( @@ -98,21 +100,39 @@ pub async fn update( path: web::Path, body: web::Json, ) -> Result { - let name = path.into_inner(); - let encrypted = crypto::encrypt(&body.value, &state.config.encryption_key) - .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; + if let Some(ref provider) = body.provider { + KmsProviderType::from_str_val(provider).ok_or_else(|| { + AppError::InvalidRequest(format!( + "Unsupported KMS provider: '{}'. Supported: aws, gcp, vault", + provider + )) + })?; + } + if let Some(ref reference) = body.reference { + if reference.is_empty() { + return Err(AppError::InvalidRequest( + "Secret reference cannot be empty".into(), + )); + } + } + + let name = path.into_inner(); let mut conn = kronos_common::db::scoped::scoped_connection(&state.pool, &ws.0.schema_name) .await .map_err(AppError::from)?; - let secret = db::secrets::update(&mut *conn, &name, &encrypted) - .await? - .ok_or_else(|| AppError::SecretNotFound(name))?; + let secret = db::secrets::update( + &mut *conn, + &name, + body.provider.as_deref(), + body.reference.as_deref(), + ) + .await? + .ok_or_else(|| AppError::SecretNotFound(name))?; - Ok(HttpResponse::Ok().json(serde_json::json!({ "data": { - "name": secret.name, "created_at": secret.created_at, "updated_at": secret.updated_at, - }}))) + let resp = SecretResponse::from(secret); + Ok(HttpResponse::Ok().json(serde_json::json!({ "data": resp }))) } pub async fn delete( diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 445c1c1..2cae0c8 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -15,8 +15,9 @@ anyhow.workspace = true tracing.workspace = true actix-web.workspace = true dashmap.workspace = true -aes-gcm.workspace = true -rand.workspace = true +aws-config.workspace = true +aws-sdk-secretsmanager.workspace = true +async-trait.workspace = true base64.workspace = true envy.workspace = true jsonschema.workspace = true diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 84f41c3..1829806 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -10,8 +10,12 @@ pub struct AppConfig { pub db_pool_size: u32, #[serde(default = "default_api_key")] pub api_key: String, - #[serde(default = "default_encryption_key")] - pub encryption_key: String, + + // KMS + #[serde(default = "default_kms_provider")] + pub kms_provider: String, + pub kms_aws_region: Option, + pub kms_aws_endpoint_url: Option, // Worker #[serde(default = "default_max_concurrent")] @@ -39,8 +43,8 @@ fn default_db_pool_size() -> u32 { fn default_api_key() -> String { "dev-api-key".into() } -fn default_encryption_key() -> String { - "0".repeat(64) +fn default_kms_provider() -> String { + "aws".into() } fn default_max_concurrent() -> usize { 50 @@ -72,8 +76,10 @@ impl AppConfig { .and_then(|v| v.parse().ok()) .unwrap_or_else(default_db_pool_size), api_key: std::env::var("TE_API_KEY").unwrap_or_else(|_| default_api_key()), - encryption_key: std::env::var("TE_ENCRYPTION_KEY") - .unwrap_or_else(|_| default_encryption_key()), + kms_provider: std::env::var("TE_KMS_PROVIDER") + .unwrap_or_else(|_| default_kms_provider()), + kms_aws_region: std::env::var("TE_KMS_AWS_REGION").ok(), + kms_aws_endpoint_url: std::env::var("TE_KMS_AWS_ENDPOINT_URL").ok(), worker_max_concurrent: std::env::var("TE_WORKER_MAX_CONCURRENT") .ok() .and_then(|v| v.parse().ok()) diff --git a/crates/common/src/crypto.rs b/crates/common/src/crypto.rs deleted file mode 100644 index 1ba029a..0000000 --- a/crates/common/src/crypto.rs +++ /dev/null @@ -1,67 +0,0 @@ -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; -use rand::RngCore; - -pub fn encrypt(plaintext: &str, key_hex: &str) -> anyhow::Result> { - let key_bytes = hex_decode(key_hex)?; - let cipher = Aes256Gcm::new_from_slice(&key_bytes) - .map_err(|e| anyhow::anyhow!("Invalid key length: {}", e))?; - - let mut nonce_bytes = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, plaintext.as_bytes()) - .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; - - // Prepend nonce to ciphertext - let mut result = nonce_bytes.to_vec(); - result.extend_from_slice(&ciphertext); - Ok(result) -} - -pub fn decrypt(encrypted: &[u8], key_hex: &str) -> anyhow::Result { - if encrypted.len() < 12 { - anyhow::bail!("Encrypted data too short"); - } - - let key_bytes = hex_decode(key_hex)?; - let cipher = Aes256Gcm::new_from_slice(&key_bytes) - .map_err(|e| anyhow::anyhow!("Invalid key length: {}", e))?; - - let (nonce_bytes, ciphertext) = encrypted.split_at(12); - let nonce = Nonce::from_slice(nonce_bytes); - - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?; - - Ok(String::from_utf8(plaintext)?) -} - -fn hex_decode(hex: &str) -> anyhow::Result> { - if hex.len() != 64 { - anyhow::bail!("Encryption key must be 64 hex chars (32 bytes)"); - } - (0..hex.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(Into::into)) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encrypt_decrypt() { - let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - let plaintext = "my-secret-value"; - let encrypted = encrypt(plaintext, key).unwrap(); - let decrypted = decrypt(&encrypted, key).unwrap(); - assert_eq!(decrypted, plaintext); - } -} diff --git a/crates/common/src/db/secrets.rs b/crates/common/src/db/secrets.rs index a53b9b2..ee33e41 100644 --- a/crates/common/src/db/secrets.rs +++ b/crates/common/src/db/secrets.rs @@ -4,21 +4,23 @@ use sqlx::PgConnection; pub async fn create( conn: &mut PgConnection, name: &str, - encrypted_value: &[u8], + provider: &str, + reference: &str, ) -> Result { sqlx::query_as::<_, Secret>( - "INSERT INTO secrets (name, encrypted_value) VALUES ($1, $2) - RETURNING name, encrypted_value, created_at, updated_at", + "INSERT INTO secrets (name, provider, reference) VALUES ($1, $2, $3) + RETURNING name, provider, reference, created_at, updated_at", ) .bind(name) - .bind(encrypted_value) + .bind(provider) + .bind(reference) .fetch_one(&mut *conn) .await } pub async fn get(conn: &mut PgConnection, name: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Secret>( - "SELECT name, encrypted_value, created_at, updated_at FROM secrets WHERE name = $1", + "SELECT name, provider, reference, created_at, updated_at FROM secrets WHERE name = $1", ) .bind(name) .fetch_optional(&mut *conn) @@ -33,7 +35,7 @@ pub async fn list( match cursor { Some(c) => { sqlx::query_as::<_, Secret>( - "SELECT name, encrypted_value, created_at, updated_at FROM secrets + "SELECT name, provider, reference, created_at, updated_at FROM secrets WHERE name > $1 ORDER BY name ASC LIMIT $2", ) .bind(c) @@ -43,7 +45,7 @@ pub async fn list( } None => { sqlx::query_as::<_, Secret>( - "SELECT name, encrypted_value, created_at, updated_at FROM secrets + "SELECT name, provider, reference, created_at, updated_at FROM secrets ORDER BY name ASC LIMIT $1", ) .bind(limit) @@ -53,18 +55,31 @@ pub async fn list( } } +pub async fn list_all(conn: &mut PgConnection) -> Result, sqlx::Error> { + sqlx::query_as::<_, Secret>( + "SELECT name, provider, reference, created_at, updated_at FROM secrets ORDER BY name ASC", + ) + .fetch_all(&mut *conn) + .await +} + pub async fn update( conn: &mut PgConnection, name: &str, - encrypted_value: &[u8], + provider: Option<&str>, + reference: Option<&str>, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Secret>( - "UPDATE secrets SET encrypted_value = $2, updated_at = now() + "UPDATE secrets SET + provider = COALESCE($2, provider), + reference = COALESCE($3, reference), + updated_at = now() WHERE name = $1 - RETURNING name, encrypted_value, created_at, updated_at", + RETURNING name, provider, reference, created_at, updated_at", ) .bind(name) - .bind(encrypted_value) + .bind(provider) + .bind(reference) .fetch_optional(&mut *conn) .await } @@ -77,7 +92,10 @@ pub async fn delete(conn: &mut PgConnection, name: &str) -> Result 0) } -pub async fn has_dependent_endpoints(conn: &mut PgConnection, name: &str) -> Result { +pub async fn has_dependent_endpoints( + conn: &mut PgConnection, + name: &str, +) -> Result { let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM endpoints WHERE spec::TEXT LIKE '%{{secret.' || $1 || '}}%'", ) diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index f64ffc8..5626eea 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -41,6 +41,8 @@ pub enum AppError { InputValidationFailed(String), #[error("Template resolution failed: {0}")] TemplateResolutionFailed(String), + #[error("KMS error: {0}")] + KmsError(String), #[error("Rate limited")] RateLimited, #[error("Internal error: {0}")] @@ -88,6 +90,7 @@ impl AppError { StatusCode::UNPROCESSABLE_ENTITY, "TEMPLATE_RESOLUTION_FAILED", ), + Self::KmsError(_) => (StatusCode::SERVICE_UNAVAILABLE, "KMS_ERROR"), Self::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"), } diff --git a/crates/common/src/kms/aws.rs b/crates/common/src/kms/aws.rs new file mode 100644 index 0000000..efe8332 --- /dev/null +++ b/crates/common/src/kms/aws.rs @@ -0,0 +1,68 @@ +use async_trait::async_trait; +use aws_sdk_secretsmanager::Client; + +pub struct AwsKmsProvider { + client: Client, +} + +impl AwsKmsProvider { + pub async fn new( + region: Option, + endpoint_url: Option, + ) -> anyhow::Result { + let mut config_loader = + aws_config::defaults(aws_config::BehaviorVersion::latest()); + if let Some(r) = region { + config_loader = config_loader.region(aws_config::Region::new(r)); + } + let config = config_loader.load().await; + + let mut sm_config = aws_sdk_secretsmanager::config::Builder::from(&config); + if let Some(url) = endpoint_url { + sm_config = sm_config.endpoint_url(url); + } + + Ok(Self { + client: Client::from_conf(sm_config.build()), + }) + } +} + +#[async_trait] +impl super::KmsProvider for AwsKmsProvider { + async fn get_secret(&self, reference: &str) -> anyhow::Result { + let resp = self + .client + .get_secret_value() + .secret_id(reference) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "AWS Secrets Manager fetch failed for '{}': {}", + reference, + e + ) + })?; + + resp.secret_string() + .map(|s| s.to_string()) + .ok_or_else(|| { + anyhow::anyhow!( + "Secret '{}' has no string value (binary secrets not supported)", + reference + ) + }) + } + + fn validate_reference(&self, reference: &str) -> anyhow::Result<()> { + if reference.is_empty() { + anyhow::bail!("Reference cannot be empty"); + } + Ok(()) + } + + fn provider_type(&self) -> super::KmsProviderType { + super::KmsProviderType::Aws + } +} diff --git a/crates/common/src/kms/mock.rs b/crates/common/src/kms/mock.rs new file mode 100644 index 0000000..d833c59 --- /dev/null +++ b/crates/common/src/kms/mock.rs @@ -0,0 +1,158 @@ +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// In-memory KMS provider for testing. +/// Pre-load it with secrets via `with_secret()` and track fetch counts. +pub struct MockKmsProvider { + secrets: Arc>, + fetch_count: Arc, +} + +impl MockKmsProvider { + pub fn new() -> Self { + Self { + secrets: Arc::new(HashMap::new()), + fetch_count: Arc::new(AtomicU64::new(0)), + } + } + + pub fn with_secret(mut self, reference: &str, value: &str) -> Self { + Arc::get_mut(&mut self.secrets) + .expect("MockKmsProvider::with_secret called after clone") + .insert(reference.to_string(), value.to_string()); + self + } + + /// How many times `get_secret` was called. + pub fn fetch_count(&self) -> u64 { + self.fetch_count.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl super::KmsProvider for MockKmsProvider { + async fn get_secret(&self, reference: &str) -> anyhow::Result { + self.fetch_count.fetch_add(1, Ordering::SeqCst); + self.secrets + .get(reference) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Mock secret not found: {}", reference)) + } + + fn validate_reference(&self, reference: &str) -> anyhow::Result<()> { + if reference.is_empty() { + anyhow::bail!("Reference cannot be empty"); + } + Ok(()) + } + + fn provider_type(&self) -> super::KmsProviderType { + super::KmsProviderType::Aws + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::SecretCache; + use crate::kms::KmsProvider; + + #[tokio::test] + async fn test_mock_provider_returns_secret() { + let provider = MockKmsProvider::new() + .with_secret("arn:aws:sm:us-east-1:123:secret:api-key", "sk-live-abc123"); + + let value = provider + .get_secret("arn:aws:sm:us-east-1:123:secret:api-key") + .await + .unwrap(); + assert_eq!(value, "sk-live-abc123"); + assert_eq!(provider.fetch_count(), 1); + } + + #[tokio::test] + async fn test_mock_provider_missing_secret() { + let provider = MockKmsProvider::new(); + let result = provider.get_secret("nonexistent").await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("nonexistent")); + } + + #[tokio::test] + async fn test_cache_hit_skips_kms() { + let provider = MockKmsProvider::new() + .with_secret("my-ref", "secret-value"); + + let cache = SecretCache::new(300); + + // Cache miss: should call KMS + assert!(cache.get("my-secret").is_none()); + let value = provider.get_secret("my-ref").await.unwrap(); + cache.set("my-secret".to_string(), value.clone()); + assert_eq!(provider.fetch_count(), 1); + + // Cache hit: no KMS call needed + let cached = cache.get("my-secret").unwrap(); + assert_eq!(cached, "secret-value"); + assert_eq!(provider.fetch_count(), 1); // still 1 + + // Invalidate cache → next get returns None + cache.invalidate("my-secret"); + assert!(cache.get("my-secret").is_none()); + + // Re-fetch from KMS + let value2 = provider.get_secret("my-ref").await.unwrap(); + cache.set("my-secret".to_string(), value2); + assert_eq!(provider.fetch_count(), 2); + } + + #[tokio::test] + async fn test_cache_ttl_expiry_triggers_refetch() { + let provider = MockKmsProvider::new() + .with_secret("ref-1", "value-1"); + + // 0-second TTL = always expired + let cache = SecretCache::new(0); + + let value = provider.get_secret("ref-1").await.unwrap(); + cache.set("key".to_string(), value); + assert_eq!(provider.fetch_count(), 1); + + // Even though we just set it, TTL=0 means it's already expired + assert!(cache.get("key").is_none()); + + // Would need to re-fetch + let _value2 = provider.get_secret("ref-1").await.unwrap(); + assert_eq!(provider.fetch_count(), 2); + } + + #[test] + fn test_validate_reference_empty() { + let provider = MockKmsProvider::new(); + let result = provider.validate_reference(""); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn test_validate_reference_valid() { + let provider = MockKmsProvider::new(); + assert!(provider.validate_reference("arn:aws:sm:us-east-1:123:secret:key").is_ok()); + assert!(provider.validate_reference("any-non-empty-string").is_ok()); + } + + #[tokio::test] + async fn test_multiple_secrets() { + let provider = MockKmsProvider::new() + .with_secret("ref-api-key", "sk-123") + .with_secret("ref-db-pass", "pg-secret-456"); + + let v1 = provider.get_secret("ref-api-key").await.unwrap(); + let v2 = provider.get_secret("ref-db-pass").await.unwrap(); + assert_eq!(v1, "sk-123"); + assert_eq!(v2, "pg-secret-456"); + assert_eq!(provider.fetch_count(), 2); + } +} diff --git a/crates/common/src/kms/mod.rs b/crates/common/src/kms/mod.rs new file mode 100644 index 0000000..d0322b1 --- /dev/null +++ b/crates/common/src/kms/mod.rs @@ -0,0 +1,84 @@ +pub mod aws; +#[cfg(test)] +pub mod mock; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KmsProviderType { + Aws, + Gcp, + Vault, +} + +impl fmt::Display for KmsProviderType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Aws => write!(f, "aws"), + Self::Gcp => write!(f, "gcp"), + Self::Vault => write!(f, "vault"), + } + } +} + +impl KmsProviderType { + pub fn from_str_val(s: &str) -> Option { + match s { + "aws" => Some(Self::Aws), + "gcp" => Some(Self::Gcp), + "vault" => Some(Self::Vault), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_str_val_valid() { + assert_eq!(KmsProviderType::from_str_val("aws"), Some(KmsProviderType::Aws)); + assert_eq!(KmsProviderType::from_str_val("gcp"), Some(KmsProviderType::Gcp)); + assert_eq!(KmsProviderType::from_str_val("vault"), Some(KmsProviderType::Vault)); + } + + #[test] + fn test_from_str_val_invalid() { + assert_eq!(KmsProviderType::from_str_val(""), None); + assert_eq!(KmsProviderType::from_str_val("AWS"), None); + assert_eq!(KmsProviderType::from_str_val("azure"), None); + assert_eq!(KmsProviderType::from_str_val("unknown"), None); + } + + #[test] + fn test_display() { + assert_eq!(KmsProviderType::Aws.to_string(), "aws"); + assert_eq!(KmsProviderType::Gcp.to_string(), "gcp"); + assert_eq!(KmsProviderType::Vault.to_string(), "vault"); + } + + #[test] + fn test_serde_roundtrip() { + let provider = KmsProviderType::Aws; + let json = serde_json::to_string(&provider).unwrap(); + assert_eq!(json, "\"aws\""); + let parsed: KmsProviderType = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, provider); + } +} + +#[async_trait] +pub trait KmsProvider: Send + Sync { + /// Fetch the plaintext secret value given its opaque reference (e.g., ARN for AWS). + async fn get_secret(&self, reference: &str) -> anyhow::Result; + + /// Validate that a reference string is well-formed for this provider. + fn validate_reference(&self, reference: &str) -> anyhow::Result<()>; + + /// Return the provider type. + fn provider_type(&self) -> KmsProviderType; +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 119cf91..36426fa 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,8 +1,8 @@ pub mod cache; pub mod config; -pub mod crypto; pub mod db; pub mod error; +pub mod kms; pub mod metrics; pub mod models; pub mod pagination; diff --git a/crates/common/src/models/secret.rs b/crates/common/src/models/secret.rs index 98dbfa4..990ba01 100644 --- a/crates/common/src/models/secret.rs +++ b/crates/common/src/models/secret.rs @@ -2,10 +2,11 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -#[derive(Debug, Clone, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Secret { pub name: String, - pub encrypted_value: Vec, + pub provider: String, + pub reference: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -13,6 +14,8 @@ pub struct Secret { #[derive(Debug, Clone, Serialize)] pub struct SecretResponse { pub name: String, + pub provider: String, + pub reference: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -21,6 +24,8 @@ impl From for SecretResponse { fn from(s: Secret) -> Self { Self { name: s.name, + provider: s.provider, + reference: s.reference, created_at: s.created_at, updated_at: s.updated_at, } @@ -30,10 +35,12 @@ impl From for SecretResponse { #[derive(Debug, Deserialize)] pub struct CreateSecret { pub name: String, - pub value: String, + pub provider: String, + pub reference: String, } #[derive(Debug, Deserialize)] pub struct UpdateSecret { - pub value: String, + pub provider: Option, + pub reference: Option, } diff --git a/crates/common/src/template.rs b/crates/common/src/template.rs index 3961752..cce1026 100644 --- a/crates/common/src/template.rs +++ b/crates/common/src/template.rs @@ -1,4 +1,23 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +/// Extract all `{{secret.NAME}}` references from a JSON spec. +/// Returns the set of unique secret names found. +pub fn extract_secret_names(spec: &serde_json::Value) -> HashSet { + let spec_str = spec.to_string(); + let mut names = HashSet::new(); + let mut start = 0; + while let Some(pos) = spec_str[start..].find("{{secret.") { + let abs_pos = start + pos + 9; // skip "{{secret." + if let Some(end) = spec_str[abs_pos..].find("}}") { + let secret_name = &spec_str[abs_pos..abs_pos + end]; + names.insert(secret_name.to_string()); + start = abs_pos + end + 2; + } else { + break; + } + } + names +} pub fn resolve( value: &serde_json::Value, @@ -150,4 +169,158 @@ mod tests { let template = serde_json::json!("{{input.missing}}"); assert!(resolve(&template, &input, &config, &secrets).is_err()); } + + #[test] + fn test_unknown_namespace() { + let input = HashMap::new(); + let config = HashMap::new(); + let secrets = HashMap::new(); + + let template = serde_json::json!("{{unknown.foo}}"); + let err = resolve(&template, &input, &config, &secrets).unwrap_err(); + assert!(err.contains("Unknown template namespace")); + } + + #[test] + fn test_secret_only_interpolation() { + let input = HashMap::new(); + let config = HashMap::new(); + let mut secrets = HashMap::new(); + secrets.insert("api_key".into(), "sk-live-abc".into()); + + // Whole-string template + let template = serde_json::json!("{{secret.api_key}}"); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, serde_json::json!("sk-live-abc")); + + // String interpolation with prefix + let template = serde_json::json!("Bearer {{secret.api_key}}"); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, serde_json::json!("Bearer sk-live-abc")); + } + + #[test] + fn test_multiple_secrets_in_one_string() { + let input = HashMap::new(); + let config = HashMap::new(); + let mut secrets = HashMap::new(); + secrets.insert("user".into(), "admin".into()); + secrets.insert("pass".into(), "hunter2".into()); + + let template = serde_json::json!("{{secret.user}}:{{secret.pass}}"); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, serde_json::json!("admin:hunter2")); + } + + #[test] + fn test_secret_in_array() { + let input = HashMap::new(); + let config = HashMap::new(); + let mut secrets = HashMap::new(); + secrets.insert("token".into(), "tok-123".into()); + + let template = serde_json::json!(["{{secret.token}}", "static"]); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, serde_json::json!(["tok-123", "static"])); + } + + #[test] + fn test_no_templates_passthrough() { + let input = HashMap::new(); + let config = HashMap::new(); + let secrets = HashMap::new(); + + let template = serde_json::json!({ + "url": "https://example.com", + "count": 42, + "flag": true, + "nothing": null + }); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, template); + } + + #[test] + fn test_missing_secret_variable() { + let input = HashMap::new(); + let config = HashMap::new(); + let secrets = HashMap::new(); + + let template = serde_json::json!("Bearer {{secret.missing_key}}"); + let err = resolve(&template, &input, &config, &secrets).unwrap_err(); + assert!(err.contains("Unresolved template variable")); + assert!(err.contains("secret.missing_key")); + } + + #[test] + fn test_extract_secret_names_none() { + let spec = serde_json::json!({ + "url": "https://example.com", + "headers": { "Content-Type": "application/json" } + }); + let names = extract_secret_names(&spec); + assert!(names.is_empty()); + } + + #[test] + fn test_extract_secret_names_single() { + let spec = serde_json::json!({ + "headers": { "Authorization": "Bearer {{secret.api_key}}" } + }); + let names = extract_secret_names(&spec); + assert_eq!(names.len(), 1); + assert!(names.contains("api_key")); + } + + #[test] + fn test_extract_secret_names_multiple() { + let spec = serde_json::json!({ + "headers": { + "Authorization": "Bearer {{secret.api_key}}", + "X-DB-Pass": "{{secret.db_password}}" + }, + "url": "https://{{secret.host}}/path" + }); + let names = extract_secret_names(&spec); + assert_eq!(names.len(), 3); + assert!(names.contains("api_key")); + assert!(names.contains("db_password")); + assert!(names.contains("host")); + } + + #[test] + fn test_extract_secret_names_duplicates() { + let spec = serde_json::json!({ + "headers": { "Authorization": "Bearer {{secret.token}}" }, + "body": { "token": "{{secret.token}}" } + }); + let names = extract_secret_names(&spec); + assert_eq!(names.len(), 1); + assert!(names.contains("token")); + } + + #[test] + fn test_extract_ignores_other_namespaces() { + let spec = serde_json::json!({ + "url": "{{config.base_url}}/{{input.id}}", + "headers": { "Authorization": "Bearer {{secret.key}}" } + }); + let names = extract_secret_names(&spec); + assert_eq!(names.len(), 1); + assert!(names.contains("key")); + } + + #[test] + fn test_mixed_namespaces_in_one_string() { + let mut input = HashMap::new(); + input.insert("id".into(), serde_json::json!("123")); + let mut config = HashMap::new(); + config.insert("base".into(), serde_json::json!("https://api.example.com")); + let mut secrets = HashMap::new(); + secrets.insert("key".into(), "sk-abc".into()); + + let template = serde_json::json!("{{config.base}}/{{input.id}}?key={{secret.key}}"); + let result = resolve(&template, &input, &config, &secrets).unwrap(); + assert_eq!(result, serde_json::json!("https://api.example.com/123?key=sk-abc")); + } } diff --git a/crates/dashboard/src/api/models.rs b/crates/dashboard/src/api/models.rs index 3f0088c..ee3fe3b 100644 --- a/crates/dashboard/src/api/models.rs +++ b/crates/dashboard/src/api/models.rs @@ -199,6 +199,8 @@ pub struct UpdatePayloadSpec { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Secret { pub name: String, + pub provider: String, + pub reference: String, pub created_at: String, pub updated_at: String, } @@ -206,12 +208,14 @@ pub struct Secret { #[derive(Debug, Clone, Serialize)] pub struct CreateSecret { pub name: String, - pub value: String, + pub provider: String, + pub reference: String, } #[derive(Debug, Clone, Serialize)] pub struct UpdateSecret { - pub value: String, + pub provider: Option, + pub reference: Option, } // -- Attempt -- diff --git a/crates/dashboard/src/pages/workspace_detail.rs b/crates/dashboard/src/pages/workspace_detail.rs index 8100400..efa46f5 100644 --- a/crates/dashboard/src/pages/workspace_detail.rs +++ b/crates/dashboard/src/pages/workspace_detail.rs @@ -790,6 +790,8 @@ fn SecretsTab(org_id: String, workspace_id: String) -> impl IntoView { "Name" + "Provider" + "Reference" "Created" "Updated" "Actions" @@ -802,6 +804,8 @@ fn SecretsTab(org_id: String, workspace_id: String) -> impl IntoView { view! { {secret.name.clone()} + {secret.provider.clone()} + {secret.reference.clone()} {format_date(&secret.created_at)} {format_date(&secret.updated_at)} @@ -855,7 +859,8 @@ fn CreateSecretForm( set_refresh: WriteSignal, ) -> impl IntoView { let (name, set_name) = signal(String::new()); - let (value, set_value) = signal(String::new()); + let (provider, set_provider) = signal("aws".to_string()); + let (reference, set_reference) = signal(String::new()); let (error, set_error) = signal(Option::::None); let (submitting, set_submitting) = signal(false); @@ -864,11 +869,12 @@ fn CreateSecretForm( let oid = org_id.clone(); let wid = workspace_id.clone(); let n = name.get_untracked(); - let v = value.get_untracked(); + let p = provider.get_untracked(); + let r = reference.get_untracked(); set_submitting.set(true); set_error.set(None); leptos::task::spawn_local(async move { - let body = CreateSecret { name: n, value: v }; + let body = CreateSecret { name: n, provider: p, reference: r }; match api::create_secret(&oid, &wid, &body).await { Ok(_) => { set_modal_open.set(false); @@ -893,11 +899,21 @@ fn CreateSecretForm( placeholder="MY_SECRET_KEY" />
- - + + +
+
+ +
- - + + +
+
+ +