From 3a21a68792b763dcb4c27110664e93ebb2f4117f Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 26 Aug 2026 08:51:15 +0900 Subject: [PATCH] feat: add Workspace Repository SSH secret authority --- Cargo.lock | 520 ++++- Cargo.toml | 1 + crates/workspace-api/src/lib.rs | 110 + crates/workspace-server/Cargo.toml | 2 + crates/workspace-server/src/lib.rs | 3 + .../workspace-server/src/repository_access.rs | 1779 +++++++++++++++++ crates/workspace-server/src/server.rs | 274 ++- crates/workspace-server/src/store.rs | 198 +- 8 files changed, 2860 insertions(+), 27 deletions(-) create mode 100644 crates/workspace-server/src/repository_access.rs diff --git a/Cargo.lock b/Cargo.lock index db812ef9..b2e4fbc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,16 @@ dependencies = [ "pom", ] +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -37,6 +47,20 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "agen" version = "0.2.1" @@ -326,6 +350,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -338,6 +368,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "base64urlsafedata" version = "0.5.5" @@ -349,6 +385,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -403,6 +450,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" version = "1.12.1" @@ -435,6 +492,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -495,6 +558,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chrono" version = "0.4.44" @@ -654,6 +728,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -937,6 +1017,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -966,6 +1058,41 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1056,6 +1183,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.1", ] @@ -1175,12 +1314,66 @@ dependencies = [ "cipher", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1284,6 +1477,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1568,6 +1778,16 @@ 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 = "gimli" version = "0.33.0" @@ -1636,6 +1856,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1724,6 +1955,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -2212,6 +2452,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2672,6 +2915,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2698,6 +2957,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2705,6 +2974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2765,6 +3035,12 @@ 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" @@ -2818,6 +3094,44 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2847,6 +3161,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "pdf-extract" version = "0.10.0" @@ -2864,6 +3187,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3009,6 +3341,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3021,6 +3374,29 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "pom" version = "1.1.0" @@ -3101,6 +3477,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3525,6 +3910,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3539,6 +3934,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -3745,6 +4161,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrets" version = "0.1.0" @@ -4060,6 +4490,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4103,6 +4543,72 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4934,6 +5440,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6259,11 +6775,13 @@ dependencies = [ "project-record", "protocol", "reqwest", + "ring", "rusqlite", "serde", "serde_json", "serde_yaml", "sha2 0.11.0", + "ssh-key", "tempfile", "thiserror 2.0.18", "ticket", diff --git a/Cargo.toml b/Cargo.toml index 044f958b..07f33556 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,7 @@ tar = "0.4" rusqlite = { version = "0.37", features = ["backup", "bundled"] } ring = "0.17.14" sha2 = "0.11" +ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] } tempfile = "3.27" thiserror = "2.0" tokio = "1.52" diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index db00728a..8e2d51c8 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -373,6 +373,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest { pub language: String, } +/// Public metadata for one Workspace-scoped Repository SSH credential. +/// +/// Secret references and secret material are deliberately not part of this DTO. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RepositorySshCredential { + pub credential_id: String, + pub workspace_id: String, + pub name: String, + pub public_key_algorithm: String, + pub public_key_fingerprint: String, + pub current_revision: u64, + pub status: String, + pub created_at: String, + pub rotated_at: Option, + #[serde(default)] + pub referenced_repositories: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateRepositorySshCredentialRequest { + pub operation_id: String, + pub credential_id: String, + pub name: String, + pub private_key: String, + #[serde(default)] + pub passphrase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RotateRepositorySshCredentialRequest { + pub operation_id: String, + pub expected_revision: u64, + pub private_key: String, + #[serde(default)] + pub passphrase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DeleteRepositorySshCredentialRequest { + pub operation_id: String, + pub expected_revision: u64, +} + +/// Public metadata for an explicitly pinned SSH host key. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RepositorySshHostTrust { + pub host_trust_id: String, + pub workspace_id: String, + pub hostname: String, + pub port: u16, + pub key_algorithm: String, + pub host_key: String, + pub fingerprint: String, + pub current_revision: u64, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub referenced_repositories: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PutRepositorySshHostTrustRequest { + pub operation_id: String, + pub host_trust_id: String, + pub hostname: String, + pub port: u16, + pub host_key: String, + #[serde(default)] + pub expected_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DeleteRepositorySshHostTrustRequest { + pub operation_id: String, + pub expected_revision: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryAccessMode { + ReadOnly, + ReadWrite, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RepositorySshAccessBinding { + pub repository_id: String, + pub credential_id: String, + pub host_trust_id: String, + pub access: RepositoryAccessMode, +} + +/// Secret-free active Repository access projection consumed by later Runtime work. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RepositoryAccessProjection { + pub workspace_id: String, + pub config_revision: u64, + pub projection_digest: String, + pub bindings: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index ec9d8fb1..b0c8f447 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -25,11 +25,13 @@ manifest.workspace = true protocol = { workspace = true } project-record.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } +ring.workspace = true rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_yaml.workspace = true sha2.workspace = true +ssh-key.workspace = true thiserror.workspace = true ticket.workspace = true memory.workspace = true diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 14e7e2c3..ddab7e34 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod records; #[cfg(feature = "typescript")] pub use records::ticket_api_typescript; pub mod repositories; +pub mod repository_access; pub mod repository_source; pub mod resource_broker; pub mod retention; @@ -116,6 +117,8 @@ pub enum Error { TicketAssignmentConflict(String), #[error("Workdir attachment conflict: {0}")] WorkdirAttachmentConflict(String), + #[error("Workspace permission denied: {0}")] + WorkspacePermissionDenied(String), #[error("Workspace config update conflict: {0}")] WorkspaceConfigConflict(String), #[error("Registry inconsistency: {0}")] diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs new file mode 100644 index 00000000..84361176 --- /dev/null +++ b/crates/workspace-server/src/repository_access.rs @@ -0,0 +1,1779 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::OpenOptions; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::{SecondsFormat, Utc}; +use config_source::ConfigSchemaContribution; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use ring::rand::{SecureRandom, SystemRandom}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey}; +use workspace_api::{ + CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, + DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode, + RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential, + RepositorySshHostTrust, RotateRepositorySshCredentialRequest, +}; + +use crate::config_source::{ + EvaluatedConfigCandidate, WorkspaceConfigSchemaProvider, WorkspaceConfigState, + evaluate_workspace_config_state, +}; +use crate::store::{ControlPlaneStore, SqliteWorkspaceStore}; +use crate::{Error, Result}; + +const REPOSITORY_ACCESS_SCHEMA_SOURCE: &str = r#"{ + repository_access = { + ...{ + ssh = { + credential_id = String; + host_trust_id = String; + access = String; + }; + } + } default {}; +}"#; +const MAX_SECRET_BYTES: usize = 256 * 1024; +const MAX_NAME_BYTES: usize = 200; +const MAX_IDENTIFIER_BYTES: usize = 128; +const MASTER_KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 12; + +#[derive(Debug, Default)] +pub struct RepositoryAccessConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for RepositoryAccessConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new( + "builtin:repository-access", + "repository_access", + "1", + REPOSITORY_ACCESS_SCHEMA_SOURCE, + ) + .map_err(|error| Error::Config(error.to_string())) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualWorkspaceConfig { + #[serde(default)] + repository_access: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualRepositoryAccess { + ssh: VirtualRepositorySshAccess, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualRepositorySshAccess { + credential_id: String, + host_trust_id: String, + access: RepositoryAccessMode, +} + +pub fn project_repository_access_candidate( + store: &dyn ControlPlaneStore, + secrets: &RepositorySecretService, + workspace_id: &str, + candidate: &EvaluatedConfigCandidate, +) -> Result { + project_repository_access_evaluation( + store, + secrets, + workspace_id, + candidate.base_revision + 1, + &candidate.evaluation.projection_digest, + &candidate.evaluation, + ) +} + +pub fn project_repository_access_state( + store: &dyn ControlPlaneStore, + secrets: &RepositorySecretService, + workspace_id: &str, + state: &WorkspaceConfigState, +) -> Result { + let has_schema = state + .contract + .schema_bundle + .contributions + .iter() + .any(|entry| entry.provider_id == "builtin:repository-access"); + if !has_schema { + return Ok(RepositoryAccessProjection { + workspace_id: workspace_id.to_string(), + config_revision: state.snapshot.revision, + projection_digest: state.projection_digest.clone(), + bindings: Vec::new(), + }); + } + let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?; + if evaluation.projection_digest != state.projection_digest { + return Err(Error::RegistryInconsistency(format!( + "Repository access projection digest mismatch for Workspace {workspace_id}" + ))); + } + project_repository_access_evaluation( + store, + secrets, + workspace_id, + state.snapshot.revision, + &state.projection_digest, + &evaluation, + ) +} + +fn project_repository_access_evaluation( + store: &dyn ControlPlaneStore, + secrets: &RepositorySecretService, + workspace_id: &str, + config_revision: u64, + projection_digest: &str, + evaluation: &config_source::EvaluationResult, +) -> Result { + let projection = evaluation.projections.first().ok_or_else(|| { + Error::InvalidInput("Workspace config produced no active projection".to_string()) + })?; + let config: VirtualWorkspaceConfig = serde_json::from_value(projection.data_json.clone()) + .map_err(|error| { + Error::InvalidInput(format!("invalid Repository access config: {error}")) + })?; + let mut bindings = Vec::with_capacity(config.repository_access.len()); + for (repository_id, access) in config.repository_access { + validate_identifier("repository_id", &repository_id)?; + validate_identifier("credential_id", &access.ssh.credential_id)?; + validate_identifier("host_trust_id", &access.ssh.host_trust_id)?; + let repository = store + .get_repository(workspace_id, &repository_id)? + .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_id}`")))?; + if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + return Err(Error::InvalidInput(format!( + "Repository `{repository_id}` is not an ssh:// Repository" + ))); + } + let credential = secrets + .get_credential(workspace_id, &access.ssh.credential_id, &[])? + .ok_or_else(|| { + Error::InvalidInput(format!( + "unknown Repository SSH credential `{}`", + access.ssh.credential_id + )) + })?; + if credential.status != "active" { + return Err(Error::InvalidInput(format!( + "Repository SSH credential `{}` is not active", + access.ssh.credential_id + ))); + } + let host_trust = secrets + .get_host_trust(workspace_id, &access.ssh.host_trust_id, &[])? + .ok_or_else(|| { + Error::InvalidInput(format!( + "unknown Repository SSH host trust `{}`", + access.ssh.host_trust_id + )) + })?; + let uri = url::Url::parse(&repository.source.uri).map_err(|_| { + Error::InvalidInput(format!( + "Repository `{repository_id}` has an invalid SSH URI" + )) + })?; + if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() { + return Err(Error::InvalidInput(format!( + "Repository `{repository_id}` must use ssh://user@host[:port]/path without credentials" + ))); + } + let hostname = uri.host_str().ok_or_else(|| { + Error::InvalidInput(format!( + "Repository `{repository_id}` SSH URI has no hostname" + )) + })?; + let port = uri.port().unwrap_or(22); + if hostname != host_trust.hostname || port != host_trust.port { + return Err(Error::InvalidInput(format!( + "Repository `{repository_id}` SSH host does not match host trust `{}`", + access.ssh.host_trust_id + ))); + } + bindings.push(RepositorySshAccessBinding { + repository_id, + credential_id: access.ssh.credential_id, + host_trust_id: access.ssh.host_trust_id, + access: access.ssh.access, + }); + } + bindings.sort_by(|left, right| left.repository_id.cmp(&right.repository_id)); + Ok(RepositoryAccessProjection { + workspace_id: workspace_id.to_string(), + config_revision, + projection_digest: projection_digest.to_string(), + bindings, + }) +} + +#[derive(Clone)] +pub struct RepositorySecretService { + store: Arc, + master_key: Option>, +} + +impl RepositorySecretService { + pub fn open(store: Arc, database_path: &Path) -> Result { + let key_path = master_key_path(database_path)?; + let key = load_or_create_master_key(&key_path)?; + Ok(Self { + store, + master_key: Some(Arc::new(key)), + }) + } + + pub fn create_credential( + &self, + workspace_id: &str, + request: CreateRepositorySshCredentialRequest, + actor_account_id: &str, + ) -> Result { + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + let credential_id = validate_identifier("credential_id", &request.credential_id)?; + let name = normalize_name(&request.name)?; + let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?; + let fingerprint = credential_fingerprint( + "create", + &credential_id, + &name, + 0, + &request.private_key, + request.passphrase.as_deref(), + ); + let private_secret = self.seal( + workspace_id, + &credential_id, + 1, + "private_key", + request.private_key.as_bytes(), + )?; + let passphrase_secret = request + .passphrase + .as_deref() + .map(|value| { + self.seal( + workspace_id, + &credential_id, + 1, + "passphrase", + value.as_bytes(), + ) + }) + .transpose()?; + let now = now(); + self.store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(replayed) = replay_credential_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + &credential_id, + )? { + tx.commit()?; + return Ok(replayed); + } + ensure_workspace_exists(&tx, workspace_id)?; + if credential_row_exists(&tx, workspace_id, &credential_id)? { + return Err(Error::WorkspaceConfigConflict(format!( + "Repository SSH credential `{credential_id}` already exists" + ))); + } + tx.execute( + r#"INSERT INTO repository_ssh_credentials ( + workspace_id, credential_id, name, public_key_algorithm, + public_key_fingerprint, current_revision, status, created_at, rotated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 'active', ?6, NULL)"#, + params![ + workspace_id, + credential_id, + name, + parsed.algorithm, + parsed.fingerprint, + now + ], + )?; + tx.execute( + r#"INSERT INTO repository_ssh_credential_revisions ( + workspace_id, credential_id, revision, public_key_algorithm, + public_key_fingerprint, created_at + ) VALUES (?1, ?2, 1, ?3, ?4, ?5)"#, + params![ + workspace_id, + credential_id, + parsed.algorithm, + parsed.fingerprint, + now + ], + )?; + insert_secret( + &tx, + workspace_id, + &credential_id, + 1, + "private_key", + &private_secret, + &now, + )?; + if let Some(secret) = passphrase_secret.as_ref() { + insert_secret( + &tx, + workspace_id, + &credential_id, + 1, + "passphrase", + secret, + &now, + )?; + } + insert_audit( + &tx, + workspace_id, + "credential_created", + &credential_id, + 1, + actor_account_id, + &now, + )?; + insert_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + "credential", + &credential_id, + 1, + &now, + )?; + let record = read_credential(&tx, workspace_id, &credential_id)?.ok_or_else(|| { + Error::RegistryInconsistency("created credential could not be reloaded".to_string()) + })?; + tx.commit()?; + Ok(record) + }) + } + + pub fn rotate_credential( + &self, + workspace_id: &str, + credential_id: &str, + request: RotateRepositorySshCredentialRequest, + actor_account_id: &str, + ) -> Result { + let credential_id = validate_identifier("credential_id", credential_id)?; + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?; + let next_revision = request + .expected_revision + .checked_add(1) + .ok_or_else(|| Error::InvalidInput("credential revision overflow".to_string()))?; + let fingerprint = credential_fingerprint( + "rotate", + &credential_id, + "", + request.expected_revision, + &request.private_key, + request.passphrase.as_deref(), + ); + let private_secret = self.seal( + workspace_id, + &credential_id, + next_revision, + "private_key", + request.private_key.as_bytes(), + )?; + let passphrase_secret = request + .passphrase + .as_deref() + .map(|value| { + self.seal( + workspace_id, + &credential_id, + next_revision, + "passphrase", + value.as_bytes(), + ) + }) + .transpose()?; + let now = now(); + self.store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(replayed) = replay_credential_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + &credential_id, + )? { + tx.commit()?; + return Ok(replayed); + } + let current = read_credential(&tx, workspace_id, &credential_id)? + .ok_or_else(|| Error::InvalidRecordId(credential_id.clone()))?; + if current.current_revision != request.expected_revision || current.status != "active" { + return Err(Error::WorkspaceConfigConflict(format!( + "credential `{credential_id}` revision/status changed" + ))); + } + tx.execute( + r#"INSERT INTO repository_ssh_credential_revisions ( + workspace_id, credential_id, revision, public_key_algorithm, + public_key_fingerprint, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + workspace_id, + credential_id, + next_revision, + parsed.algorithm, + parsed.fingerprint, + now + ], + )?; + insert_secret( + &tx, + workspace_id, + &credential_id, + next_revision, + "private_key", + &private_secret, + &now, + )?; + if let Some(secret) = passphrase_secret.as_ref() { + insert_secret( + &tx, + workspace_id, + &credential_id, + next_revision, + "passphrase", + secret, + &now, + )?; + } + let updated = tx.execute( + r#"UPDATE repository_ssh_credentials + SET public_key_algorithm = ?4, public_key_fingerprint = ?5, + current_revision = ?3, rotated_at = ?6 + WHERE workspace_id = ?1 AND credential_id = ?2 + AND current_revision = ?7 AND status = 'active'"#, + params![ + workspace_id, + credential_id, + next_revision, + parsed.algorithm, + parsed.fingerprint, + now, + request.expected_revision + ], + )?; + if updated != 1 { + return Err(Error::WorkspaceConfigConflict(format!( + "credential `{credential_id}` revision changed" + ))); + } + insert_audit( + &tx, + workspace_id, + "credential_rotated", + &credential_id, + next_revision, + actor_account_id, + &now, + )?; + insert_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + "credential", + &credential_id, + next_revision, + &now, + )?; + let record = read_credential(&tx, workspace_id, &credential_id)?.ok_or_else(|| { + Error::RegistryInconsistency("rotated credential could not be reloaded".to_string()) + })?; + tx.commit()?; + Ok(record) + }) + } + + pub fn delete_credential( + &self, + workspace_id: &str, + credential_id: &str, + request: DeleteRepositorySshCredentialRequest, + actor_account_id: &str, + projection: &RepositoryAccessProjection, + ) -> Result<()> { + let credential_id = validate_identifier("credential_id", credential_id)?; + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + let references = credential_references(projection, &credential_id); + if !references.is_empty() { + return Err(Error::WorkspaceConfigConflict(format!( + "credential `{credential_id}` is referenced by active Workspace config" + ))); + } + let fingerprint = simple_operation_fingerprint( + "delete_credential", + &credential_id, + request.expected_revision, + ); + let now = now(); + self.store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id)? { + tx.commit()?; + return Ok(()); + } + let current = read_credential(&tx, workspace_id, &credential_id)? + .ok_or_else(|| Error::InvalidRecordId(credential_id.clone()))?; + if current.current_revision != request.expected_revision { + return Err(Error::WorkspaceConfigConflict(format!( + "credential `{credential_id}` revision changed" + ))); + } + insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?; + let deleted = tx.execute( + "DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3", + params![workspace_id, credential_id, request.expected_revision], + )?; + if deleted != 1 { + return Err(Error::WorkspaceConfigConflict(format!( + "credential `{credential_id}` revision changed" + ))); + } + insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id, request.expected_revision, &now)?; + tx.commit()?; + Ok(()) + }) + } + + pub fn list_credentials( + &self, + workspace_id: &str, + projection: &RepositoryAccessProjection, + ) -> Result> { + self.store.with_conn(|conn| { + let mut statement = conn.prepare( + r#"SELECT workspace_id, credential_id, name, public_key_algorithm, + public_key_fingerprint, current_revision, status, created_at, rotated_at + FROM repository_ssh_credentials WHERE workspace_id = ?1 + ORDER BY credential_id"#, + )?; + statement + .query_map([workspace_id], read_credential_row)? + .collect::, _>>()? + .into_iter() + .map(|mut record| { + record.referenced_repositories = + credential_references(projection, &record.credential_id); + Ok(record) + }) + .collect() + }) + } + + pub fn get_credential( + &self, + workspace_id: &str, + credential_id: &str, + references: &[String], + ) -> Result> { + self.store.with_conn(|conn| { + let mut record = read_credential(conn, workspace_id, credential_id)?; + if let Some(record) = record.as_mut() { + record.referenced_repositories = references.to_vec(); + } + Ok(record) + }) + } + + pub fn put_host_trust( + &self, + workspace_id: &str, + request: PutRepositorySshHostTrustRequest, + actor_account_id: &str, + ) -> Result { + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + let host_trust_id = validate_identifier("host_trust_id", &request.host_trust_id)?; + let hostname = normalize_hostname(&request.hostname)?; + if request.port == 0 { + return Err(Error::InvalidInput( + "SSH host trust port must be non-zero".to_string(), + )); + } + let parsed = parse_host_key(&request.host_key)?; + let fingerprint = host_operation_fingerprint(&request, &hostname, &parsed.fingerprint); + let now = now(); + self.store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(replayed) = replay_host_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + &host_trust_id, + )? { + tx.commit()?; + return Ok(replayed); + } + ensure_workspace_exists(&tx, workspace_id)?; + let current = read_host_trust(&tx, workspace_id, &host_trust_id)?; + let next_revision = match (current.as_ref(), request.expected_revision) { + (None, None) => 1, + (Some(current), Some(expected)) if current.current_revision == expected => { + expected.checked_add(1).ok_or_else(|| { + Error::InvalidInput("host trust revision overflow".to_string()) + })? + } + (None, Some(_)) | (Some(_), None) => { + return Err(Error::WorkspaceConfigConflict(format!( + "host trust `{host_trust_id}` create/update precondition failed" + ))); + } + (Some(_), Some(_)) => { + return Err(Error::WorkspaceConfigConflict(format!( + "host trust `{host_trust_id}` revision changed" + ))); + } + }; + if current.is_none() { + tx.execute( + r#"INSERT INTO repository_ssh_host_trusts ( + workspace_id, host_trust_id, hostname, port, key_algorithm, + host_key, fingerprint, current_revision, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)"#, + params![ + workspace_id, + host_trust_id, + hostname, + request.port, + parsed.algorithm, + parsed.canonical_key, + parsed.fingerprint, + next_revision, + now + ], + )?; + } else { + tx.execute( + r#"UPDATE repository_ssh_host_trusts + SET hostname = ?4, port = ?5, key_algorithm = ?6, + host_key = ?7, fingerprint = ?8, + current_revision = ?3, updated_at = ?9 + WHERE workspace_id = ?1 AND host_trust_id = ?2 + AND current_revision = ?10"#, + params![ + workspace_id, + host_trust_id, + next_revision, + hostname, + request.port, + parsed.algorithm, + parsed.canonical_key, + parsed.fingerprint, + now, + request.expected_revision + ], + )?; + } + tx.execute( + r#"INSERT INTO repository_ssh_host_trust_revisions ( + workspace_id, host_trust_id, revision, hostname, port, + key_algorithm, host_key, fingerprint, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + params![ + workspace_id, + host_trust_id, + next_revision, + hostname, + request.port, + parsed.algorithm, + parsed.canonical_key, + parsed.fingerprint, + now + ], + )?; + let event = if next_revision == 1 { + "host_trust_created" + } else { + "host_trust_rotated" + }; + insert_audit( + &tx, + workspace_id, + event, + &host_trust_id, + next_revision, + actor_account_id, + &now, + )?; + insert_operation( + &tx, + workspace_id, + &operation_id, + &fingerprint, + "host_trust", + &host_trust_id, + next_revision, + &now, + )?; + let record = read_host_trust(&tx, workspace_id, &host_trust_id)?.ok_or_else(|| { + Error::RegistryInconsistency("host trust could not be reloaded".to_string()) + })?; + tx.commit()?; + Ok(record) + }) + } + + pub fn delete_host_trust( + &self, + workspace_id: &str, + host_trust_id: &str, + request: DeleteRepositorySshHostTrustRequest, + actor_account_id: &str, + projection: &RepositoryAccessProjection, + ) -> Result<()> { + let host_trust_id = validate_identifier("host_trust_id", host_trust_id)?; + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + if !host_trust_references(projection, &host_trust_id).is_empty() { + return Err(Error::WorkspaceConfigConflict(format!( + "host trust `{host_trust_id}` is referenced by active Workspace config" + ))); + } + let fingerprint = simple_operation_fingerprint( + "delete_host_trust", + &host_trust_id, + request.expected_revision, + ); + let now = now(); + self.store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id)? { + tx.commit()?; + return Ok(()); + } + let current = read_host_trust(&tx, workspace_id, &host_trust_id)? + .ok_or_else(|| Error::InvalidRecordId(host_trust_id.clone()))?; + if current.current_revision != request.expected_revision { + return Err(Error::WorkspaceConfigConflict(format!( + "host trust `{host_trust_id}` revision changed" + ))); + } + insert_audit(&tx, workspace_id, "host_trust_deleted", &host_trust_id, current.current_revision, actor_account_id, &now)?; + let deleted = tx.execute( + "DELETE FROM repository_ssh_host_trusts WHERE workspace_id = ?1 AND host_trust_id = ?2 AND current_revision = ?3", + params![workspace_id, host_trust_id, request.expected_revision], + )?; + if deleted != 1 { + return Err(Error::WorkspaceConfigConflict(format!( + "host trust `{host_trust_id}` revision changed" + ))); + } + insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id, request.expected_revision, &now)?; + tx.commit()?; + Ok(()) + }) + } + + pub fn list_host_trusts( + &self, + workspace_id: &str, + projection: &RepositoryAccessProjection, + ) -> Result> { + self.store.with_conn(|conn| { + let mut statement = conn.prepare( + r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, + host_key, fingerprint, current_revision, created_at, updated_at + FROM repository_ssh_host_trusts WHERE workspace_id = ?1 + ORDER BY host_trust_id"#, + )?; + statement + .query_map([workspace_id], read_host_trust_row)? + .collect::, _>>()? + .into_iter() + .map(|mut record| { + record.referenced_repositories = + host_trust_references(projection, &record.host_trust_id); + Ok(record) + }) + .collect() + }) + } + + pub fn get_host_trust( + &self, + workspace_id: &str, + host_trust_id: &str, + references: &[String], + ) -> Result> { + self.store.with_conn(|conn| { + let mut record = read_host_trust(conn, workspace_id, host_trust_id)?; + if let Some(record) = record.as_mut() { + record.referenced_repositories = references.to_vec(); + } + Ok(record) + }) + } + + fn seal( + &self, + workspace_id: &str, + credential_id: &str, + revision: u64, + purpose: &str, + plaintext: &[u8], + ) -> Result { + if plaintext.is_empty() || plaintext.len() > MAX_SECRET_BYTES { + return Err(Error::InvalidInput( + "Repository SSH secret input is empty or too large".to_string(), + )); + } + let key = self.master_key.as_ref().ok_or_else(|| { + Error::Store("Repository secret encryption authority is unavailable".to_string()) + })?; + let unbound = UnboundKey::new(&AES_256_GCM, key.as_slice()) + .map_err(|_| Error::Store("Repository secret encryption key is invalid".to_string()))?; + let key = LessSafeKey::new(unbound); + let mut nonce = [0u8; NONCE_BYTES]; + SystemRandom::new() + .fill(&mut nonce) + .map_err(|_| Error::Store("Repository secret nonce generation failed".to_string()))?; + let mut ciphertext = plaintext.to_vec(); + let aad = secret_aad(workspace_id, credential_id, revision, purpose); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad.as_bytes()), + &mut ciphertext, + ) + .map_err(|_| Error::Store("Repository secret encryption failed".to_string()))?; + Ok(SealedSecret { nonce, ciphertext }) + } +} + +#[derive(Debug)] +struct ParsedKey { + algorithm: String, + fingerprint: String, +} + +fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result { + if private_key.is_empty() || private_key.len() > MAX_SECRET_BYTES { + return Err(Error::InvalidInput( + "Repository SSH private key is empty or too large".to_string(), + )); + } + let key = PrivateKey::from_openssh(private_key) + .map_err(|_| Error::InvalidInput("Repository SSH private key is malformed".to_string()))?; + let key = if key.is_encrypted() { + let passphrase = passphrase.ok_or_else(|| { + Error::InvalidInput( + "encrypted Repository SSH private key requires a passphrase".to_string(), + ) + })?; + key.decrypt(passphrase).map_err(|_| { + Error::InvalidInput("Repository SSH private key passphrase is invalid".to_string()) + })? + } else { + if passphrase.is_some() { + return Err(Error::InvalidInput( + "passphrase was supplied for an unencrypted Repository SSH private key".to_string(), + )); + } + key + }; + if key.algorithm() != Algorithm::Ed25519 { + return Err(Error::InvalidInput( + "only ssh-ed25519 Repository private keys are supported".to_string(), + )); + } + let public_key = key.public_key(); + Ok(ParsedKey { + algorithm: public_key.algorithm().to_string(), + fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(), + }) +} + +struct ParsedHostKey { + algorithm: String, + canonical_key: String, + fingerprint: String, +} + +fn parse_host_key(host_key: &str) -> Result { + if host_key.is_empty() || host_key.len() > MAX_SECRET_BYTES { + return Err(Error::InvalidInput( + "SSH host key is empty or too large".to_string(), + )); + } + let key = PublicKey::from_openssh(host_key) + .map_err(|_| Error::InvalidInput("SSH host key is malformed".to_string()))?; + if key.algorithm() != Algorithm::Ed25519 { + return Err(Error::InvalidInput( + "only ssh-ed25519 host keys are supported".to_string(), + )); + } + Ok(ParsedHostKey { + algorithm: key.algorithm().to_string(), + canonical_key: key + .to_openssh() + .map_err(|_| Error::InvalidInput("SSH host key cannot be encoded".to_string()))?, + fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), + }) +} + +#[derive(Debug)] +struct SealedSecret { + nonce: [u8; NONCE_BYTES], + ciphertext: Vec, +} + +fn insert_secret( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + credential_id: &str, + revision: u64, + purpose: &str, + secret: &SealedSecret, + created_at: &str, +) -> Result<()> { + tx.execute( + r#"INSERT INTO server_secret_versions ( + workspace_id, secret_id, revision, purpose, encryption_algorithm, + nonce, ciphertext, created_at + ) VALUES (?1, ?2, ?3, ?4, 'aes-256-gcm-v1', ?5, ?6, ?7)"#, + params![ + workspace_id, + credential_id, + revision, + purpose, + secret.nonce.as_slice(), + secret.ciphertext, + created_at + ], + )?; + Ok(()) +} + +fn replay_credential_operation( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + operation_id: &str, + fingerprint: &str, + credential_id: &str, +) -> Result> { + let operation = read_operation(tx, workspace_id, operation_id)?; + let Some((stored_fingerprint, kind, resource_id, _)) = operation else { + return Ok(None); + }; + if stored_fingerprint != fingerprint || kind != "credential" || resource_id != credential_id { + return Err(Error::WorkspaceConfigConflict( + "Repository secret operation id was reused with different input".to_string(), + )); + } + read_credential(tx, workspace_id, credential_id)? + .map(Some) + .ok_or_else(|| { + Error::RegistryInconsistency( + "Repository secret operation replay points to a missing credential".to_string(), + ) + }) +} + +fn replay_host_operation( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + operation_id: &str, + fingerprint: &str, + host_trust_id: &str, +) -> Result> { + let operation = read_operation(tx, workspace_id, operation_id)?; + let Some((stored_fingerprint, kind, resource_id, _)) = operation else { + return Ok(None); + }; + if stored_fingerprint != fingerprint || kind != "host_trust" || resource_id != host_trust_id { + return Err(Error::WorkspaceConfigConflict( + "Repository host trust operation id was reused with different input".to_string(), + )); + } + read_host_trust(tx, workspace_id, host_trust_id)? + .map(Some) + .ok_or_else(|| { + Error::RegistryInconsistency( + "Repository host trust operation replay points to a missing record".to_string(), + ) + }) +} + +fn replay_deleted_operation( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + operation_id: &str, + fingerprint: &str, + kind: &str, + resource_id: &str, +) -> Result { + let Some((stored_fingerprint, stored_kind, stored_resource, _)) = + read_operation(tx, workspace_id, operation_id)? + else { + return Ok(false); + }; + if stored_fingerprint != fingerprint || stored_kind != kind || stored_resource != resource_id { + return Err(Error::WorkspaceConfigConflict( + "Repository secret operation id was reused with different input".to_string(), + )); + } + Ok(true) +} + +fn read_operation( + conn: &rusqlite::Connection, + workspace_id: &str, + operation_id: &str, +) -> Result> { + conn.query_row( + r#"SELECT request_fingerprint, resource_kind, resource_id, result_revision + FROM repository_secret_operations + WHERE workspace_id = ?1 AND operation_id = ?2"#, + params![workspace_id, operation_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get::<_, i64>(3)? as u64, + )) + }, + ) + .optional() + .map_err(Into::into) +} + +fn insert_operation( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + operation_id: &str, + fingerprint: &str, + kind: &str, + resource_id: &str, + revision: u64, + created_at: &str, +) -> Result<()> { + tx.execute( + r#"INSERT INTO repository_secret_operations ( + workspace_id, operation_id, request_fingerprint, resource_kind, + resource_id, result_revision, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + params![ + workspace_id, + operation_id, + fingerprint, + kind, + resource_id, + revision, + created_at + ], + )?; + Ok(()) +} + +fn insert_audit( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + kind: &str, + resource_id: &str, + revision: u64, + actor_account_id: &str, + created_at: &str, +) -> Result<()> { + tx.execute( + r#"INSERT INTO repository_secret_audit_events ( + workspace_id, event_id, kind, resource_id, revision, + actor_account_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + params![ + workspace_id, + format!("repo-secret-audit-{}", uuid::Uuid::now_v7()), + kind, + resource_id, + revision, + actor_account_id, + created_at + ], + )?; + Ok(()) +} + +fn ensure_workspace_exists(conn: &rusqlite::Connection, workspace_id: &str) -> Result<()> { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + [workspace_id], + |row| row.get(0), + )?; + if !exists { + return Err(Error::WorkspaceIdMismatch); + } + Ok(()) +} + +fn credential_row_exists( + conn: &rusqlite::Connection, + workspace_id: &str, + credential_id: &str, +) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2)", + params![workspace_id, credential_id], + |row| row.get(0), + ) + .map_err(Into::into) +} + +fn read_credential( + conn: &rusqlite::Connection, + workspace_id: &str, + credential_id: &str, +) -> Result> { + conn.query_row( + r#"SELECT workspace_id, credential_id, name, public_key_algorithm, + public_key_fingerprint, current_revision, status, created_at, rotated_at + FROM repository_ssh_credentials + WHERE workspace_id = ?1 AND credential_id = ?2"#, + params![workspace_id, credential_id], + read_credential_row, + ) + .optional() + .map_err(Into::into) +} + +fn read_credential_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(RepositorySshCredential { + workspace_id: row.get(0)?, + credential_id: row.get(1)?, + name: row.get(2)?, + public_key_algorithm: row.get(3)?, + public_key_fingerprint: row.get(4)?, + current_revision: row.get::<_, i64>(5)? as u64, + status: row.get(6)?, + created_at: row.get(7)?, + rotated_at: row.get(8)?, + referenced_repositories: Vec::new(), + }) +} + +fn read_host_trust( + conn: &rusqlite::Connection, + workspace_id: &str, + host_trust_id: &str, +) -> Result> { + conn.query_row( + r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, + host_key, fingerprint, current_revision, created_at, updated_at + FROM repository_ssh_host_trusts + WHERE workspace_id = ?1 AND host_trust_id = ?2"#, + params![workspace_id, host_trust_id], + read_host_trust_row, + ) + .optional() + .map_err(Into::into) +} + +fn read_host_trust_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(RepositorySshHostTrust { + workspace_id: row.get(0)?, + host_trust_id: row.get(1)?, + hostname: row.get(2)?, + port: row.get::<_, i64>(3)? as u16, + key_algorithm: row.get(4)?, + host_key: row.get(5)?, + fingerprint: row.get(6)?, + current_revision: row.get::<_, i64>(7)? as u64, + created_at: row.get(8)?, + updated_at: row.get(9)?, + referenced_repositories: Vec::new(), + }) +} + +fn credential_references( + projection: &RepositoryAccessProjection, + credential_id: &str, +) -> Vec { + projection + .bindings + .iter() + .filter(|binding| binding.credential_id == credential_id) + .map(|binding| binding.repository_id.clone()) + .collect() +} + +fn host_trust_references( + projection: &RepositoryAccessProjection, + host_trust_id: &str, +) -> Vec { + projection + .bindings + .iter() + .filter(|binding| binding.host_trust_id == host_trust_id) + .map(|binding| binding.repository_id.clone()) + .collect() +} + +fn master_key_path(database_path: &Path) -> Result { + let parent = database_path.parent().ok_or_else(|| { + Error::Config("Server database has no parent for secret master key".to_string()) + })?; + Ok(parent.join("repository-secrets.master-key")) +} + +fn load_or_create_master_key(path: &Path) -> Result<[u8; MASTER_KEY_BYTES]> { + match std::fs::read(path) { + Ok(bytes) => return master_key_from_bytes(&bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(Error::Store( + "Repository secret master key could not be read".to_string(), + )); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|_| { + Error::Store("Repository secret master key directory could not be created".to_string()) + })?; + } + let mut key = [0u8; MASTER_KEY_BYTES]; + SystemRandom::new() + .fill(&mut key) + .map_err(|_| Error::Store("Repository secret master key generation failed".to_string()))?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(mut file) => { + file.write_all(&key) + .and_then(|_| file.sync_all()) + .map_err(|_| { + Error::Store("Repository secret master key could not be persisted".to_string()) + })?; + Ok(key) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let bytes = std::fs::read(path).map_err(|_| { + Error::Store("Repository secret master key race could not be resolved".to_string()) + })?; + master_key_from_bytes(&bytes) + } + Err(_) => Err(Error::Store( + "Repository secret master key could not be created".to_string(), + )), + } +} + +fn master_key_from_bytes(bytes: &[u8]) -> Result<[u8; MASTER_KEY_BYTES]> { + bytes + .try_into() + .map_err(|_| Error::Store("Repository secret master key has an invalid length".to_string())) +} + +fn secret_aad(workspace_id: &str, credential_id: &str, revision: u64, purpose: &str) -> String { + format!("yoi/repository-secret/v1/{workspace_id}/{credential_id}/{revision}/{purpose}") +} + +fn validate_identifier(field: &str, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > MAX_IDENTIFIER_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(Error::InvalidInput(format!( + "{field} must be 1-{MAX_IDENTIFIER_BYTES} ASCII identifier characters" + ))); + } + Ok(value.to_string()) +} + +fn normalize_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > MAX_NAME_BYTES || value.chars().any(char::is_control) { + return Err(Error::InvalidInput(format!( + "credential name must be 1-{MAX_NAME_BYTES} non-control bytes" + ))); + } + Ok(value.to_string()) +} + +fn normalize_hostname(value: &str) -> Result { + let value = value.trim().trim_end_matches('.').to_ascii_lowercase(); + if value.is_empty() + || value.len() > 253 + || value.starts_with('-') + || value.ends_with('-') + || value.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(Error::InvalidInput( + "SSH host trust hostname is invalid".to_string(), + )); + } + Ok(value) +} + +fn credential_fingerprint( + kind: &str, + credential_id: &str, + name: &str, + expected_revision: u64, + private_key: &str, + passphrase: Option<&str>, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi repository credential operation v1"); + hasher.update(kind.as_bytes()); + hasher.update(credential_id.as_bytes()); + hasher.update(name.as_bytes()); + hasher.update(expected_revision.to_be_bytes()); + hasher.update(Sha256::digest(private_key.as_bytes())); + hasher.update( + passphrase + .map(|value| Sha256::digest(value.as_bytes()).to_vec()) + .unwrap_or_default(), + ); + format!("sha256:{}", encode_hex(&hasher.finalize())) +} + +fn host_operation_fingerprint( + request: &PutRepositorySshHostTrustRequest, + hostname: &str, + key_fingerprint: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi repository host trust operation v1"); + hasher.update(request.host_trust_id.as_bytes()); + hasher.update(hostname.as_bytes()); + hasher.update(request.port.to_be_bytes()); + hasher.update(key_fingerprint.as_bytes()); + hasher.update(request.expected_revision.unwrap_or(0).to_be_bytes()); + format!("sha256:{}", encode_hex(&hasher.finalize())) +} + +fn simple_operation_fingerprint(kind: &str, resource_id: &str, revision: u64) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi repository secret simple operation v1"); + hasher.update(kind.as_bytes()); + hasher.update(resource_id.as_bytes()); + hasher.update(revision.to_be_bytes()); + format!("sha256:{}", encode_hex(&hasher.finalize())) +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +pub fn credential_reference_set(projection: &RepositoryAccessProjection) -> BTreeSet { + projection + .bindings + .iter() + .map(|binding| binding.credential_id.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{RepositoryRecord, WorkspaceRecord}; + use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, DEFAULT_IMPORT_POLICY_VERSION, + DEFAULT_SCHEMA_VERSION, SnapshotEnvironment, ToolchainContract, VirtualPath, + WorkspaceConfigSchemaBundle, + }; + use ssh_key::private::Ed25519Keypair; + use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; + + fn test_private_key(seed: u8) -> (String, String) { + let key = PrivateKey::from(Ed25519Keypair::from_seed(&[seed; 32])); + let private = key.to_openssh(ssh_key::LineEnding::LF).unwrap().to_string(); + let public = key.public_key().to_openssh().unwrap(); + (private, public) + } + + fn test_service() -> ( + tempfile::TempDir, + Arc, + RepositorySecretService, + ) { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let timestamp = now(); + for workspace_id in ["workspace-a", "workspace-b"] { + futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { + workspace_id: workspace_id.to_string(), + owner_account_id: None, + display_name: workspace_id.to_string(), + state: "active".to_string(), + created_at: timestamp.clone(), + updated_at: timestamp.clone(), + })) + .unwrap(); + } + let service = RepositorySecretService::open(store.clone(), &database).unwrap(); + (dir, store, service) + } + + #[test] + fn schema_accepts_repository_keyed_ssh_access() { + let contribution = RepositoryAccessConfigSchemaProvider.contribution().unwrap(); + assert_eq!(contribution.namespace, "repository_access"); + assert!(contribution.source.contains("...{")); + assert!(!contribution.source.contains("private_key")); + assert!(!contribution.source.contains("secret_ref")); + } + + #[test] + fn master_key_is_external_and_stable() { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + let first = load_or_create_master_key(&master_key_path(&database).unwrap()).unwrap(); + let second = load_or_create_master_key(&master_key_path(&database).unwrap()).unwrap(); + assert_eq!(first, second); + assert!(!database.exists()); + } + + #[test] + fn invalid_private_key_error_never_echoes_input() { + let secret = "DO NOT ECHO THIS PRIVATE KEY"; + let error = parse_private_key(secret, None).unwrap_err().to_string(); + assert!(!error.contains(secret)); + } + + #[test] + fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() { + let (_dir, store, service) = test_service(); + let (private_key, _) = test_private_key(7); + let request = CreateRepositorySshCredentialRequest { + operation_id: "create-one".to_string(), + credential_id: "deploy-main".to_string(), + name: "Main deploy key".to_string(), + private_key: private_key.clone(), + passphrase: None, + }; + let created = service + .create_credential("workspace-a", request.clone(), "owner-a") + .unwrap(); + let replayed = service + .create_credential("workspace-a", request, "owner-a") + .unwrap(); + assert_eq!(created, replayed); + assert_eq!(created.current_revision, 1); + assert!( + service + .get_credential("workspace-b", "deploy-main", &[]) + .unwrap() + .is_none() + ); + + let serialized = serde_json::to_string(&created).unwrap(); + assert!(!serialized.contains("private_key")); + assert!(!serialized.contains("secret_ref")); + assert!(!serialized.contains("BEGIN OPENSSH")); + store + .with_conn(|conn| { + let (nonce, ciphertext): (Vec, Vec) = conn.query_row( + "SELECT nonce, ciphertext FROM server_secret_versions WHERE workspace_id = 'workspace-a' AND secret_id = 'deploy-main' AND revision = 1 AND purpose = 'private_key'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(nonce.len(), NONCE_BYTES); + assert!(!ciphertext.windows(private_key.len()).any(|window| window == private_key.as_bytes())); + Ok(()) + }) + .unwrap(); + + let (rotated_key, _) = test_private_key(8); + let rotated = service + .rotate_credential( + "workspace-a", + "deploy-main", + RotateRepositorySshCredentialRequest { + operation_id: "rotate-one".to_string(), + expected_revision: 1, + private_key: rotated_key, + passphrase: None, + }, + "owner-a", + ) + .unwrap(); + assert_eq!(rotated.current_revision, 2); + assert_ne!( + rotated.public_key_fingerprint, + created.public_key_fingerprint + ); + } + + #[test] + fn pinned_host_trust_has_stable_fingerprint_and_revision() { + let (_dir, _store, service) = test_service(); + let (_, public_key) = test_private_key(9); + let created = service + .put_host_trust( + "workspace-a", + PutRepositorySshHostTrustRequest { + operation_id: "host-create".to_string(), + host_trust_id: "github".to_string(), + hostname: "GitHub.COM.".to_string(), + port: 22, + host_key: public_key.clone(), + expected_revision: None, + }, + "owner-a", + ) + .unwrap(); + assert_eq!(created.hostname, "github.com"); + assert_eq!(created.current_revision, 1); + let updated = service + .put_host_trust( + "workspace-a", + PutRepositorySshHostTrustRequest { + operation_id: "host-update".to_string(), + host_trust_id: "github".to_string(), + hostname: "github.com".to_string(), + port: 22, + host_key: public_key, + expected_revision: Some(1), + }, + "owner-a", + ) + .unwrap(); + assert_eq!(updated.current_revision, 2); + assert_eq!(updated.fingerprint, created.fingerprint); + } + + fn config_state(source: &str) -> WorkspaceConfigState { + let path = VirtualPath::parse("main.dcdl").unwrap(); + let entry = ConfigEntry::new(path.clone(), ConfigContentType::Decodal, source).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries(1, vec![entry]).unwrap(); + let schema_bundle = WorkspaceConfigSchemaBundle::compose(vec![ + RepositoryAccessConfigSchemaProvider.contribution().unwrap(), + ]) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + DEFAULT_SCHEMA_VERSION, + vec![path], + DEFAULT_IMPORT_POLICY_VERSION, + schema_bundle, + ); + let evaluation = SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .unwrap(); + WorkspaceConfigState { + snapshot, + contract, + projection_digest: evaluation.projection_digest, + } + } + + #[test] + fn workspace_config_projection_resolves_scoped_records_and_exact_host() { + let (_dir, store, service) = test_service(); + let timestamp = now(); + let source = RepositorySource { + kind: RepositorySourceKind::Ssh, + uri: "ssh://git@example.test/org/repo.git".to_string(), + }; + let source_fingerprint = crate::repository_source::repository_source_fingerprint(&source); + store + .upsert_repository(&RepositoryRecord { + workspace_id: "workspace-a".to_string(), + repository_id: "remote".to_string(), + name: "Remote".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source, + default_ref: Some("main".to_string()), + source_revision: 1, + source_fingerprint, + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: timestamp.clone(), + updated_at: timestamp, + }) + .unwrap(); + assert_eq!( + store + .get_repository("workspace-a", "remote") + .unwrap() + .unwrap() + .source + .kind, + RepositorySourceKind::Ssh + ); + let (private_key, public_key) = test_private_key(11); + service + .create_credential( + "workspace-a", + CreateRepositorySshCredentialRequest { + operation_id: "config-credential".to_string(), + credential_id: "deploy".to_string(), + name: "Deploy".to_string(), + private_key, + passphrase: None, + }, + "owner-a", + ) + .unwrap(); + service + .put_host_trust( + "workspace-a", + PutRepositorySshHostTrustRequest { + operation_id: "config-host".to_string(), + host_trust_id: "example".to_string(), + hostname: "example.test".to_string(), + port: 22, + host_key: public_key, + expected_revision: None, + }, + "owner-a", + ) + .unwrap(); + let state = config_state( + r#"{ + repository_access = { + remote = { + ssh = { + credential_id = "deploy"; + host_trust_id = "example"; + access = "read_only"; + }; + }; + }; + } as WorkspaceConfigSchema"#, + ); + let projection = + project_repository_access_state(&*store, &service, "workspace-a", &state).unwrap(); + assert_eq!(projection.bindings.len(), 1); + assert_eq!(projection.bindings[0].repository_id, "remote"); + assert_eq!( + projection.bindings[0].access, + RepositoryAccessMode::ReadOnly + ); + + let unknown = config_state( + r#"{ + repository_access = { + remote = { + ssh = { + credential_id = "missing"; + host_trust_id = "example"; + access = "read_only"; + }; + }; + }; + } as WorkspaceConfigSchema"#, + ); + let error = project_repository_access_state(&*store, &service, "workspace-a", &unknown) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unknown Repository SSH credential") + ); + } + + #[test] + fn referenced_resources_cannot_be_deleted() { + let (_dir, _store, service) = test_service(); + let (private_key, public_key) = test_private_key(10); + service + .create_credential( + "workspace-a", + CreateRepositorySshCredentialRequest { + operation_id: "create-ref".to_string(), + credential_id: "deploy".to_string(), + name: "Deploy".to_string(), + private_key, + passphrase: None, + }, + "owner-a", + ) + .unwrap(); + service + .put_host_trust( + "workspace-a", + PutRepositorySshHostTrustRequest { + operation_id: "host-ref".to_string(), + host_trust_id: "host".to_string(), + hostname: "example.test".to_string(), + port: 22, + host_key: public_key, + expected_revision: None, + }, + "owner-a", + ) + .unwrap(); + let projection = RepositoryAccessProjection { + workspace_id: "workspace-a".to_string(), + config_revision: 3, + projection_digest: "sha256:test".to_string(), + bindings: vec![RepositorySshAccessBinding { + repository_id: "main".to_string(), + credential_id: "deploy".to_string(), + host_trust_id: "host".to_string(), + access: RepositoryAccessMode::ReadOnly, + }], + }; + let error = service + .delete_credential( + "workspace-a", + "deploy", + DeleteRepositorySshCredentialRequest { + operation_id: "delete-ref".to_string(), + expected_revision: 1, + }, + "owner-a", + &projection, + ) + .unwrap_err(); + assert!(matches!(error, Error::WorkspaceConfigConflict(_))); + assert!( + service + .get_credential("workspace-a", "deploy", &[]) + .unwrap() + .is_some() + ); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 60a07fdb..2cf6fc6b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Weak}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use axum::extract::{Path as AxumPath, Query, Request, State}; +use axum::extract::{Extension, Path as AxumPath, Query, Request, State}; use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, SET_COOKIE}; use axum::http::{HeaderMap, Method, StatusCode, Uri}; use axum::middleware::{self, Next}; @@ -58,8 +58,12 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ - ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, + DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, + ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, + RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust, + RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, }; use crate::auth::{ @@ -109,6 +113,10 @@ use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, }; +use crate::repository_access::{ + RepositoryAccessConfigSchemaProvider, RepositorySecretService, + project_repository_access_candidate, project_repository_access_state, +}; use crate::resource_broker::BackendResourceBroker; use crate::runtime_settings::RuntimeConfigSchemaProvider; use crate::runtime_subscription::RuntimeSubscriptionBroker; @@ -342,6 +350,7 @@ pub struct WorkspaceApi { pub(crate) config: ServerConfig, pub(crate) store: Arc, config_store: Arc, + repository_secrets: Arc, config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, @@ -1053,6 +1062,7 @@ async fn authorize_workspace_api_request( return StatusCode::FORBIDDEN.into_response(); } } + request.extensions_mut().insert(actor); next.run(request).await } @@ -1343,12 +1353,17 @@ impl WorkspaceApi { let config_store = Arc::new(crate::SqliteWorkspaceStore::open( config.database_path.clone(), )?); + let repository_secrets = Arc::new(RepositorySecretService::open( + config_store.clone(), + &config.database_path, + )?); let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default() .with_provider(Arc::new( crate::profile_settings::ProfileConfigSchemaProvider, )) .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .with_provider(Arc::new(RuntimeConfigSchemaProvider)) + .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider)); config_store.ensure_workspace_config_materialized_with_schema( &config.workspace_id, @@ -1357,6 +1372,7 @@ impl WorkspaceApi { )?; let api = Self { config_store, + repository_secrets, config_schema_registry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache::default(), @@ -1863,6 +1879,32 @@ fn build_inner_router(api: WorkspaceApi) -> Router { get(scoped_get_workspace_memory_settings) .put(scoped_update_workspace_memory_settings), ) + .route( + "/api/w/{workspace_id}/settings/repository-access", + get(scoped_get_repository_access_projection), + ) + .route( + "/api/w/{workspace_id}/settings/repository-access/credentials", + get(scoped_list_repository_ssh_credentials) + .post(scoped_create_repository_ssh_credential), + ) + .route( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", + delete(scoped_delete_repository_ssh_credential), + ) + .route( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate", + post(scoped_rotate_repository_ssh_credential), + ) + .route( + "/api/w/{workspace_id}/settings/repository-access/host-trusts", + get(scoped_list_repository_ssh_host_trusts) + .post(scoped_put_repository_ssh_host_trust), + ) + .route( + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", + delete(scoped_delete_repository_ssh_host_trust), + ) .route( "/api/w/{workspace_id}/config/source-tree", get(scoped_get_workspace_config_tree), @@ -2986,6 +3028,18 @@ struct ScopedRepositoryPath { repository_id: String, } +#[derive(Debug, Deserialize)] +struct ScopedRepositoryCredentialPath { + workspace_id: String, + credential_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedRepositoryHostTrustPath { + workspace_id: String, + host_trust_id: String, +} + #[derive(Debug, Deserialize)] struct ScopedProfileArchivePath { workspace_id: String, @@ -3274,6 +3328,162 @@ async fn scoped_update_workspace_memory_settings( })) } +async fn require_manage_repository_secrets( + api: &WorkspaceApi, + workspace_id: &str, + actor: &RequestActor, +) -> ApiResult<()> { + validate_workspace_scope(api, workspace_id)?; + let workspace = api + .store + .get_workspace(workspace_id) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + if workspace.owner_account_id.as_deref() != Some(actor.account_id.as_str()) { + return Err(Error::WorkspacePermissionDenied( + "ManageSecrets requires the Workspace owner account".to_string(), + ) + .into()); + } + Ok(()) +} + +fn active_repository_access_projection( + api: &WorkspaceApi, + workspace_id: &str, +) -> ApiResult { + let state = api + .config_store + .load_workspace_config(workspace_id)? + .ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?; + Ok(project_repository_access_state( + &*api.store, + &api.repository_secrets, + workspace_id, + &state, + )?) +} + +async fn scoped_get_repository_access_projection( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + Ok(Json(active_repository_access_projection( + &api, + &path.workspace_id, + )?)) +} + +async fn scoped_list_repository_ssh_credentials( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult>> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + Ok(Json( + api.repository_secrets + .list_credentials(&path.workspace_id, &projection)?, + )) +} + +async fn scoped_create_repository_ssh_credential( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let credential = + api.repository_secrets + .create_credential(&path.workspace_id, request, &actor.account_id)?; + Ok((StatusCode::CREATED, Json(credential))) +} + +async fn scoped_rotate_repository_ssh_credential( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + Ok(Json(api.repository_secrets.rotate_credential( + &path.workspace_id, + &path.credential_id, + request, + &actor.account_id, + )?)) +} + +async fn scoped_delete_repository_ssh_credential( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + api.repository_secrets.delete_credential( + &path.workspace_id, + &path.credential_id, + request, + &actor.account_id, + &projection, + )?; + Ok(StatusCode::NO_CONTENT) +} + +async fn scoped_list_repository_ssh_host_trusts( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult>> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + Ok(Json( + api.repository_secrets + .list_host_trusts(&path.workspace_id, &projection)?, + )) +} + +async fn scoped_put_repository_ssh_host_trust( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let status = if request.expected_revision.is_some() { + StatusCode::OK + } else { + StatusCode::CREATED + }; + let host_trust = + api.repository_secrets + .put_host_trust(&path.workspace_id, request, &actor.account_id)?; + Ok((status, Json(host_trust))) +} + +async fn scoped_delete_repository_ssh_host_trust( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + api.repository_secrets.delete_host_trust( + &path.workspace_id, + &path.host_trust_id, + request, + &actor.account_id, + &projection, + )?; + Ok(StatusCode::NO_CONTENT) +} + async fn scoped_get_workspace_config_tree( State(api): State, AxumPath(path): AxumPath, @@ -3333,6 +3543,12 @@ async fn scoped_commit_workspace_config_tree( api.config_schema_registry.compose()?, )?; crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; + project_repository_access_candidate( + &*api.store, + &api.repository_secrets, + &path.workspace_id, + &candidate, + )?; let state = api .config_store .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?; @@ -14070,7 +14286,9 @@ impl ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.error { - Error::BrowserReopenConfirmationRequired => StatusCode::FORBIDDEN, + Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { + StatusCode::FORBIDDEN + } Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) | Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT, @@ -19157,6 +19375,54 @@ mod tests { assert_eq!(detail.provenance.id, "workspace:triage-errors"); } + #[tokio::test] + async fn repository_secret_management_is_owner_only() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let timestamp = Utc::now().to_rfc3339(); + api.store + .upsert_account(&crate::store::AccountRecord { + account_id: "owner-account".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: timestamp.clone(), + updated_at: timestamp, + }) + .unwrap(); + let mut workspace = api + .store + .get_workspace(TEST_WORKSPACE_ID) + .await + .unwrap() + .unwrap(); + workspace.owner_account_id = Some("owner-account".to_string()); + api.store.upsert_workspace(&workspace).await.unwrap(); + + let owner = RequestActor { + user_id: "owner-user".to_string(), + account_id: "owner-account".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + auth_method: ActorAuthMethod::BrowserSession, + }; + require_manage_repository_secrets(&api, TEST_WORKSPACE_ID, &owner) + .await + .unwrap(); + + let non_owner = RequestActor { + user_id: "other-user".to_string(), + account_id: "other-account".to_string(), + handle: "other".to_string(), + display_name: "Other".to_string(), + auth_method: ActorAuthMethod::ApiToken, + }; + let error = require_manage_repository_secrets(&api, TEST_WORKSPACE_ID, &non_owner) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + } + async fn test_api_with_recording_backend( workspace_root: impl Into, ) -> (WorkspaceApi, Arc) { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 92fd6dfb..694ebf24 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -252,6 +252,11 @@ const MIGRATIONS: &[Migration] = &[ name: "create Workdir create operations", apply: create_workdir_create_operations, }, + Migration { + version: 46, + name: "create Workspace Repository SSH secret authority", + apply: create_repository_ssh_secret_authority, + }, ]; struct Migration { @@ -6703,6 +6708,110 @@ fn create_workdir_create_operations(conn: &Connection) -> Result<()> { Ok(()) } +fn create_repository_ssh_secret_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + CREATE TABLE repository_ssh_credentials ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TEXT NOT NULL, + rotated_at TEXT, + PRIMARY KEY (workspace_id, credential_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE repository_ssh_credential_revisions ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, credential_id, revision), + FOREIGN KEY (workspace_id, credential_id) + REFERENCES repository_ssh_credentials(workspace_id, credential_id) + ON DELETE CASCADE + ); + CREATE TABLE server_secret_versions ( + workspace_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), + encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, secret_id, revision, purpose), + FOREIGN KEY (workspace_id, secret_id, revision) + REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision) + ON DELETE CASCADE + ); + CREATE TABLE repository_ssh_host_trusts ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE repository_ssh_host_trust_revisions ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id, revision), + FOREIGN KEY (workspace_id, host_trust_id) + REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) + ON DELETE CASCADE + ); + CREATE TABLE repository_secret_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), + resource_id TEXT NOT NULL, + result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE repository_secret_audit_events ( + workspace_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX idx_repository_ssh_credentials_workspace_status + ON repository_ssh_credentials(workspace_id, status, credential_id); + CREATE INDEX idx_repository_ssh_host_trusts_workspace_host + ON repository_ssh_host_trusts(workspace_id, hostname, port); + CREATE INDEX idx_repository_secret_audit_workspace_created + ON repository_secret_audit_events(workspace_id, created_at, event_id); + "#, + )?; + Ok(()) +} + fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -9588,7 +9697,7 @@ mod tests { apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); let remote = conn .query_row( "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ @@ -9666,7 +9775,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 45); + assert_eq!(plan.target_schema_version, 46); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -9680,7 +9789,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 45); + assert_eq!(current_schema_version(conn)?, 46); Ok(()) }) .unwrap(); @@ -9816,7 +9925,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -9945,7 +10054,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -10063,7 +10172,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -10081,7 +10190,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); let settings = conn .query_row( "SELECT settings_revision, language FROM workspace_memory_settings \ @@ -10122,7 +10231,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -10189,7 +10298,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -10372,7 +10481,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 45); + assert_eq!(store.schema_version().await.unwrap(), 46); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -10389,7 +10498,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 45); + assert_eq!(reopened.schema_version().await.unwrap(), 46); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -11143,7 +11252,7 @@ INSERT INTO worker_registry ( let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); migrated .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 45); + assert_eq!(current_schema_version(conn)?, 46); assert_eq!( conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, 1, @@ -11492,14 +11601,21 @@ INSERT INTO worker_registry ( configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute_batch( - "DROP TABLE workdir_create_operations; - DELETE FROM __yoi_schema_migrations WHERE version = 45;", + "DROP TABLE repository_secret_audit_events; + DROP TABLE repository_secret_operations; + DROP TABLE server_secret_versions; + DROP TABLE repository_ssh_credential_revisions; + DROP TABLE repository_ssh_credentials; + DROP TABLE repository_ssh_host_trust_revisions; + DROP TABLE repository_ssh_host_trusts; + DROP TABLE workdir_create_operations; + DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46);", ) .unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 44); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); assert!(table_exists(&conn, "workdir_create_operations").unwrap()); let columns = table_columns(&conn, "workdir_create_operations").unwrap(); for required in [ @@ -11518,19 +11634,57 @@ INSERT INTO worker_registry ( } } + #[test] + fn schema_v46_adds_repository_ssh_secret_authority_to_v45_database() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + conn.execute_batch( + "DROP TABLE repository_secret_audit_events; + DROP TABLE repository_secret_operations; + DROP TABLE server_secret_versions; + DROP TABLE repository_ssh_credential_revisions; + DROP TABLE repository_ssh_credentials; + DROP TABLE repository_ssh_host_trust_revisions; + DROP TABLE repository_ssh_host_trusts; + DELETE FROM __yoi_schema_migrations WHERE version = 46;", + ) + .unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 45); + + apply_migrations(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 46); + for table in [ + "repository_ssh_credentials", + "repository_ssh_credential_revisions", + "server_secret_versions", + "repository_ssh_host_trusts", + "repository_ssh_host_trust_revisions", + "repository_secret_operations", + "repository_secret_audit_events", + ] { + assert!(table_exists(&conn, table).unwrap(), "missing table {table}"); + } + let foreign_key_error: Option = conn + .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) + .optional() + .unwrap(); + assert!(foreign_key_error.is_none()); + } + #[test] fn server_refuses_a_database_from_a_newer_schema_generation() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (46, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (47, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 46 is newer"), "{error}"); + assert!(error.contains("schema version 47 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -11751,7 +11905,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- apply_migrations(&mut conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); + assert_eq!(current_schema_version(&conn).unwrap(), 46); let workspace_id: Option = conn .query_row( "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", @@ -12374,7 +12528,7 @@ WHERE workspace_id = 'workspace-a' .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 45); + assert_eq!(store.schema_version().await.unwrap(), 46); store .with_conn(|conn| { @@ -12563,7 +12717,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 45); + assert_eq!(store.schema_version().await.unwrap(), 46); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -12641,7 +12795,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 45); + assert_eq!(store.schema_version().await.unwrap(), 46); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -13048,7 +13202,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 45); + assert_eq!(store.schema_version().await.unwrap(), 46); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(),