mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-22 09:21:22 -05:00
Compare commits
5 Commits
v3.17.0-rc
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9b936f4b | ||
|
|
dceda3f6f2 | ||
|
|
c6241f7e7f | ||
|
|
92f1c2b75a | ||
|
|
4d53291c8a |
@@ -71,7 +71,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,5 +23,10 @@ export const rateLimitConfigs = {
|
||||
actions: {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
sendLinkSurveyEmail: {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "action:send-link-survey-email",
|
||||
}, // 10 per hour
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
|
||||
import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data";
|
||||
@@ -12,6 +14,14 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro
|
||||
export const sendLinkSurveyEmailAction = actionClient
|
||||
.schema(ZLinkSurveyEmailData)
|
||||
.action(async ({ parsedInput }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.actions.sendLinkSurveyEmail);
|
||||
|
||||
const survey = await getSurveyWithMetadata(parsedInput.surveyId);
|
||||
|
||||
if (!survey.isVerifyEmailEnabled) {
|
||||
throw new InvalidInputError("EMAIL_VERIFICATION_NOT_ENABLED");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||
|
||||
|
||||
8
infra/.envrc
Normal file
8
infra/.envrc
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This is a better (faster) alternative to the built-in Nix support
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||
fi
|
||||
|
||||
use flake
|
||||
3
infra/.gitignore
vendored
Normal file
3
infra/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.terraform/
|
||||
builds
|
||||
/.direnv/
|
||||
15
infra/README.md
Normal file
15
infra/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
### Nix Flakes
|
||||
|
||||
This project uses Nix Flakes via direnv.
|
||||
|
||||
Ensure your `~/.config/nix/nix.conf` (or `/etc/nix/nix.conf`) contains:
|
||||
|
||||
```bash
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
If your environment does not support flakes, you can still enter the development shell with:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
61
infra/flake.lock
generated
Normal file
61
infra/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754767907,
|
||||
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
46
infra/flake.nix
Normal file
46
infra/flake.nix
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
helm-with-plugins = (
|
||||
pkgs.wrapHelm pkgs.kubernetes-helm {
|
||||
plugins = with pkgs.kubernetes-helmPlugins; [
|
||||
helm-secrets
|
||||
helm-diff
|
||||
helm-s3
|
||||
helm-git
|
||||
];
|
||||
}
|
||||
);
|
||||
helmfile-with-plugins = pkgs.helmfile-wrapped.override {
|
||||
inherit (helm-with-plugins) pluginsDir;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
awscli
|
||||
kubectl
|
||||
helm-with-plugins
|
||||
helmfile-with-plugins
|
||||
terraform
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,9 @@ deployment:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
db-secrets:
|
||||
nameSuffix: db-secrets
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: spot
|
||||
reloadOnChange: true
|
||||
@@ -103,6 +106,9 @@ externalSecret:
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/secrets
|
||||
db-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/terraform/rds/credentials
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
|
||||
237
infra/terraform/.terraform.lock.hcl
generated
237
infra/terraform/.terraform.lock.hcl
generated
@@ -2,51 +2,71 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.89.0"
|
||||
constraints = ">= 5.46.0, >= 5.79.0, >= 5.83.0"
|
||||
version = "5.100.0"
|
||||
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
|
||||
hashes = [
|
||||
"h1:rFvk42jJEKiSUhK1cbERfNgYm4mD+8tq0ZcxCwpXSJs=",
|
||||
"zh:0e55784d6effc33b9098ffab7fb77a242e0223a59cdcf964caa0be94d14684af",
|
||||
"zh:23c64f3eaeffcafb007c89db3dfca94c8adf06b120af55abddaca55a6c6c924c",
|
||||
"zh:338f620133cb607ce980f1725a0a78f61cbd42f4c601808ec1ee01a6c16c9811",
|
||||
"zh:6ab0499172f17484d7b39924cf06782789df1473d31ebae0c7f3294f6e7a1227",
|
||||
"zh:6dcde3e29e538cdf80971cbdce3b285056fd0e31dd64b02d2dcdf4c02f21d0a9",
|
||||
"zh:75c9b594d77c9125bfb1aaf3fbd77a49e392841d53029b5726eb71d64de1233e",
|
||||
"zh:7b334c23091e7b4c142e378416586292197c40a31a5bdb3b29c4f9afddd286f0",
|
||||
"zh:991bbba72e5eb6eb351f466d68080992f5b0495f862a6723f386d1b4c965aa7d",
|
||||
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bd2f12eef4a5dceafc211ab3b9a63f0e3e224007a60c1bbb842f76e0377033d",
|
||||
"zh:b1ac1eb3b3e1a79fa5e5ad3364615f23b9ee0b093ceeb809fd386a4d40e7abb4",
|
||||
"zh:cea91f43151b30c428c441b97c3b98bf1e5fb72ef72f6971308e3895e23437f4",
|
||||
"zh:d3f000a1696a43d8186a516aace7d476d1fd76443627980504133477e19c8ecb",
|
||||
"zh:d6f526fbbb3e51b3acc3b9640a158f7acc4a089632fca8ec6db430b450673f25",
|
||||
"zh:e0c542950f96c93e761d50602e449fef8447f1389a6d5242a0a7dc9b06826d0b",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.6"
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:afnqn3XPnO40laFt+SVHPPKsg1j3HXT0VAO0xBVvmrY=",
|
||||
"zh:1321b5ddede56be3f9b35bf75d7cda79adcb357fad62eb8677b6595e0baaa6cd",
|
||||
"zh:265d66e61b9cd16ca1182ebf094cc0a08fb3687e8193a1dbac6899b16c237151",
|
||||
"zh:3875c3a20e082ac55d5ff24bcaf7133ebc90c7f999fd0fb37cf0f0003474c94c",
|
||||
"zh:68ce41ccd07757c451682703840cae1ec270ed5275cd491bbf8279782dfcbb73",
|
||||
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:8dca3bb3f85ff8ac4d1b3f93975dcb751ed788396c56ebf0c3737ce1a4c60492",
|
||||
"zh:9339bdaa99939291cedf543861353c8e7171ec5231c0dfacaa9bdb3338978dab",
|
||||
"zh:a8510c2256e9a78697910bb5542aeca457c81225ea88130335f6d14a36a36c74",
|
||||
"zh:af7ed71b8fceb60a5e3b7fa663be171e0bd41bb0af30e0e1f06a004c7b584e4a",
|
||||
"zh:bc9de0f921b69d07f5fc1ea65f8af71d8d1a7053aafb500788b30bfce64b8fbe",
|
||||
"zh:bccd0a49f161a91660d7d30dd6b389e6820f29752ccf351f10a3297c96973823",
|
||||
"zh:c69321caca20009abead617f888a67aca990276cb7388b738b19157b88749190",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.3.5"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
|
||||
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
|
||||
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
|
||||
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
|
||||
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
|
||||
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
|
||||
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
|
||||
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
|
||||
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
|
||||
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
|
||||
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/helm" {
|
||||
version = "2.17.0"
|
||||
constraints = "~> 2.17"
|
||||
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
|
||||
hashes = [
|
||||
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
|
||||
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
|
||||
@@ -65,100 +85,121 @@ provider "registry.terraform.io/hashicorp/helm" {
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "2.36.0"
|
||||
constraints = "~> 2.36"
|
||||
version = "2.38.0"
|
||||
constraints = ">= 2.20.0, ~> 2.36"
|
||||
hashes = [
|
||||
"h1:94wlXkBzfXwyLVuJVhMdzK+VGjFnMjdmFkYhQ1RUFhI=",
|
||||
"zh:07f38fcb7578984a3e2c8cf0397c880f6b3eb2a722a120a08a634a607ea495ca",
|
||||
"zh:1adde61769c50dbb799d8bf8bfd5c8c504a37017dfd06c7820f82bcf44ca0d39",
|
||||
"zh:39707f23ab58fd0e686967c0f973c0f5a39c14d6ccfc757f97c345fdd0cd4624",
|
||||
"zh:4cc3dc2b5d06cc22d1c734f7162b0a8fdc61990ff9efb64e59412d65a7ccc92a",
|
||||
"zh:8382dcb82ba7303715b5e67939e07dd1c8ecddbe01d12f39b82b2b7d7357e1d9",
|
||||
"zh:88e8e4f90034186b8bfdea1b8d394621cbc46a064ff2418027e6dba6807d5227",
|
||||
"zh:a6276a75ad170f76d88263fdb5f9558998bf3a3f7650d7bd3387b396410e59f3",
|
||||
"zh:bc816c7e0606e5df98a0c7634b240bb0c8100c3107b8b17b554af702edc6a0c5",
|
||||
"zh:cb2f31d58f37020e840af52755c18afd1f09a833c4903ac59270ab440fab57b7",
|
||||
"zh:ee0d103b8d0089fb1918311683110b4492a9346f0471b136af46d3b019576b22",
|
||||
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
|
||||
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:f688b9ec761721e401f6859c19c083e3be20a650426f4747cd359cdc079d212a",
|
||||
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.5.3"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
|
||||
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
|
||||
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
|
||||
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
|
||||
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
|
||||
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
|
||||
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
|
||||
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
|
||||
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
|
||||
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
|
||||
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.2.3"
|
||||
constraints = ">= 3.0.0"
|
||||
version = "3.2.4"
|
||||
constraints = ">= 2.0.0, >= 3.0.0"
|
||||
hashes = [
|
||||
"h1:I0Um8UkrMUb81Fxq/dxbr3HLP2cecTH2WMJiwKSrwQY=",
|
||||
"zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2",
|
||||
"zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d",
|
||||
"zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3",
|
||||
"zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f",
|
||||
"zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1",
|
||||
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
|
||||
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301",
|
||||
"zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670",
|
||||
"zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed",
|
||||
"zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65",
|
||||
"zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd",
|
||||
"zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5",
|
||||
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
|
||||
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
|
||||
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
|
||||
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
|
||||
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
|
||||
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
|
||||
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
|
||||
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
|
||||
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
|
||||
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.1"
|
||||
version = "3.7.2"
|
||||
constraints = ">= 2.0.0, >= 3.6.0"
|
||||
hashes = [
|
||||
"h1:t152MY0tQH4a8fLzTtEWx70ITd3azVOrFDn/pQblbto=",
|
||||
"zh:3193b89b43bf5805493e290374cdda5132578de6535f8009547c8b5d7a351585",
|
||||
"zh:3218320de4be943e5812ed3de995946056db86eb8d03aa3f074e0c7316599bef",
|
||||
"zh:419861805a37fa443e7d63b69fb3279926ccf98a79d256c422d5d82f0f387d1d",
|
||||
"zh:4df9bd9d839b8fc11a3b8098a604b9b46e2235eb65ef15f4432bde0e175f9ca6",
|
||||
"zh:5814be3f9c9cc39d2955d6f083bae793050d75c572e70ca11ccceb5517ced6b1",
|
||||
"zh:63c6548a06de1231c8ee5570e42ca09c4b3db336578ded39b938f2156f06dd2e",
|
||||
"zh:697e434c6bdee0502cc3deb098263b8dcd63948e8a96d61722811628dce2eba1",
|
||||
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:a0b8e44927e6327852bbfdc9d408d802569367f1e22a95bcdd7181b1c3b07601",
|
||||
"zh:b7d3af018683ef22794eea9c218bc72d7c35a2b3ede9233b69653b3c782ee436",
|
||||
"zh:d63b911d618a6fe446c65bfc21e793a7663e934b2fef833d42d3ccd38dd8d68d",
|
||||
"zh:fa985cd0b11e6d651f47cff3055f0a9fd085ec190b6dbe99bf5448174434cdea",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/time" {
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
constraints = ">= 0.9.0"
|
||||
hashes = [
|
||||
"h1:JzYsPugN8Fb7C4NlfLoFu7BBPuRVT2/fCOdCaxshveI=",
|
||||
"zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2",
|
||||
"zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea",
|
||||
"zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511",
|
||||
"zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9",
|
||||
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
|
||||
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
|
||||
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
|
||||
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
|
||||
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38",
|
||||
"zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869",
|
||||
"zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e",
|
||||
"zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625",
|
||||
"zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136",
|
||||
"zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b",
|
||||
"zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44",
|
||||
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
|
||||
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
|
||||
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
|
||||
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
|
||||
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
|
||||
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
|
||||
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.0.6"
|
||||
version = "4.1.0"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:n3M50qfWfRSpQV9Pwcvuse03pEizqrmYEryxKky4so4=",
|
||||
"zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8",
|
||||
"zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297",
|
||||
"zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb",
|
||||
"zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1",
|
||||
"zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509",
|
||||
"zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8",
|
||||
"zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a",
|
||||
"zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18",
|
||||
"zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50",
|
||||
"zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27",
|
||||
"zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb",
|
||||
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
|
||||
121
infra/terraform/db_users/cloudwatch.tf
Normal file
121
infra/terraform/db_users/cloudwatch.tf
Normal file
@@ -0,0 +1,121 @@
|
||||
resource "aws_sns_topic" "this" {
|
||||
name = "lambda-metrics-alarm"
|
||||
}
|
||||
|
||||
module "alarm" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "lambda-duration-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda duration is too high"
|
||||
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold = 10
|
||||
period = 60
|
||||
unit = "Milliseconds"
|
||||
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Duration"
|
||||
statistic = "Maximum"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
}
|
||||
|
||||
module "alarm_metric_query" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "mq-lambda-duration-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda error rate is too high"
|
||||
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold = 10
|
||||
|
||||
metric_query = [{
|
||||
id = "e1"
|
||||
|
||||
return_data = true
|
||||
expression = "m2/m1*100"
|
||||
label = "Error Rate"
|
||||
}, {
|
||||
id = "m1"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Invocations"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
id = "m2"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Errors"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
}]
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
|
||||
tags = {
|
||||
Secure = "maybe"
|
||||
}
|
||||
}
|
||||
|
||||
module "alarm_anomaly" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "lambda-invocations-anomaly-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda invocations anomaly"
|
||||
comparison_operator = "LessThanLowerOrGreaterThanUpperThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold_metric_id = "ad1"
|
||||
|
||||
metric_query = [{
|
||||
id = "ad1"
|
||||
|
||||
return_data = true
|
||||
expression = "ANOMALY_DETECTION_BAND(m1, 2)"
|
||||
label = "Invocations (expected)"
|
||||
return_data = "true"
|
||||
},
|
||||
{
|
||||
id = "m1"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Invocations"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
return_data = "true"
|
||||
}]
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
|
||||
tags = {
|
||||
Secure = "maybe"
|
||||
}
|
||||
}
|
||||
20
infra/terraform/db_users/data.tf
Normal file
20
infra/terraform/db_users/data.tf
Normal file
@@ -0,0 +1,20 @@
|
||||
data "aws_region" "selected" {}
|
||||
|
||||
data "aws_secretsmanager_secret" "rds_credentials" {
|
||||
arn = data.terraform_remote_state.main.outputs.rds_secret_staging_arn
|
||||
}
|
||||
|
||||
# Default KMS key for Secrets Manager
|
||||
data "aws_kms_key" "secretsmanager" {
|
||||
key_id = "alias/aws/secretsmanager"
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "main" {
|
||||
backend = "s3"
|
||||
|
||||
config = {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
}
|
||||
}
|
||||
71
infra/terraform/db_users/lambda.tf
Normal file
71
infra/terraform/db_users/lambda.tf
Normal file
@@ -0,0 +1,71 @@
|
||||
resource "aws_lambda_layer_version" "psycopg2_layer" {
|
||||
layer_name = "psycopg2-layer"
|
||||
description = "Psycopg2 PostgreSQL driver for AWS Lambda"
|
||||
compatible_runtimes = ["python3.9"]
|
||||
filename = "./lambda/deps/psycopg2-layer.zip"
|
||||
}
|
||||
|
||||
module "lambda_rotate_db_secret" {
|
||||
source = "terraform-aws-modules/lambda/aws"
|
||||
version = "7.20.1"
|
||||
|
||||
function_name = "lbda-rotate-db-secret"
|
||||
description = "Rotate Aurora Serverless PostgreSQL DB secret"
|
||||
handler = "lambda_function.lambda_handler"
|
||||
source_path = "./lambda/src/lambda_function.py"
|
||||
create_package = true
|
||||
package_type = "Zip"
|
||||
runtime = "python3.9"
|
||||
timeout = 30
|
||||
memory_size = 128
|
||||
layers = [aws_lambda_layer_version.psycopg2_layer.arn]
|
||||
create_role = true
|
||||
role_name = "iamr-lbda-rotate-db-secret-role"
|
||||
policy_name = "iamp-lbda-rotate-db-secret-policy"
|
||||
attach_policy_json = true
|
||||
policy_json = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = [
|
||||
"kms:GenerateDataKey",
|
||||
"kms:Encrypt",
|
||||
"kms:DescribeKey",
|
||||
"kms:Decrypt"
|
||||
]
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowKMS"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"secretsmanager:UpdateSecretVersionStage",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowSecretsManager"
|
||||
},
|
||||
{
|
||||
Action = "secretsmanager:GetRandomPassword"
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowSecretsManagerRandomPassword"
|
||||
}
|
||||
]
|
||||
})
|
||||
tags = {
|
||||
Environment = "dev"
|
||||
Project = "aurora-serverless"
|
||||
Zone = "db-zone"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "AllowSecretsManager" {
|
||||
statement_id = "AllowSecretsManager"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = module.lambda_rotate_db_secret.lambda_function_name
|
||||
principal = "secretsmanager.amazonaws.com"
|
||||
}
|
||||
589
infra/terraform/db_users/lambda/src/lambda_function.py
Normal file
589
infra/terraform/db_users/lambda/src/lambda_function.py
Normal file
@@ -0,0 +1,589 @@
|
||||
# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: MIT-0
|
||||
# https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/SecretsManagerRDSPostgreSQLRotationSingleUser/lambda_function.py
|
||||
# Updated this function library from pg, pgdb to psycopg2 to support python3.9
|
||||
|
||||
import re
|
||||
import boto3
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""Secrets Manager RDS PostgreSQL Handler
|
||||
|
||||
This handler uses the single-user rotation scheme to rotate an RDS PostgreSQL user credential. This rotation
|
||||
scheme logs into the database as the user and rotates the user's own password, immediately invalidating the
|
||||
user's previous password.
|
||||
|
||||
The Secret SecretString is expected to be a JSON string with the following format:
|
||||
{
|
||||
'engine': <required: must be set to 'postgres'>,
|
||||
'host': <required: instance host name>,
|
||||
'username': <required: username>,
|
||||
'password': <required: password>,
|
||||
'dbname': <optional: database name, default to 'postgres'>,
|
||||
'port': <optional: if not specified, default port 5432 will be used>
|
||||
}
|
||||
|
||||
Args:
|
||||
event (dict): Lambda dictionary of event parameters. These keys must include the following:
|
||||
- SecretId: The secret ARN or identifier
|
||||
- ClientRequestToken: The ClientRequestToken of the secret version
|
||||
- Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret)
|
||||
|
||||
context (LambdaContext): The Lambda runtime information
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not properly configured for rotation
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
arn = event["SecretId"]
|
||||
token = event["ClientRequestToken"]
|
||||
step = event["Step"]
|
||||
|
||||
# Setup the client
|
||||
service_client = boto3.client(
|
||||
"secretsmanager", endpoint_url=os.environ["SECRETS_MANAGER_ENDPOINT"]
|
||||
)
|
||||
|
||||
# Make sure the version is staged correctly
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
if "RotationEnabled" in metadata and not metadata["RotationEnabled"]:
|
||||
logger.error("Secret %s is not enabled for rotation" % arn)
|
||||
raise ValueError("Secret %s is not enabled for rotation" % arn)
|
||||
versions = metadata["VersionIdsToStages"]
|
||||
if token not in versions:
|
||||
logger.error(
|
||||
"Secret version %s has no stage for rotation of secret %s." % (token, arn)
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s has no stage for rotation of secret %s." % (token, arn)
|
||||
)
|
||||
if "AWSCURRENT" in versions[token]:
|
||||
logger.info(
|
||||
"Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)
|
||||
)
|
||||
return
|
||||
elif "AWSPENDING" not in versions[token]:
|
||||
logger.error(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
|
||||
# Call the appropriate step
|
||||
if step == "createSecret":
|
||||
create_secret(service_client, arn, token)
|
||||
|
||||
elif step == "setSecret":
|
||||
set_secret(service_client, arn, token)
|
||||
|
||||
elif step == "testSecret":
|
||||
test_secret(service_client, arn, token)
|
||||
|
||||
elif step == "finishSecret":
|
||||
finish_secret(service_client, arn, token)
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)
|
||||
)
|
||||
raise ValueError("Invalid step parameter %s for secret %s" % (step, arn))
|
||||
|
||||
|
||||
def create_secret(service_client, arn, token):
|
||||
"""Generate a new secret
|
||||
|
||||
This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a
|
||||
new secret and put it with the passed in token.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ValueError: If the current secret is not valid JSON
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Make sure the current secret exists
|
||||
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
|
||||
|
||||
# Now try to get the secret version, if that fails, put a new secret
|
||||
try:
|
||||
get_secret_dict(service_client, arn, "AWSPENDING", token)
|
||||
logger.info("createSecret: Successfully retrieved secret for %s." % arn)
|
||||
except service_client.exceptions.ResourceNotFoundException:
|
||||
# Generate a random password
|
||||
current_dict["password"] = get_random_password(service_client)
|
||||
# Put the secret
|
||||
service_client.put_secret_value(
|
||||
SecretId=arn,
|
||||
ClientRequestToken=token,
|
||||
SecretString=json.dumps(current_dict),
|
||||
VersionStages=["AWSPENDING"],
|
||||
)
|
||||
logger.info(
|
||||
"createSecret: Successfully put secret for ARN %s and version %s."
|
||||
% (arn, token)
|
||||
)
|
||||
|
||||
|
||||
def set_secret(service_client, arn, token):
|
||||
"""Set the pending secret in the database
|
||||
|
||||
This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it
|
||||
tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password
|
||||
as the user password in the database. Else, it throws a ValueError.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON or valid credentials are found to login to the database
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
try:
|
||||
previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS")
|
||||
except (service_client.exceptions.ResourceNotFoundException, KeyError):
|
||||
previous_dict = None
|
||||
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
|
||||
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token)
|
||||
|
||||
# First try to login with the pending secret, if it succeeds, return
|
||||
conn = get_connection(pending_dict)
|
||||
if conn:
|
||||
conn.close()
|
||||
logger.info(
|
||||
"setSecret: AWSPENDING secret is already set as password in PostgreSQL DB for secret arn %s."
|
||||
% arn
|
||||
)
|
||||
return
|
||||
|
||||
# Make sure the user from current and pending match
|
||||
if current_dict["username"] != pending_dict["username"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user %s other than current user %s"
|
||||
% (pending_dict["username"], current_dict["username"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user %s other than current user %s"
|
||||
% (pending_dict["username"], current_dict["username"])
|
||||
)
|
||||
|
||||
# Make sure the host from current and pending match
|
||||
if current_dict["host"] != pending_dict["host"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user for host %s other than current host %s"
|
||||
% (pending_dict["host"], current_dict["host"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user for host %s other than current host %s"
|
||||
% (pending_dict["host"], current_dict["host"])
|
||||
)
|
||||
|
||||
# Now try the current password
|
||||
conn = get_connection(current_dict)
|
||||
|
||||
# If both current and pending do not work, try previous
|
||||
if not conn and previous_dict:
|
||||
# Update previous_dict to leverage current SSL settings
|
||||
previous_dict.pop("ssl", None)
|
||||
if "ssl" in current_dict:
|
||||
previous_dict["ssl"] = current_dict["ssl"]
|
||||
|
||||
conn = get_connection(previous_dict)
|
||||
|
||||
# Make sure the user/host from previous and pending match
|
||||
if previous_dict["username"] != pending_dict["username"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user %s other than previous valid user %s"
|
||||
% (pending_dict["username"], previous_dict["username"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user %s other than previous valid user %s"
|
||||
% (pending_dict["username"], previous_dict["username"])
|
||||
)
|
||||
if previous_dict["host"] != pending_dict["host"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user for host %s other than previous valid host %s"
|
||||
% (pending_dict["host"], previous_dict["host"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user for host %s other than current previous valid %s"
|
||||
% (pending_dict["host"], previous_dict["host"])
|
||||
)
|
||||
|
||||
# If we still don't have a connection, raise a ValueError
|
||||
if not conn:
|
||||
logger.error(
|
||||
"setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s"
|
||||
% arn
|
||||
)
|
||||
raise ValueError(
|
||||
"Unable to log into database with previous, current, or pending secret of secret arn %s"
|
||||
% arn
|
||||
)
|
||||
|
||||
# Now set the password to the pending password
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Get escaped username via quote_ident
|
||||
cur.execute("SELECT quote_ident(%s)", (pending_dict["username"],))
|
||||
escaped_username = cur.fetchone()[0]
|
||||
|
||||
alter_role = "ALTER USER %s" % escaped_username
|
||||
cur.execute(alter_role + " WITH PASSWORD %s", (pending_dict["password"],))
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"setSecret: Successfully set password for user %s in PostgreSQL DB for secret arn %s."
|
||||
% (pending_dict["username"], arn)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_secret(service_client, arn, token):
|
||||
"""Test the pending secret against the database
|
||||
|
||||
This method tries to log into the database with the secrets staged with AWSPENDING and runs
|
||||
a permissions check to ensure the user has the corrrect permissions.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON or valid credentials are found to login to the database
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Try to login with the pending secret, if it succeeds, return
|
||||
conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token))
|
||||
if conn:
|
||||
# This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to
|
||||
# tailor these validations to your needs
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT NOW()")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"testSecret: Successfully signed into PostgreSQL DB with AWSPENDING secret in %s."
|
||||
% arn
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error(
|
||||
"testSecret: Unable to log into database with pending secret of secret ARN %s"
|
||||
% arn
|
||||
)
|
||||
raise ValueError(
|
||||
"Unable to log into database with pending secret of secret ARN %s" % arn
|
||||
)
|
||||
|
||||
|
||||
def finish_secret(service_client, arn, token):
|
||||
"""Finish the rotation by marking the pending secret as current
|
||||
|
||||
This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
"""
|
||||
# First describe the secret to get the current version
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
current_version = None
|
||||
for version in metadata["VersionIdsToStages"]:
|
||||
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
|
||||
if version == token:
|
||||
# The correct version is already marked as current, return
|
||||
logger.info(
|
||||
"finishSecret: Version %s already marked as AWSCURRENT for %s"
|
||||
% (version, arn)
|
||||
)
|
||||
return
|
||||
current_version = version
|
||||
break
|
||||
|
||||
# Finalize by staging the secret version current
|
||||
service_client.update_secret_version_stage(
|
||||
SecretId=arn,
|
||||
VersionStage="AWSCURRENT",
|
||||
MoveToVersionId=token,
|
||||
RemoveFromVersionId=current_version,
|
||||
)
|
||||
logger.info(
|
||||
"finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
|
||||
|
||||
def get_connection(secret_dict):
|
||||
"""Gets a connection to PostgreSQL DB from a secret dictionary
|
||||
|
||||
This helper function uses connectivity information from the secret dictionary to initiate
|
||||
connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when
|
||||
initial connection fails using SSL and fall_back is True.
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
|
||||
Returns:
|
||||
Connection: The psycopg2 connection object if successful. None otherwise
|
||||
|
||||
Raises:
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Parse and validate the secret JSON string
|
||||
port = int(secret_dict.get("port", 5432))
|
||||
dbname = secret_dict.get("dbname", "postgres")
|
||||
|
||||
# Get SSL connectivity configuration
|
||||
use_ssl, fall_back = get_ssl_config(secret_dict)
|
||||
|
||||
# Attempt initial connection
|
||||
conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl)
|
||||
if conn or not fall_back:
|
||||
return conn
|
||||
|
||||
# Attempt fallback connection without SSL
|
||||
return connect_and_authenticate(secret_dict, port, dbname, False)
|
||||
|
||||
|
||||
def get_ssl_config(secret_dict):
|
||||
"""Gets the desired SSL and fall back behavior using a secret dictionary
|
||||
|
||||
This helper function uses the existance and value the 'ssl' key in a secret dictionary
|
||||
to determine desired SSL connectivity configuration. Its behavior is as follows:
|
||||
- 'ssl' key DNE or invalid type/value: return True, True
|
||||
- 'ssl' key is bool: return secret_dict['ssl'], False
|
||||
- 'ssl' key equals "true" ignoring case: return True, False
|
||||
- 'ssl' key equals "false" ignoring case: return False, False
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
|
||||
Returns:
|
||||
Tuple(use_ssl, fall_back): SSL configuration
|
||||
- use_ssl (bool): Flag indicating if an SSL connection should be attempted
|
||||
- fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails
|
||||
|
||||
"""
|
||||
# Default to True for SSL and fall_back mode if 'ssl' key DNE
|
||||
if "ssl" not in secret_dict:
|
||||
return True, True
|
||||
|
||||
# Handle type bool
|
||||
if isinstance(secret_dict["ssl"], bool):
|
||||
return secret_dict["ssl"], False
|
||||
|
||||
# Handle type string
|
||||
if isinstance(secret_dict["ssl"], str):
|
||||
ssl = secret_dict["ssl"].lower()
|
||||
if ssl == "true":
|
||||
return True, False
|
||||
elif ssl == "false":
|
||||
return False, False
|
||||
else:
|
||||
# Invalid string value, default to True for both SSL and fall_back mode
|
||||
return True, True
|
||||
|
||||
# Invalid type, default to True for both SSL and fall_back mode
|
||||
return True, True
|
||||
|
||||
|
||||
def connect_and_authenticate(secret_dict, port, dbname, use_ssl):
|
||||
"""Attempt to connect and authenticate to a PostgreSQL instance using psycopg2
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
port (int): The database port to connect to
|
||||
dbname (str): Name of the database
|
||||
use_ssl (bool): Flag indicating whether connection should use SSL/TLS
|
||||
|
||||
Returns:
|
||||
Connection: The psycopg2 connection object if successful. None otherwise
|
||||
"""
|
||||
try:
|
||||
conn_params = {
|
||||
"host": secret_dict["host"],
|
||||
"user": secret_dict["username"],
|
||||
"password": secret_dict["password"],
|
||||
"dbname": dbname,
|
||||
"port": port,
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
|
||||
if use_ssl:
|
||||
conn_params.update(
|
||||
{"sslmode": "verify-full", "sslrootcert": "/etc/pki/tls/cert.pem"}
|
||||
)
|
||||
else:
|
||||
conn_params["sslmode"] = "disable"
|
||||
|
||||
conn = psycopg2.connect(**conn_params)
|
||||
logging.info(
|
||||
"Successfully established %s connection as user '%s' with host: '%s'",
|
||||
"SSL/TLS" if use_ssl else "non SSL/TLS",
|
||||
secret_dict["username"],
|
||||
secret_dict["host"],
|
||||
)
|
||||
return conn
|
||||
except psycopg2.OperationalError as e:
|
||||
error_message = str(e)
|
||||
if "server does not support SSL, but SSL was required" in error_message:
|
||||
logging.error(
|
||||
"Unable to establish SSL/TLS handshake, SSL/TLS is not enabled on the host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
elif re.search(
|
||||
r'server common name ".+" does not match host name ".+"', error_message
|
||||
):
|
||||
logging.error(
|
||||
"Hostname verification failed when establishing SSL/TLS Handshake with host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
elif re.search(r'no pg_hba.conf entry for host ".+", SSL off', error_message):
|
||||
logging.error(
|
||||
"Unable to establish SSL/TLS handshake, SSL/TLS is enforced on the host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_secret_dict(service_client, arn, stage, token=None):
|
||||
"""Gets the secret dictionary corresponding for the secret arn, stage, and token
|
||||
|
||||
This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired
|
||||
|
||||
stage (string): The stage identifying the secret version
|
||||
|
||||
Returns:
|
||||
SecretDictionary: Secret dictionary
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON
|
||||
|
||||
"""
|
||||
required_fields = ["host", "username", "password"]
|
||||
|
||||
# Only do VersionId validation against the stage if a token is passed in
|
||||
if token:
|
||||
secret = service_client.get_secret_value(
|
||||
SecretId=arn, VersionId=token, VersionStage=stage
|
||||
)
|
||||
else:
|
||||
secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage)
|
||||
plaintext = secret["SecretString"]
|
||||
secret_dict = json.loads(plaintext)
|
||||
|
||||
# Run validations against the secret
|
||||
supported_engines = ["postgres", "aurora-postgresql"]
|
||||
if "engine" not in secret_dict or secret_dict["engine"] not in supported_engines:
|
||||
raise KeyError(
|
||||
"Database engine must be set to 'postgres' in order to use this rotation lambda"
|
||||
)
|
||||
for field in required_fields:
|
||||
if field not in secret_dict:
|
||||
raise KeyError("%s key is missing from secret JSON" % field)
|
||||
|
||||
# Parse and return the secret JSON string
|
||||
return secret_dict
|
||||
|
||||
|
||||
def get_environment_bool(variable_name, default_value):
|
||||
"""Loads the environment variable and converts it to the boolean.
|
||||
|
||||
Args:
|
||||
variable_name (string): Name of environment variable
|
||||
|
||||
default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist.
|
||||
|
||||
Returns:
|
||||
bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes'
|
||||
"""
|
||||
variable = os.environ.get(variable_name, str(default_value))
|
||||
return variable.lower() in ["true", "1", "y", "yes"]
|
||||
|
||||
|
||||
def get_random_password(service_client):
|
||||
"""Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment
|
||||
variables. When environment variable is missing sensible defaults are chosen.
|
||||
|
||||
Supported environment variables:
|
||||
- EXCLUDE_CHARACTERS
|
||||
- PASSWORD_LENGTH
|
||||
- EXCLUDE_NUMBERS
|
||||
- EXCLUDE_PUNCTUATION
|
||||
- EXCLUDE_UPPERCASE
|
||||
- EXCLUDE_LOWERCASE
|
||||
- REQUIRE_EACH_INCLUDED_TYPE
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
Returns:
|
||||
string: The randomly generated password.
|
||||
"""
|
||||
passwd = service_client.get_random_password(
|
||||
ExcludeCharacters=os.environ.get("EXCLUDE_CHARACTERS", ":/@\"'\\"),
|
||||
PasswordLength=int(os.environ.get("PASSWORD_LENGTH", 32)),
|
||||
ExcludeNumbers=get_environment_bool("EXCLUDE_NUMBERS", False),
|
||||
ExcludePunctuation=get_environment_bool("EXCLUDE_PUNCTUATION", True),
|
||||
ExcludeUppercase=get_environment_bool("EXCLUDE_UPPERCASE", False),
|
||||
ExcludeLowercase=get_environment_bool("EXCLUDE_LOWERCASE", False),
|
||||
RequireEachIncludedType=get_environment_bool(
|
||||
"REQUIRE_EACH_INCLUDED_TYPE", True
|
||||
),
|
||||
)
|
||||
return passwd["RandomPassword"]
|
||||
173
infra/terraform/db_users/locals.tf
Normal file
173
infra/terraform/db_users/locals.tf
Normal file
@@ -0,0 +1,173 @@
|
||||
locals {
|
||||
env_roles = {
|
||||
staging = { dev_users = "ro", ops_users = "rw", sa_rw_users = "rw", sa_ro_users = "ro", admin_users = "admin" }
|
||||
production = { dev_users = "ro", ops_users = "ro", sa_rw_users = "rw", sa_ro_users = "ro", admin_users = "admin" }
|
||||
}
|
||||
|
||||
# List of application user identities
|
||||
app_users = {
|
||||
dev_users = [
|
||||
"harsh",
|
||||
]
|
||||
ops_users = [
|
||||
"piotr",
|
||||
]
|
||||
admin_users = [
|
||||
"johannes",
|
||||
"matti",
|
||||
]
|
||||
sa_rw_users = [
|
||||
"formbricks-app",
|
||||
]
|
||||
}
|
||||
|
||||
# Flatten users across all teams, creating a map of username => role
|
||||
db_users = merge([
|
||||
for team, users in local.app_users : {
|
||||
for user in users : user => {
|
||||
role = local.env_roles[var.env_name][team]
|
||||
}
|
||||
}
|
||||
]...)
|
||||
|
||||
# FIXME: this shouldn't be hardcoded here
|
||||
rds_database_name = "formbricks-cloud"
|
||||
|
||||
role_prefix = replace(local.rds_database_name, "-", "_")
|
||||
|
||||
# Map of username => role
|
||||
sql_users_map = merge([
|
||||
for team, users in local.app_users : {
|
||||
for user in users : user => {
|
||||
role = "${local.role_prefix}_user_${local.env_roles[var.env_name][team]}"
|
||||
}
|
||||
}
|
||||
]...)
|
||||
|
||||
# SQL to create read-only role
|
||||
sql_create_read_only_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the read-only role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_ro') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_ro;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE on the schema
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %I TO ${local.role_prefix}_user_ro;', schema_name);
|
||||
|
||||
-- Grant SELECT on all tables in the schema
|
||||
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_ro;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# SQL to create read-write role
|
||||
sql_create_read_write_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the read-write role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_rw') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_rw;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE and CREATE on the schema
|
||||
EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO ${local.role_prefix}_user_rw;', schema_name);
|
||||
|
||||
-- Grant CRUD permissions on all existing tables
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_rw;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# SQL to create admin role
|
||||
sql_create_admin_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the admin role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_admin') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_admin;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE and CREATE on the schema (allowing schema usage and object creation)
|
||||
EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant INSERT, UPDATE, DELETE on existing tables in the schema
|
||||
EXECUTE format('GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant full privileges on schema (implicitly includes ability to alter the schema)
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant the ability to drop tables (delete tables) by owning the tables
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate SQL statements to create users and set passwords
|
||||
sql_create_user = {
|
||||
for user, user_info in local.sql_users_map : user => {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
BEGIN
|
||||
-- Create user if it does not exist
|
||||
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${user}') THEN
|
||||
EXECUTE format('CREATE USER %I WITH PASSWORD %L;', '${user}', '${random_password.db_user_secrets[user].result}');
|
||||
ELSE
|
||||
-- Update password if the user already exists
|
||||
EXECUTE format('ALTER USER %I WITH PASSWORD %L;', '${user}', '${random_password.db_user_secrets[user].result}');
|
||||
END IF;
|
||||
|
||||
-- Ensure role exists
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user_info.role}') THEN
|
||||
RAISE EXCEPTION 'Role ${user_info.role} does not exist';
|
||||
END IF;
|
||||
|
||||
-- Assign role to the user
|
||||
EXECUTE format('GRANT %I TO %I;', '${user_info.role}', '${user}');
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
12
infra/terraform/db_users/provider.tf
Normal file
12
infra/terraform/db_users/provider.tf
Normal file
@@ -0,0 +1,12 @@
|
||||
provider "aws" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "formbricks/db_users/terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
dynamodb_table = "terraform-lock"
|
||||
}
|
||||
}
|
||||
77
infra/terraform/db_users/roles.tf
Normal file
77
infra/terraform/db_users/roles.tf
Normal file
@@ -0,0 +1,77 @@
|
||||
module "create_postgres_user_read_only_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_read_only_role.sql)
|
||||
]
|
||||
}
|
||||
|
||||
module "create_postgres_user_read_write_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_read_write_role.sql)
|
||||
]
|
||||
|
||||
depends_on = [
|
||||
module.create_postgres_user_read_only_role
|
||||
]
|
||||
}
|
||||
|
||||
module "create_postgres_user_admin_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_admin_role.sql)
|
||||
]
|
||||
|
||||
depends_on = [
|
||||
module.create_postgres_user_read_write_role
|
||||
]
|
||||
}
|
||||
|
||||
# Create a SQL users
|
||||
module "create_postgres_user" {
|
||||
for_each = {
|
||||
for user, user_info in local.sql_users_map :
|
||||
user => user_info
|
||||
if var.env_name != "localstack"
|
||||
}
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUser"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_user[each.key].sql)
|
||||
]
|
||||
}
|
||||
63
infra/terraform/db_users/secrets.tf
Normal file
63
infra/terraform/db_users/secrets.tf
Normal file
@@ -0,0 +1,63 @@
|
||||
resource "random_password" "db_user_secrets" {
|
||||
for_each = local.db_users
|
||||
length = 32
|
||||
numeric = true
|
||||
upper = true
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_user_secrets" {
|
||||
for_each = local.db_users
|
||||
name = "rds-db-credentials/${data.terraform_remote_state.main.outputs.rds["stage"].cluster_resource_id}/${each.key}"
|
||||
description = "RDS database ${data.terraform_remote_state.main.outputs.rds["stage"].cluster_id} credentials for ${each.key}"
|
||||
kms_key_id = data.aws_kms_key.secretsmanager.id
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_id = each.value.id
|
||||
secret_string = jsonencode({
|
||||
engine = "postgres"
|
||||
host = data.terraform_remote_state.main.outputs.rds["stage"].cluster_endpoint
|
||||
username = each.key
|
||||
password = random_password.db_user_secrets[each.key].result
|
||||
dbname = local.rds_database_name
|
||||
port = data.terraform_remote_state.main.outputs.rds["stage"].cluster_port
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_policy" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_arn = each.value.arn
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17",
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Deny",
|
||||
Principal = "*",
|
||||
Action = ["secretsmanager:GetSecretValue"],
|
||||
Resource = each.value.arn,
|
||||
Condition = {
|
||||
StringNotLike = {
|
||||
"aws:userId" = flatten(concat([
|
||||
"*:${each.key}@formbricks.com", "*:piotr@formbricks.com"
|
||||
]))
|
||||
|
||||
},
|
||||
ArnNotEquals = {
|
||||
"aws:PrincipalArn" = module.lambda_rotate_db_secret.lambda_function_arn
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_rotation" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_id = each.value.id
|
||||
rotation_lambda_arn = module.lambda_rotate_db_secret.lambda_function_arn
|
||||
rotation_rules {
|
||||
automatically_after_days = 1
|
||||
}
|
||||
}
|
||||
6
infra/terraform/db_users/variables.tf
Normal file
6
infra/terraform/db_users/variables.tf
Normal file
@@ -0,0 +1,6 @@
|
||||
#
|
||||
variable "env_name" {
|
||||
description = "env_name"
|
||||
type = string
|
||||
default = "staging"
|
||||
}
|
||||
10
infra/terraform/db_users/versions.tf
Normal file
10
infra/terraform/db_users/versions.tf
Normal file
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.46"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
infra/terraform/locals.tf
Normal file
34
infra/terraform/locals.tf
Normal file
@@ -0,0 +1,34 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
@@ -1,38 +1,3 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route53 Hosted Zone
|
||||
################################################################################
|
||||
@@ -131,7 +96,7 @@ module "ebs_csi_driver_irsa" {
|
||||
|
||||
module "eks" {
|
||||
source = "terraform-aws-modules/eks/aws"
|
||||
version = "20.33.1"
|
||||
version = "20.37.2"
|
||||
|
||||
cluster_name = "${local.name}-eks"
|
||||
cluster_version = "1.32"
|
||||
@@ -149,14 +114,14 @@ module "eks" {
|
||||
most_recent = true
|
||||
}
|
||||
aws-ebs-csi-driver = {
|
||||
most_recent = true
|
||||
addon_version = "v1.46.0-eksbuild.1"
|
||||
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
|
||||
}
|
||||
kube-proxy = {
|
||||
most_recent = true
|
||||
}
|
||||
vpc-cni = {
|
||||
most_recent = true
|
||||
addon_version = "v1.20.0-eksbuild.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,125 +243,125 @@ output "karpenter_node_role" {
|
||||
|
||||
|
||||
|
||||
resource "helm_release" "karpenter_crds" {
|
||||
name = "karpenter-crds"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter-crd"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
values = [
|
||||
<<-EOT
|
||||
webhook:
|
||||
enabled: true
|
||||
serviceNamespace: ${local.karpenter_namespace}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
# resource "helm_release" "karpenter_crds" {
|
||||
# name = "karpenter-crds"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter-crd"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# webhook:
|
||||
# enabled: true
|
||||
# serviceNamespace: ${local.karpenter_namespace}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
|
||||
resource "helm_release" "karpenter" {
|
||||
name = "karpenter"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
skip_crds = true
|
||||
# resource "helm_release" "karpenter" {
|
||||
# name = "karpenter"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# skip_crds = true
|
||||
#
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# nodeSelector:
|
||||
# karpenter.sh/controller: 'true'
|
||||
# dnsPolicy: Default
|
||||
# settings:
|
||||
# clusterName: ${module.eks.cluster_name}
|
||||
# clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
# interruptionQueue: ${module.karpenter.queue_name}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# resource "kubernetes_manifest" "ec2_node_class" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.k8s.aws/v1"
|
||||
# kind = "EC2NodeClass"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# amiSelectorTerms = [
|
||||
# {
|
||||
# alias = "bottlerocket@latest"
|
||||
# }
|
||||
# ]
|
||||
# role = module.karpenter.node_iam_role_name
|
||||
# subnetSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# securityGroupSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
values = [
|
||||
<<-EOT
|
||||
nodeSelector:
|
||||
karpenter.sh/controller: 'true'
|
||||
dnsPolicy: Default
|
||||
settings:
|
||||
clusterName: ${module.eks.cluster_name}
|
||||
clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
interruptionQueue: ${module.karpenter.queue_name}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "ec2_node_class" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.k8s.aws/v1"
|
||||
kind = "EC2NodeClass"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
amiSelectorTerms = [
|
||||
{
|
||||
alias = "bottlerocket@latest"
|
||||
}
|
||||
]
|
||||
role = module.karpenter.node_iam_role_name
|
||||
subnetSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
securityGroupSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "node_pool" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.sh/v1"
|
||||
kind = "NodePool"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
template = {
|
||||
spec = {
|
||||
nodeClassRef = {
|
||||
group = "karpenter.k8s.aws"
|
||||
kind = "EC2NodeClass"
|
||||
name = "default"
|
||||
}
|
||||
requirements = [
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-family"
|
||||
operator = "In"
|
||||
values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-cpu"
|
||||
operator = "In"
|
||||
values = ["2", "4", "8"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
operator = "In"
|
||||
values = ["nitro"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
limits = {
|
||||
cpu = 1000
|
||||
}
|
||||
disruption = {
|
||||
consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
consolidateAfter = "30s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# resource "kubernetes_manifest" "node_pool" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.sh/v1"
|
||||
# kind = "NodePool"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# template = {
|
||||
# spec = {
|
||||
# nodeClassRef = {
|
||||
# group = "karpenter.k8s.aws"
|
||||
# kind = "EC2NodeClass"
|
||||
# name = "default"
|
||||
# }
|
||||
# requirements = [
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-family"
|
||||
# operator = "In"
|
||||
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-cpu"
|
||||
# operator = "In"
|
||||
# values = ["2", "4", "8"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
# operator = "In"
|
||||
# values = ["nitro"]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
# limits = {
|
||||
# cpu = 1000
|
||||
# }
|
||||
# disruption = {
|
||||
# consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
# consolidateAfter = "30s"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
module "eks_blueprints_addons" {
|
||||
source = "aws-ia/eks-blueprints-addons/aws"
|
||||
|
||||
10
infra/terraform/outputs.tf
Normal file
10
infra/terraform/outputs.tf
Normal file
@@ -0,0 +1,10 @@
|
||||
output "rds" {
|
||||
description = "RDS created for cluster"
|
||||
value = module.rds-aurora
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "rds_secret_staging_arn" {
|
||||
description = "RDS secret created for cluster"
|
||||
value = aws_secretsmanager_secret.rds_credentials["stage"].arn
|
||||
}
|
||||
@@ -75,5 +75,4 @@ module "rds-aurora" {
|
||||
}
|
||||
|
||||
tags = local.tags_map[each.key]
|
||||
|
||||
}
|
||||
|
||||
@@ -22,3 +22,23 @@ resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "rds_credentials" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/terraform/rds/credentials"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "rds_credentials" {
|
||||
for_each = local.envs
|
||||
secret_id = aws_secretsmanager_secret.rds_credentials[each.key].id
|
||||
secret_string = <<EOF
|
||||
{
|
||||
"username": "${module.rds-aurora[each.key].cluster_master_username}",
|
||||
"password": "${random_password.postgres[each.key].result}",
|
||||
"engine": data.aws_rds_engine_version.postgresql.engine,
|
||||
"host": "${module.rds-aurora[each.key].cluster_endpoint}",
|
||||
"port": ${module.rds-aurora[each.key].cluster_port},
|
||||
"dbClusterIdentifier": "${module.rds-aurora[each.key].cluster_id}"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExpandIcon } from "@/components/icons/expand-icon";
|
||||
import { ImageDownIcon } from "@/components/icons/image-down-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
import { useState } from "preact/hooks";
|
||||
@@ -72,23 +74,9 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={"Open in new tab"}
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
27
packages/surveys/src/components/icons/expand-icon.test.tsx
Normal file
27
packages/surveys/src/components/icons/expand-icon.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/preact";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ExpandIcon } from "./expand-icon";
|
||||
|
||||
describe("ExpandIcon", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders SVG with correct attributes", () => {
|
||||
const { container } = render(<ExpandIcon />);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
expect(svg).toHaveAttribute("fill", "none");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("applies additional className", () => {
|
||||
const { container } = render(<ExpandIcon className="custom-class" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
28
packages/surveys/src/components/icons/expand-icon.tsx
Normal file
28
packages/surveys/src/components/icons/expand-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExpandIconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ExpandIcon = ({ className = "", size = 24 }: ExpandIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={cn("lucide lucide-expand", className)}>
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/preact";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ImageDownIcon } from "./image-down-icon";
|
||||
|
||||
describe("ImageDownIcon", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders SVG with correct attributes", () => {
|
||||
const { container } = render(<ImageDownIcon />);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
expect(svg).toHaveAttribute("fill", "none");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("applies additional className", () => {
|
||||
const { container } = render(<ImageDownIcon className="custom-class" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
28
packages/surveys/src/components/icons/image-down-icon.tsx
Normal file
28
packages/surveys/src/components/icons/image-down-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageDownIconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ImageDownIcon = ({ className = "", size = 24 }: ImageDownIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={cn("lucide lucide-image-down-icon lucide-image-down", className)}>
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
import { QuestionMedia } from "@/components/general/question-media";
|
||||
import { Subheader } from "@/components/general/subheader";
|
||||
import { ImageDownIcon } from "@/components/icons/image-down-icon";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getOriginalFileNameFromUrl } from "@/lib/storage";
|
||||
@@ -199,23 +200,7 @@ export function PictureSelectionQuestion({
|
||||
}}
|
||||
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
|
||||
<span className="fb-sr-only">Open in new tab</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="lucide lucide-image-down-icon lucide-image-down">
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</svg>
|
||||
<ImageDownIcon />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user