From 86e24eca9e93eaf27ee5557b089dd0eae07bcc0f Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Wed, 20 May 2026 07:05:11 -0400 Subject: [PATCH] Add AWS Secrets Manager secret provider --- README.md | 10 ++ bun.lock | 84 +++++++++ package.json | 2 + .../providers/macos-lima-user-v2/provider.ts | 3 + src/rootcell/providers/factory.ts | 2 + src/rootcell/rootcell.test.ts | 160 ++++++++++++++++- src/rootcell/rootcell.ts | 2 + .../secrets/aws-secrets-manager-config.ts | 167 ++++++++++++++++++ src/rootcell/secrets/aws-secrets-manager.ts | 76 ++++++++ src/rootcell/types.ts | 2 + 10 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 src/rootcell/secrets/aws-secrets-manager-config.ts create mode 100644 src/rootcell/secrets/aws-secrets-manager.ts diff --git a/README.md b/README.md index 47a9783..62980fc 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,7 @@ first run. Edit that file for instance-local settings such as: ```sh AWS_REGION=us-west-2 +ROOTCELL_AWS_SECRETS_MANAGER_PROVIDERS={"aws-prod":{"aws_profile":"prod","aws_region":"us-west-2"},"aws-dev":{"aws_profile":"dev"}} ROOTCELL_SUBNET_POOL_START=192.168.100.0 ROOTCELL_SUBNET_POOL_END=192.168.254.0 ``` @@ -397,6 +398,7 @@ secrets may come from different providers: ```sh AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key +OTHER_TOKEN=aws-prod:other-token-a1b2c3 ``` For example, to inject an additional `ANTHROPIC_API_KEY`: @@ -406,6 +408,14 @@ security add-generic-password -a "$USER" -s anthropic-api-key -w "" echo 'ANTHROPIC_API_KEY=macos-keychain:anthropic-api-key' >> "$INSTANCE_DIR/secrets.env" ``` +AWS Secrets Manager providers are registered in `/.env` with +`ROOTCELL_AWS_SECRETS_MANAGER_PROVIDERS`. The JSON object keys are provider ids; +each value includes `aws_profile` and optional `aws_region`. If `aws_region` is +omitted, rootcell uses the region configured for that AWS profile in +`~/.aws/config`, then `AWS_REGION` or `AWS_DEFAULT_REGION`. The `secrets.env` +reference is the secret resource name only, such as `name-a1b2c3`, not the full +ARN. + If you want to use Anthropic or OpenAI subscriptions, you can log in from inside the VM. diff --git a/bun.lock b/bun.lock index 5db652c..7e1ec37 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "rootcell", "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.1050.0", + "@aws-sdk/credential-providers": "^3.1050.0", "yargs": "18.0.0", "zod": "^4.4.3", }, @@ -22,6 +24,56 @@ }, }, "packages": { + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1050.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-VBbHeLRUoprJE57zc8WEbqC+bLUbHGlMI8S7NUuvEZ3svsYp0SGzkw81xuq2W7zbnlNFJL1jMIINjNMGo0Rn1Q=="], + + "@aws-sdk/client-secrets-manager": ["@aws-sdk/client-secrets-manager@3.1050.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-mVOzxK4inCJgbGSQTeENYaACA9qGikru07b3HJyrKEeLVhuLKmo5csVzvKuz++ROdhX9gR7WzzJkoDOO5Xy/EQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.35", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-mMQsBJv40oi5QdqRj4Xbc9jTlWMxqWfs5zWu+RhbOuF5F0AxxWXT70hm0abOmLbF2M/Tkuygs01H4eWIQMfoMw=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-login": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1050.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1050.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-cognito-identity": "^3.972.35", "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.42", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IvGKl6+Vwf/x/wzgfWHjUwKu+0x5BeB7uwSO3QGH/9ssuAdpZR+maBUDP+HYbBgG2mU+CufN/eTelVwnWh10FQ=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.10", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1049.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -58,6 +110,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -92,6 +146,24 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + + "@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -162,6 +234,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -214,6 +288,10 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -300,6 +378,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -334,6 +414,8 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -368,6 +450,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], diff --git a/package.json b/package.json index 8de4a5f..5c1bffe 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "vitest": "^4.0.0" }, "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.1050.0", + "@aws-sdk/credential-providers": "^3.1050.0", "yargs": "18.0.0", "zod": "^4.4.3" } diff --git a/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts b/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts index 5166cc2..c7efa05 100644 --- a/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts +++ b/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts @@ -18,6 +18,7 @@ import { } from "../../../providers/macos-lima-user-v2-network.ts"; import type { ProviderBundle } from "../../../providers/types.ts"; import { preflightMacOsLimaUserV2Integration } from "./preflight.ts"; +import { AwsSecretsManagerSecretProvider } from "../../../secrets/aws-secrets-manager.ts"; import { MacOsKeychainSecretProvider } from "../../../secrets/macos-keychain.ts"; import { StaticSecretProviderRegistry } from "../../../secrets/registry.ts"; @@ -43,6 +44,7 @@ export function createBundle( vm: new LimaVmProvider(config, log), secrets: new StaticSecretProviderRegistry([ new MacOsKeychainSecretProvider(), + ...config.awsSecretsManagerProviders.map((providerConfig) => new AwsSecretsManagerSecretProvider(providerConfig)), ]), }; } @@ -142,6 +144,7 @@ function limaCleanupConfig(repoDir: string, instance: string, env: NodeJS.Proces agentIp: "192.168.109.11", networkPrefix: "24", imageManifestUrl: "https://example.invalid/manifest.json", + awsSecretsManagerProviders: [], }; } diff --git a/src/rootcell/providers/factory.ts b/src/rootcell/providers/factory.ts index 8e6eef7..a917e38 100644 --- a/src/rootcell/providers/factory.ts +++ b/src/rootcell/providers/factory.ts @@ -2,6 +2,7 @@ import type { RootcellConfig } from "../types.ts"; import type { ProviderBundle } from "./types.ts"; import { LimaVmProvider } from "./lima.ts"; import { MacOsLimaUserV2NetworkProvider, type LimaUserV2NetworkAttachment } from "./macos-lima-user-v2-network.ts"; +import { AwsSecretsManagerSecretProvider } from "../secrets/aws-secrets-manager.ts"; import { MacOsKeychainSecretProvider } from "../secrets/macos-keychain.ts"; import { StaticSecretProviderRegistry } from "../secrets/registry.ts"; @@ -14,6 +15,7 @@ export function createProviderBundle( vm: new LimaVmProvider(config, log), secrets: new StaticSecretProviderRegistry([ new MacOsKeychainSecretProvider(), + ...config.awsSecretsManagerProviders.map((providerConfig) => new AwsSecretsManagerSecretProvider(providerConfig)), ]), }; } diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 9234567..c6e7ffe 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -39,6 +39,12 @@ import { import { MacOsKeychainSecretProvider } from "./secrets/macos-keychain.ts"; import { StaticSecretProviderRegistry } from "./secrets/registry.ts"; import { SecretEnvMappingSchema } from "./secrets/types.ts"; +import { AwsSecretsManagerSecretProvider } from "./secrets/aws-secrets-manager.ts"; +import { + AWS_SECRETS_MANAGER_PROVIDERS_ENV, + parseAwsSecretsManagerProviderConfigs, + resolveAwsSecretsManagerRegion, +} from "./secrets/aws-secrets-manager-config.ts"; const EmptyStringArraySchema = z.array(z.string()).length(0); const DefaultSpyOptionsSchema = z.object({ @@ -211,7 +217,7 @@ describe("environment parsing", () => { test("validates secret mappings", () => { const mappings = parseSecretMappings([ "AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key", - "AWS_SECRET_ACCESS_KEY=aws-prod:arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key", + "AWS_SECRET_ACCESS_KEY=aws-prod:prod-key-a1b2c3", "ONEPASSWORD_TOKEN=1password:op://Private/token/password", "", ].join("\n")); @@ -225,7 +231,7 @@ describe("environment parsing", () => { envName: "AWS_SECRET_ACCESS_KEY", secret: { providerId: "aws-prod", - reference: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key", + reference: "prod-key-a1b2c3", }, }, { @@ -252,6 +258,22 @@ describe("environment parsing", () => { expect(config.firewallIp).toBe("192.168.109.10"); expect(config.agentIp).toBe("192.168.109.11"); expect(config.imageManifestUrl).toBe("https://github.com/rootcell-ai/rootcell/releases/latest/download/manifest.json"); + expect(config.awsSecretsManagerProviders).toEqual([]); + }); + + test("builds config with AWS Secrets Manager providers", () => { + const config = buildConfig("/repo", { + [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ + "aws-prod": { aws_profile: "prod", aws_region: "us-east-1" }, + "aws-dev": { aws_profile: "dev" }, + }), + }, fakeInstance("dev")); + + expect(config).toEqual(expect.schemaMatching(RootcellConfigSchema)); + expect(config.awsSecretsManagerProviders).toEqual([ + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { id: "aws-dev", awsProfile: "dev" }, + ]); }); }); @@ -357,6 +379,128 @@ describe("secret providers", () => { ])).toThrow("duplicate secret provider id"); }); + test("parses AWS Secrets Manager provider configuration", () => { + expect(parseAwsSecretsManagerProviderConfigs({})).toEqual([]); + expect(parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "" })).toEqual([]); + expect(parseAwsSecretsManagerProviderConfigs({ + [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ + "aws-prod": { aws_profile: "prod", aws_region: "us-east-1" }, + "aws-dev": { aws_profile: "dev" }, + }), + })).toEqual([ + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { id: "aws-dev", awsProfile: "dev" }, + ]); + + expect(() => parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "[]" })).toThrow("must be a JSON object"); + expect(() => parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "{" })).toThrow("must be valid JSON"); + expect(() => parseAwsSecretsManagerProviderConfigs({ + [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ "bad/id": { aws_profile: "prod" } }), + })).toThrow("invalid AWS Secrets Manager provider id"); + expect(() => parseAwsSecretsManagerProviderConfigs({ + [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ "aws-prod": { aws_region: "us-east-1" } }), + })).toThrow("aws_profile"); + }); + + test("resolves AWS Secrets Manager regions from provider config, AWS config, and environment", () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-aws-")); + try { + const configPath = join(dir, "config"); + const credentialsPath = join(dir, "credentials"); + writeFileSync(configPath, [ + "[default]", + "region = us-east-2", + "[profile prod]", + "region = us-west-2", + "", + ].join("\n"), "utf8"); + writeFileSync(credentialsPath, [ + "[fallback]", + "region = ap-south-1", + "", + ].join("\n"), "utf8"); + const env: NodeJS.ProcessEnv = { + AWS_CONFIG_FILE: configPath, + AWS_SHARED_CREDENTIALS_FILE: credentialsPath, + }; + + expect(resolveAwsSecretsManagerRegion({ id: "aws-prod", awsProfile: "prod", awsRegion: "eu-central-1" }, env)).toBe("eu-central-1"); + expect(resolveAwsSecretsManagerRegion({ id: "aws-prod", awsProfile: "prod" }, env)).toBe("us-west-2"); + expect(resolveAwsSecretsManagerRegion({ id: "aws-default", awsProfile: "default" }, env)).toBe("us-east-2"); + expect(resolveAwsSecretsManagerRegion({ id: "aws-fallback", awsProfile: "fallback" }, env)).toBe("ap-south-1"); + expect(resolveAwsSecretsManagerRegion({ id: "aws-env", awsProfile: "missing" }, { + ...env, + AWS_REGION: "ca-central-1", + })).toBe("ca-central-1"); + expect(() => resolveAwsSecretsManagerRegion({ id: "aws-missing", awsProfile: "missing" }, env)).toThrow("has no region"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("AWS Secrets Manager provider reads SecretString by exact secret id", async () => { + const profiles: string[] = []; + const clientConfigs: { readonly region: string }[] = []; + const commands: unknown[] = []; + const provider = new AwsSecretsManagerSecretProvider( + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { + credentialFactory: (profile) => { + profiles.push(profile); + return { accessKeyId: "access", secretAccessKey: "secret" }; + }, + clientFactory: (clientConfig) => { + clientConfigs.push({ region: clientConfig.region }); + return { + send: (command) => { + commands.push(command.input); + return Promise.resolve({ SecretString: "secret-value", $metadata: {} }); + }, + }; + }, + }, + ); + + await expect(provider.read("bedrock-token-a1b2c3")).resolves.toBe("secret-value"); + expect(profiles).toEqual(["prod"]); + expect(clientConfigs).toEqual([{ region: "us-east-1" }]); + expect(commands).toEqual([{ SecretId: "bedrock-token-a1b2c3" }]); + }); + + test("AWS Secrets Manager provider rejects ARN, binary, and missing string secrets", async () => { + const arnProvider = new AwsSecretsManagerSecretProvider( + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { + clientFactory: () => { + throw new Error("client should not be created for ARN references"); + }, + }, + ); + await expect(arnProvider.read("arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key")).rejects.toThrow("not ARNs"); + + const binaryProvider = new AwsSecretsManagerSecretProvider( + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { + credentialFactory: () => ({ accessKeyId: "access", secretAccessKey: "secret" }), + clientFactory: () => ({ + send: () => Promise.resolve({ SecretBinary: new Uint8Array([1]), $metadata: {} }), + }), + }, + ); + await expect(binaryProvider.read("binary-secret-a1b2c3")).rejects.toThrow("SecretBinary"); + + const missingProvider = new AwsSecretsManagerSecretProvider( + { id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" }, + { + credentialFactory: () => ({ accessKeyId: "access", secretAccessKey: "secret" }), + clientFactory: () => ({ + send: () => Promise.resolve({ $metadata: {} }), + }), + }, + ); + await expect(missingProvider.read("missing-secret-a1b2c3")).rejects.toThrow("returned no SecretString"); + }); + test("macOS Keychain provider reads generic passwords", async () => { const calls: { command: string; args: readonly string[]; allowFailure: boolean | undefined }[] = []; const provider = new MacOsKeychainSecretProvider("macos-keychain", (command, args, options) => { @@ -394,6 +538,18 @@ describe("VM and network providers", () => { expect(providers.secrets.ids).toEqual(["macos-keychain"]); }); + test("factory registers configured AWS Secrets Manager providers", () => { + const config = buildConfig("/repo", { + [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ + "aws-prod": { aws_profile: "prod", aws_region: "us-east-1" }, + "aws-dev": { aws_profile: "dev" }, + }), + }, fakeInstance("dev")); + const providers = createProviderBundle(config, ignoreLog); + + expect(providers.secrets.ids).toEqual(["macos-keychain", "aws-prod", "aws-dev"]); + }); + test("macOS Lima user-v2 provider exposes egress firewall and private-only agent attachments", () => { const config = buildConfig("/repo", {}, fakeInstance("dev")); const plan = new MacOsLimaUserV2NetworkProvider(config, ignoreLog).plan(); diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 5430a30..fa336a4 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -22,6 +22,7 @@ import { commandExists, runCapture, runInherited } from "./process.ts"; import { createProviderBundle } from "./providers/factory.ts"; import type { NetworkPlan, ProviderBundle, VmNetworkAttachment, VmStatus } from "./providers/types.ts"; import { parseSchema } from "./schema.ts"; +import { parseAwsSecretsManagerProviderConfigs } from "./secrets/aws-secrets-manager-config.ts"; import { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts"; const GUEST_USER = "luser"; @@ -114,6 +115,7 @@ export function buildConfig(repoDir: string, env: NodeJS.ProcessEnv, instance: R networkPrefix: String(instance.state.networkPrefix), imageManifestUrl: env.ROOTCELL_IMAGE_MANIFEST_URL ?? DEFAULT_IMAGE_MANIFEST_URL, ...(env.ROOTCELL_IMAGE_DIR === undefined || env.ROOTCELL_IMAGE_DIR.length === 0 ? {} : { imageDir: env.ROOTCELL_IMAGE_DIR }), + awsSecretsManagerProviders: parseAwsSecretsManagerProviderConfigs(env), }, `invalid rootcell config for ${instance.name}`); } diff --git a/src/rootcell/secrets/aws-secrets-manager-config.ts b/src/rootcell/secrets/aws-secrets-manager-config.ts new file mode 100644 index 0000000..352daec --- /dev/null +++ b/src/rootcell/secrets/aws-secrets-manager-config.ts @@ -0,0 +1,167 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { + NonEmptyStringSchema, + parseSchema, +} from "../schema.ts"; +import { SecretProviderIdSchema } from "./types.ts"; + +export const AWS_SECRETS_MANAGER_PROVIDERS_ENV = "ROOTCELL_AWS_SECRETS_MANAGER_PROVIDERS"; + +export const AwsSecretsManagerSecretProviderConfigSchema = z.object({ + id: SecretProviderIdSchema, + awsProfile: NonEmptyStringSchema, + awsRegion: NonEmptyStringSchema.optional(), +}).strict(); + +export type AwsSecretsManagerSecretProviderConfig = Readonly< + z.infer +>; + +const AwsSecretsManagerProviderEnvConfigSchema = z.object({ + aws_profile: NonEmptyStringSchema, + aws_region: NonEmptyStringSchema.optional(), +}).strict(); + +type AwsSecretsManagerProviderEnvConfig = Readonly< + z.infer +>; + +export function parseAwsSecretsManagerProviderConfigs(env: NodeJS.ProcessEnv): AwsSecretsManagerSecretProviderConfig[] { + const raw = env[AWS_SECRETS_MANAGER_PROVIDERS_ENV]; + if (raw === undefined || raw.trim().length === 0) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`${AWS_SECRETS_MANAGER_PROVIDERS_ENV} must be valid JSON: ${messageFromUnknown(error)}`, { cause: error }); + } + + if (!isPlainObject(parsed)) { + throw new Error(`${AWS_SECRETS_MANAGER_PROVIDERS_ENV} must be a JSON object`); + } + + return Object.entries(parsed).map(([id, rawConfig]) => { + const providerId = parseSchema(SecretProviderIdSchema, id, "invalid AWS Secrets Manager provider id"); + const providerConfig = parseSchema( + AwsSecretsManagerProviderEnvConfigSchema, + rawConfig, + `invalid AWS Secrets Manager provider '${providerId}'`, + ); + return configFromEnvConfig(providerId, providerConfig); + }); +} + +export function resolveAwsSecretsManagerRegion( + config: AwsSecretsManagerSecretProviderConfig, + env: NodeJS.ProcessEnv = process.env, +): string { + if (config.awsRegion !== undefined && config.awsRegion.length > 0) { + return config.awsRegion; + } + + const profileRegion = sharedAwsProfileRegion(config.awsProfile, env); + if (profileRegion !== undefined && profileRegion.length > 0) { + return profileRegion; + } + + const envRegion = env.AWS_REGION ?? env.AWS_DEFAULT_REGION; + if (envRegion !== undefined && envRegion.length > 0) { + return envRegion; + } + + throw new Error( + `AWS Secrets Manager provider '${config.id}' has no region; set aws_region in ${AWS_SECRETS_MANAGER_PROVIDERS_ENV}, set region for AWS profile '${config.awsProfile}' in ~/.aws/config, or set AWS_REGION`, + ); +} + +function configFromEnvConfig( + id: string, + envConfig: AwsSecretsManagerProviderEnvConfig, +): AwsSecretsManagerSecretProviderConfig { + return parseSchema(AwsSecretsManagerSecretProviderConfigSchema, { + id, + awsProfile: envConfig.aws_profile, + ...(envConfig.aws_region === undefined ? {} : { awsRegion: envConfig.aws_region }), + }, `invalid AWS Secrets Manager provider '${id}'`); +} + +function sharedAwsProfileRegion(profile: string, env: NodeJS.ProcessEnv): string | undefined { + const configRegion = profileRegionFromFile(awsConfigFile(env), profile, "config"); + if (configRegion !== undefined) { + return configRegion; + } + return profileRegionFromFile(awsCredentialsFile(env), profile, "credentials"); +} + +function awsConfigFile(env: NodeJS.ProcessEnv): string { + return env.AWS_CONFIG_FILE === undefined || env.AWS_CONFIG_FILE.length === 0 + ? join(homedir(), ".aws", "config") + : env.AWS_CONFIG_FILE; +} + +function awsCredentialsFile(env: NodeJS.ProcessEnv): string { + return env.AWS_SHARED_CREDENTIALS_FILE === undefined || env.AWS_SHARED_CREDENTIALS_FILE.length === 0 + ? join(homedir(), ".aws", "credentials") + : env.AWS_SHARED_CREDENTIALS_FILE; +} + +function profileRegionFromFile(path: string, profile: string, kind: "config" | "credentials"): string | undefined { + if (!existsSync(path)) { + return undefined; + } + const sections = parseIniSections(readFileSync(path, "utf8")); + const sectionNames = kind === "config" && profile !== "default" + ? [`profile ${profile}`] + : [profile]; + for (const sectionName of sectionNames) { + const region = sections.get(sectionName)?.get("region"); + if (region !== undefined && region.length > 0) { + return region; + } + } + return undefined; +} + +function parseIniSections(text: string): Map> { + const sections = new Map>(); + let currentSection: Map | undefined; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) { + continue; + } + if (line.startsWith("[") && line.endsWith("]")) { + const name = line.slice(1, -1).trim(); + currentSection = new Map(); + sections.set(name, currentSection); + continue; + } + if (currentSection === undefined) { + continue; + } + const equalsAt = line.indexOf("="); + if (equalsAt === -1) { + continue; + } + const key = line.slice(0, equalsAt).trim(); + const value = line.slice(equalsAt + 1).trim(); + currentSection.set(key, value); + } + + return sections; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function messageFromUnknown(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/rootcell/secrets/aws-secrets-manager.ts b/src/rootcell/secrets/aws-secrets-manager.ts new file mode 100644 index 0000000..e2b67c9 --- /dev/null +++ b/src/rootcell/secrets/aws-secrets-manager.ts @@ -0,0 +1,76 @@ +import { + GetSecretValueCommand, + SecretsManagerClient, + type GetSecretValueCommandOutput, + type SecretsManagerClientConfig, +} from "@aws-sdk/client-secrets-manager"; +import { fromIni } from "@aws-sdk/credential-providers"; +import { + resolveAwsSecretsManagerRegion, + type AwsSecretsManagerSecretProviderConfig, +} from "./aws-secrets-manager-config.ts"; +import type { SecretProvider } from "./types.ts"; + +export interface AwsSecretsManagerClientLike { + send(command: GetSecretValueCommand): Promise; +} + +export type AwsSecretsManagerClientFactory = ( + config: SecretsManagerClientConfig & { readonly region: string }, +) => AwsSecretsManagerClientLike; + +export type AwsCredentialFactory = (profile: string) => NonNullable; + +export interface AwsSecretsManagerSecretProviderOptions { + readonly env?: NodeJS.ProcessEnv; + readonly clientFactory?: AwsSecretsManagerClientFactory; + readonly credentialFactory?: AwsCredentialFactory; +} + +export class AwsSecretsManagerSecretProvider implements SecretProvider { + readonly id: string; + private client: AwsSecretsManagerClientLike | undefined; + + constructor( + private readonly config: AwsSecretsManagerSecretProviderConfig, + private readonly options: AwsSecretsManagerSecretProviderOptions = {}, + ) { + this.id = config.id; + } + + async read(reference: string): Promise { + if (reference.startsWith("arn:")) { + throw new Error("AWS Secrets Manager references must be secret resource names, not ARNs"); + } + + const response = await this.getClient().send(new GetSecretValueCommand({ + SecretId: reference, + })); + + if (response.SecretString !== undefined) { + return response.SecretString; + } + if (response.SecretBinary !== undefined) { + throw new Error(`AWS Secrets Manager provider '${this.id}' returned SecretBinary; configure the secret as a string value`); + } + throw new Error(`AWS Secrets Manager provider '${this.id}' returned no SecretString`); + } + + private getClient(): AwsSecretsManagerClientLike { + if (this.client !== undefined) { + return this.client; + } + + const region = resolveAwsSecretsManagerRegion(this.config, this.options.env); + const credentials = (this.options.credentialFactory ?? defaultCredentialFactory)(this.config.awsProfile); + this.client = (this.options.clientFactory ?? defaultClientFactory)({ + region, + credentials, + }); + return this.client; + } +} + +const defaultClientFactory: AwsSecretsManagerClientFactory = (config) => new SecretsManagerClient(config); + +const defaultCredentialFactory: AwsCredentialFactory = (profile) => fromIni({ profile }); diff --git a/src/rootcell/types.ts b/src/rootcell/types.ts index f12dc9e..b78b6fc 100644 --- a/src/rootcell/types.ts +++ b/src/rootcell/types.ts @@ -5,6 +5,7 @@ import { NonEmptyStringSchema, NonNegativeSafeIntegerSchema, } from "./schema.ts"; +import { AwsSecretsManagerSecretProviderConfigSchema } from "./secrets/aws-secrets-manager-config.ts"; export const CommandResultSchema = z.object({ status: NonNegativeSafeIntegerSchema, @@ -38,6 +39,7 @@ export const RootcellConfigSchema = z.object({ networkPrefix: NonEmptyStringSchema, imageManifestUrl: NonEmptyStringSchema, imageDir: NonEmptyStringSchema.optional(), + awsSecretsManagerProviders: z.array(AwsSecretsManagerSecretProviderConfigSchema), }); export type RootcellConfig = Readonly>;