diff --git a/.changeset/bright-dolls-tie.md b/.changeset/bright-dolls-tie.md new file mode 100644 index 0000000000..223ea7b755 --- /dev/null +++ b/.changeset/bright-dolls-tie.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/auth-construct-alpha': patch +--- + +Change default behavior of user attributes to mutable. diff --git a/.changeset/shy-paws-lie.md b/.changeset/shy-paws-lie.md new file mode 100644 index 0000000000..a0d90ead4c --- /dev/null +++ b/.changeset/shy-paws-lie.md @@ -0,0 +1,12 @@ +--- +'@aws-amplify/backend-function': patch +'@aws-amplify/backend-graphql': patch +'@aws-amplify/backend-storage': patch +'@aws-amplify/backend-secret': major +'@aws-amplify/client-config': patch +'@aws-amplify/backend-auth': patch +'@aws-amplify/plugin-types': patch +'@aws-amplify/backend': minor +--- + +Implements backend secret feature, include backend secret resolver and the backend-secret pkg. diff --git a/.eslintrc.json b/.eslintrc.json index 85bc2606e5..60dd4ab062 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,7 +23,7 @@ "parserOptions": { "ecmaVersion": "latest", "sourceType": "module", - "project": ["tsconfig.base.json"] + "project": ["**/tsconfig.json"] }, "plugins": [ "@typescript-eslint", @@ -186,6 +186,7 @@ "func", "gitignore", "gitignored", + "grantable", "graphql", "hotswap", "idps", @@ -197,7 +198,7 @@ "mfas", "mkdtemp", "multifactor", - "nodejs18.x", + "nodejs", "npmrc", "npx", "nullability", diff --git a/package-lock.json b/package-lock.json index a833d426b6..a4a9b2da0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,10 @@ "resolved": "packages/backend-output-schemas", "link": true }, + "node_modules/@aws-amplify/backend-secret": { + "resolved": "packages/backend-secret", + "link": true + }, "node_modules/@aws-amplify/backend-storage": { "resolved": "packages/backend-storage", "link": true @@ -5094,6 +5098,12 @@ "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", "dev": true }, + "node_modules/@types/aws-lambda": { + "version": "8.10.121", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.121.tgz", + "integrity": "sha512-Y/jsUwO18HuC0a39BuMQkSOd/kMGATh/h5LNksw8FlTafbQ3Ge3578ZoT8w8gSOsWl2qH1p/SS/R61vc0X5jIQ==", + "dev": true + }, "node_modules/@types/debounce-promise": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.6.tgz", @@ -6569,6 +6579,89 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/aws-lambda": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/aws-lambda/-/aws-lambda-1.0.7.tgz", + "integrity": "sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==", + "dev": true, + "dependencies": { + "aws-sdk": "^2.814.0", + "commander": "^3.0.2", + "js-yaml": "^3.14.1", + "watchpack": "^2.0.0-beta.10" + }, + "bin": { + "lambda": "bin/lambda" + } + }, + "node_modules/aws-lambda/node_modules/commander": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz", + "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==", + "dev": true + }, + "node_modules/aws-sdk": { + "version": "2.1459.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1459.0.tgz", + "integrity": "sha512-My45PgQYhRTh6fOeZ94ELUoXzza/6gTy0J22aK4iy0DEA+uE5gjr1VthnIwbLYNMeEqn8xwJZuNJqvi/WaUUcQ==", + "dev": true, + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/aws-sdk/node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/aws-sdk/node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", + "dev": true + }, + "node_modules/aws-sdk/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -6605,7 +6698,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -6732,6 +6826,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7429,7 +7524,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/constant-case": { "version": "3.0.4", @@ -9608,6 +9704,7 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -9783,6 +9880,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -10219,6 +10322,7 @@ "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, "engines": { "node": ">= 4" } @@ -10323,6 +10427,22 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -10907,6 +11027,15 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11013,6 +11142,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -11647,6 +11777,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12703,6 +12834,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } @@ -12722,6 +12854,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -13351,10 +13493,17 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", + "dev": true + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13369,6 +13518,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13379,7 +13529,8 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.18.0", @@ -14867,6 +15018,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, "engines": { "node": ">= 10.0.0" } @@ -14953,6 +15105,35 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dev": true, + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", + "dev": true + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15189,6 +15370,19 @@ "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", "dev": true }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -15469,6 +15663,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -15488,6 +15704,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "dev": true, "engines": { "node": ">= 14" } @@ -15618,12 +15835,12 @@ }, "packages/auth-construct": { "name": "@aws-amplify/auth-construct-alpha", - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2" }, "peerDependencies": { - "@aws-amplify/plugin-types": "^0.1.1-alpha.3", + "@aws-amplify/plugin-types": "^0.1.1-alpha.4", "aws-cdk-lib": "~2.80.0", "constructs": "^10.0.0" } @@ -15632,7 +15849,12 @@ "name": "@aws-amplify/backend", "version": "0.1.1-alpha.3", "dependencies": { - "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2" + "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2", + "@aws-amplify/backend-secret": "^0.1.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.119", + "aws-lambda": "^1.0.7" }, "peerDependencies": { "@aws-amplify/plugin-types": "^0.1.1-alpha.3", @@ -15642,15 +15864,15 @@ }, "packages/backend-auth": { "name": "@aws-amplify/backend-auth", - "version": "0.1.1-alpha.2", + "version": "0.2.0-alpha.3", "dependencies": { - "@aws-amplify/auth-construct-alpha": "^0.2.0-alpha.3" + "@aws-amplify/auth-construct-alpha": "^0.2.0-alpha.5" }, "devDependencies": { "@aws-amplify/backend": "^0.1.1-alpha.2" }, "peerDependencies": { - "@aws-amplify/plugin-types": "^0.1.1-alpha.2", + "@aws-amplify/plugin-types": "^0.1.1-alpha.4", "aws-cdk-lib": "~2.80.0", "constructs": "^10.0.0" } @@ -15703,12 +15925,26 @@ "name": "@aws-amplify/backend-output-schemas", "version": "0.2.0-alpha.2", "devDependencies": { - "@aws-amplify/plugin-types": "0.1.1-alpha.3" + "@aws-amplify/plugin-types": "0.1.1-alpha.4" }, "peerDependencies": { "zod": "^3.21.4" } }, + "packages/backend-secret": { + "name": "@aws-amplify/backend-secret", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-ssm": "^3.398.0" + }, + "devDependencies": { + "@aws-sdk/types": "^3.370.0" + }, + "peerDependencies": { + "@aws-amplify/plugin-types": "^0.1.1-alpha.0", + "aws-cdk-lib": "~2.80.0" + } + }, "packages/backend-storage": { "name": "@aws-amplify/backend-storage", "version": "0.1.1-alpha.2", @@ -15726,7 +15962,7 @@ }, "packages/cli": { "name": "@aws-amplify/backend-cli", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-providers": "^3.360.0", @@ -15746,8 +15982,8 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@aws-amplify/client-config": "^0.2.0-alpha.4", - "@aws-amplify/sandbox": "^0.1.1-alpha.4", + "@aws-amplify/client-config": "^0.2.0-alpha.5", + "@aws-amplify/sandbox": "^0.1.1-alpha.5", "@aws-sdk/types": "^3.347.0" } }, @@ -15853,12 +16089,12 @@ }, "packages/client-config": { "name": "@aws-amplify/client-config", - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2", "@aws-sdk/client-amplify": "^3.376.0", "@aws-sdk/client-cloudformation": "^3.376.0", - "@aws-sdk/client-ssm": "^3.370.0", + "@aws-sdk/client-ssm": "^3.398.0", "@aws-sdk/credential-providers": "^3.370.0", "zod": "^3.21.4" }, @@ -15867,7 +16103,7 @@ } }, "packages/create-amplify": { - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "dependencies": { "execa": "^7.2.0" }, @@ -15884,7 +16120,7 @@ }, "packages/function-construct": { "name": "@aws-amplify/function-construct-alpha", - "version": "0.1.1-alpha.1", + "version": "0.1.1-alpha.2", "peerDependencies": { "aws-cdk-lib": "~2.80.0", "constructs": "^10.0.0" @@ -15892,10 +16128,10 @@ }, "packages/integration-tests": { "name": "@aws-amplify/integration-tests", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "devDependencies": { "@aws-amplify/backend": "0.1.1-alpha.3", - "@aws-amplify/backend-auth": "0.1.1-alpha.2", + "@aws-amplify/backend-auth": "0.2.0-alpha.3", "@aws-amplify/backend-storage": "0.1.1-alpha.2", "execa": "^8.0.1", "fs-extra": "^11.1.1", @@ -15978,7 +16214,7 @@ }, "packages/plugin-types": { "name": "@aws-amplify/plugin-types", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "peerDependencies": { "aws-cdk-lib": "~2.80.0", "constructs": "^10.0.0" @@ -15986,10 +16222,10 @@ }, "packages/sandbox": { "name": "@aws-amplify/sandbox", - "version": "0.1.1-alpha.4", + "version": "0.1.1-alpha.5", "dependencies": { "@aws-amplify/backend-deployer": "0.1.1-alpha.3", - "@aws-amplify/client-config": "0.2.0-alpha.4", + "@aws-amplify/client-config": "0.2.0-alpha.5", "@aws-sdk/credential-providers": "^3.382.0", "@aws-sdk/types": "^3.378.0", "@parcel/watcher": "^2.3.0", diff --git a/packages/auth-construct/API.md b/packages/auth-construct/API.md index b0682c3cf7..c827064552 100644 --- a/packages/auth-construct/API.md +++ b/packages/auth-construct/API.md @@ -31,7 +31,7 @@ export abstract class AuthCustomAttributeBase { constructor(name: string); // (undocumented) protected attribute: Mutable; - mutable: () => this; + immutable: () => this; } // @public @@ -77,7 +77,7 @@ export type AuthProps = { // @public export class AuthStandardAttribute { constructor(name: keyof StandardAttributes); - mutable: () => AuthStandardAttribute; + immutable: () => AuthStandardAttribute; required: () => AuthStandardAttribute; } diff --git a/packages/auth-construct/src/attributes.ts b/packages/auth-construct/src/attributes.ts index 5728c8671f..9c2db5e51f 100644 --- a/packages/auth-construct/src/attributes.ts +++ b/packages/auth-construct/src/attributes.ts @@ -5,7 +5,7 @@ import { } from 'aws-cdk-lib/aws-cognito'; /** * Examples: - * AuthAttributeFactory('address').mutable().required(); + * AuthAttributeFactory('address').immutable().required(); * AuthCustomAttributeFactory.string('color').minLength(10).maxLength(100); */ @@ -23,7 +23,7 @@ export type Mutable = { * This class facilitates creation of Standard user attributes. */ export class AuthStandardAttribute { - private isMutable = false; + private isMutable = true; private isRequired = false; /** * Create a Standard Attribute. @@ -33,11 +33,11 @@ export class AuthStandardAttribute { this.name = name; } /** - * Makes this attribute mutable. + * Makes this attribute immutable. * @returns the attribute */ - mutable = (): AuthStandardAttribute => { - this.isMutable = true; + immutable = (): AuthStandardAttribute => { + this.isMutable = false; return this; }; /** @@ -76,15 +76,15 @@ export abstract class AuthCustomAttributeBase { this.name = name; this.attribute = { dataType: '', - mutable: false, + mutable: true, }; } /** - * Makes this attribute mutable. + * Makes this attribute immutable. * @returns the attribute */ - mutable = () => { - this.attribute.mutable = true; + immutable = () => { + this.attribute.mutable = false; return this; }; /** @@ -117,7 +117,7 @@ export class AuthCustomStringAttribute extends AuthCustomAttributeBase { super(name); this.attribute = { dataType: 'String', - mutable: false, + mutable: true, stringConstraints: {}, }; } @@ -156,7 +156,7 @@ export class AuthCustomNumberAttribute extends AuthCustomAttributeBase { super(name); this.attribute = { dataType: 'Number', - mutable: false, + mutable: true, numberConstraints: {}, }; } @@ -195,7 +195,7 @@ export class AuthCustomDateTimeAttribute extends AuthCustomAttributeBase { super(name); this.attribute = { dataType: 'DateTime', - mutable: false, + mutable: true, }; } } @@ -211,7 +211,7 @@ export class AuthCustomBooleanAttribute extends AuthCustomAttributeBase { super(name); this.attribute = { dataType: 'Boolean', - mutable: false, + mutable: true, }; } } diff --git a/packages/auth-construct/src/construct.test.ts b/packages/auth-construct/src/construct.test.ts index 9f7e2166c4..51cc9dab97 100644 --- a/packages/auth-construct/src/construct.test.ts +++ b/packages/auth-construct/src/construct.test.ts @@ -361,7 +361,7 @@ describe('Auth construct', () => { new AmplifyAuth(stack, 'test', { loginWith: { email: true }, userAttributes: [ - AmplifyAuth.attribute('address').mutable(), + AmplifyAuth.attribute('address').immutable(), AmplifyAuth.attribute('familyName').required(), AmplifyAuth.customAttribute.string('defaultString'), AmplifyAuth.customAttribute @@ -381,24 +381,24 @@ describe('Auth construct', () => { Required: true, }, { - Mutable: true, + Mutable: false, Name: 'address', Required: false, }, { - Mutable: false, + Mutable: true, Name: 'family_name', Required: true, }, { AttributeDataType: 'String', - Mutable: false, + Mutable: true, Name: 'defaultString', StringAttributeConstraints: {}, }, { AttributeDataType: 'String', - Mutable: false, + Mutable: true, Name: 'minMaxString', StringAttributeConstraints: { MinLength: '0', @@ -407,12 +407,12 @@ describe('Auth construct', () => { }, { AttributeDataType: 'DateTime', - Mutable: false, + Mutable: true, Name: 'birthDateTime', }, { AttributeDataType: 'Number', - Mutable: false, + Mutable: true, Name: 'numberMinMax', NumberAttributeConstraints: { MaxValue: '5', @@ -449,7 +449,7 @@ describe('Auth construct', () => { new AmplifyAuth(stack, 'test', { loginWith: { email: true }, userAttributes: [ - AmplifyAuth.attribute('address').mutable(), + AmplifyAuth.attribute('address').immutable(), AmplifyAuth.attribute('address').required(), ], }), diff --git a/packages/auth-construct/src/construct.ts b/packages/auth-construct/src/construct.ts index 732be19ab9..1d7b892f06 100644 --- a/packages/auth-construct/src/construct.ts +++ b/packages/auth-construct/src/construct.ts @@ -489,7 +489,7 @@ export class AmplifyAuth * * Example: * userAttributes: [ - * AmplifyAuth.attribute('address').mutable().required(), + * AmplifyAuth.attribute('address').immutable().required(), * ] */ public static attribute = AuthAttributeFactory; diff --git a/packages/backend-auth/src/factory.test.ts b/packages/backend-auth/src/factory.test.ts index 970be08618..dd3e20961f 100644 --- a/packages/backend-auth/src/factory.test.ts +++ b/packages/backend-auth/src/factory.test.ts @@ -44,6 +44,7 @@ describe('AmplifyAuthFactory', () => { importPathVerifier = new ToggleableImportPathVerifier(false); }); + it('returns singleton instance', () => { const instance1 = authFactory.getInstance({ constructContainer, diff --git a/packages/backend-function/src/factory.test.ts b/packages/backend-function/src/factory.test.ts index 8f0eda81c5..26338ab7b0 100644 --- a/packages/backend-function/src/factory.test.ts +++ b/packages/backend-function/src/factory.test.ts @@ -6,30 +6,30 @@ import { SingletonConstructContainer, StackMetadataBackendOutputStorageStrategy, } from '@aws-amplify/backend/test-utils'; -import { - BackendOutputEntry, - BackendOutputStorageStrategy, - ConstructContainer, -} from '@aws-amplify/plugin-types'; +import { ConstructFactoryGetInstanceProps } from '@aws-amplify/plugin-types'; import assert from 'node:assert'; import { fileURLToPath } from 'url'; import * as path from 'path'; describe('AmplifyFunctionFactory', () => { - let constructContainer: ConstructContainer; - let outputStorageStrategy: BackendOutputStorageStrategy; + let getInstanceProps: ConstructFactoryGetInstanceProps; beforeEach(() => { const app = new App(); const stack = new Stack(app, 'testStack'); - constructContainer = new SingletonConstructContainer( + const constructContainer = new SingletonConstructContainer( new NestedStackResolver(stack) ); - outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( + const outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( stack ); + + getInstanceProps = { + constructContainer, + outputStorageStrategy, + }; }); it('creates singleton function instance', () => { @@ -37,14 +37,8 @@ describe('AmplifyFunctionFactory', () => { name: 'testFunc', codePath: path.join('..', 'test-assets', 'test-lambda'), }); - const instance1 = functionFactory.getInstance({ - constructContainer, - outputStorageStrategy, - }); - const instance2 = functionFactory.getInstance({ - constructContainer, - outputStorageStrategy, - }); + const instance1 = functionFactory.getInstance(getInstanceProps); + const instance2 = functionFactory.getInstance(getInstanceProps); assert.strictEqual(instance1, instance2); }); @@ -61,7 +55,7 @@ describe('AmplifyFunctionFactory', () => { outDir: path.join('..', 'test-assets', 'test-lambda'), buildCommand: 'test command', }) - ).getInstance({ constructContainer, outputStorageStrategy }); + ).getInstance(getInstanceProps); assert.strictEqual(commandExecutorMock.mock.callCount(), 1); assert.deepStrictEqual(commandExecutorMock.mock.calls[0].arguments, [ diff --git a/packages/backend-graphql/src/factory.test.ts b/packages/backend-graphql/src/factory.test.ts index 1af0d32cf2..b4835f7a81 100644 --- a/packages/backend-graphql/src/factory.test.ts +++ b/packages/backend-graphql/src/factory.test.ts @@ -14,6 +14,7 @@ import { BackendOutputEntry, BackendOutputStorageStrategy, ConstructContainer, + ConstructFactoryGetInstanceProps, ImportPathVerifier, ResourceProvider, } from '@aws-amplify/plugin-types'; @@ -41,6 +42,7 @@ describe('DataFactory', () => { let outputStorageStrategy: BackendOutputStorageStrategy; let importPathVerifier: ImportPathVerifier; let dataFactory: DataFactory; + let getInstanceProps: ConstructFactoryGetInstanceProps; beforeEach(() => { dataFactory = new DataFactory({ schema: testSchema }); @@ -82,50 +84,41 @@ describe('DataFactory', () => { stack ); importPathVerifier = new ToggleableImportPathVerifier(false); - }); - it('returns singleton instance', () => { - const instance1 = dataFactory.getInstance({ - constructContainer, - outputStorageStrategy, - importPathVerifier, - }); - const instance2 = dataFactory.getInstance({ + + getInstanceProps = { constructContainer, outputStorageStrategy, importPathVerifier, - }); + }; + }); + + it('returns singleton instance', () => { + const instance1 = dataFactory.getInstance(getInstanceProps); + const instance2 = dataFactory.getInstance(getInstanceProps); assert.strictEqual(instance1, instance2); }); it('adds construct to stack', () => { - const dataConstruct = dataFactory.getInstance({ - constructContainer, - outputStorageStrategy, - importPathVerifier, - }); + const dataConstruct = dataFactory.getInstance(getInstanceProps); const template = Template.fromStack(Stack.of(dataConstruct)); template.resourceCountIs('AWS::AppSync::GraphQLApi', 1); }); it('sets output using storage strategy', () => { - dataFactory.getInstance({ - constructContainer, - outputStorageStrategy, - importPathVerifier, - }); + dataFactory.getInstance(getInstanceProps); const template = Template.fromStack(stack); template.hasOutput('awsAppsyncApiEndpoint', {}); }); + it('verifies constructor import path', () => { const importPathVerifier = { verify: mock.fn(), }; dataFactory.getInstance({ - constructContainer, - outputStorageStrategy, + ...getInstanceProps, importPathVerifier, }); diff --git a/packages/backend-secret/.npmignore b/packages/backend-secret/.npmignore new file mode 100644 index 0000000000..dbde1fb5db --- /dev/null +++ b/packages/backend-secret/.npmignore @@ -0,0 +1,14 @@ +# Be very careful editing this file. It is crafted to work around [this issue](https://github.com/npm/npm/issues/4479) + +# First ignore everything +**/* + +# Then add back in transpiled js and ts declaration files +!lib/**/*.js +!lib/**/*.d.ts + +# Then ignore test js and ts declaration files +*.test.js +*.test.d.ts + +# This leaves us with including only js and ts declaration files of functional code diff --git a/packages/backend-secret/API.md b/packages/backend-secret/API.md new file mode 100644 index 0000000000..521d05c55a --- /dev/null +++ b/packages/backend-secret/API.md @@ -0,0 +1,58 @@ +## API Report File for "@aws-amplify/backend-secret" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import { BackendId } from '@aws-amplify/plugin-types'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { SSMServiceException } from '@aws-sdk/client-ssm'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +// @public +export const getSecretClient: (credentialProvider?: AwsCredentialIdentityProvider) => SecretClient; + +// @public +export type Secret = { + secretIdentifier: SecretIdentifier; + value: string; +}; + +// @public +export type SecretAction = 'GET' | 'SET' | 'REMOVE' | 'LIST'; + +// @public +export type SecretClient = { + getSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretIdentifier: SecretIdentifier) => Promise; + listSecrets: (backendIdentifier: UniqueBackendIdentifier | BackendId) => Promise; + setSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretName: string, secretValue: string) => Promise; + removeSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretName: string) => Promise; + grantPermission: (resource: iam.IGrantable, backendIdentifier: UniqueBackendIdentifier, secretActions: SecretAction[]) => void; +}; + +// @public +export class SecretError extends Error { + constructor(message: string, options?: { + cause?: SecretErrorCause; + httpStatusCode?: number; + }); + // (undocumented) + cause: SecretErrorCause; + static fromSSMException: (ssmException: SSMServiceException) => SecretError; + // (undocumented) + httpStatusCode: number | undefined; +} + +// @public (undocumented) +export type SecretErrorCause = SSMServiceException | undefined; + +// @public +export type SecretIdentifier = { + name: string; + version?: number; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/backend-secret/api-extractor.json b/packages/backend-secret/api-extractor.json new file mode 100644 index 0000000000..0f56de03f6 --- /dev/null +++ b/packages/backend-secret/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.base.json" +} diff --git a/packages/backend-secret/package.json b/packages/backend-secret/package.json new file mode 100644 index 0000000000..036544bad4 --- /dev/null +++ b/packages/backend-secret/package.json @@ -0,0 +1,29 @@ +{ + "name": "@aws-amplify/backend-secret", + "version": "0.1.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.js" + } + }, + "types": "lib/index.d.ts", + "scripts": { + "update:api": "api-extractor run --local" + }, + "dependencies": { + "@aws-sdk/client-ssm": "^3.398.0" + }, + "peerDependencies": { + "@aws-amplify/plugin-types": "^0.1.1-alpha.0", + "aws-cdk-lib": "~2.80.0" + }, + "devDependencies": { + "@aws-sdk/types": "^3.370.0" + } +} diff --git a/packages/backend-secret/src/index.ts b/packages/backend-secret/src/index.ts new file mode 100644 index 0000000000..97edbd07d9 --- /dev/null +++ b/packages/backend-secret/src/index.ts @@ -0,0 +1,2 @@ +export * from './secret.js'; +export * from './secret_error.js'; diff --git a/packages/backend-secret/src/secret.test.ts b/packages/backend-secret/src/secret.test.ts new file mode 100644 index 0000000000..56481c94f1 --- /dev/null +++ b/packages/backend-secret/src/secret.test.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'node:test'; +import { getSecretClient } from './secret.js'; +import { SSMSecretClient } from './ssm_secret.js'; +import assert from 'node:assert'; + +describe('SecretClient', () => { + it('returns a secret client instance', () => { + const secretClient = getSecretClient(); + assert.ok(secretClient instanceof SSMSecretClient); + }); +}); diff --git a/packages/backend-secret/src/secret.ts b/packages/backend-secret/src/secret.ts new file mode 100644 index 0000000000..e397db3a5d --- /dev/null +++ b/packages/backend-secret/src/secret.ts @@ -0,0 +1,85 @@ +import { SSMSecretClient } from './ssm_secret.js'; +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { BackendId, UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { SSM } from '@aws-sdk/client-ssm'; + +/** + * The unique identifier of the secret. + */ +export type SecretIdentifier = { + name: string; + version?: number; +}; + +/** + * The secret object. + */ +export type Secret = { + secretIdentifier: SecretIdentifier; + value: string; +}; + +/** + * The client to manage backend secret. + */ +export type SecretClient = { + /** + * Get a secret value. + */ + getSecret: ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretIdentifier: SecretIdentifier + ) => Promise; + + /** + * List secrets. + */ + listSecrets: ( + backendIdentifier: UniqueBackendIdentifier | BackendId + ) => Promise; + + /** + * Set a secret. + */ + setSecret: ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretName: string, + secretValue: string + ) => Promise; + + /** + * Remove a secret. + */ + removeSecret: ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretName: string + ) => Promise; + + /** + * Grant permission to operate on secrets. + */ + grantPermission: ( + resource: iam.IGrantable, + backendIdentifier: UniqueBackendIdentifier, + secretActions: SecretAction[] + ) => void; +}; + +/** + * Secret action type. + */ +export type SecretAction = 'GET' | 'SET' | 'REMOVE' | 'LIST'; + +/** + * Creates an Amplify secret client. + */ +export const getSecretClient = ( + credentialProvider?: AwsCredentialIdentityProvider +): SecretClient => { + return new SSMSecretClient( + new SSM({ + credentials: credentialProvider, + }) + ); +}; diff --git a/packages/backend-secret/src/secret_error.test.ts b/packages/backend-secret/src/secret_error.test.ts new file mode 100644 index 0000000000..2152479c91 --- /dev/null +++ b/packages/backend-secret/src/secret_error.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test'; +import { SecretError } from './secret_error.js'; +import { SSMServiceException } from '@aws-sdk/client-ssm'; +import assert from 'node:assert'; + +describe('SecretError', () => { + it('creates from SSM exception', () => { + const ssmException = { + message: 'ssm exception error message', + $metadata: { + httpStatusCode: 500, + }, + } as SSMServiceException; + + const expectedErr = new SecretError(JSON.stringify(ssmException), { + cause: ssmException, + httpStatusCode: ssmException.$metadata.httpStatusCode, + }); + + const actualErr = SecretError.fromSSMException(ssmException); + assert.deepStrictEqual(actualErr, expectedErr); + }); +}); diff --git a/packages/backend-secret/src/secret_error.ts b/packages/backend-secret/src/secret_error.ts new file mode 100644 index 0000000000..60c4088039 --- /dev/null +++ b/packages/backend-secret/src/secret_error.ts @@ -0,0 +1,41 @@ +import { SSMServiceException } from '@aws-sdk/client-ssm'; + +export type SecretErrorCause = SSMServiceException | undefined; + +/** + * Secret Error. + */ +export class SecretError extends Error { + public cause: SecretErrorCause; + public httpStatusCode: number | undefined; + + /** + * Creates a secret error instance. + */ + constructor( + message: string, + options?: { + cause?: SecretErrorCause; + httpStatusCode?: number; + } + ) { + super(message); + this.name = 'SecretError'; + this.cause = options?.cause; + this.httpStatusCode = options?.httpStatusCode; + } + + /** + * Creates a secret error from an SSM exception. + */ + static fromSSMException = ( + ssmException: SSMServiceException + ): SecretError => { + // the SSM error message is wrong/misleading, like 'UnknownError'. We will stringify the + // whole err object instead. + return new SecretError(JSON.stringify(ssmException), { + cause: ssmException, + httpStatusCode: ssmException.$metadata.httpStatusCode, + }); + }; +} diff --git a/packages/backend-secret/src/ssm_secret.test.ts b/packages/backend-secret/src/ssm_secret.test.ts new file mode 100644 index 0000000000..60ee6aaafa --- /dev/null +++ b/packages/backend-secret/src/ssm_secret.test.ts @@ -0,0 +1,380 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { + GetParametersByPathCommandOutput, + InternalServerError, + ParameterNotFound, + SSM, +} from '@aws-sdk/client-ssm'; +import { SSMSecretClient } from './ssm_secret.js'; +import assert from 'node:assert'; +import { SecretError } from './secret_error.js'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Secret, SecretIdentifier } from './secret.js'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +const shared = 'shared'; +const testBackendId = 'testBackendId'; +const testBranchName = 'testBranchName'; +const testSecretName = 'testSecretName'; +const testSecretValue = 'testSecretValue'; +const testSecretVersion = 20; +const testBranchPath = `/amplify/${testBackendId}/${testBranchName}`; +const testSharedPath = `/amplify/${shared}/${testBackendId}`; +const testBranchSecretFullNamePath = `${testBranchPath}/${testSecretName}`; +const testSharedSecretFullNamePath = `${testSharedPath}/${testSecretName}`; + +const testSecretId: SecretIdentifier = { + name: testSecretName, +}; + +const testSecretIdWithVersion: SecretIdentifier = { + ...testSecretId, + version: testSecretVersion, +}; + +const testSecret: Secret = { + secretIdentifier: testSecretIdWithVersion, + value: testSecretValue, +}; + +const testBackendIdentifier: UniqueBackendIdentifier = { + backendId: testBackendId, + branchName: testBranchName, +}; + +describe('SSMSecret', () => { + describe('getSecret', () => { + const ssmClient = new SSM(); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const mockGetParameter = mock.method(ssmClient, 'getParameter', () => + Promise.resolve({ + $metadata: {}, + Parameter: { + Name: testSecretName, + Value: testSecretValue, + Version: testSecretVersion, + }, + }) + ); + + beforeEach(() => { + mockGetParameter.mock.resetCalls(); + }); + + it('gets branch secret value', async () => { + const resp = await ssmSecretClient.getSecret( + testBackendIdentifier, + testSecretId + ); + + assert.deepEqual(resp, testSecret); + assert.deepStrictEqual(mockGetParameter.mock.calls[0].arguments[0], { + Name: testBranchSecretFullNamePath, + WithDecryption: true, + }); + }); + + it('gets branch secret value with a specific version', async () => { + const resp = await ssmSecretClient.getSecret( + testBackendIdentifier, + testSecretIdWithVersion + ); + + assert.deepEqual(resp, testSecret); + assert.deepStrictEqual(mockGetParameter.mock.calls[0].arguments[0], { + Name: `${testBranchSecretFullNamePath}:${testSecretVersion}`, + WithDecryption: true, + }); + }); + + it('gets app-shared secret value', async () => { + const resp = await ssmSecretClient.getSecret(testBackendId, testSecretId); + assert.deepEqual(resp, testSecret); + assert.deepStrictEqual(mockGetParameter.mock.calls[0].arguments[0], { + Name: testSharedSecretFullNamePath, + WithDecryption: true, + }); + }); + + it('throws error', async () => { + const ssmNotFoundException = new ParameterNotFound({ + $metadata: {}, + message: '', + }); + + mock.method(ssmClient, 'getParameter', () => + Promise.reject(ssmNotFoundException) + ); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const expectedErr = SecretError.fromSSMException(ssmNotFoundException); + await assert.rejects( + () => ssmSecretClient.getSecret('', { name: '' }), + expectedErr + ); + }); + }); + + describe('setSecret', () => { + const ssmClient = new SSM(); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const mockSetParameter = mock.method(ssmClient, 'putParameter', () => + Promise.resolve({ + $metadata: {}, + Version: testSecretVersion, + }) + ); + + beforeEach(() => { + mockSetParameter.mock.resetCalls(); + }); + + it('set branch secret', async () => { + const resp = await ssmSecretClient.setSecret( + testBackendIdentifier, + testSecretName, + testSecretValue + ); + + assert.deepEqual(resp, testSecretIdWithVersion); + assert.deepStrictEqual(mockSetParameter.mock.calls[0].arguments[0], { + Name: testBranchSecretFullNamePath, + Type: 'SecureString', + Value: testSecretValue, + Description: `Amplify Secret`, + Overwrite: true, + }); + }); + + it('set app-shared secret', async () => { + const mockSetParameter = mock.method(ssmClient, 'putParameter', () => + Promise.resolve({ + $metadata: {}, + Version: testSecretVersion, + }) + ); + + const resp = await ssmSecretClient.setSecret( + testBackendId, + testSecretName, + testSecretValue + ); + + assert.deepEqual(resp, testSecretIdWithVersion); + assert.deepStrictEqual(mockSetParameter.mock.calls[0].arguments[0], { + Name: testSharedSecretFullNamePath, + Type: 'SecureString', + Value: testSecretValue, + Description: `Amplify Secret`, + Overwrite: true, + }); + }); + + it('throws error', async () => { + const ssmNotFoundException = new ParameterNotFound({ + $metadata: {}, + message: '', + }); + + mock.method(ssmClient, 'putParameter', () => + Promise.reject(ssmNotFoundException) + ); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const expectedErr = SecretError.fromSSMException(ssmNotFoundException); + await assert.rejects( + () => ssmSecretClient.setSecret('', '', ''), + expectedErr + ); + }); + }); + + describe('removeSecret', () => { + const ssmClient = new SSM(); + const ssmSecretClient = new SSMSecretClient(ssmClient); + + it('remove a branch secret', async () => { + const mockDeleteParameter = mock.method( + ssmClient, + 'deleteParameter', + () => Promise.resolve() + ); + + await ssmSecretClient.removeSecret(testBackendIdentifier, testSecretName); + assert.deepStrictEqual(mockDeleteParameter.mock.calls[0].arguments[0], { + Name: testBranchSecretFullNamePath, + }); + }); + + it('remove a backend shared secret', async () => { + const mockDeleteParameter = mock.method( + ssmClient, + 'deleteParameter', + () => Promise.resolve() + ); + await ssmSecretClient.removeSecret(testBackendId, testSecretName); + assert.deepStrictEqual(mockDeleteParameter.mock.calls[0].arguments[0], { + Name: testSharedSecretFullNamePath, + }); + }); + + it('throws error', async () => { + const ssmNotFoundException = new ParameterNotFound({ + $metadata: {}, + message: '', + }); + + mock.method(ssmClient, 'deleteParameter', () => + Promise.reject(ssmNotFoundException) + ); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const expectedErr = SecretError.fromSSMException(ssmNotFoundException); + await assert.rejects( + () => ssmSecretClient.removeSecret('', ''), + expectedErr + ); + }); + }); + + describe('listSecrets', () => { + const ssmClient = new SSM(); + const ssmSecretClient = new SSMSecretClient(ssmClient); + + const testSecretName2 = 'testSecretName2'; + const testSecretValue2 = 'testSecretValue2'; + const testSecretFullNamePath2 = `${testBranchPath}/${testSecretName2}`; + const testSecretVersion2 = 33; + const testSecretIdWithVersion2: SecretIdentifier = { + name: testSecretName2, + version: testSecretVersion2, + }; + + it('lists branch secrets', async () => { + const mockGetParametersByPath = mock.method( + ssmClient, + 'getParametersByPath', + () => + Promise.resolve({ + Parameters: [ + { + Name: testBranchSecretFullNamePath, + Value: testSecretValue, + Version: testSecretVersion, + }, + { + Name: testSecretFullNamePath2, + Value: testSecretValue2, + Version: testSecretVersion2, + }, + ], + } as GetParametersByPathCommandOutput) + ); + + const secrets = await ssmSecretClient.listSecrets(testBackendIdentifier); + assert.deepStrictEqual( + mockGetParametersByPath.mock.calls[0].arguments[0], + { + Path: testBranchPath, + WithDecryption: true, + } + ); + assert.deepEqual(secrets, [ + testSecretIdWithVersion, + testSecretIdWithVersion2, + ] as SecretIdentifier[]); + }); + + it('lists shared secrets', async () => { + const mockGetParametersByPath = mock.method( + ssmClient, + 'getParametersByPath', + () => + Promise.resolve({ + Parameters: [ + { + Name: testSharedSecretFullNamePath, + Value: testSecretValue, + Version: testSecretVersion, + }, + ], + } as GetParametersByPathCommandOutput) + ); + + const secrets = await ssmSecretClient.listSecrets(testBackendId); + assert.deepStrictEqual( + mockGetParametersByPath.mock.calls[0].arguments[0], + { + Path: testSharedPath, + WithDecryption: true, + } + ); + assert.deepEqual(secrets, [testSecretIdWithVersion]); + }); + + it('lists an empty list', async () => { + const mockGetParametersByPath = mock.method( + ssmClient, + 'getParametersByPath', + () => Promise.resolve({} as GetParametersByPathCommandOutput) + ); + + const secrets = await ssmSecretClient.listSecrets({ + backendId: testBackendId, + branchName: testBranchName, + }); + assert.deepStrictEqual( + mockGetParametersByPath.mock.calls[0].arguments[0], + { + Path: testBranchPath, + WithDecryption: true, + } + ); + + assert.deepEqual(secrets, []); + }); + + it('throws error', async () => { + const ssmInternalServerError = new InternalServerError({ + $metadata: {}, + message: '', + }); + + mock.method(ssmClient, 'getParametersByPath', () => + Promise.reject(ssmInternalServerError) + ); + const ssmSecretClient = new SSMSecretClient(ssmClient); + const expectedErr = SecretError.fromSSMException(ssmInternalServerError); + await assert.rejects(() => ssmSecretClient.listSecrets(''), expectedErr); + }); + }); + + describe('grantPermission', () => { + const ssmSecretClient = new SSMSecretClient(new SSM()); + it('grants permission', () => { + const mockAddToPrincipalPolicy = mock.fn(); + ssmSecretClient.grantPermission( + { + grantPrincipal: { + addToPrincipalPolicy: mockAddToPrincipalPolicy, + } as unknown as iam.IPrincipal, + } as iam.IGrantable, + { + backendId: testBackendId, + branchName: testBranchName, + }, + ['GET', 'LIST'] + ); + const expected = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['ssm:GetParameter', 'ssm:GetParametersByPath'], + resources: [ + `arn:aws:ssm:*:*:parameter/amplify/${testBackendId}/${testBranchName}/*`, + `arn:aws:ssm:*:*:parameter/amplify/${shared}/${testBackendId}/*`, + ], + }); + assert.equal(mockAddToPrincipalPolicy.mock.callCount(), 1); + assert.deepStrictEqual( + mockAddToPrincipalPolicy.mock.calls[0].arguments[0], + expected + ); + }); + }); +}); diff --git a/packages/backend-secret/src/ssm_secret.ts b/packages/backend-secret/src/ssm_secret.ts new file mode 100644 index 0000000000..78aad4f112 --- /dev/null +++ b/packages/backend-secret/src/ssm_secret.ts @@ -0,0 +1,223 @@ +import { SSM, SSMServiceException } from '@aws-sdk/client-ssm'; +import { SecretError } from './secret_error.js'; +import { + Secret, + SecretAction, + SecretClient, + SecretIdentifier, +} from './secret.js'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { BackendId, UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +const SHARED_SECRET = 'shared'; + +/** + * This class implements Amplify Secret using SSM parameter store. + */ +export class SSMSecretClient implements SecretClient { + /** + * Creates a new instance of SSMSecret. + */ + constructor(private readonly ssmClient: SSM) {} + + /** + * Get a branch-specific parameter prefix. + */ + private getBranchParameterPrefix = ( + backendIdentifier: UniqueBackendIdentifier + ): string => { + return `/amplify/${backendIdentifier.backendId}/${backendIdentifier.branchName}`; + }; + + /** + * Get a branch-specific parameter full path. + */ + private getBranchParameterFullPath = ( + backendIdentifier: UniqueBackendIdentifier, + secretName: string + ): string => { + return `${this.getBranchParameterPrefix(backendIdentifier)}/${secretName}`; + }; + + /** + * Get a shared parameter prefix. + */ + private getSharedParameterPrefix = (backendId: BackendId): string => { + return `/amplify/${SHARED_SECRET}/${backendId}`; + }; + + /** + * Get a shared parameter full path. + */ + private getSharedParameterFullPath = ( + backendId: BackendId, + secretName: string + ): string => { + return `${this.getSharedParameterPrefix(backendId)}/${secretName}`; + }; + + /** + * Get a parameter full path. + */ + private getParameterFullPath = ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretName: string + ): string => { + if (typeof backendIdentifier === 'object') { + return this.getBranchParameterFullPath(backendIdentifier, secretName); + } + return this.getSharedParameterFullPath(backendIdentifier, secretName); + }; + + /** + * Get a parameter prefix. + */ + private getParameterPrefix = ( + backendIdentifier: UniqueBackendIdentifier | BackendId + ): string => { + if (typeof backendIdentifier === 'object') { + return this.getBranchParameterPrefix(backendIdentifier); + } + return this.getSharedParameterPrefix(backendIdentifier); + }; + + /** + * Get a secret from SSM parameter store. + */ + public getSecret = async ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretIdentifier: SecretIdentifier + ): Promise => { + const name = this.getParameterFullPath( + backendIdentifier, + secretIdentifier.name + ); + try { + const resp = await this.ssmClient.getParameter({ + Name: secretIdentifier.version + ? `${name}:${secretIdentifier.version}` + : `${name}`, + WithDecryption: true, + }); + if (resp.Parameter?.Name && resp.Parameter?.Value) { + return { + secretIdentifier: { + name: resp.Parameter?.Name, + version: resp.Parameter?.Version, + }, + value: resp.Parameter?.Value, + }; + } + return; + } catch (err) { + throw SecretError.fromSSMException(err as SSMServiceException); + } + }; + + /** + * List secrets from SSM parameter store. + */ + public listSecrets = async ( + backendIdentifier: UniqueBackendIdentifier | BackendId + ): Promise => { + const path = this.getParameterPrefix(backendIdentifier); + const result: SecretIdentifier[] = []; + + try { + const resp = await this.ssmClient.getParametersByPath({ + Path: path, + WithDecryption: true, + }); + + resp.Parameters?.forEach((param) => { + if (!param.Name || !param.Value) { + return; + } + const secretName = param.Name.split('/').pop(); + if (secretName) { + result.push({ + name: secretName, + version: param.Version, + }); + } + }); + return result; + } catch (err) { + throw SecretError.fromSSMException(err as SSMServiceException); + } + }; + + /** + * Set a secret in SSM parameter store. + */ + public setSecret = async ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretName: string, + secretValue: string + ): Promise => { + const name = this.getParameterFullPath(backendIdentifier, secretName); + try { + const resp = await this.ssmClient.putParameter({ + Name: name, + Type: 'SecureString', + Value: secretValue, + Description: `Amplify Secret`, + Overwrite: true, + }); + return { + name: secretName, + version: resp.Version, + }; + } catch (err) { + throw SecretError.fromSSMException(err as SSMServiceException); + } + }; + + /** + * Remove a secret from SSM parameter store. + */ + public removeSecret = async ( + backendIdentifier: UniqueBackendIdentifier | BackendId, + secretName: string + ) => { + const name = this.getParameterFullPath(backendIdentifier, secretName); + try { + await this.ssmClient.deleteParameter({ + Name: name, + }); + } catch (err) { + throw SecretError.fromSSMException(err as SSMServiceException); + } + }; + + /** + * Get required IAM policy statement to perform the input actions. + */ + public grantPermission = ( + resource: iam.IGrantable, + backendIdentifier: UniqueBackendIdentifier, + secretActions: SecretAction[] + ) => { + const actionMap: { [K in SecretAction]: string } = { + ['GET']: 'ssm:GetParameter', + ['SET']: 'ssm:PutParameter', + ['LIST']: 'ssm:GetParametersByPath', + ['REMOVE']: 'ssm:DeleteParameter', + }; + + const secretPaths = [ + this.getParameterPrefix(backendIdentifier), + this.getParameterPrefix(backendIdentifier.backendId), + ]; + + resource.grantPrincipal.addToPrincipalPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: secretActions.map((action) => actionMap[action]), + resources: secretPaths.map( + (path) => `arn:aws:ssm:*:*:parameter${path}/*` + ), + }) + ); + }; +} diff --git a/packages/backend-secret/tsconfig.json b/packages/backend-secret/tsconfig.json new file mode 100644 index 0000000000..aa0dbd926c --- /dev/null +++ b/packages/backend-secret/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib" }, + "references": [{ "path": "../plugin-types" }] +} diff --git a/packages/backend-secret/typedoc.json b/packages/backend-secret/typedoc.json new file mode 100644 index 0000000000..35fed2c958 --- /dev/null +++ b/packages/backend-secret/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} diff --git a/packages/backend-storage/src/factory.test.ts b/packages/backend-storage/src/factory.test.ts index dc6989a81b..b75646adf0 100644 --- a/packages/backend-storage/src/factory.test.ts +++ b/packages/backend-storage/src/factory.test.ts @@ -13,6 +13,7 @@ import { BackendOutputEntry, BackendOutputStorageStrategy, ConstructContainer, + ConstructFactoryGetInstanceProps, ImportPathVerifier, } from '@aws-amplify/plugin-types'; @@ -21,6 +22,7 @@ describe('AmplifyStorageFactory', () => { let constructContainer: ConstructContainer; let outputStorageStrategy: BackendOutputStorageStrategy; let importPathVerifier: ImportPathVerifier; + let getInstanceProps: ConstructFactoryGetInstanceProps; beforeEach(() => { storageFactory = new AmplifyStorageFactory({}); @@ -36,28 +38,22 @@ describe('AmplifyStorageFactory', () => { ); importPathVerifier = new ToggleableImportPathVerifier(false); - }); - it('returns singleton instance', () => { - const instance1 = storageFactory.getInstance({ - constructContainer, - outputStorageStrategy, - importPathVerifier, - }); - const instance2 = storageFactory.getInstance({ + + getInstanceProps = { constructContainer, outputStorageStrategy, importPathVerifier, - }); + }; + }); + it('returns singleton instance', () => { + const instance1 = storageFactory.getInstance(getInstanceProps); + const instance2 = storageFactory.getInstance(getInstanceProps); assert.strictEqual(instance1, instance2); }); it('adds construct to stack', () => { - const storageConstruct = storageFactory.getInstance({ - constructContainer, - outputStorageStrategy, - importPathVerifier, - }); + const storageConstruct = storageFactory.getInstance(getInstanceProps); const template = Template.fromStack(Stack.of(storageConstruct)); @@ -76,8 +72,8 @@ describe('AmplifyStorageFactory', () => { const importPathVerifier = new ToggleableImportPathVerifier(false); storageFactory.getInstance({ - constructContainer, outputStorageStrategy, + constructContainer, importPathVerifier, }); @@ -90,11 +86,12 @@ describe('AmplifyStorageFactory', () => { }; storageFactory.getInstance({ - constructContainer, - outputStorageStrategy, + ...getInstanceProps, importPathVerifier, }); + storageFactory.getInstance(getInstanceProps); + assert.ok( (importPathVerifier.verify.mock.calls[0].arguments[0] as string).includes( 'AmplifyStorageFactory' diff --git a/packages/backend/API.md b/packages/backend/API.md index f4b3a12c38..918b34f071 100644 --- a/packages/backend/API.md +++ b/packages/backend/API.md @@ -4,6 +4,7 @@ ```ts +import { BackendSecret } from '@aws-amplify/plugin-types'; import { Construct } from 'constructs'; import { ConstructFactory } from '@aws-amplify/plugin-types'; import { Stack } from 'aws-cdk-lib'; @@ -17,6 +18,9 @@ export class Backend>> { }; } +// @public +export const secret: (name: string, version?: number) => BackendSecret; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend/package.json b/packages/backend/package.json index a94ec786a4..b13f006032 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,11 +22,16 @@ "update:api": "api-extractor run --local" }, "dependencies": { - "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2" + "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2", + "@aws-amplify/backend-secret": "^0.1.0" }, "peerDependencies": { "@aws-amplify/plugin-types": "^0.1.1-alpha.3", "aws-cdk-lib": "~2.80.0", "constructs": "^10.0.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.119", + "aws-lambda": "^1.0.7" } } diff --git a/packages/backend/src/backend.test.ts b/packages/backend/src/backend.test.ts index 9f1d91fc73..02315438b9 100644 --- a/packages/backend/src/backend.test.ts +++ b/packages/backend/src/backend.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from 'node:test'; +import { beforeEach, describe, it } from 'node:test'; import { ConstructFactory } from '@aws-amplify/plugin-types'; import { Bucket } from 'aws-cdk-lib/aws-s3'; import { Construct } from 'constructs'; @@ -7,7 +7,20 @@ import { App, Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import assert from 'node:assert'; +const createStackAndSetContext = (): Stack => { + const app = new App(); + app.node.setContext('branch-name', 'testEnvName'); + app.node.setContext('backend-id', 'testBackendId'); + const stack = new Stack(app); + return stack; +}; + describe('Backend', () => { + let rootStack: Stack; + beforeEach(() => { + rootStack = createStackAndSetContext(); + }); + it('initializes constructs in given app', () => { const testConstructFactory: ConstructFactory = { getInstance({ constructContainer }): Bucket { @@ -20,8 +33,6 @@ describe('Backend', () => { }, }; - const app = new App(); - const rootStack = new Stack(app); new Backend( { testConstructFactory, @@ -57,8 +68,6 @@ describe('Backend', () => { }, }; - const app = new App(); - const rootStack = new Stack(app); new Backend( { testConstructFactory, @@ -101,8 +110,6 @@ describe('Backend', () => { }, }; - const app = new App(); - const rootStack = new Stack(app); const backend = new Backend( { testConstructFactory, @@ -114,8 +121,6 @@ describe('Backend', () => { describe('getOrCreateStack', () => { it('returns nested stack', () => { - const app = new App(); - const rootStack = new Stack(app); const backend = new Backend({}, rootStack); const testStack = backend.getOrCreateStack('testStack'); assert.strictEqual(rootStack.node.findChild('testStack'), testStack); diff --git a/packages/backend/src/backend_identifier.ts b/packages/backend/src/backend_identifier.ts new file mode 100644 index 0000000000..2492d1c258 --- /dev/null +++ b/packages/backend/src/backend_identifier.ts @@ -0,0 +1,29 @@ +import { Construct } from 'constructs'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +const backendIdCDKContextKey = 'backend-id'; +const branchNameCDKContextKey = 'branch-name'; + +/** + * Populates a unique backend identifier based on CDK context values. + */ +export const getUniqueBackendIdentifier = ( + scope: Construct +): UniqueBackendIdentifier => { + const backendId = scope.node.getContext(backendIdCDKContextKey); + const branchName = scope.node.getContext(branchNameCDKContextKey); + if (typeof backendId !== 'string') { + throw new Error( + `${backendIdCDKContextKey} CDK context value is not a string` + ); + } + if (typeof branchName !== 'string') { + throw new Error( + `${branchNameCDKContextKey} CDK context value is not a string` + ); + } + return { + backendId, + branchName, + }; +}; diff --git a/packages/backend/src/default_stack_factory.ts b/packages/backend/src/default_stack_factory.ts index c6837f7748..1a756304c2 100644 --- a/packages/backend/src/default_stack_factory.ts +++ b/packages/backend/src/default_stack_factory.ts @@ -1,10 +1,6 @@ import { App, Stack } from 'aws-cdk-lib'; -import { Construct } from 'constructs'; import { ProjectEnvironmentMainStackCreator } from './project_environment_main_stack_creator.js'; -import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; - -const backendIdCDKContextKey = 'backend-id'; -const branchNameCDKContextKey = 'branch-name'; +import { getUniqueBackendIdentifier } from './backend_identifier.js'; /** * Creates a default CDK scope for the Amplify backend to use if no scope is provided to the constructor @@ -16,27 +12,3 @@ export const createDefaultStack = (app = new App()): Stack => { ); return mainStackCreator.getOrCreateMainStack(); }; - -/** - * Populates an instance of DeploymentContext based on CDK context values. - */ -const getUniqueBackendIdentifier = ( - scope: Construct -): UniqueBackendIdentifier => { - const backendId = scope.node.getContext(backendIdCDKContextKey); - const branchName = scope.node.getContext(branchNameCDKContextKey); - if (typeof backendId !== 'string') { - throw new Error( - `${backendIdCDKContextKey} CDK context value is not a string` - ); - } - if (typeof branchName !== 'string') { - throw new Error( - `${branchNameCDKContextKey} CDK context value is not a string` - ); - } - return { - backendId, - branchName, - }; -}; diff --git a/packages/backend/src/engine/backend-secret/backend_secret.test.ts b/packages/backend/src/engine/backend-secret/backend_secret.test.ts new file mode 100644 index 0000000000..7970d2ad0d --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret.test.ts @@ -0,0 +1,43 @@ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { CfnTokenBackendSecret } from './backend_secret.js'; +import { App, SecretValue, Stack } from 'aws-cdk-lib'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { BackendSecretFetcherProviderFactory } from './backend_secret_fetcher_provider_factory.js'; +import { BackendSecretFetcherFactory } from './backend_secret_fetcher_factory.js'; + +const backendId = 'testId'; +const branchName = 'testBranch'; +const testSecretName = 'testSecretName'; +const testSecretValue = 'testSecretValue'; +const uniqueBackendIdentifier: UniqueBackendIdentifier = { + backendId, + branchName, +}; + +describe('BackendSecret', () => { + const providerFactory = new BackendSecretFetcherProviderFactory( + getSecretClient() + ); + const resourceFactory = new BackendSecretFetcherFactory(providerFactory); + + it('resolves a secret', () => { + const mockGetOrCreate = mock.method(resourceFactory, 'getOrCreate', () => { + return { + getAttString: (): string => testSecretValue, + }; + }); + + const app = new App(); + const stack = new Stack(app); + const secret = new CfnTokenBackendSecret( + testSecretName, + 1, + resourceFactory + ); + const val = secret.resolve(stack, uniqueBackendIdentifier); + assert.deepStrictEqual(val, new SecretValue(testSecretValue)); + assert.deepStrictEqual(mockGetOrCreate.mock.callCount(), 1); + }); +}); diff --git a/packages/backend/src/engine/backend-secret/backend_secret.ts b/packages/backend/src/engine/backend-secret/backend_secret.ts new file mode 100644 index 0000000000..9dc5e89a35 --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret.ts @@ -0,0 +1,38 @@ +import { + BackendSecret, + UniqueBackendIdentifier, +} from '@aws-amplify/plugin-types'; +import { Construct } from 'constructs'; +import { SecretValue } from 'aws-cdk-lib'; +import { BackendSecretFetcherFactory } from './backend_secret_fetcher_factory.js'; + +/** + * Resolves a backend secret to a CFN token via a lambda-backed CFN custom resource. + */ +export class CfnTokenBackendSecret implements BackendSecret { + /** + * The name of the secret to fetch. + */ + constructor( + private readonly name: string, + private readonly version: number, + private readonly secretResourceFactory: BackendSecretFetcherFactory + ) {} + /** + * Get a reference to the value within a CDK scope. + */ + resolve = ( + scope: Construct, + backendIdentifier: UniqueBackendIdentifier + ): SecretValue => { + const secretResource = this.secretResourceFactory.getOrCreate( + scope, + this.name, + this.version, + backendIdentifier + ); + + const val = secretResource.getAttString('secretValue'); + return SecretValue.unsafePlainText(val); // safe since 'val' is a cdk token. + }; +} diff --git a/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.test.ts b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.test.ts new file mode 100644 index 0000000000..9ddb7cde3b --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.test.ts @@ -0,0 +1,82 @@ +import { App, Stack } from 'aws-cdk-lib'; +import { describe, it } from 'node:test'; +import { BackendSecretFetcherProviderFactory } from './backend_secret_fetcher_provider_factory.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Template } from 'aws-cdk-lib/assertions'; +import assert from 'node:assert'; +import { + BackendSecretFetcherFactory, + SECRET_RESOURCE_PROVIDER_ID, +} from './backend_secret_fetcher_factory.js'; + +const secretResourceType = 'Custom::SecretFetcherResource'; +const backendId = 'testId'; +const branchName = 'testBranch'; +const secretName1 = 'testSecretName1'; +const secretName2 = 'testSecretName2'; +const uniqueBackendIdentifier: UniqueBackendIdentifier = { + backendId, + branchName, +}; + +describe('getOrCreate', () => { + const providerFactory = new BackendSecretFetcherProviderFactory( + getSecretClient() + ); + const resourceFactory = new BackendSecretFetcherFactory(providerFactory); + + it('create different secrets', () => { + const app = new App(); + const stack = new Stack(app); + resourceFactory.getOrCreate(stack, secretName1, 1, uniqueBackendIdentifier); + resourceFactory.getOrCreate(stack, secretName2, 1, uniqueBackendIdentifier); + + const template = Template.fromStack(stack); + template.resourceCountIs(secretResourceType, 2); + let customResources = template.findResources(secretResourceType, { + Properties: { + backendId, + branchName, + secretName: secretName1, + }, + }); + assert.equal(Object.keys(customResources).length, 1); + + customResources = template.findResources(secretResourceType, { + Properties: { + backendId, + branchName, + secretName: secretName2, + }, + }); + assert.equal(Object.keys(customResources).length, 1); + + // only 1 secret fetcher lambda and 1 resource provider lambda are created. + const providers = template.findResources('AWS::Lambda::Function'); + const names = Object.keys(providers); + assert.equal(Object.keys(names).length, 2); + assert.equal( + names.filter((name) => name.startsWith(SECRET_RESOURCE_PROVIDER_ID)) + .length, + 2 + ); + }); + + it('does not create duplicate resource for the same secret name', () => { + const app = new App(); + const stack = new Stack(app); + resourceFactory.getOrCreate(stack, secretName1, 1, uniqueBackendIdentifier); + resourceFactory.getOrCreate(stack, secretName1, 1, uniqueBackendIdentifier); + + const template = Template.fromStack(stack); + template.resourceCountIs(secretResourceType, 1); + const customResources = template.findResources(secretResourceType); + const resourceName = Object.keys(customResources)[0]; + + const body = customResources[resourceName]['Properties']; + assert.strictEqual(body['backendId'], backendId); + assert.strictEqual(body['branchName'], branchName); + assert.strictEqual(body['secretName'], secretName1); + }); +}); diff --git a/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.ts b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.ts new file mode 100644 index 0000000000..61e0538c50 --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_factory.ts @@ -0,0 +1,63 @@ +import { Construct } from 'constructs'; +import { BackendSecretFetcherProviderFactory } from './backend_secret_fetcher_provider_factory.js'; +import { CustomResource } from 'aws-cdk-lib'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +/** + * Resource provider ID for the backend secret resource. + */ +export const SECRET_RESOURCE_PROVIDER_ID = 'SecretFetcherResourceProvider'; + +/** + * Type of the backend custom CFN resource. + */ +const SECRET_RESOURCE_TYPE = `Custom::SecretFetcherResource`; + +/** + * The factory to create backend secret-fetcher resource. + */ +export class BackendSecretFetcherFactory { + /** + * Creates a backend secret-fetcher resource factory. + */ + constructor( + private readonly secretProviderFactory: BackendSecretFetcherProviderFactory + ) {} + + /** + * Returns a resource if it exists in the input scope. Otherwise, + * creates a new one. + */ + getOrCreate = ( + scope: Construct, + secretName: string, + secretVersion: number, + backendIdentifier: UniqueBackendIdentifier + ): CustomResource => { + const secretResourceId = `${secretName}SecretFetcherResource`; + const existingResource = scope.node.tryFindChild( + secretResourceId + ) as CustomResource; + + if (existingResource) { + return existingResource; + } + + const provider = this.secretProviderFactory.getOrCreateInstance( + scope, + SECRET_RESOURCE_PROVIDER_ID, + backendIdentifier + ); + + return new CustomResource(scope, secretResourceId, { + serviceToken: provider.serviceToken, + properties: { + backendId: backendIdentifier.backendId, + branchName: backendIdentifier.branchName, + secretName: secretName, + secretVersion: secretVersion, + }, + resourceType: SECRET_RESOURCE_TYPE, + }); + }; +} diff --git a/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.test.ts b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.test.ts new file mode 100644 index 0000000000..13a2e8d7e3 --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.test.ts @@ -0,0 +1,69 @@ +import { App, Stack } from 'aws-cdk-lib'; +import { describe, it } from 'node:test'; +import { BackendSecretFetcherProviderFactory } from './backend_secret_fetcher_provider_factory.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Template } from 'aws-cdk-lib/assertions'; +import assert from 'node:assert'; + +const testProviderId1 = 'testProvider1'; +const testProviderId2 = 'testProvider2'; +const backendIdentifier: UniqueBackendIdentifier = { + backendId: 'testBackendId', + branchName: 'testBranchName', +}; + +describe('getOrCreate', () => { + const providerFactory = new BackendSecretFetcherProviderFactory( + getSecretClient() + ); + it('creates new providers', () => { + const app = new App(); + const stack = new Stack(app); + providerFactory.getOrCreateInstance( + stack, + testProviderId1, + backendIdentifier + ); + providerFactory.getOrCreateInstance( + stack, + testProviderId2, + backendIdentifier + ); + const template = Template.fromStack(stack); + + const resources = template.findResources('AWS::Lambda::Function'); + const names = Object.keys(resources); + // each provider creates 2 lambda resources + assert.equal(Object.keys(names).length, 4); + assert.equal( + names.filter((name) => name.startsWith(testProviderId1)).length, + 2 + ); + assert.equal( + names.filter((name) => name.startsWith(testProviderId2)).length, + 2 + ); + }); + + it('returns an existing provider', () => { + const app = new App(); + const stack = new Stack(app); + providerFactory.getOrCreateInstance( + stack, + testProviderId1, + backendIdentifier + ); + providerFactory.getOrCreateInstance( + stack, + testProviderId1, + backendIdentifier + ); + const template = Template.fromStack(stack); + + const resources = template.findResources('AWS::Lambda::Function'); + const names = Object.keys(resources); + assert.equal(Object.keys(names).length, 2); + names.forEach((name) => assert.ok(name.startsWith(testProviderId1))); + }); +}); diff --git a/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.ts b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.ts new file mode 100644 index 0000000000..fca1fec051 --- /dev/null +++ b/packages/backend/src/engine/backend-secret/backend_secret_fetcher_provider_factory.ts @@ -0,0 +1,55 @@ +import { Construct } from 'constructs'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Duration } from 'aws-cdk-lib'; +import * as path from 'path'; +import { Runtime as LambdaRuntime } from 'aws-cdk-lib/aws-lambda'; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { fileURLToPath } from 'url'; +import { SecretClient } from '@aws-amplify/backend-secret'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const resourcesRoot = path.normalize(path.join(dirname, 'lambda')); +const backendSecretLambdaFilePath = path.join( + resourcesRoot, + 'backend_secret_fetcher.js' +); + +/** + * The factory to create secret-fetcher provider. + */ +export class BackendSecretFetcherProviderFactory { + /** + * Creates a secret-fetcher provider factory. + */ + constructor(private readonly secretClient: SecretClient) {} + + /** + * Returns a resource provider if it exists in the input scope. Otherwise, + * creates a new provider. + */ + getOrCreateInstance = ( + scope: Construct, + providerId: string, + backendIdentifier: UniqueBackendIdentifier + ) => { + const provider = scope.node.tryFindChild(providerId) as Provider; + if (provider) { + return provider; + } + + const secretLambda = new NodejsFunction(scope, `${providerId}Lambda`, { + runtime: LambdaRuntime.NODEJS_18_X, + timeout: Duration.seconds(10), + entry: backendSecretLambdaFilePath, + handler: 'handler', + }); + + this.secretClient.grantPermission(secretLambda, backendIdentifier, ['GET']); + + return new Provider(scope, providerId, { + onEventHandler: secretLambda, + }); + }; +} diff --git a/packages/backend/src/engine/backend-secret/lambda/.eslintrc.json b/packages/backend/src/engine/backend-secret/lambda/.eslintrc.json new file mode 100644 index 0000000000..d5ba8f9d9c --- /dev/null +++ b/packages/backend/src/engine/backend-secret/lambda/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "off" + } +} diff --git a/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts new file mode 100644 index 0000000000..2ec677350a --- /dev/null +++ b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts @@ -0,0 +1,189 @@ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { handleCreateUpdateEvent, handler } from './backend_secret_fetcher.js'; +import { + CloudFormationCustomResourceEvent, + CloudFormationCustomResourceSuccessResponse, +} from 'aws-lambda'; +import { + Secret, + SecretClient, + SecretError, + SecretIdentifier, + getSecretClient, +} from '@aws-amplify/backend-secret'; +import { BackendId, UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +const testBackendId = 'testBackendId'; +const testBranchName = 'testBranchName'; +const testSecretName = 'testSecretName'; +const testSecretValue = 'testSecretValue'; +const testSecretVersion = 10; +const testSecretId: SecretIdentifier = { + name: testSecretName, + version: testSecretVersion, +}; + +const testSecret: Secret = { + secretIdentifier: testSecretId, + value: testSecretValue, +}; + +const testBackendIdentifier: UniqueBackendIdentifier = { + backendId: testBackendId, + branchName: testBranchName, +}; + +const customResourceEventCommon = { + ServiceToken: 'mockServiceToken', + ResponseURL: 'mockPreSignedS3Url', + StackId: 'mockStackId', + RequestId: '123', + LogicalResourceId: 'logicalId', + PhysicalResourceId: 'physicalId', + ResourceType: 'AWS::CloudFormation::CustomResource', + ResourceProperties: { + backendId: testBackendId, + branchName: testBranchName, + secretName: testSecretName, + secretVersion: testSecretVersion, + ServiceToken: 'token', + }, + OldResourceProperties: {}, +}; + +const createCfnEvent: CloudFormationCustomResourceEvent = { + RequestType: 'Create', + ...customResourceEventCommon, +}; + +const deleteCfnEvent: CloudFormationCustomResourceEvent = { + RequestType: 'Delete', + ...customResourceEventCommon, +}; + +describe('handle', () => { + it('handles delete operation', async () => { + const resp = await handler(deleteCfnEvent); + assert.deepStrictEqual(resp, { + RequestId: deleteCfnEvent.RequestId, + LogicalResourceId: deleteCfnEvent.LogicalResourceId, + PhysicalResourceId: deleteCfnEvent.PhysicalResourceId, + Data: undefined, + NoEcho: true, + StackId: deleteCfnEvent.StackId, + Status: 'SUCCESS', + } as CloudFormationCustomResourceSuccessResponse); + }); +}); + +describe('handleCreateUpdateEvent', () => { + const secretHandler: SecretClient = getSecretClient(); + const serverErr = new SecretError('server error', { httpStatusCode: 500 }); + const clientErr = new SecretError('client error', { httpStatusCode: 400 }); + + it('gets a backend secret from a branch', async () => { + const mockGetSecret = mock.method(secretHandler, 'getSecret', () => + Promise.resolve(testSecret) + ); + const val = await handleCreateUpdateEvent(secretHandler, createCfnEvent); + assert.equal(val, testSecretValue); + + assert.equal(mockGetSecret.mock.callCount(), 1); + assert.deepStrictEqual(mockGetSecret.mock.calls[0].arguments, [ + testBackendIdentifier, + testSecretId, + ]); + }); + + it('throws if receiving server error when getting a branch secret', async () => { + const mockGetSecret = mock.method(secretHandler, 'getSecret', () => + Promise.reject(serverErr) + ); + await assert.rejects(() => + handleCreateUpdateEvent(secretHandler, createCfnEvent) + ); + assert.equal(mockGetSecret.mock.callCount(), 1); + assert.deepStrictEqual(mockGetSecret.mock.calls[0].arguments, [ + testBackendIdentifier, + testSecretId, + ]); + }); + + it('gets a shared backend secret if the branch returns client error', async () => { + const mockGetSecret = mock.method( + secretHandler, + 'getSecret', + (backendIdentifier: UniqueBackendIdentifier | BackendId) => { + if (typeof backendIdentifier === 'object') { + return Promise.reject(clientErr); + } + return Promise.resolve(testSecret); + } + ); + + const val = await handleCreateUpdateEvent(secretHandler, createCfnEvent); + assert.equal(val, testSecretValue); + + assert.equal(mockGetSecret.mock.callCount(), 2); + assert.deepStrictEqual(mockGetSecret.mock.calls[0].arguments, [ + testBackendIdentifier, + testSecretId, + ]); + assert.deepStrictEqual(mockGetSecret.mock.calls[1].arguments, [ + testBackendId, + testSecretId, + ]); + }); + + it('gets a shared backend secret if the branch returns undefined', async () => { + const mockGetSecret = mock.method( + secretHandler, + 'getSecret', + (backendIdentifier: UniqueBackendIdentifier | BackendId) => { + if (typeof backendIdentifier === 'object') { + return Promise.resolve(undefined); + } + return Promise.resolve(testSecret); + } + ); + const val = await handleCreateUpdateEvent(secretHandler, createCfnEvent); + assert.equal(val, testSecretValue); + + assert.equal(mockGetSecret.mock.callCount(), 2); + assert.deepStrictEqual(mockGetSecret.mock.calls[0].arguments, [ + testBackendIdentifier, + testSecretId, + ]); + assert.deepStrictEqual(mockGetSecret.mock.calls[1].arguments, [ + testBackendId, + testSecretId, + ]); + }); + + it('throws if receiving server error when getting shared secret', async () => { + const mockGetSecret = mock.method( + secretHandler, + 'getSecret', + (backendIdentifier: UniqueBackendIdentifier | BackendId) => { + if (typeof backendIdentifier === 'object') { + return Promise.reject(clientErr); + } + return Promise.reject(serverErr); + } + ); + await assert.rejects(() => + handleCreateUpdateEvent(secretHandler, createCfnEvent) + ); + + assert.equal(mockGetSecret.mock.callCount(), 2); + assert.deepStrictEqual(mockGetSecret.mock.calls[0].arguments, [ + testBackendIdentifier, + testSecretId, + ]); + assert.deepStrictEqual(mockGetSecret.mock.calls[1].arguments, [ + testBackendId, + testSecretId, + ]); + }); +}); diff --git a/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.ts b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.ts new file mode 100644 index 0000000000..7c25b0f78a --- /dev/null +++ b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.ts @@ -0,0 +1,107 @@ +import { + CloudFormationCustomResourceEvent, + CloudFormationCustomResourceSuccessResponse, +} from 'aws-lambda'; +import { + SecretClient, + SecretError, + getSecretClient, +} from '@aws-amplify/backend-secret'; +import { randomUUID } from 'node:crypto'; + +type SecretResourceProps = { + backendId: string; + branchName: string; + secretName: string; + secretVersion: number; +}; + +const secretClient = getSecretClient(); + +/** + * Entry point for the lambda-backend custom resource to retrieve a backend secret. + */ +export const handler = async ( + event: CloudFormationCustomResourceEvent +): Promise => { + console.info(`Received '${event.RequestType}' event`); + + const physicalId = + event.RequestType === 'Create' ? randomUUID() : event.PhysicalResourceId; + let data: { secretValue: string } | undefined = undefined; + if (event.RequestType === 'Update' || event.RequestType === 'Create') { + const val = await handleCreateUpdateEvent(secretClient, event); + data = { + secretValue: val, + }; + } + + return { + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalId, + Data: data, + StackId: event.StackId, + NoEcho: true, + Status: 'SUCCESS', + } as CloudFormationCustomResourceSuccessResponse; +}; + +/** + * Handles create/update event for the secret custom resource. + */ +export const handleCreateUpdateEvent = async ( + secretClient: SecretClient, + event: CloudFormationCustomResourceEvent +): Promise => { + const props = event.ResourceProperties as unknown as SecretResourceProps; + let secret: string | undefined; + + try { + const resp = await secretClient.getSecret( + { + backendId: props.backendId, + branchName: props.branchName, + }, + { + name: props.secretName, + version: props.secretVersion, + } + ); + secret = resp?.value; + } catch (err) { + const secretErr = err as SecretError; + if (secretErr.httpStatusCode && secretErr.httpStatusCode >= 500) { + throw new Error( + `Failed to retrieve backend secret '${props.secretName}' for '${ + props.backendId + }/${props.branchName}'. Reason: ${JSON.stringify(err)}` + ); + } + } + + // if the secret is not available in branch path, retrieve it at app-level. + if (!secret) { + try { + const resp = await secretClient.getSecret(props.backendId, { + name: props.secretName, + version: props.secretVersion, + }); + secret = resp?.value; + } catch (err) { + throw new Error( + `Failed to retrieve backend secret '${props.secretName}' for '${ + props.backendId + }'. Reason: ${JSON.stringify(err)}` + ); + } + } + + if (!secret) { + throw new Error( + `Unable to find backend secret for backend '${props.backendId}', branch '${props.branchName}', name '${props.secretName}'` + ); + } + + return secret; +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 30ca8fac7d..6c3ad1a642 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1 +1,2 @@ export * from './backend.js'; +export * from './secret.js'; diff --git a/packages/backend/src/secret.ts b/packages/backend/src/secret.ts new file mode 100644 index 0000000000..29919e46ee --- /dev/null +++ b/packages/backend/src/secret.ts @@ -0,0 +1,18 @@ +import { BackendSecret } from '@aws-amplify/plugin-types'; +import { CfnTokenBackendSecret } from './engine/backend-secret/backend_secret.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { BackendSecretFetcherProviderFactory } from './engine/backend-secret/backend_secret_fetcher_provider_factory.js'; +import { BackendSecretFetcherFactory } from './engine/backend-secret/backend_secret_fetcher_factory.js'; + +/** + * Factory function for initializing a backend secret. + */ +export const secret = (name: string, version = 1): BackendSecret => { + const secretProviderFactory = new BackendSecretFetcherProviderFactory( + getSecretClient() + ); + const secretResourceFactory = new BackendSecretFetcherFactory( + secretProviderFactory + ); + return new CfnTokenBackendSecret(name, version, secretResourceFactory); +}; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 8e6ba2e792..59b24d580a 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "lib" }, "references": [ { "path": "../backend-output-schemas" }, + { "path": "../backend-secret" }, { "path": "../plugin-types" } ] } diff --git a/packages/client-config/package.json b/packages/client-config/package.json index 4487c72625..5e07c5896e 100644 --- a/packages/client-config/package.json +++ b/packages/client-config/package.json @@ -20,7 +20,7 @@ "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.2", "@aws-sdk/client-amplify": "^3.376.0", "@aws-sdk/client-cloudformation": "^3.376.0", - "@aws-sdk/client-ssm": "^3.370.0", + "@aws-sdk/client-ssm": "^3.398.0", "@aws-sdk/credential-providers": "^3.370.0", "zod": "^3.21.4" }, diff --git a/packages/plugin-types/API.md b/packages/plugin-types/API.md index 76bf2df79f..d1de4e0469 100644 --- a/packages/plugin-types/API.md +++ b/packages/plugin-types/API.md @@ -11,6 +11,7 @@ import { Function as Function_2 } from 'aws-cdk-lib/aws-lambda'; import { IRole } from 'aws-cdk-lib/aws-iam'; import { IUserPool } from 'aws-cdk-lib/aws-cognito'; import { IUserPoolClient } from 'aws-cdk-lib/aws-cognito'; +import { SecretValue } from 'aws-cdk-lib'; import { Stack } from 'aws-cdk-lib'; // @public (undocumented) @@ -62,6 +63,11 @@ export type BackendOutputWriter = { storeOutput: (outputStorageStrategy: BackendOutputStorageStrategy) => void; }; +// @public (undocumented) +export type BackendSecret = { + resolve: (scope: Construct, uniqueBackendIdentifier: UniqueBackendIdentifier) => SecretValue; +}; + // @public export type ConstructContainer = { getOrCompute: (generator: ConstructContainerEntryGenerator) => Construct; diff --git a/packages/plugin-types/src/backend_secret_resolver.ts b/packages/plugin-types/src/backend_secret_resolver.ts new file mode 100644 index 0000000000..a7169660ee --- /dev/null +++ b/packages/plugin-types/src/backend_secret_resolver.ts @@ -0,0 +1,13 @@ +import { Construct } from 'constructs'; +import { SecretValue } from 'aws-cdk-lib'; +import { UniqueBackendIdentifier } from './unique_backend_identifier.js'; + +export type BackendSecret = { + /** + * Resolves the given secret to a CDK token. + */ + resolve: ( + scope: Construct, + uniqueBackendIdentifier: UniqueBackendIdentifier + ) => SecretValue; +}; diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index ec4b3cd48f..361e0c7db1 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -10,5 +10,6 @@ export * from './auth_resources.js'; export * from './backend_output.js'; export * from './import_path_verifier.js'; export * from './resource_provider.js'; +export * from './backend_secret_resolver.js'; export * from './function_resources.js'; export * from './amplify_function.js'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..ffcbb9477e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.base.json" +}