Compare commits

...

156 Commits

Author SHA1 Message Date
2c12d8b404 improvement: make query timeout const 2025-03-05 16:26:56 -08:00
64fe4187ab fix: update audit log endpoint and nginx timeout to handle lengthy queries 2025-03-05 16:19:07 -08:00
e351a16b5a Merge pull request #3184 from Infisical/feat/add-secret-approval-review-comment
feat: add secret approval review comment
2025-03-05 12:24:59 -05:00
2cfca823f2 Merge pull request #3187 from akhilmhdh/feat/connector
feat: added ca to cli
2025-03-05 10:13:27 -05:00
=
a8398a7009 feat: added ca to cli 2025-03-05 20:00:45 +05:30
8c054cedfc misc: added section for approval and rejections 2025-03-05 22:30:26 +08:00
24d4f8100c Merge pull request #3183 from akhilmhdh/feat/connector
feat: fixed cli issues in gateway
2025-03-05 08:26:04 -05:00
08f23e2d3c remove background context 2025-03-05 08:24:56 -05:00
d1ad605ac4 misc: address nit 2025-03-05 21:19:41 +08:00
9dd5857ff5 misc: minor UI 2025-03-05 19:32:26 +08:00
babbacdc96 feat: add secret approval review comment 2025-03-05 19:25:56 +08:00
=
76427f43f7 feat: fixed cli issues in gateway 2025-03-05 16:16:07 +05:30
3badcea95b added permission refresh and main context 2025-03-05 01:07:36 -05:00
1a4c0fe8d9 make heartbeat method simple + fix import 2025-03-04 23:21:26 -05:00
04f6864abc Merge pull request #3177 from Infisical/improve-secret-scanning-setup
Improvement: Clear Secret Scanning Query Params after Setup
2025-03-05 04:05:38 +04:00
fcbe0f59d2 Merge pull request #3180 from Infisical/daniel/fix-vercel-custom-envs
fix: vercel integration custom envs
2025-03-04 13:45:48 -08:00
e95b6fdeaa cleanup 2025-03-05 01:36:06 +04:00
5391bcd3b2 fix: vercel integration custom envs 2025-03-05 01:33:58 +04:00
48fd9e2a56 Merge pull request #3179 from akhilmhdh/feat/connector
feat: quick fix for quic
2025-03-04 15:52:48 -05:00
=
7b5926d865 feat: quick fix for quic 2025-03-05 02:14:00 +05:30
034123bcdf Merge pull request #3175 from Infisical/feat/grantServerAdminAccessToUsers
Allow server admins to grant server admin access to other users
2025-03-04 15:25:09 -05:00
f3786788fd Improve UserPanelTable, moved from useState to handlePopUpOpen 2025-03-04 16:54:28 -03:00
c406f6d78d Update release_build_infisical_cli.yml 2025-03-04 14:52:01 -05:00
eb66295dd4 Update release_build_infisical_cli.yml 2025-03-04 14:41:44 -05:00
798215e84c Update release_build_infisical_cli.yml 2025-03-04 14:36:39 -05:00
53f7491441 Update UpgradePlanModal message to show relevant message on user actions 2025-03-04 16:30:22 -03:00
53f6ab118b Merge pull request #3178 from akhilmhdh/feat/connector
Add QUIC to gateway
2025-03-04 14:06:42 -05:00
=
0f5a1b13a6 fix: lint and typecheck 2025-03-05 00:33:28 +05:30
5c606fe45f improvement: replace window reload with query refetch 2025-03-04 10:39:40 -08:00
bbf60169eb Update Server Admin Console documentation and add a fix for endpoint /admin-access 2025-03-04 15:29:34 -03:00
=
e004be22e3 feat: updated docker image and resolved build error 2025-03-04 23:58:31 +05:30
=
016cb4a7ba feat: completed gateway in quic mode 2025-03-04 23:55:40 +05:30
=
9bfc2a5dd2 feat: updated gateway to quic 2025-03-04 23:55:40 +05:30
72dbef97fb improvement: clear query params after setup to avoid false error messages 2025-03-04 10:14:56 -08:00
f376eaae13 Merge pull request #3174 from Infisical/feat/addFolderDescription
Add descriptions to secret folders
2025-03-04 14:56:43 -03:00
026f883d21 Merge pull request #3176 from Infisical/misc/replaced-otel-auto-instrumentation-with-manual
misc: replaced otel auto instrumentation with manual
2025-03-04 12:24:14 -05:00
e42f860261 misc: removed host metrics 2025-03-05 01:20:06 +08:00
08ec8c9b73 Fix linter issue and remove background colors from dropdown list 2025-03-04 13:58:34 -03:00
1512d4f496 Fix folder empty description issue and added icon to display it 2025-03-04 13:44:40 -03:00
9f7b42ad91 misc: replaced otel auto instrumentation with manual 2025-03-05 00:16:15 +08:00
3045477c32 Merge pull request #3169 from Infisical/bitbucket-workspace-select-fix
Fix: Address Bitbucket Configuration UI Bug Preventing Workspace Selection
2025-03-05 01:14:09 +09:00
be4adc2759 Allow server admins to grant server admin access to other users 2025-03-04 12:38:27 -03:00
4eba80905a Lint fixes 2025-03-04 10:44:26 -03:00
b023bc7442 Type fixes 2025-03-04 10:26:23 -03:00
a0029ab469 Add descriptions to secret folders 2025-03-04 10:11:20 -03:00
53605c3880 improvement: address feedback 2025-03-03 15:11:48 -08:00
e5bca5b5df Merge pull request #3171 from Infisical/remove-mention-of-affixes-for-secret-syncs
Documentation: Remove Secret Sync Affix Options Reference
2025-03-03 14:51:56 -08:00
4091bc19e9 Merge pull request #3172 from Infisical/fix/secretReminderSubmitOnModalClose
Save Secret Reminder from Modal
2025-03-03 15:25:42 -05:00
23bd048bb9 Fix delete secret reminder notification 2025-03-03 17:20:44 -03:00
17a4674821 Fix success notification message on reminder updates 2025-03-03 17:04:02 -03:00
ec9631107d Type fixes 2025-03-03 16:36:14 -03:00
3fa450b9a7 Fix for secrets reminder modal, now saving the reminder on modal close 2025-03-03 16:13:03 -03:00
3b9c62c366 Merge pull request #3153 from Infisical/daniel/secret-requests
feat(secret-sharing): secret requests
2025-03-04 04:04:39 +09:00
cb3d171d48 documentation: remove reference to secret affixes in secret syncs overview (temp) 2025-03-03 10:59:31 -08:00
c29841fbcf Merge pull request #3170 from Infisical/misc/updated-notices-doc
misc: updated notices doc
2025-03-03 13:57:06 -05:00
fcccf1bd8d misc: updated notices doc 2025-03-04 02:46:25 +08:00
4382825162 fix: address ui preventing from selecting non-default workspace 2025-03-03 10:16:15 -08:00
f80ef1dcc8 Merge pull request #3168 from Infisical/misc/add-datadog-profiler
misc: add datadog profiler
2025-03-04 01:54:07 +08:00
7abf3e3642 misc: re-added dd-trace 2025-03-04 01:51:58 +08:00
82ef35bd08 Merge remote-tracking branch 'origin/main' into misc/add-datadog-profiler 2025-03-04 01:51:13 +08:00
4eb668b5a5 misc: uninstalled dd-trace 2025-03-04 01:50:57 +08:00
18edea9f26 Merge pull request #3154 from Infisical/misc/gov-banner-and-consent-reqs
misc: add instance banner and consent support
2025-03-04 01:46:54 +08:00
787c091948 requested changes 2025-03-03 21:44:40 +04:00
ff269b1063 Update RequestedSecretsRow.tsx 2025-03-03 21:14:40 +04:00
ca0636cb25 minor fixes 2025-03-03 21:14:40 +04:00
b995358b7e fix: type fixes 2025-03-03 21:14:40 +04:00
7aaf0f4ed3 feat(secret-sharing): secret requests 2025-03-03 21:14:40 +04:00
68646bcdf8 doc: added docs 2025-03-04 00:36:42 +08:00
9989ceb6d1 misc: addressed comments 2025-03-03 23:55:11 +08:00
95d7ba5f22 misc: add datadog profiler 2025-03-03 22:39:55 +08:00
2aa6fdf983 Merge pull request #3165 from akoullick1/patch-10
Update spending-money.mdx
2025-03-02 17:47:01 -08:00
be5a32a5d6 Merge pull request #3164 from akoullick1/patch-9
Update onboarding.mdx
2025-03-02 17:45:57 -08:00
f009cd329b Update spending-money.mdx 2025-03-02 15:56:44 -08:00
e2778864e2 Update onboarding.mdx 2025-03-02 15:50:35 -08:00
ea7375b2c6 Merge pull request #3159 from akhilmhdh/fix/migration-dev
feat: added dev migration commands
2025-03-01 09:26:45 +09:00
d42566c335 Merge pull request #3158 from Infisical/fix-secret-approval-generation-when-new-key-name-with-tags
Fix: Use New Secret Key for Approval Policy Generation for Tag Resolution
2025-03-01 02:57:56 +09:00
=
45cbd9f006 feat: added dev migration commands 2025-02-28 15:37:51 +05:30
8580602ea7 Merge pull request #3156 from Infisical/feat/add-auto-redeploy-daemonset-and-statefulset
feat: add auto redeploy for daemonset and statefulset
2025-02-28 17:00:52 +09:00
7ff75cdfab Merge pull request #3150 from thomas-infisical/remove-service-token-deprecation
docs: remove service token deprecation warning
2025-02-28 13:43:57 +09:00
bd8c8871c0 fix: use new secret key value if present for tags when resolving update for secret approval 2025-02-28 13:38:42 +09:00
d5aa13b277 Merge pull request #3157 from Infisical/increase-secret-reminder-note-max-length
Improvement: Increase Secret v2 Reminder Note Max Length
2025-02-28 13:12:55 +09:00
428dc5d371 misc: add rbac/permissions for daemonsets and statefulsets 2025-02-28 13:01:45 +09:00
f1facf1f2c improvement: increase secret v2 reminder note max length 2025-02-28 12:26:30 +09:00
31dc36d4e2 misc: updated helm version 2025-02-27 16:31:00 +09:00
51f29e5357 feat: add auto redeploy for daemonset and statefulset 2025-02-27 16:26:43 +09:00
30f0f174d1 Merge pull request #3155 from akhilmhdh/feat/connector
feat: removed pool config from knex and better closing in cli
2025-02-27 10:28:26 +09:00
=
3e7110f334 feat: removed pool config from knex and better closing in cli 2025-02-27 01:32:03 +05:30
e6af7a6fb9 Merge pull request #3149 from Infisical/doc/add-ab-initio-docs
doc: add ab-initio docs
2025-02-27 02:59:50 +09:00
de420fd02c update verifyHostInputValidity 2025-02-27 02:56:28 +09:00
41a3ca149d remove on prem fet for gateway 2025-02-27 01:50:42 +09:00
da38d1a261 Merge pull request #3126 from akhilmhdh/feat/connector
Feat/connector
2025-02-27 01:20:09 +09:00
=
b0d8c8fb23 fix: resolved integration test failing 2025-02-26 21:33:49 +05:30
=
d84bac5fba feat: changes based on reviews 2025-02-26 20:45:40 +05:30
=
44f74e4d12 feat: small change 2025-02-26 20:45:40 +05:30
=
c16a4e00d8 feat: fixed feedback 2025-02-26 20:45:40 +05:30
=
11f2719842 feat: updated 2025-02-26 20:45:39 +05:30
f8153dd896 small typos 2025-02-26 20:45:39 +05:30
b104f8c07d update example to use universal auth 2025-02-26 20:45:39 +05:30
746687e5b5 update heading 2025-02-26 20:45:39 +05:30
080b1e1550 fix wording 2025-02-26 20:45:39 +05:30
38a6fd140c redo gateway docs 2025-02-26 20:45:39 +05:30
=
19d66abc38 feat: changed order 2025-02-26 20:45:39 +05:30
=
e61c0be6db fix: resolved failing test 2025-02-26 20:45:39 +05:30
=
917573931f feat: switched to projecet gateway, updated icon, cli 2025-02-26 20:45:38 +05:30
=
929a41065c feat: first doc for gateway 2025-02-26 20:45:38 +05:30
=
9b44972e77 feat: added better message on heartbeat 2025-02-26 20:45:38 +05:30
=
17e576511b feat: updated backend 2025-02-26 20:45:38 +05:30
=
afd444cad6 feat: permission changes 2025-02-26 20:45:38 +05:30
=
55b1fbdf52 feat: added validation for relay ip 2025-02-26 20:45:38 +05:30
=
46ca5c8efa feat: resolved type error from rebase 2025-02-26 20:45:38 +05:30
=
f7406ea8f8 feat: review feedback, and more changes in cli 2025-02-26 20:45:37 +05:30
=
f34370cb9d feat: completed frontend 2025-02-26 20:45:37 +05:30
=
78718cd299 feat: completed cli for gateway 2025-02-26 20:45:37 +05:30
=
1307fa49d4 feat: completed backend for gateway 2025-02-26 20:45:37 +05:30
=
a7ca242f5d feat: frontend changes for connector management 2025-02-26 20:45:37 +05:30
=
c6b3b24312 feat: gateway in cli first version\ 2025-02-26 20:45:37 +05:30
=
8520029958 feat: updated backend to new data structure for org 2025-02-26 20:45:37 +05:30
=
7905017121 feat: added gateway page in ui for org 2025-02-26 20:45:36 +05:30
=
4bbe80c083 feat: resolved permission in backend 2025-02-26 20:45:36 +05:30
=
d65ae2c61b feat: added instance gateway setup ui 2025-02-26 20:45:36 +05:30
=
84c534ef70 feat: added api for admin and org gateway management 2025-02-26 20:45:36 +05:30
ce4c5d8ea1 misc: add instance banner and consent support 2025-02-26 23:58:45 +09:00
617aa2f533 Merge pull request #3151 from akhilmhdh/fix/vite-error
Added vite error handler to resolve post build error
2025-02-26 18:21:21 +09:00
=
e9dd3340bf feat: added vite error handler to resolve post build error 2025-02-26 14:33:31 +05:30
1c2b4e91ba docs: remove service token deprecation warning 2025-02-26 13:38:36 +09:00
fb030401ab doc: add ab-initio docs 2025-02-26 13:37:19 +09:00
f4bd48fd1d Merge pull request #3142 from Infisical/sidebar-update
improve sidebars
2025-02-25 13:20:53 +04:00
177ccf6c9e Update SecretDetailSidebar.tsx 2025-02-25 18:15:27 +09:00
9200137d6c Merge pull request #3144 from Infisical/revert-3130-snyk-fix-9bc3e8652a6384afdd415f17c0d6ac68
Revert "[Snyk] Fix for 4 vulnerabilities"
2025-02-25 18:12:32 +09:00
a196028064 Revert "[Snyk] Fix for 4 vulnerabilities" 2025-02-25 18:12:12 +09:00
0c0e20f00e Merge pull request #3143 from Infisical/revert-3129-snyk-fix-e021ef688dc4b4af03b9ad04389eee3f
Revert "[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1"
2025-02-25 18:11:56 +09:00
710429c805 Revert "[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1" 2025-02-25 18:10:30 +09:00
c121bd930b fix nav 2025-02-25 18:03:13 +09:00
87d383a9c4 Update SecretDetailSidebar.tsx 2025-02-25 17:44:55 +09:00
6e590a78a0 fix lint issues 2025-02-25 17:30:15 +09:00
ab4b6c17b3 fix lint issues 2025-02-25 17:23:05 +09:00
27cd40c8ce fix lint issues 2025-02-25 17:20:52 +09:00
5f089e0b9d improve sidebars 2025-02-25 17:07:53 +09:00
19940522aa Merge pull request #3138 from Infisical/daniel/go-sdk-batch-create-docs
docs: go sdk bulk create secrets
2025-02-23 15:14:36 +09:00
28b18c1cb1 Merge pull request #3129 from Infisical/snyk-fix-e021ef688dc4b4af03b9ad04389eee3f
[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1
2025-02-23 15:13:25 +09:00
7ae2cc2db8 Merge pull request #3130 from Infisical/snyk-fix-9bc3e8652a6384afdd415f17c0d6ac68
[Snyk] Fix for 4 vulnerabilities
2025-02-23 15:12:59 +09:00
4a51b4d619 Merge pull request #3139 from akhilmhdh/fix/shared-link-min-check
feat: added min check for secret sharing
2025-02-23 15:07:40 +09:00
478e0c5ff5 Merge pull request #3134 from Infisical/aws-secrets-manager-additional-features
Feature: AWS Syncs - Additional Features
2025-02-21 08:23:06 -08:00
5c08136fca improvement: address feedback 2025-02-21 07:51:15 -08:00
cb8528adc4 merge main 2025-02-21 07:39:24 -08:00
=
d7935d30ce feat: made the function shared one 2025-02-21 14:47:04 +05:30
=
ac3bab3074 feat: added min check for secret sharing 2025-02-21 14:38:34 +05:30
63b8301065 Merge pull request #3137 from Infisical/flyio-integration-propagate-errors
Fix: Propagate Set Secrets Errors for Flyio Integration
2025-02-20 16:26:35 -08:00
babe70e00f fix: propagate set secrets error for flyio integration 2025-02-20 16:06:58 -08:00
f23ea0991c improvement: address feedback 2025-02-20 11:48:47 -08:00
f8ab2bcdfd feature: kms key, tags, and sync secret metadata support for aws secrets manager 2025-02-19 20:38:18 -08:00
9cdb4dcde9 improvement: address feedback 2025-02-19 16:52:48 -08:00
69fb87bbfc reduce max height for resource tags 2025-02-18 20:32:57 -08:00
b0cd5bd10d feature: add support for kms key, tags, and syncing secret metadata to aws parameter store sync 2025-02-18 20:28:27 -08:00
15119ffda9 fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-OCTOKITENDPOINT-8730856
- https://snyk.io/vuln/SNYK-JS-OCTOKITPLUGINPAGINATEREST-8730855
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUEST-8730853
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUESTERROR-8730854
2025-02-19 04:10:31 +00:00
4df409e627 fix: frontend/package.json & frontend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-OCTOKITENDPOINT-8730856
- https://snyk.io/vuln/SNYK-JS-OCTOKITPLUGINPAGINATEREST-8730855
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUEST-8730853
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUESTERROR-8730854
2025-02-19 03:23:48 +00:00
254 changed files with 12869 additions and 2444 deletions

View File

@ -112,4 +112,11 @@ INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL=
# azure app connection
INF_APP_CONNECTION_AZURE_CLIENT_ID=
INF_APP_CONNECTION_AZURE_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_CLIENT_SECRET=
# datadog
SHOULD_USE_DATADOG_TRACER=
DATADOG_PROFILING_ENABLED=
DATADOG_ENV=
DATADOG_SERVICE=
DATADOG_HOSTNAME=

View File

@ -32,10 +32,10 @@ jobs:
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
- name: Start the server
run: |
echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env
echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env
echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env
docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET -e ENCRYPTION_KEY=$ENCRYPTION_KEY --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs"
echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env
echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env
echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env
docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET -e ENCRYPTION_KEY=$ENCRYPTION_KEY --env-file .env --entrypoint '/bin/sh' infisical-api
env:
REDIS_URL: redis://172.17.0.1:6379
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
@ -43,7 +43,7 @@ jobs:
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
- uses: actions/setup-go@v5
with:
go-version: '1.21.5'
go-version: "1.21.5"
- name: Wait for container to be stable and check logs
run: |
SECONDS=0
@ -61,7 +61,7 @@ jobs:
sleep 2
SECONDS=$((SECONDS+2))
done
if [ $HEALTHY -ne 1 ]; then
echo "Container did not become healthy in time"
exit 1

View File

@ -26,7 +26,7 @@ jobs:
CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE: ${{ secrets.CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE }}
npm-release:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
env:
working-directory: ./npm
needs:
@ -83,7 +83,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
goreleaser:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
needs: [cli-integration-tests]
steps:
- uses: actions/checkout@v3
@ -103,11 +103,12 @@ jobs:
go-version: ">=1.19.3"
cache: true
cache-dependency-path: cli/go.sum
- name: libssl1.1 => libssl1.0-dev for OSXCross
- name: Setup for libssl1.0-dev
run: |
echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list
sudo apt update && apt-cache policy libssl1.0-dev
sudo apt-get install libssl1.0-dev
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32
sudo apt update
sudo apt-get install -y libssl1.0-dev
- name: OSXCross for CGO Support
run: |
mkdir ../../osxcross

View File

@ -161,6 +161,9 @@ COPY --from=backend-runner /app /backend
COPY --from=frontend-runner /app ./backend/frontend-build
ARG INFISICAL_PLATFORM_VERSION
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
ENV PORT 8080
ENV HOST=0.0.0.0
ENV HTTPS_ENABLED false

View File

@ -3,13 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
ARG INTERCOM_ID=intercom-id
ARG CAPTCHA_SITE_KEY=captcha-site-key
FROM node:20-alpine AS base
FROM node:20-slim AS base
FROM base AS frontend-dependencies
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
@ -45,8 +42,8 @@ RUN npm run build
FROM base AS frontend-runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 non-root-user
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 --gid nodejs non-root-user
COPY --from=frontend-builder --chown=non-root-user:nodejs /app/dist ./
@ -56,21 +53,23 @@ USER non-root-user
## BACKEND
##
FROM base AS backend-build
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 non-root-user
WORKDIR /app
# Install all required dependencies for build
RUN apk --update add \
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
unixodbc \
freetds \
freetds-bin \
unixodbc-dev \
libc-dev \
freetds-dev
freetds-dev \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 --gid nodejs non-root-user
COPY backend/package*.json ./
RUN npm ci --only-production
@ -86,18 +85,19 @@ FROM base AS backend-runner
WORKDIR /app
# Install all required dependencies for runtime
RUN apk --update add \
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
unixodbc \
freetds \
freetds-bin \
unixodbc-dev \
libc-dev \
freetds-dev
freetds-dev \
&& rm -rf /var/lib/apt/lists/*
# Configure ODBC
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini
COPY backend/package*.json ./
RUN npm ci --only-production
@ -109,34 +109,36 @@ RUN mkdir frontend-build
# Production stage
FROM base AS production
RUN apk add --upgrade --no-cache ca-certificates
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical=0.31.1 && apk add --no-cache git
WORKDIR /
# Install all required runtime dependencies
RUN apk --update add \
RUN apt-get update && apt-get install -y \
ca-certificates \
bash \
curl \
git \
python3 \
make \
g++ \
unixodbc \
freetds \
freetds-bin \
unixodbc-dev \
libc-dev \
freetds-dev \
bash \
curl \
git \
openssh
wget \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Install Infisical CLI
RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y infisical=0.31.1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /
# Configure ODBC in production
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini
# Setup user permissions
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 non-root-user
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs non-root-user
# Give non-root-user permission to update SSL certs
RUN chown -R non-root-user /etc/ssl/certs
@ -154,11 +156,11 @@ ENV INTERCOM_ID=$INTERCOM_ID
ARG CAPTCHA_SITE_KEY
ENV CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY
COPY --from=backend-runner /app /backend
COPY --from=frontend-runner /app ./backend/frontend-build
ARG INFISICAL_PLATFORM_VERSION
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION
ENV PORT 8080
ENV HOST=0.0.0.0
@ -166,6 +168,7 @@ ENV HTTPS_ENABLED false
ENV NODE_ENV production
ENV STANDALONE_BUILD true
ENV STANDALONE_MODE true
WORKDIR /backend
ENV TELEMETRY_ENABLED true

View File

@ -1,23 +1,22 @@
# Build stage
FROM node:20-alpine AS build
FROM node:20-slim AS build
WORKDIR /app
# Required for pkcs11js
RUN apk --update add \
python3 \
make \
g++ \
openssh
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
openssh-client
# install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apk add --no-cache \
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
unixodbc \
freetds \
freetds-bin \
freetds-dev \
unixodbc-dev \
libc-dev \
freetds-dev
libc-dev
COPY package*.json ./
RUN npm ci --only-production
@ -26,36 +25,36 @@ COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
FROM node:20-slim
WORKDIR /app
ENV npm_config_cache /home/node/.npm
COPY package*.json ./
RUN apk --update add \
python3 \
make \
g++
RUN apt-get update && apt-get install -y \
python3 \
make \
g++
# install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apk add --no-cache \
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
unixodbc \
freetds \
freetds-bin \
freetds-dev \
unixodbc-dev \
libc-dev \
freetds-dev
libc-dev
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
RUN npm ci --only-production && npm cache clean --force
COPY --from=build /app .
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical=0.8.1 && apk add --no-cache git
# Install Infisical CLI
RUN apt-get install -y curl bash && \
curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \
apt-get update && apt-get install -y infisical=0.8.1 git
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
CMD node healthcheck.js

View File

@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:20-slim
# ? Setup a test SoftHSM module. In production a real HSM is used.
@ -7,32 +7,32 @@ ARG SOFTHSM2_VERSION=2.5.0
ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \
SOFTHSM2_SOURCES=/tmp/softhsm2
# install build dependencies including python3 (required for pkcs11js and partially TDS driver)
RUN apk --update add \
alpine-sdk \
autoconf \
automake \
git \
libtool \
openssl-dev \
python3 \
make \
g++ \
openssh
# Install build dependencies including python3 (required for pkcs11js and partially TDS driver)
RUN apt-get update && apt-get install -y \
build-essential \
autoconf \
automake \
git \
libtool \
libssl-dev \
python3 \
make \
g++ \
openssh-client \
curl \
pkg-config
# install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apk add --no-cache \
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
unixodbc \
freetds \
unixodbc-dev \
libc-dev \
freetds-dev
freetds-dev \
freetds-bin \
tdsodbc
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
# build and install SoftHSM2
# Build and install SoftHSM2
RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES}
WORKDIR ${SOFTHSM2_SOURCES}
@ -45,16 +45,18 @@ RUN git checkout ${SOFTHSM2_VERSION} -b ${SOFTHSM2_VERSION} \
WORKDIR /root
RUN rm -fr ${SOFTHSM2_SOURCES}
# install pkcs11-tool
RUN apk --update add opensc
# Install pkcs11-tool
RUN apt-get install -y opensc
RUN softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000
RUN mkdir -p /etc/softhsm2/tokens && \
softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000
# ? App setup
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical=0.8.1 && apk add --no-cache git
# Install Infisical CLI
RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \
apt-get update && \
apt-get install -y infisical=0.8.1
WORKDIR /app

2239
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,13 @@
"migration:status": "npm run auditlog-migration:status && knex --knexfile ./dist/db/knexfile.mjs --client pg migrate:status",
"migration:rollback": "npm run auditlog-migration:rollback && knex --knexfile ./dist/db/knexfile.mjs migrate:rollback",
"migration:unlock": "npm run auditlog-migration:unlock && knex --knexfile ./dist/db/knexfile.mjs migrate:unlock",
"migration:up-dev": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:up",
"migration:down-dev": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:down",
"migration:list-dev": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:list",
"migration:latest-dev": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest",
"migration:status-dev": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:status",
"migration:rollback-dev": "knex --knexfile ./src/db/knexfile.ts migrate:rollback",
"migration:unlock-dev": "knex --knexfile ./src/db/knexfile.ts migrate:unlock",
"migrate:org": "tsx ./scripts/migrate-organization.ts",
"seed:new": "tsx ./scripts/create-seed-file.ts",
"seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run",
@ -138,6 +145,7 @@
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.1.0",
"@google-cloud/kms": "^4.5.0",
"@infisical/quic": "^1.0.8",
"@node-saml/passport-saml": "^4.0.4",
"@octokit/auth-app": "^7.1.1",
"@octokit/plugin-retry": "^5.0.5",
@ -145,10 +153,10 @@
"@octokit/webhooks-types": "^7.3.1",
"@octopusdeploy/api-client": "^3.4.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.53.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.55.0",
"@opentelemetry/exporter-prometheus": "^0.55.0",
"@opentelemetry/instrumentation": "^0.55.0",
"@opentelemetry/instrumentation-http": "^0.57.2",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-metrics": "^1.28.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
@ -169,6 +177,7 @@
"cassandra-driver": "^4.7.2",
"connect-redis": "^7.1.1",
"cron": "^3.1.7",
"dd-trace": "^5.40.0",
"dotenv": "^16.4.1",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
@ -177,6 +186,7 @@
"handlebars": "^4.7.8",
"hdb": "^0.19.10",
"ioredis": "^5.3.2",
"isomorphic-dompurify": "^2.22.0",
"jmespath": "^0.16.0",
"jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4",

View File

@ -13,6 +13,7 @@ import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGroupServiceFactory } from "@app/ee/services/group/group-service";
import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service";
@ -228,6 +229,7 @@ declare module "fastify" {
secretSync: TSecretSyncServiceFactory;
kmip: TKmipServiceFactory;
kmipOperation: TKmipOperationServiceFactory;
gateway: TGatewayServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@ -68,6 +68,9 @@ import {
TExternalKms,
TExternalKmsInsert,
TExternalKmsUpdate,
TGateways,
TGatewaysInsert,
TGatewaysUpdate,
TGitAppInstallSessions,
TGitAppInstallSessionsInsert,
TGitAppInstallSessionsUpdate,
@ -179,6 +182,9 @@ import {
TOrgBots,
TOrgBotsInsert,
TOrgBotsUpdate,
TOrgGatewayConfig,
TOrgGatewayConfigInsert,
TOrgGatewayConfigUpdate,
TOrgMemberships,
TOrgMembershipsInsert,
TOrgMembershipsUpdate,
@ -200,6 +206,9 @@ import {
TProjectEnvironments,
TProjectEnvironmentsInsert,
TProjectEnvironmentsUpdate,
TProjectGateways,
TProjectGatewaysInsert,
TProjectGatewaysUpdate,
TProjectKeys,
TProjectKeysInsert,
TProjectKeysUpdate,
@ -930,5 +939,16 @@ declare module "knex/types/tables" {
TKmipClientCertificatesInsert,
TKmipClientCertificatesUpdate
>;
[TableName.Gateway]: KnexOriginal.CompositeTableType<TGateways, TGatewaysInsert, TGatewaysUpdate>;
[TableName.ProjectGateway]: KnexOriginal.CompositeTableType<
TProjectGateways,
TProjectGatewaysInsert,
TProjectGatewaysUpdate
>;
[TableName.OrgGatewayConfig]: KnexOriginal.CompositeTableType<
TOrgGatewayConfig,
TOrgGatewayConfigInsert,
TOrgGatewayConfigUpdate
>;
}
}

View File

@ -39,7 +39,7 @@ export default {
},
migrations: {
tableName: "infisical_migrations",
loadExtensions: [".mjs"]
loadExtensions: [".mjs", ".ts"]
}
},
production: {
@ -64,7 +64,7 @@ export default {
},
migrations: {
tableName: "infisical_migrations",
loadExtensions: [".mjs"]
loadExtensions: [".mjs", ".ts"]
}
}
} as Knex.Config;

View File

@ -0,0 +1,115 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.OrgGatewayConfig))) {
await knex.schema.createTable(TableName.OrgGatewayConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("rootCaKeyAlgorithm").notNullable();
t.datetime("rootCaIssuedAt").notNullable();
t.datetime("rootCaExpiration").notNullable();
t.string("rootCaSerialNumber").notNullable();
t.binary("encryptedRootCaCertificate").notNullable();
t.binary("encryptedRootCaPrivateKey").notNullable();
t.datetime("clientCaIssuedAt").notNullable();
t.datetime("clientCaExpiration").notNullable();
t.string("clientCaSerialNumber");
t.binary("encryptedClientCaCertificate").notNullable();
t.binary("encryptedClientCaPrivateKey").notNullable();
t.string("clientCertSerialNumber").notNullable();
t.string("clientCertKeyAlgorithm").notNullable();
t.datetime("clientCertIssuedAt").notNullable();
t.datetime("clientCertExpiration").notNullable();
t.binary("encryptedClientCertificate").notNullable();
t.binary("encryptedClientPrivateKey").notNullable();
t.datetime("gatewayCaIssuedAt").notNullable();
t.datetime("gatewayCaExpiration").notNullable();
t.string("gatewayCaSerialNumber").notNullable();
t.binary("encryptedGatewayCaCertificate").notNullable();
t.binary("encryptedGatewayCaPrivateKey").notNullable();
t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
t.unique("orgId");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.OrgGatewayConfig);
}
if (!(await knex.schema.hasTable(TableName.Gateway))) {
await knex.schema.createTable(TableName.Gateway, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("serialNumber").notNullable();
t.string("keyAlgorithm").notNullable();
t.datetime("issuedAt").notNullable();
t.datetime("expiration").notNullable();
t.datetime("heartbeat");
t.binary("relayAddress").notNullable();
t.uuid("orgGatewayRootCaId").notNullable();
t.foreign("orgGatewayRootCaId").references("id").inTable(TableName.OrgGatewayConfig).onDelete("CASCADE");
t.uuid("identityId").notNullable();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.Gateway);
}
if (!(await knex.schema.hasTable(TableName.ProjectGateway))) {
await knex.schema.createTable(TableName.ProjectGateway, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("gatewayId").notNullable();
t.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("CASCADE");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.ProjectGateway);
}
if (await knex.schema.hasTable(TableName.DynamicSecret)) {
const doesGatewayColExist = await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId");
await knex.schema.alterTable(TableName.DynamicSecret, (t) => {
// not setting a foreign constraint so that cascade effects are not triggered
if (!doesGatewayColExist) {
t.uuid("projectGatewayId");
t.foreign("projectGatewayId").references("id").inTable(TableName.ProjectGateway);
}
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.DynamicSecret)) {
const doesGatewayColExist = await knex.schema.hasColumn(TableName.DynamicSecret, "projectGatewayId");
await knex.schema.alterTable(TableName.DynamicSecret, (t) => {
if (doesGatewayColExist) t.dropColumn("projectGatewayId");
});
}
await knex.schema.dropTableIfExists(TableName.ProjectGateway);
await dropOnUpdateTrigger(knex, TableName.ProjectGateway);
await knex.schema.dropTableIfExists(TableName.Gateway);
await dropOnUpdateTrigger(knex, TableName.Gateway);
await knex.schema.dropTableIfExists(TableName.OrgGatewayConfig);
await dropOnUpdateTrigger(knex, TableName.OrgGatewayConfig);
}

View File

@ -0,0 +1,25 @@
import { Knex } from "knex";
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type");
await knex.schema.alterTable(TableName.SecretSharing, (table) => {
if (!hasSharingTypeColumn) {
table.string("type", 32).defaultTo(SecretSharingType.Share).notNullable();
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type");
await knex.schema.alterTable(TableName.SecretSharing, (table) => {
if (hasSharingTypeColumn) {
table.dropColumn("type");
}
});
}

View File

@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasAuthConsentContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "authConsentContent");
const hasPageFrameContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "pageFrameContent");
if (await knex.schema.hasTable(TableName.SuperAdmin)) {
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (!hasAuthConsentContentCol) {
t.text("authConsentContent");
}
if (!hasPageFrameContentCol) {
t.text("pageFrameContent");
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasAuthConsentContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "authConsentContent");
const hasPageFrameContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "pageFrameContent");
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (hasAuthConsentContentCol) {
t.dropColumn("authConsentContent");
}
if (hasPageFrameContentCol) {
t.dropColumn("pageFrameContent");
}
});
}

View File

@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
for await (const tableName of [
TableName.SecretV2,
TableName.SecretVersionV2,
TableName.SecretApprovalRequestSecretV2
]) {
const hasReminderNoteCol = await knex.schema.hasColumn(tableName, "reminderNote");
if (hasReminderNoteCol) {
await knex.schema.alterTable(tableName, (t) => {
t.string("reminderNote", 1024).alter();
});
}
}
}
export async function down(knex: Knex): Promise<void> {
for await (const tableName of [
TableName.SecretV2,
TableName.SecretVersionV2,
TableName.SecretApprovalRequestSecretV2
]) {
const hasReminderNoteCol = await knex.schema.hasColumn(tableName, "reminderNote");
if (hasReminderNoteCol) {
await knex.schema.alterTable(tableName, (t) => {
t.string("reminderNote").alter();
});
}
}
}

View File

@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
const hasProjectDescription = await knex.schema.hasColumn(TableName.SecretFolder, "description");
if (!hasProjectDescription) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.string("description");
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasProjectDescription = await knex.schema.hasColumn(TableName.SecretFolder, "description");
if (hasProjectDescription) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.dropColumn("description");
});
}
}

View File

@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "comment"))) {
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (t) => {
t.string("comment");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "comment")) {
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (t) => {
t.dropColumn("comment");
});
}
}

View File

@ -26,7 +26,8 @@ export const DynamicSecretsSchema = z.object({
statusDetails: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
encryptedInput: zodBuffer
encryptedInput: zodBuffer,
projectGatewayId: z.string().uuid().nullable().optional()
});
export type TDynamicSecrets = z.infer<typeof DynamicSecretsSchema>;

View File

@ -0,0 +1,29 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const GatewaysSchema = z.object({
id: z.string().uuid(),
name: z.string(),
serialNumber: z.string(),
keyAlgorithm: z.string(),
issuedAt: z.date(),
expiration: z.date(),
heartbeat: z.date().nullable().optional(),
relayAddress: zodBuffer,
orgGatewayRootCaId: z.string().uuid(),
identityId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TGateways = z.infer<typeof GatewaysSchema>;
export type TGatewaysInsert = Omit<z.input<typeof GatewaysSchema>, TImmutableDBKeys>;
export type TGatewaysUpdate = Partial<Omit<z.input<typeof GatewaysSchema>, TImmutableDBKeys>>;

View File

@ -20,6 +20,7 @@ export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";
export * from "./external-kms";
export * from "./gateways";
export * from "./git-app-install-sessions";
export * from "./git-app-org";
export * from "./group-project-membership-roles";
@ -57,6 +58,7 @@ export * from "./ldap-group-maps";
export * from "./models";
export * from "./oidc-configs";
export * from "./org-bots";
export * from "./org-gateway-config";
export * from "./org-memberships";
export * from "./org-roles";
export * from "./organizations";
@ -65,6 +67,7 @@ export * from "./pki-collection-items";
export * from "./pki-collections";
export * from "./project-bots";
export * from "./project-environments";
export * from "./project-gateways";
export * from "./project-keys";
export * from "./project-memberships";
export * from "./project-roles";

View File

@ -113,6 +113,10 @@ export enum TableName {
SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2",
SnapshotSecretV2 = "secret_snapshot_secrets_v2",
ProjectSplitBackfillIds = "project_split_backfill_ids",
// Gateway
OrgGatewayConfig = "org_gateway_config",
Gateway = "gateways",
ProjectGateway = "project_gateways",
// junction tables with tags
SecretV2JnTag = "secret_v2_tag_junction",
JnSecretTag = "secret_tag_junction",

View File

@ -0,0 +1,43 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const OrgGatewayConfigSchema = z.object({
id: z.string().uuid(),
rootCaKeyAlgorithm: z.string(),
rootCaIssuedAt: z.date(),
rootCaExpiration: z.date(),
rootCaSerialNumber: z.string(),
encryptedRootCaCertificate: zodBuffer,
encryptedRootCaPrivateKey: zodBuffer,
clientCaIssuedAt: z.date(),
clientCaExpiration: z.date(),
clientCaSerialNumber: z.string().nullable().optional(),
encryptedClientCaCertificate: zodBuffer,
encryptedClientCaPrivateKey: zodBuffer,
clientCertSerialNumber: z.string(),
clientCertKeyAlgorithm: z.string(),
clientCertIssuedAt: z.date(),
clientCertExpiration: z.date(),
encryptedClientCertificate: zodBuffer,
encryptedClientPrivateKey: zodBuffer,
gatewayCaIssuedAt: z.date(),
gatewayCaExpiration: z.date(),
gatewayCaSerialNumber: z.string(),
encryptedGatewayCaCertificate: zodBuffer,
encryptedGatewayCaPrivateKey: zodBuffer,
orgId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TOrgGatewayConfig = z.infer<typeof OrgGatewayConfigSchema>;
export type TOrgGatewayConfigInsert = Omit<z.input<typeof OrgGatewayConfigSchema>, TImmutableDBKeys>;
export type TOrgGatewayConfigUpdate = Partial<Omit<z.input<typeof OrgGatewayConfigSchema>, TImmutableDBKeys>>;

View File

@ -0,0 +1,20 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const ProjectGatewaysSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
gatewayId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TProjectGateways = z.infer<typeof ProjectGatewaysSchema>;
export type TProjectGatewaysInsert = Omit<z.input<typeof ProjectGatewaysSchema>, TImmutableDBKeys>;
export type TProjectGatewaysUpdate = Partial<Omit<z.input<typeof ProjectGatewaysSchema>, TImmutableDBKeys>>;

View File

@ -13,7 +13,8 @@ export const SecretApprovalRequestsReviewersSchema = z.object({
requestId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
reviewerUserId: z.string().uuid()
reviewerUserId: z.string().uuid(),
comment: z.string().nullable().optional()
});
export type TSecretApprovalRequestsReviewers = z.infer<typeof SecretApprovalRequestsReviewersSchema>;

View File

@ -15,7 +15,8 @@ export const SecretFoldersSchema = z.object({
updatedAt: z.date(),
envId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
isReserved: z.boolean().default(false).nullable().optional()
isReserved: z.boolean().default(false).nullable().optional(),
description: z.string().nullable().optional()
});
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;

View File

@ -12,6 +12,7 @@ import { TImmutableDBKeys } from "./models";
export const SecretSharingSchema = z.object({
id: z.string().uuid(),
encryptedValue: z.string().nullable().optional(),
type: z.string(),
iv: z.string().nullable().optional(),
tag: z.string().nullable().optional(),
hashedHex: z.string().nullable().optional(),

View File

@ -23,7 +23,9 @@ export const SuperAdminSchema = z.object({
defaultAuthOrgId: z.string().uuid().nullable().optional(),
enabledLoginMethods: z.string().array().nullable().optional(),
encryptedSlackClientId: zodBuffer.nullable().optional(),
encryptedSlackClientSecret: zodBuffer.nullable().optional()
encryptedSlackClientSecret: zodBuffer.nullable().optional(),
authConsentContent: z.string().nullable().optional(),
pageFrameContent: z.string().nullable().optional()
});
export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;

View File

@ -0,0 +1,265 @@
import { z } from "zod";
import { GatewaysSchema } from "@app/db/schemas";
import { isValidIp } from "@app/lib/ip";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
const SanitizedGatewaySchema = GatewaysSchema.pick({
id: true,
identityId: true,
name: true,
createdAt: true,
updatedAt: true,
issuedAt: true,
serialNumber: true,
heartbeat: true
});
const isValidRelayAddress = (relayAddress: string) => {
const [ip, port] = relayAddress.split(":");
return isValidIp(ip) && Number(port) <= 65535 && Number(port) >= 40000;
};
export const registerGatewayRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/register-identity",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
turnServerUsername: z.string(),
turnServerPassword: z.string(),
turnServerRealm: z.string(),
turnServerAddress: z.string(),
infisicalStaticIp: z.string().optional()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const relayDetails = await server.services.gateway.getGatewayRelayDetails(
req.permission.id,
req.permission.orgId,
req.permission.authMethod
);
return relayDetails;
}
});
server.route({
method: "POST",
url: "/exchange-cert",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
relayAddress: z.string().refine(isValidRelayAddress, { message: "Invalid relay address" })
}),
response: {
200: z.object({
serialNumber: z.string(),
privateKey: z.string(),
certificate: z.string(),
certificateChain: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const gatewayCertificates = await server.services.gateway.exchangeAllocatedRelayAddress({
identityOrg: req.permission.orgId,
identityId: req.permission.id,
relayAddress: req.body.relayAddress,
identityOrgAuthMethod: req.permission.authMethod
});
return gatewayCertificates;
}
});
server.route({
method: "POST",
url: "/heartbeat",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
await server.services.gateway.heartbeat({
orgPermission: req.permission
});
return { message: "Successfully registered heartbeat" };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
projectId: z.string().optional()
}),
response: {
200: z.object({
gateways: SanitizedGatewaySchema.extend({
identity: z.object({
name: z.string(),
id: z.string()
}),
projects: z
.object({
name: z.string(),
id: z.string(),
slug: z.string()
})
.array()
}).array()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateways = await server.services.gateway.listGateways({
orgPermission: req.permission
});
return { gateways };
}
});
server.route({
method: "GET",
url: "/projects/:projectId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectId: z.string()
}),
response: {
200: z.object({
gateways: SanitizedGatewaySchema.extend({
identity: z.object({
name: z.string(),
id: z.string()
}),
projectGatewayId: z.string()
}).array()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateways = await server.services.gateway.getProjectGateways({
projectId: req.params.projectId,
projectPermission: req.permission
});
return { gateways };
}
});
server.route({
method: "GET",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
gateway: SanitizedGatewaySchema.extend({
identity: z.object({
name: z.string(),
id: z.string()
})
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateway = await server.services.gateway.getGatewayById({
orgPermission: req.permission,
id: req.params.id
});
return { gateway };
}
});
server.route({
method: "PATCH",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
body: z.object({
name: slugSchema({ field: "name" }).optional(),
projectIds: z.string().array().optional()
}),
response: {
200: z.object({
gateway: SanitizedGatewaySchema
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateway = await server.services.gateway.updateGatewayById({
orgPermission: req.permission,
id: req.params.id,
name: req.body.name,
projectIds: req.body.projectIds
});
return { gateway };
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
gateway: SanitizedGatewaySchema
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateway = await server.services.gateway.deleteGatewayById({
orgPermission: req.permission,
id: req.params.id
});
return { gateway };
}
});
};

View File

@ -7,6 +7,7 @@ import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
import { registerExternalKmsRouter } from "./external-kms-router";
import { registerGatewayRouter } from "./gateway-router";
import { registerGroupRouter } from "./group-router";
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerKmipRouter } from "./kmip-router";
@ -67,6 +68,8 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
{ prefix: "/dynamic-secrets" }
);
await server.register(registerGatewayRouter, { prefix: "/gateways" });
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" });

View File

@ -159,7 +159,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
id: z.string()
}),
body: z.object({
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED])
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]),
comment: z.string().optional()
}),
response: {
200: z.object({
@ -175,8 +176,25 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
approvalId: req.params.id,
status: req.body.status
status: req.body.status,
comment: req.body.comment
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: review.projectId,
event: {
type: EventType.SECRET_APPROVAL_REQUEST_REVIEW,
metadata: {
secretApprovalRequestId: review.requestId,
reviewedBy: review.reviewerUserId,
status: review.status as ApprovalStatus,
comment: review.comment || ""
}
}
});
return { review };
}
});
@ -235,7 +253,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
const tagSchema = SecretTagsSchema.pick({
id: true,
slug: true,
name: true,
color: true
})
.array()
@ -268,7 +285,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(),
committerUser: approvalRequestUser,
reviewers: approvalRequestUser.extend({ status: z.string() }).array(),
reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(),
secretPath: z.string(),
commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true })

View File

@ -35,7 +35,6 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => {
tags: SecretTagsSchema.pick({
id: true,
slug: true,
name: true,
color: true
}).array()
})

View File

@ -22,6 +22,7 @@ import {
} from "@app/services/secret-sync/secret-sync-types";
import { KmipPermission } from "../kmip/kmip-enum";
import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types";
export type TListProjectAuditLogDTO = {
filter: {
@ -165,6 +166,7 @@ export enum EventType {
SECRET_APPROVAL_REQUEST = "secret-approval-request",
SECRET_APPROVAL_CLOSED = "secret-approval-closed",
SECRET_APPROVAL_REOPENED = "secret-approval-reopened",
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review",
SIGN_SSH_KEY = "sign-ssh-key",
ISSUE_SSH_CREDS = "issue-ssh-creds",
CREATE_SSH_CA = "create-ssh-certificate-authority",
@ -250,6 +252,7 @@ export enum EventType {
UPDATE_APP_CONNECTION = "update-app-connection",
DELETE_APP_CONNECTION = "delete-app-connection",
CREATE_SHARED_SECRET = "create-shared-secret",
CREATE_SECRET_REQUEST = "create-secret-request",
DELETE_SHARED_SECRET = "delete-shared-secret",
READ_SHARED_SECRET = "read-shared-secret",
GET_SECRET_SYNCS = "get-secret-syncs",
@ -1141,6 +1144,7 @@ interface CreateFolderEvent {
folderId: string;
folderName: string;
folderPath: string;
description?: string;
};
}
@ -1312,6 +1316,16 @@ interface SecretApprovalRequest {
};
}
interface SecretApprovalRequestReview {
type: EventType.SECRET_APPROVAL_REQUEST_REVIEW;
metadata: {
secretApprovalRequestId: string;
reviewedBy: string;
status: ApprovalStatus;
comment: string;
};
}
interface SignSshKey {
type: EventType.SIGN_SSH_KEY;
metadata: {
@ -2020,6 +2034,15 @@ interface CreateSharedSecretEvent {
};
}
interface CreateSecretRequestEvent {
type: EventType.CREATE_SECRET_REQUEST;
metadata: {
id: string;
accessType: string;
name?: string;
};
}
interface DeleteSharedSecretEvent {
type: EventType.DELETE_SHARED_SECRET;
metadata: {
@ -2470,4 +2493,6 @@ export type Event =
| KmipOperationActivateEvent
| KmipOperationRevokeEvent
| KmipOperationLocateEvent
| KmipOperationRegisterEvent;
| KmipOperationRegisterEvent
| CreateSecretRequestEvent
| SecretApprovalRequestReview;

View File

@ -1,20 +1,31 @@
import crypto from "node:crypto";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { getDbConnectionHost } from "@app/lib/knex";
export const verifyHostInputValidity = (host: string) => {
export const verifyHostInputValidity = (host: string, isGateway = false) => {
const appCfg = getConfig();
const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
// no need for validation when it's dev
if (appCfg.NODE_ENV === "development") return;
if (host === "host.docker.internal") throw new BadRequestError({ message: "Invalid db host" });
if (
appCfg.isCloud &&
!isGateway &&
// localhost
// internal ips
(host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/))
(host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/))
)
throw new BadRequestError({ message: "Invalid db host" });
if (host === "localhost" || host === "127.0.0.1" || dbHost === host) {
if (
host === "localhost" ||
host === "127.0.0.1" ||
(dbHost?.length === host.length && crypto.timingSafeEqual(Buffer.from(dbHost || ""), Buffer.from(host)))
) {
throw new BadRequestError({ message: "Invalid db host" });
}
};

View File

@ -16,6 +16,7 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold
import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue";
import { TProjectGatewayDALFactory } from "../gateway/project-gateway-dal";
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
import {
DynamicSecretStatus,
@ -44,6 +45,7 @@ type TDynamicSecretServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
projectGatewayDAL: Pick<TProjectGatewayDALFactory, "findOne">;
};
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
@ -57,7 +59,8 @@ export const dynamicSecretServiceFactory = ({
permissionService,
dynamicSecretQueueService,
projectDAL,
kmsService
kmsService,
projectGatewayDAL
}: TDynamicSecretServiceFactoryDep) => {
const create = async ({
path,
@ -108,6 +111,18 @@ export const dynamicSecretServiceFactory = ({
const selectedProvider = dynamicSecretProviders[provider.type];
const inputs = await selectedProvider.validateProviderInputs(provider.inputs);
let selectedGatewayId: string | null = null;
if (inputs && typeof inputs === "object" && "projectGatewayId" in inputs && inputs.projectGatewayId) {
const projectGatewayId = inputs.projectGatewayId as string;
const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId });
if (!projectGateway)
throw new NotFoundError({
message: `Project gateway with ${projectGatewayId} not found`
});
selectedGatewayId = projectGateway.id;
}
const isConnected = await selectedProvider.validateConnection(provider.inputs);
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
@ -123,7 +138,8 @@ export const dynamicSecretServiceFactory = ({
maxTTL,
defaultTTL,
folderId: folder.id,
name
name,
projectGatewayId: selectedGatewayId
});
return dynamicSecretCfg;
};
@ -195,6 +211,23 @@ export const dynamicSecretServiceFactory = ({
const newInput = { ...decryptedStoredInput, ...(inputs || {}) };
const updatedInput = await selectedProvider.validateProviderInputs(newInput);
let selectedGatewayId: string | null = null;
if (
updatedInput &&
typeof updatedInput === "object" &&
"projectGatewayId" in updatedInput &&
updatedInput?.projectGatewayId
) {
const projectGatewayId = updatedInput.projectGatewayId as string;
const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId });
if (!projectGateway)
throw new NotFoundError({
message: `Project gateway with ${projectGatewayId} not found`
});
selectedGatewayId = projectGateway.id;
}
const isConnected = await selectedProvider.validateConnection(newInput);
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
@ -204,7 +237,8 @@ export const dynamicSecretServiceFactory = ({
defaultTTL,
name: newName ?? name,
status: null,
statusDetails: null
statusDetails: null,
projectGatewayId: selectedGatewayId
});
return updatedDynamicCfg;

View File

@ -1,5 +1,6 @@
import { SnowflakeProvider } from "@app/ee/services/dynamic-secret/providers/snowflake";
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
@ -16,8 +17,14 @@ import { SapHanaProvider } from "./sap-hana";
import { SqlDatabaseProvider } from "./sql-database";
import { TotpProvider } from "./totp";
export const buildDynamicSecretProviders = (): Record<DynamicSecretProviders, TDynamicProviderFns> => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(),
type TBuildDynamicSecretProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTls">;
};
export const buildDynamicSecretProviders = ({
gatewayService
}: TBuildDynamicSecretProviderDTO): Record<DynamicSecretProviders, TDynamicProviderFns> => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider({ gatewayService }),
[DynamicSecretProviders.Cassandra]: CassandraProvider(),
[DynamicSecretProviders.AwsIam]: AwsIamProvider(),
[DynamicSecretProviders.Redis]: RedisDatabaseProvider(),

View File

@ -103,7 +103,8 @@ export const DynamicSecretSqlDBSchema = z.object({
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional()
ca: z.string().optional(),
projectGatewayId: z.string().nullable().optional()
});
export const DynamicSecretCassandraSchema = z.object({

View File

@ -3,8 +3,10 @@ import knex from "knex";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { withGatewayProxy } from "@app/lib/gateway";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models";
@ -25,10 +27,14 @@ const generateUsername = (provider: SqlProviders) => {
return alphaNumericNanoId(32);
};
export const SqlDatabaseProvider = (): TDynamicProviderFns => {
type TSqlDatabaseProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTls">;
};
export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host);
verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId));
return providerInputs;
};
@ -45,7 +51,6 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
user: providerInputs.username,
password: providerInputs.password,
ssl,
pool: { min: 0, max: 1 },
// @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver
// https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66
// https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19
@ -61,61 +66,112 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
return db;
};
const gatewayProxyWrapper = async (
providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>,
gatewayCallback: (host: string, port: number) => Promise<void>
) => {
const relayDetails = await gatewayService.fnGetGatewayClientTls(providerInputs.projectGatewayId as string);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
await withGatewayProxy(
async (port) => {
await gatewayCallback("localhost", port);
},
{
targetHost: providerInputs.host,
targetPort: providerInputs.port,
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const db = await $getClient(providerInputs);
// oracle needs from keyword
const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1";
let isConnected = false;
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host });
// oracle needs from keyword
const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1";
const isConnected = await db.raw(testStatement).then(() => true);
await db.destroy();
isConnected = await db.raw(testStatement).then(() => true);
await db.destroy();
};
if (providerInputs.projectGatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return isConnected;
};
const create = async (inputs: unknown, expireAt: number) => {
const providerInputs = await validateProviderInputs(inputs);
const db = await $getClient(providerInputs);
const username = generateUsername(providerInputs.client);
const password = generatePassword(providerInputs.client);
const { database } = providerInputs;
const expiration = new Date(expireAt).toISOString();
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host });
try {
const { database } = providerInputs;
const expiration = new Date(expireAt).toISOString();
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username,
password,
expiration,
database
});
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username,
password,
expiration,
database
});
const queries = creationStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
const queries = creationStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
} finally {
await db.destroy();
}
});
await db.destroy();
};
if (providerInputs.projectGatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs);
const db = await $getClient(providerInputs);
const username = entityId;
const { database } = providerInputs;
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database });
const queries = revokeStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host });
try {
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database });
const queries = revokeStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
} finally {
await db.destroy();
}
});
await db.destroy();
};
if (providerInputs.projectGatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return { entityId: username };
};
@ -123,28 +179,35 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
const providerInputs = await validateProviderInputs(inputs);
if (!providerInputs.renewStatement) return { entityId };
const db = await $getClient(providerInputs);
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host });
const expiration = new Date(expireAt).toISOString();
const { database } = providerInputs;
const expiration = new Date(expireAt).toISOString();
const { database } = providerInputs;
const renewStatement = handlebars.compile(providerInputs.renewStatement)({
username: entityId,
expiration,
database
});
if (renewStatement) {
const queries = renewStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
const renewStatement = handlebars.compile(providerInputs.renewStatement)({
username: entityId,
expiration,
database
});
try {
if (renewStatement) {
const queries = renewStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
}
} finally {
await db.destroy();
}
};
if (providerInputs.projectGatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
await db.destroy();
return { entityId };
};

View File

@ -0,0 +1,86 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { GatewaysSchema, TableName, TGateways } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import {
buildFindFilter,
ormify,
selectAllTableCols,
sqlNestRelationships,
TFindFilter,
TFindOpt
} from "@app/lib/knex";
export type TGatewayDALFactory = ReturnType<typeof gatewayDALFactory>;
export const gatewayDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.Gateway);
const find = async (filter: TFindFilter<TGateways>, { offset, limit, sort, tx }: TFindOpt<TGateways> = {}) => {
try {
const query = (tx || db)(TableName.Gateway)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter))
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`)
.leftJoin(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`)
.leftJoin(TableName.Project, `${TableName.Project}.id`, `${TableName.ProjectGateway}.projectId`)
.select(selectAllTableCols(TableName.Gateway))
.select(
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug"),
db.ref("id").withSchema(TableName.Project).as("projectId")
);
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const docs = await query;
return sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (data) => ({
...GatewaysSchema.parse(data),
identity: { id: data.identityId, name: data.identityName }
}),
childrenMapper: [
{
key: "projectId",
label: "projects" as const,
mapper: ({ projectId, projectName, projectSlug }) => ({
id: projectId,
name: projectName,
slug: projectSlug
})
}
]
});
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.Gateway}: Find` });
}
};
const findByProjectId = async (projectId: string, tx?: Knex) => {
try {
const query = (tx || db)(TableName.Gateway)
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`)
.join(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`)
.select(selectAllTableCols(TableName.Gateway))
.select(
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.ref("id").withSchema(TableName.ProjectGateway).as("projectGatewayId")
)
.where({ [`${TableName.ProjectGateway}.projectId` as "projectId"]: projectId });
const docs = await query;
return docs.map((el) => ({ ...el, identity: { id: el.identityId, name: el.identityName } }));
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.Gateway}: Find by project id` });
}
};
return { ...orm, find, findByProjectId };
};

View File

@ -0,0 +1,652 @@
import crypto from "node:crypto";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { z } from "zod";
import { ActionProjectType } from "@app/db/schemas";
import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { pingGatewayAndVerify } from "@app/lib/gateway";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { getTurnCredentials } from "@app/lib/turn/credentials";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import {
createSerialNumber,
keyAlgorithmToAlgCfg
} from "@app/services/certificate-authority/certificate-authority-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TGatewayDALFactory } from "./gateway-dal";
import {
TExchangeAllocatedRelayAddressDTO,
TGetGatewayByIdDTO,
TGetProjectGatewayByIdDTO,
THeartBeatDTO,
TListGatewaysDTO,
TUpdateGatewayByIdDTO
} from "./gateway-types";
import { TOrgGatewayConfigDALFactory } from "./org-gateway-config-dal";
import { TProjectGatewayDALFactory } from "./project-gateway-dal";
type TGatewayServiceFactoryDep = {
gatewayDAL: TGatewayDALFactory;
projectGatewayDAL: TProjectGatewayDALFactory;
orgGatewayConfigDAL: Pick<TOrgGatewayConfigDALFactory, "findOne" | "create" | "transaction" | "findById">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "decryptWithRootKey">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getProjectPermission">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry">;
};
export type TGatewayServiceFactory = ReturnType<typeof gatewayServiceFactory>;
const TURN_SERVER_CREDENTIALS_SCHEMA = z.object({
username: z.string(),
password: z.string()
});
export const gatewayServiceFactory = ({
gatewayDAL,
licenseService,
kmsService,
permissionService,
orgGatewayConfigDAL,
keyStore,
projectGatewayDAL
}: TGatewayServiceFactoryDep) => {
const $validateOrgAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => {
// if (!licenseService.onPremFeatures.gateway) {
// throw new BadRequestError({
// message:
// "Gateway handshake failed due to instance plan restrictions. Please upgrade your instance to Infisical's Enterprise plan."
// });
// }
const orgLicensePlan = await licenseService.getPlan(orgId);
if (!orgLicensePlan.gateway) {
throw new BadRequestError({
message:
"Gateway handshake failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan."
});
}
const { permission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
actorId,
orgId,
actorAuthMethod,
orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
};
const getGatewayRelayDetails = async (actorId: string, actorOrgId: string, actorAuthMethod: ActorAuthMethod) => {
const TURN_CRED_EXPIRY = 10 * 60; // 10 minutes
const envCfg = getConfig();
await $validateOrgAccessToGateway(actorOrgId, actorId, actorAuthMethod);
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: actorOrgId
});
if (!envCfg.GATEWAY_RELAY_AUTH_SECRET || !envCfg.GATEWAY_RELAY_ADDRESS || !envCfg.GATEWAY_RELAY_REALM) {
throw new BadRequestError({
message: "Gateway handshake failed due to missing instance configuration."
});
}
let turnServerUsername = "";
let turnServerPassword = "";
// keep it in redis for 5mins to avoid generating so many credentials
const previousCredential = await keyStore.getItem(KeyStorePrefixes.GatewayIdentityCredential(actorId));
if (previousCredential) {
const el = await TURN_SERVER_CREDENTIALS_SCHEMA.parseAsync(
JSON.parse(decryptor({ cipherTextBlob: Buffer.from(previousCredential, "hex") }).toString())
);
turnServerUsername = el.username;
turnServerPassword = el.password;
} else {
const el = getTurnCredentials(actorId, envCfg.GATEWAY_RELAY_AUTH_SECRET);
await keyStore.setItemWithExpiry(
KeyStorePrefixes.GatewayIdentityCredential(actorId),
TURN_CRED_EXPIRY,
encryptor({
plainText: Buffer.from(JSON.stringify({ username: el.username, password: el.password }))
}).cipherTextBlob.toString("hex")
);
turnServerUsername = el.username;
turnServerPassword = el.password;
}
return {
turnServerUsername,
turnServerPassword,
turnServerRealm: envCfg.GATEWAY_RELAY_REALM,
turnServerAddress: envCfg.GATEWAY_RELAY_ADDRESS,
infisicalStaticIp: envCfg.GATEWAY_INFISICAL_STATIC_IP_ADDRESS
};
};
const exchangeAllocatedRelayAddress = async ({
identityId,
identityOrg,
relayAddress,
identityOrgAuthMethod
}: TExchangeAllocatedRelayAddressDTO) => {
await $validateOrgAccessToGateway(identityOrg, identityId, identityOrgAuthMethod);
const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityOrg
});
const orgGatewayConfig = await orgGatewayConfigDAL.transaction(async (tx) => {
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgGatewayRootCaInit(identityOrg)]);
const existingGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: identityOrg });
if (existingGatewayConfig) return existingGatewayConfig;
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
// generate root CA
const rootCaKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const rootCaSerialNumber = createSerialNumber();
const rootCaSkObj = crypto.KeyObject.from(rootCaKeys.privateKey);
const rootCaIssuedAt = new Date();
const rootCaKeyAlgorithm = CertKeyAlgorithm.RSA_2048;
const rootCaExpiration = new Date(new Date().setFullYear(2045));
const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({
name: `O=${identityOrg},CN=Infisical Gateway Root CA`,
serialNumber: rootCaSerialNumber,
notBefore: rootCaIssuedAt,
notAfter: rootCaExpiration,
signingAlgorithm: alg,
keys: rootCaKeys,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey)
]
});
// generate client ca
const clientCaSerialNumber = createSerialNumber();
const clientCaIssuedAt = new Date();
const clientCaExpiration = new Date(new Date().setFullYear(2045));
const clientCaKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCaSkObj = crypto.KeyObject.from(clientCaKeys.privateKey);
const clientCaCert = await x509.X509CertificateGenerator.create({
serialNumber: clientCaSerialNumber,
subject: `O=${identityOrg},CN=Client Intermediate CA`,
issuer: rootCaCert.subject,
notBefore: clientCaIssuedAt,
notAfter: clientCaExpiration,
signingKey: rootCaKeys.privateKey,
publicKey: clientCaKeys.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags.keyCertSign |
x509.KeyUsageFlags.cRLSign |
x509.KeyUsageFlags.digitalSignature |
x509.KeyUsageFlags.keyEncipherment,
true
),
new x509.BasicConstraintsExtension(true, 0, true),
await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(clientCaKeys.publicKey)
]
});
const clientKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertSerialNumber = createSerialNumber();
const clientCert = await x509.X509CertificateGenerator.create({
serialNumber: clientCertSerialNumber,
subject: `O=${identityOrg},OU=gateway-client,CN=cloud`,
issuer: clientCaCert.subject,
notAfter: clientCaExpiration,
notBefore: clientCaIssuedAt,
signingKey: clientCaKeys.privateKey,
publicKey: clientKeys.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(clientCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] |
x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] |
x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT],
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true)
]
});
const clientSkObj = crypto.KeyObject.from(clientKeys.privateKey);
// generate gateway ca
const gatewayCaSerialNumber = createSerialNumber();
const gatewayCaIssuedAt = new Date();
const gatewayCaExpiration = new Date(new Date().setFullYear(2045));
const gatewayCaKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const gatewayCaSkObj = crypto.KeyObject.from(gatewayCaKeys.privateKey);
const gatewayCaCert = await x509.X509CertificateGenerator.create({
serialNumber: gatewayCaSerialNumber,
subject: `O=${identityOrg},CN=Gateway CA`,
issuer: rootCaCert.subject,
notBefore: gatewayCaIssuedAt,
notAfter: gatewayCaExpiration,
signingKey: rootCaKeys.privateKey,
publicKey: gatewayCaKeys.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags.keyCertSign |
x509.KeyUsageFlags.cRLSign |
x509.KeyUsageFlags.digitalSignature |
x509.KeyUsageFlags.keyEncipherment,
true
),
new x509.BasicConstraintsExtension(true, 0, true),
await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(gatewayCaKeys.publicKey)
]
});
return orgGatewayConfigDAL.create({
orgId: identityOrg,
rootCaIssuedAt,
rootCaExpiration,
rootCaSerialNumber,
rootCaKeyAlgorithm,
encryptedRootCaPrivateKey: orgKmsEncryptor({
plainText: rootCaSkObj.export({
type: "pkcs8",
format: "der"
})
}).cipherTextBlob,
encryptedRootCaCertificate: orgKmsEncryptor({ plainText: Buffer.from(rootCaCert.rawData) }).cipherTextBlob,
clientCaIssuedAt,
clientCaExpiration,
clientCaSerialNumber,
encryptedClientCaPrivateKey: orgKmsEncryptor({
plainText: clientCaSkObj.export({
type: "pkcs8",
format: "der"
})
}).cipherTextBlob,
encryptedClientCaCertificate: orgKmsEncryptor({
plainText: Buffer.from(clientCaCert.rawData)
}).cipherTextBlob,
clientCertIssuedAt: clientCaIssuedAt,
clientCertExpiration: clientCaExpiration,
clientCertKeyAlgorithm: CertKeyAlgorithm.RSA_2048,
clientCertSerialNumber,
encryptedClientPrivateKey: orgKmsEncryptor({
plainText: clientSkObj.export({
type: "pkcs8",
format: "der"
})
}).cipherTextBlob,
encryptedClientCertificate: orgKmsEncryptor({
plainText: Buffer.from(clientCert.rawData)
}).cipherTextBlob,
gatewayCaIssuedAt,
gatewayCaExpiration,
gatewayCaSerialNumber,
encryptedGatewayCaPrivateKey: orgKmsEncryptor({
plainText: gatewayCaSkObj.export({
type: "pkcs8",
format: "der"
})
}).cipherTextBlob,
encryptedGatewayCaCertificate: orgKmsEncryptor({
plainText: Buffer.from(gatewayCaCert.rawData)
}).cipherTextBlob
});
});
const rootCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedRootCaCertificate
})
);
const clientCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedClientCaCertificate
})
);
const gatewayCaAlg = keyAlgorithmToAlgCfg(orgGatewayConfig.rootCaKeyAlgorithm as CertKeyAlgorithm);
const gatewayCaSkObj = crypto.createPrivateKey({
key: orgKmsDecryptor({ cipherTextBlob: orgGatewayConfig.encryptedGatewayCaPrivateKey }),
format: "der",
type: "pkcs8"
});
const gatewayCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayCaCertificate
})
);
const gatewayCaPrivateKey = await crypto.subtle.importKey(
"pkcs8",
gatewayCaSkObj.export({ format: "der", type: "pkcs8" }),
gatewayCaAlg,
true,
["sign"]
);
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const gatewayKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const certIssuedAt = new Date();
// then need to periodically init
const certExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1));
const extensions: x509.Extension[] = [
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(gatewayCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(gatewayKeys.publicKey),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT],
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true),
// san
new x509.SubjectAlternativeNameExtension([{ type: "ip", value: relayAddress.split(":")[0] }], false)
];
const serialNumber = createSerialNumber();
const privateKey = crypto.KeyObject.from(gatewayKeys.privateKey);
const gatewayCertificate = await x509.X509CertificateGenerator.create({
serialNumber,
subject: `CN=${identityId},O=${identityOrg},OU=Gateway`,
issuer: gatewayCaCert.subject,
notBefore: certIssuedAt,
notAfter: certExpireAt,
signingKey: gatewayCaPrivateKey,
publicKey: gatewayKeys.publicKey,
signingAlgorithm: alg,
extensions
});
const appCfg = getConfig();
// just for local development
const formatedRelayAddress =
appCfg.NODE_ENV === "development" ? relayAddress.replace("127.0.0.1", "host.docker.internal") : relayAddress;
await gatewayDAL.transaction(async (tx) => {
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgGatewayCertExchange(identityOrg)]);
const existingGateway = await gatewayDAL.findOne({ identityId, orgGatewayRootCaId: orgGatewayConfig.id });
if (existingGateway) {
return gatewayDAL.updateById(existingGateway.id, {
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
issuedAt: certIssuedAt,
expiration: certExpireAt,
serialNumber,
relayAddress: orgKmsEncryptor({
plainText: Buffer.from(formatedRelayAddress)
}).cipherTextBlob
});
}
return gatewayDAL.create({
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
issuedAt: certIssuedAt,
expiration: certExpireAt,
serialNumber,
relayAddress: orgKmsEncryptor({
plainText: Buffer.from(formatedRelayAddress)
}).cipherTextBlob,
identityId,
orgGatewayRootCaId: orgGatewayConfig.id,
name: `gateway-${alphaNumericNanoId(6).toLowerCase()}`
});
});
const gatewayCertificateChain = `${clientCaCert.toString("pem")}\n${rootCaCert.toString("pem")}`.trim();
return {
serialNumber,
privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string,
certificate: gatewayCertificate.toString("pem"),
certificateChain: gatewayCertificateChain
};
};
const heartbeat = async ({ orgPermission }: THeartBeatDTO) => {
await $validateOrgAccessToGateway(orgPermission.orgId, orgPermission.id, orgPermission.authMethod);
const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId });
if (!orgGatewayConfig) throw new NotFoundError({ message: `Identity with ID ${orgPermission.id} not found.` });
const [gateway] = await gatewayDAL.find({ identityId: orgPermission.id, orgGatewayRootCaId: orgGatewayConfig.id });
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${orgPermission.id} not found.` });
const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgGatewayConfig.orgId
});
const rootCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedRootCaCertificate
})
);
const gatewayCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayCaCertificate
})
);
const clientCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedClientCertificate
})
);
const privateKey = crypto
.createPrivateKey({
key: orgKmsDecryptor({ cipherTextBlob: orgGatewayConfig.encryptedClientPrivateKey }),
format: "der",
type: "pkcs8"
})
.export({ type: "pkcs8", format: "pem" });
const relayAddress = orgKmsDecryptor({ cipherTextBlob: gateway.relayAddress }).toString();
const [relayHost, relayPort] = relayAddress.split(":");
await pingGatewayAndVerify({
relayHost,
relayPort: Number(relayPort),
tlsOptions: {
key: privateKey.toString(),
ca: `${gatewayCaCert.toString("pem")}\n${rootCaCert.toString("pem")}`.trim(),
cert: clientCert.toString("pem")
},
identityId: orgPermission.id,
orgId: orgPermission.orgId
});
await gatewayDAL.updateById(gateway.id, { heartbeat: new Date() });
};
const listGateways = async ({ orgPermission }: TListGatewaysDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.ListGateways,
OrgPermissionSubjects.Gateway
);
const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId });
if (!orgGatewayConfig) return [];
const gateways = await gatewayDAL.find({
orgGatewayRootCaId: orgGatewayConfig.id
});
return gateways;
};
const getGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.ListGateways,
OrgPermissionSubjects.Gateway
);
const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId });
if (!orgGatewayConfig) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
const [gateway] = await gatewayDAL.find({ id, orgGatewayRootCaId: orgGatewayConfig.id });
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
return gateway;
};
const updateGatewayById = async ({ orgPermission, id, name, projectIds }: TUpdateGatewayByIdDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.EditGateways,
OrgPermissionSubjects.Gateway
);
const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId });
if (!orgGatewayConfig) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
const [gateway] = await gatewayDAL.update({ id, orgGatewayRootCaId: orgGatewayConfig.id }, { name });
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
if (projectIds) {
await projectGatewayDAL.transaction(async (tx) => {
await projectGatewayDAL.delete({ gatewayId: gateway.id }, tx);
await projectGatewayDAL.insertMany(
projectIds.map((el) => ({ gatewayId: gateway.id, projectId: el })),
tx
);
});
}
return gateway;
};
const deleteGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.DeleteGateways,
OrgPermissionSubjects.Gateway
);
const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId });
if (!orgGatewayConfig) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
const [gateway] = await gatewayDAL.delete({ id, orgGatewayRootCaId: orgGatewayConfig.id });
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
return gateway;
};
const getProjectGateways = async ({ projectId, projectPermission }: TGetProjectGatewayByIdDTO) => {
await permissionService.getProjectPermission({
projectId,
actor: projectPermission.type,
actorId: projectPermission.id,
actorOrgId: projectPermission.orgId,
actorAuthMethod: projectPermission.authMethod,
actionProjectType: ActionProjectType.Any
});
const gateways = await gatewayDAL.findByProjectId(projectId);
return gateways;
};
// this has no permission check and used for dynamic secrets directly
// assumes permission check is already done
const fnGetGatewayClientTls = async (projectGatewayId: string) => {
const projectGateway = await projectGatewayDAL.findById(projectGatewayId);
if (!projectGateway) throw new NotFoundError({ message: `Project gateway with ID ${projectGatewayId} not found.` });
const { gatewayId } = projectGateway;
const gateway = await gatewayDAL.findById(gatewayId);
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found.` });
const orgGatewayConfig = await orgGatewayConfigDAL.findById(gateway.orgGatewayRootCaId);
const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgGatewayConfig.orgId
});
const rootCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedRootCaCertificate
})
);
const gatewayCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayCaCertificate
})
);
const clientCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedClientCertificate
})
);
const clientSkObj = crypto.createPrivateKey({
key: orgKmsDecryptor({ cipherTextBlob: orgGatewayConfig.encryptedClientPrivateKey }),
format: "der",
type: "pkcs8"
});
return {
relayAddress: orgKmsDecryptor({ cipherTextBlob: gateway.relayAddress }).toString(),
privateKey: clientSkObj.export({ type: "pkcs8", format: "pem" }),
certificate: clientCert.toString("pem"),
certChain: `${gatewayCaCert.toString("pem")}\n${rootCaCert.toString("pem")}`.trim(),
identityId: gateway.identityId,
orgId: orgGatewayConfig.orgId
};
};
return {
getGatewayRelayDetails,
exchangeAllocatedRelayAddress,
listGateways,
getGatewayById,
updateGatewayById,
deleteGatewayById,
getProjectGateways,
fnGetGatewayClientTls,
heartbeat
};
};

View File

@ -0,0 +1,39 @@
import { OrgServiceActor } from "@app/lib/types";
import { ActorAuthMethod } from "@app/services/auth/auth-type";
export type TExchangeAllocatedRelayAddressDTO = {
identityId: string;
identityOrg: string;
identityOrgAuthMethod: ActorAuthMethod;
relayAddress: string;
};
export type TListGatewaysDTO = {
orgPermission: OrgServiceActor;
};
export type TGetGatewayByIdDTO = {
id: string;
orgPermission: OrgServiceActor;
};
export type TUpdateGatewayByIdDTO = {
id: string;
name?: string;
projectIds?: string[];
orgPermission: OrgServiceActor;
};
export type TDeleteGatewayByIdDTO = {
id: string;
orgPermission: OrgServiceActor;
};
export type TGetProjectGatewayByIdDTO = {
projectId: string;
projectPermission: OrgServiceActor;
};
export type THeartBeatDTO = {
orgPermission: OrgServiceActor;
};

View File

@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TOrgGatewayConfigDALFactory = ReturnType<typeof orgGatewayConfigDALFactory>;
export const orgGatewayConfigDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.OrgGatewayConfig);
return orm;
};

View File

@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TProjectGatewayDALFactory = ReturnType<typeof projectGatewayDALFactory>;
export const projectGatewayDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ProjectGateway);
return orm;
};

View File

@ -51,7 +51,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
pkiEst: false,
enforceMfa: false,
projectTemplates: false,
kmip: false
kmip: false,
gateway: false
});
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {

View File

@ -69,6 +69,7 @@ export type TFeatureSet = {
enforceMfa: boolean;
projectTemplates: false;
kmip: false;
gateway: false;
};
export type TOrgPlansTableDTO = {

View File

@ -32,6 +32,14 @@ export enum OrgPermissionAdminConsoleAction {
AccessAllProjects = "access-all-projects"
}
export enum OrgPermissionGatewayActions {
// is there a better word for this. This mean can an identity be a gateway
CreateGateways = "create-gateways",
ListGateways = "list-gateways",
EditGateways = "edit-gateways",
DeleteGateways = "delete-gateways"
}
export enum OrgPermissionSubjects {
Workspace = "workspace",
Role = "role",
@ -50,7 +58,8 @@ export enum OrgPermissionSubjects {
AuditLogs = "audit-logs",
ProjectTemplates = "project-templates",
AppConnections = "app-connections",
Kmip = "kmip"
Kmip = "kmip",
Gateway = "gateway"
}
export type AppConnectionSubjectFields = {
@ -73,6 +82,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway]
| [
OrgPermissionAppConnectionActions,
(
@ -180,6 +190,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionKmipActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(OrgPermissionSubjects.Gateway).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe(
"Describe what action an entity can take."
)
})
]);
@ -264,6 +280,11 @@ const buildAdminPermission = () => {
can(OrgPermissionAppConnectionActions.Delete, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
can(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip);
@ -300,6 +321,8 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
return rules;
};

View File

@ -100,6 +100,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("lastName").withSchema("committerUser").as("committerUserLastName"),
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"),
tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
@ -162,8 +163,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerEmail: email,
reviewerLastName: lastName,
reviewerUsername: username,
reviewerFirstName: firstName
}) => (userId ? { userId, status, email, firstName, lastName, username } : undefined)
reviewerFirstName: firstName,
reviewerComment: comment
}) =>
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined
},
{
key: "approverUserId",

View File

@ -320,6 +320,7 @@ export const secretApprovalRequestServiceFactory = ({
approvalId,
actor,
status,
comment,
actorId,
actorAuthMethod,
actorOrgId
@ -372,15 +373,18 @@ export const secretApprovalRequestServiceFactory = ({
return secretApprovalRequestReviewerDAL.create(
{
status,
comment,
requestId: secretApprovalRequest.id,
reviewerUserId: actorId
},
tx
);
}
return secretApprovalRequestReviewerDAL.updateById(review.id, { status }, tx);
return secretApprovalRequestReviewerDAL.updateById(review.id, { status, comment }, tx);
});
return reviewStatus;
return { ...reviewStatus, projectId: secretApprovalRequest.projectId };
};
const updateApprovalStatus = async ({
@ -1294,7 +1298,7 @@ export const secretApprovalRequestServiceFactory = ({
secretMetadata
}) => {
const secretId = updatingSecretsGroupByKey[secretKey][0].id;
if (tagIds?.length) commitTagIds[secretKey] = tagIds;
if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds;
return {
...latestSecretVersions[secretId],
secretMetadata,

View File

@ -80,6 +80,7 @@ export type TStatusChangeDTO = {
export type TReviewRequestDTO = {
approvalId: string;
status: ApprovalStatus;
comment?: string;
} & Omit<TProjectPermission, "projectId">;
export type TApprovalRequestCountDTO = TProjectPermission;

View File

@ -6,7 +6,6 @@ export const sanitizedSshCertificate = SshCertificatesSchema.pick({
sshCertificateTemplateId: true,
serialNumber: true,
certType: true,
publicKey: true,
principals: true,
keyId: true,
notBefore: true,

View File

@ -1,12 +1,15 @@
import { Redis } from "ioredis";
import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext";
import { Redlock, Settings } from "@app/lib/red-lock";
export enum PgSqlLock {
BootUpMigration = 2023,
SuperAdminInit = 2024,
KmsRootKeyInit = 2025
}
export const PgSqlLock = {
BootUpMigration: 2023,
SuperAdminInit: 2024,
KmsRootKeyInit: 2025,
OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`),
OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`)
} as const;
export type TKeyStoreFactory = ReturnType<typeof keyStoreFactory>;
@ -33,7 +36,8 @@ export const KeyStorePrefixes = {
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) =>
`identity-access-token-status:${identityAccessTokenId}`,
ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`
ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`,
GatewayIdentityCredential: (identityId: string) => `gateway-credentials:${identityId}`
};
export const KeyStoreTtls = {

View File

@ -638,7 +638,8 @@ export const FOLDERS = {
environment: "The slug of the environment to create the folder in.",
name: "The name of the folder to create.",
path: "The path of the folder to create.",
directory: "The directory of the folder to create. (Deprecated in favor of path)"
directory: "The directory of the folder to create. (Deprecated in favor of path)",
description: "An optional description label for the folder."
},
UPDATE: {
folderId: "The ID of the folder to update.",
@ -647,7 +648,8 @@ export const FOLDERS = {
path: "The path of the folder to update.",
directory: "The new directory of the folder to update. (Deprecated in favor of path)",
projectSlug: "The slug of the project where the folder is located.",
workspaceId: "The ID of the project where the folder is located."
workspaceId: "The ID of the project where the folder is located.",
description: "An optional description label for the folder."
},
DELETE: {
folderIdOrName: "The ID or name of the folder to delete.",
@ -1722,6 +1724,18 @@ export const SecretSyncs = {
initialSyncBehavior: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`
};
},
ADDITIONAL_SYNC_OPTIONS: {
AWS_PARAMETER_STORE: {
keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.",
tags: "Optional resource tags to add to parameters synced by Infisical.",
syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as resource tags to parameters synced by Infisical.`
},
AWS_SECRETS_MANAGER: {
keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.",
tags: "Optional tags to add to secrets synced by Infisical.",
syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as tags to secrets synced by Infisical.`
}
},
DESTINATION_CONFIG: {
AWS_PARAMETER_STORE: {
region: "The AWS region to sync secrets to.",

View File

@ -24,6 +24,7 @@ const databaseReadReplicaSchema = z
const envSchema = z
.object({
INFISICAL_PLATFORM_VERSION: zpStr(z.string().optional()),
PORT: z.coerce.number().default(IS_PACKAGED ? 8080 : 4000),
DISABLE_SECRET_SCANNING: z
.enum(["true", "false"])
@ -184,6 +185,14 @@ const envSchema = z
USE_PG_QUEUE: zodStrBool.default("false"),
SHOULD_INIT_PG_QUEUE: zodStrBool.default("false"),
/* Gateway----------------------------------------------------------------------------- */
GATEWAY_INFISICAL_STATIC_IP_ADDRESS: zpStr(z.string().optional()),
GATEWAY_RELAY_ADDRESS: zpStr(z.string().optional()),
GATEWAY_RELAY_REALM: zpStr(z.string().optional()),
GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()),
/* ----------------------------------------------------------------------------- */
/* App Connections ----------------------------------------------------------------------------- */
// aws
@ -208,6 +217,13 @@ const envSchema = z
INF_APP_CONNECTION_AZURE_CLIENT_ID: zpStr(z.string().optional()),
INF_APP_CONNECTION_AZURE_CLIENT_SECRET: zpStr(z.string().optional()),
// datadog
SHOULD_USE_DATADOG_TRACER: zodStrBool.default("false"),
DATADOG_PROFILING_ENABLED: zodStrBool.default("false"),
DATADOG_ENV: zpStr(z.string().optional().default("prod")),
DATADOG_SERVICE: zpStr(z.string().optional().default("infisical-core")),
DATADOG_HOSTNAME: zpStr(z.string().optional()),
/* CORS ----------------------------------------------------------------------------- */
CORS_ALLOWED_ORIGINS: zpStr(

View File

@ -0,0 +1,29 @@
// used for postgres lock
// this is something postgres does under the hood
// convert any string to a unique number
export const hashtext = (text: string) => {
// Convert text to UTF8 bytes array for consistent behavior with PostgreSQL
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
// Implementation of hash_any
let result = 0;
for (let i = 0; i < bytes.length; i += 1) {
// eslint-disable-next-line no-bitwise
result = ((result << 5) + result) ^ bytes[i];
// Keep within 32-bit integer range
// eslint-disable-next-line no-bitwise
result >>>= 0;
}
// Convert to signed 32-bit integer like PostgreSQL
// eslint-disable-next-line no-bitwise
return result | 0;
};
export const pgAdvisoryLockHashText = (text: string) => {
const hash = hashtext(text);
// Ensure positive value within PostgreSQL integer range
return Math.abs(hash) % 2 ** 31;
};

View File

@ -0,0 +1,337 @@
/* eslint-disable no-await-in-loop */
import crypto from "node:crypto";
import net from "node:net";
import * as quic from "@infisical/quic";
import { BadRequestError } from "../errors";
import { logger } from "../logger";
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_RETRY_DELAY = 1000; // 1 second
const parseSubjectDetails = (data: string) => {
const values: Record<string, string> = {};
data.split("\n").forEach((el) => {
const [key, value] = el.split("=");
values[key.trim()] = value.trim();
});
return values;
};
type TTlsOption = { ca: string; cert: string; key: string };
const createQuicConnection = async (
relayHost: string,
relayPort: number,
tlsOptions: TTlsOption,
identityId: string,
orgId: string
) => {
const client = await quic.QUICClient.createQUICClient({
host: relayHost,
port: relayPort,
config: {
ca: tlsOptions.ca,
cert: tlsOptions.cert,
key: tlsOptions.key,
applicationProtos: ["infisical-gateway"],
verifyPeer: true,
verifyCallback: async (certs) => {
if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired;
const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0]));
const caCertificate = new crypto.X509Certificate(tlsOptions.ca);
const isValidServerCertificate = serverCertificate.checkIssued(caCertificate);
if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate;
const subjectDetails = parseSubjectDetails(serverCertificate.subject);
if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) {
return quic.native.CryptoError.CertificateUnknown;
}
if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) {
return quic.native.CryptoError.CertificateExpired;
}
const formatedRelayHost =
process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost;
if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate;
},
maxIdleTimeout: 90000,
keepAliveIntervalTime: 30000
},
crypto: {
ops: {
randomBytes: async (data) => {
crypto.getRandomValues(new Uint8Array(data));
}
}
}
});
return client;
};
type TPingGatewayAndVerifyDTO = {
relayHost: string;
relayPort: number;
tlsOptions: TTlsOption;
maxRetries?: number;
identityId: string;
orgId: string;
};
export const pingGatewayAndVerify = async ({
relayHost,
relayPort,
tlsOptions,
maxRetries = DEFAULT_MAX_RETRIES,
identityId,
orgId
}: TPingGatewayAndVerifyDTO) => {
let lastError: Error | null = null;
const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => {
throw new BadRequestError({
error: err as Error
});
});
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try {
const stream = quicClient.connection.newStream("bidi");
const pingWriter = stream.writable.getWriter();
await pingWriter.write(Buffer.from("PING\n"));
pingWriter.releaseLock();
// Read PONG response
const reader = stream.readable.getReader();
const { value, done } = await reader.read();
if (done) {
throw new BadRequestError({
message: "Gateway closed before receiving PONG"
});
}
const response = Buffer.from(value).toString();
if (response !== "PONG\n" && response !== "PONG") {
throw new BadRequestError({
message: `Failed to Ping. Unexpected response: ${response}`
});
}
reader.releaseLock();
return;
} catch (err) {
lastError = err as Error;
if (attempt < maxRetries) {
await new Promise((resolve) => {
setTimeout(resolve, DEFAULT_RETRY_DELAY);
});
}
} finally {
await quicClient.destroy();
}
}
logger.error(lastError);
throw new BadRequestError({
message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}`
});
};
interface TProxyServer {
server: net.Server;
port: number;
cleanup: () => Promise<void>;
}
const setupProxyServer = async ({
targetPort,
targetHost,
tlsOptions,
relayHost,
relayPort,
identityId,
orgId
}: {
targetHost: string;
targetPort: number;
relayPort: number;
relayHost: string;
tlsOptions: TTlsOption;
identityId: string;
orgId: string;
}): Promise<TProxyServer> => {
const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => {
throw new BadRequestError({
error: err as Error
});
});
return new Promise((resolve, reject) => {
const server = net.createServer();
// eslint-disable-next-line @typescript-eslint/no-misused-promises
server.on("connection", async (clientConn) => {
try {
clientConn.setKeepAlive(true, 30000); // 30 seconds
clientConn.setNoDelay(true);
const stream = quicClient.connection.newStream("bidi");
// Send FORWARD-TCP command
const forwardWriter = stream.writable.getWriter();
await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`));
forwardWriter.releaseLock();
/* eslint-disable @typescript-eslint/no-misused-promises */
// Set up bidirectional copy
const setupCopy = async () => {
// Client to QUIC
// eslint-disable-next-line
(async () => {
try {
const writer = stream.writable.getWriter();
// Create a handler for client data
clientConn.on("data", async (chunk) => {
await writer.write(chunk);
});
// Handle client connection close
clientConn.on("end", async () => {
await writer.close();
});
clientConn.on("error", async (err) => {
await writer.abort(err);
});
} catch (err) {
clientConn.destroy();
}
})();
// QUIC to Client
void (async () => {
try {
const reader = stream.readable.getReader();
let reading = true;
while (reading) {
const { value, done } = await reader.read();
if (done) {
reading = false;
clientConn.end(); // Close client connection when QUIC stream ends
break;
}
// Write data to TCP client
const canContinue = clientConn.write(Buffer.from(value));
// Handle backpressure
if (!canContinue) {
await new Promise((res) => {
clientConn.once("drain", res);
});
}
}
} catch (err) {
clientConn.destroy();
}
})();
};
await setupCopy();
//
// Handle connection closure
clientConn.on("close", async () => {
await stream.destroy();
});
const cleanup = async () => {
clientConn?.destroy();
await stream.destroy();
};
clientConn.on("error", (err) => {
logger.error(err, "Client socket error");
void cleanup();
reject(err);
});
clientConn.on("end", cleanup);
} catch (err) {
logger.error(err, "Failed to establish target connection:");
clientConn.end();
reject(err);
}
});
server.on("error", (err) => {
reject(err);
});
server.on("close", async () => {
await quicClient?.destroy();
});
/* eslint-enable */
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to get server port"));
return;
}
logger.info("Gateway proxy started");
resolve({
server,
port: address.port,
cleanup: async () => {
server.close();
await quicClient?.destroy();
}
});
});
});
};
interface ProxyOptions {
targetHost: string;
targetPort: number;
relayHost: string;
relayPort: number;
tlsOptions: TTlsOption;
identityId: string;
orgId: string;
}
export const withGatewayProxy = async (
callback: (port: number) => Promise<void>,
options: ProxyOptions
): Promise<void> => {
const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options;
// Setup the proxy server
const { port, cleanup } = await setupProxyServer({
targetHost,
targetPort,
relayPort,
relayHost,
tlsOptions,
identityId,
orgId
});
try {
// Execute the callback with the allocated port
await callback(port);
} catch (err) {
logger.error(err, "Failed to proxy");
throw new BadRequestError({ message: (err as Error)?.message });
} finally {
// Ensure cleanup happens regardless of success or failure
await cleanup();
}
};

View File

@ -1,11 +1,12 @@
import opentelemetry, { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { Resource } from "@opentelemetry/resources";
import { AggregationTemporality, MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
import tracer from "dd-trace";
import dotenv from "dotenv";
import { initEnvConfig } from "../config/env";
@ -69,7 +70,7 @@ const initTelemetryInstrumentation = ({
opentelemetry.metrics.setGlobalMeterProvider(meterProvider);
registerInstrumentations({
instrumentations: [getNodeAutoInstrumentations()]
instrumentations: [new HttpInstrumentation()]
});
};
@ -86,6 +87,17 @@ const setupTelemetry = () => {
exportType: appCfg.OTEL_EXPORT_TYPE
});
}
if (appCfg.SHOULD_USE_DATADOG_TRACER) {
console.log("Initializing Datadog tracer");
tracer.init({
profiling: appCfg.DATADOG_PROFILING_ENABLED,
version: appCfg.INFISICAL_PLATFORM_VERSION,
env: appCfg.DATADOG_ENV,
service: appCfg.DATADOG_SERVICE,
hostname: appCfg.DATADOG_HOSTNAME
});
}
};
void setupTelemetry();

View File

@ -0,0 +1,16 @@
import crypto from "node:crypto";
const TURN_TOKEN_TTL = 60 * 60 * 1000; // 24 hours in milliseconds
export const getTurnCredentials = (id: string, authSecret: string, ttl = TURN_TOKEN_TTL) => {
const timestamp = Math.floor((Date.now() + ttl) / 1000);
const username = `${timestamp}:${id}`;
const hmac = crypto.createHmac("sha1", authSecret);
hmac.update(username);
const password = hmac.digest("base64");
return {
username,
password
};
};

View File

@ -0,0 +1,8 @@
export const QUERY_TIMEOUT = 121000; // 2 mins (query timeout) with padding
export const extendTimeout =
(timeoutMs: number) =>
(request: { raw: { socket: { setTimeout: (ms: number) => void } } }, reply: unknown, done: () => void) => {
request.raw.socket.setTimeout(timeoutMs);
done();
};

View File

@ -27,6 +27,10 @@ import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-
import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { externalKmsDALFactory } from "@app/ee/services/external-kms/external-kms-dal";
import { externalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service";
import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal";
import { projectGatewayDALFactory } from "@app/ee/services/gateway/project-gateway-dal";
import { groupDALFactory } from "@app/ee/services/group/group-dal";
import { groupServiceFactory } from "@app/ee/services/group/group-service";
import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
@ -393,6 +397,10 @@ export const registerRoutes = async (
const kmipOrgConfigDAL = kmipOrgConfigDALFactory(db);
const kmipOrgServerCertificateDAL = kmipOrgServerCertificateDALFactory(db);
const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db);
const gatewayDAL = gatewayDALFactory(db);
const projectGatewayDAL = projectGatewayDALFactory(db);
const permissionService = permissionServiceFactory({
permissionDAL,
orgRoleDAL,
@ -1088,7 +1096,9 @@ export const registerRoutes = async (
permissionService,
secretSharingDAL,
orgDAL,
kmsService
kmsService,
smtpService,
userDAL
});
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
@ -1300,7 +1310,19 @@ export const registerRoutes = async (
kmsService
});
const dynamicSecretProviders = buildDynamicSecretProviders();
const gatewayService = gatewayServiceFactory({
permissionService,
gatewayDAL,
kmsService,
licenseService,
orgGatewayConfigDAL,
keyStore,
projectGatewayDAL
});
const dynamicSecretProviders = buildDynamicSecretProviders({
gatewayService
});
const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({
queueService,
dynamicSecretLeaseDAL,
@ -1318,8 +1340,10 @@ export const registerRoutes = async (
folderDAL,
permissionService,
licenseService,
kmsService
kmsService,
projectGatewayDAL
});
const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({
projectDAL,
permissionService,
@ -1557,7 +1581,8 @@ export const registerRoutes = async (
appConnection: appConnectionService,
secretSync: secretSyncService,
kmip: kmipService,
kmipOperation: kmipOperationService
kmipOperation: kmipOperationService,
gateway: gatewayService
});
const cronJobs: CronJob[] = [];

View File

@ -1,3 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { z } from "zod";
import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas";
@ -72,7 +73,21 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
message: "At least one login method should be enabled."
}),
slackClientId: z.string().optional(),
slackClientSecret: z.string().optional()
slackClientSecret: z.string().optional(),
authConsentContent: z
.string()
.trim()
.refine((content) => DOMPurify.sanitize(content) === content, {
message: "Auth consent content contains unsafe HTML."
})
.optional(),
pageFrameContent: z
.string()
.trim()
.refine((content) => DOMPurify.sanitize(content) === content, {
message: "Page frame content contains unsafe HTML."
})
.optional()
}),
response: {
200: z.object({
@ -196,6 +211,27 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "PATCH",
url: "/user-management/users/:userId/admin-access",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
userId: z.string()
})
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
await server.services.superAdmin.grantServerAdminAccessToUser(req.params.userId);
}
});
server.route({
method: "GET",
url: "/encryption-strategies",

View File

@ -1,13 +1,19 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { z } from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import {
CreateAwsConnectionSchema,
SanitizedAwsConnectionSchema,
UpdateAwsConnectionSchema
} from "@app/services/app-connection/aws";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.AWS,
server,
@ -15,3 +21,42 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
createSchema: CreateAwsConnectionSchema,
updateSchema: UpdateAwsConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/kms-keys`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
querystring: z.object({
region: z.nativeEnum(AWSRegion),
destination: z.enum([SecretSync.AWSParameterStore, SecretSync.AWSSecretsManager])
}),
response: {
200: z.object({
kmsKeys: z.object({ alias: z.string(), id: z.string() }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const kmsKeys = await server.services.appConnection.aws.listKmsKeys(
{
connectionId,
...req.query
},
req.permission
);
return { kmsKeys };
}
});
};

View File

@ -37,6 +37,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router";
import { registerProjectRouter } from "./project-router";
import { registerSecretFolderRouter } from "./secret-folder-router";
import { registerSecretImportRouter } from "./secret-import-router";
import { registerSecretRequestsRouter } from "./secret-requests-router";
import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router";
@ -110,7 +111,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
await server.register(registerWebhookRouter, { prefix: "/webhooks" });
await server.register(registerIdentityRouter, { prefix: "/identities" });
await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" });
await server.register(
async (secretSharingRouter) => {
await secretSharingRouter.register(registerSecretSharingRouter, { prefix: "/shared" });
await secretSharingRouter.register(registerSecretRequestsRouter, { prefix: "/requests" });
},
{ prefix: "/secret-sharing" }
);
await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" });
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
await server.register(registerCmekRouter, { prefix: "/kms" });

View File

@ -14,6 +14,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs";
import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { extendTimeout, QUERY_TIMEOUT } from "@app/server/lib/utils";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
@ -108,6 +109,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
config: {
rateLimit: readLimit
},
preHandler: extendTimeout(QUERY_TIMEOUT),
schema: {
description: "Get all audit logs for an organization",
querystring: z.object({

View File

@ -47,7 +47,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.directory)
.describe(FOLDERS.CREATE.directory),
description: z.string().optional().nullable().describe(FOLDERS.CREATE.description)
}),
response: {
200: z.object({
@ -65,7 +66,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId,
...req.body,
projectId: req.body.workspaceId,
path
path,
description: req.body.description
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
@ -76,7 +78,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
environment: req.body.environment,
folderId: folder.id,
folderName: folder.name,
folderPath: path
folderPath: path,
...(req.body.description ? { description: req.body.description } : {})
}
}
});
@ -125,7 +128,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.directory)
.describe(FOLDERS.UPDATE.directory),
description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description)
}),
response: {
200: z.object({
@ -196,7 +200,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.path)
.describe(FOLDERS.UPDATE.path),
description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description)
})
.array()
.min(1)

View File

@ -0,0 +1,270 @@
import { z } from "zod";
import { SecretSharingSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SecretSharingAccessType } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerSecretRequestsRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
secretRequest: SecretSharingSchema.omit({
encryptedSecret: true,
tag: true,
iv: true,
encryptedValue: true
}).extend({
isSecretValueSet: z.boolean(),
requester: z.object({
organizationName: z.string(),
firstName: z.string().nullish(),
lastName: z.string().nullish(),
username: z.string()
})
})
})
}
},
handler: async (req) => {
const secretRequest = await req.server.services.secretSharing.getSecretRequestById({
id: req.params.id,
actorOrgId: req.permission?.orgId,
actor: req.permission?.type,
actorId: req.permission?.id,
actorAuthMethod: req.permission?.authMethod
});
return { secretRequest };
}
});
server.route({
method: "POST",
url: "/:id/set-value",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
body: z.object({
secretValue: z.string()
}),
response: {
200: z.object({
secretRequest: SecretSharingSchema.omit({
encryptedSecret: true,
tag: true,
iv: true,
encryptedValue: true
})
})
}
},
handler: async (req) => {
const secretRequest = await req.server.services.secretSharing.setSecretRequestValue({
id: req.params.id,
actorOrgId: req.permission?.orgId,
actor: req.permission?.type,
actorId: req.permission?.id,
actorAuthMethod: req.permission?.authMethod,
secretValue: req.body.secretValue
});
return { secretRequest };
}
});
server.route({
method: "POST",
url: "/:id/reveal-value",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
secretRequest: SecretSharingSchema.omit({
encryptedSecret: true,
tag: true,
iv: true,
encryptedValue: true
}).extend({
secretValue: z.string()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretRequest = await req.server.services.secretSharing.revealSecretRequestValue({
id: req.params.id,
actorOrgId: req.permission.orgId,
orgId: req.permission.orgId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod
});
return { secretRequest };
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
secretRequest: SecretSharingSchema.omit({
encryptedSecret: true,
tag: true,
iv: true,
encryptedValue: true
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretRequest = await req.server.services.secretSharing.deleteSharedSecretById({
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
sharedSecretId: req.params.id,
orgId: req.permission.orgId,
actor: req.permission.type,
type: SecretSharingType.Request
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretRequestDeleted,
distinctId: getTelemetryDistinctId(req),
properties: {
secretRequestId: req.params.id,
organizationId: req.permission.orgId,
...req.auditLogInfo
}
});
return { secretRequest };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0),
limit: z.coerce.number().min(1).max(100).default(25)
}),
response: {
200: z.object({
secrets: z.array(SecretSharingSchema),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
type: SecretSharingType.Request,
...req.query
});
return {
secrets,
totalCount
};
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
name: z.string().max(50).optional(),
expiresAt: z.string(),
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
}),
response: {
200: z.object({
id: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const shareRequest = await req.server.services.secretSharing.createSecretRequest({
actor: req.permission.type,
actorId: req.permission.id,
orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
await server.services.auditLog.createAuditLog({
orgId: req.permission.orgId,
...req.auditLogInfo,
event: {
type: EventType.CREATE_SECRET_REQUEST,
metadata: {
accessType: req.body.accessType,
name: req.body.name,
id: shareRequest.id
}
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretRequestCreated,
distinctId: getTelemetryDistinctId(req),
properties: {
secretRequestId: shareRequest.id,
organizationId: req.permission.orgId,
secretRequestName: req.body.name,
...req.auditLogInfo
}
});
return { id: shareRequest.id };
}
});
};

View File

@ -11,6 +11,7 @@ import {
} from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
export const registerSecretSharingRouter = async (server: FastifyZodProvider) => {
server.route({
@ -38,6 +39,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
type: SecretSharingType.Share,
...req.query
});
@ -211,7 +213,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
sharedSecretId
sharedSecretId,
type: SecretSharingType.Share
});
await server.services.auditLog.createAuditLog({

View File

@ -537,7 +537,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
.optional()
.nullable()
.describe(RAW_SECRETS.CREATE.secretReminderRepeatDays),
secretReminderNote: z.string().optional().nullable().describe(RAW_SECRETS.CREATE.secretReminderNote)
secretReminderNote: z
.string()
.max(1024, "Secret reminder note cannot exceed 1024 characters")
.optional()
.nullable()
.describe(RAW_SECRETS.CREATE.secretReminderNote)
}),
response: {
200: z.union([
@ -640,7 +645,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds),
metadata: z.record(z.string()).optional(),
secretMetadata: ResourceMetadataSchema.optional(),
secretReminderNote: z.string().optional().nullable().describe(RAW_SECRETS.UPDATE.secretReminderNote),
secretReminderNote: z
.string()
.max(1024, "Secret reminder note cannot exceed 1024 characters")
.optional()
.nullable()
.describe(RAW_SECRETS.UPDATE.secretReminderNote),
secretReminderRepeatDays: z
.number()
.optional()
@ -2053,7 +2063,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding),
newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName),
tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds),
secretReminderNote: z.string().optional().nullable().describe(RAW_SECRETS.UPDATE.secretReminderNote),
secretReminderNote: z
.string()
.max(1024, "Secret reminder note cannot exceed 1024 characters")
.optional()
.nullable()
.describe(RAW_SECRETS.UPDATE.secretReminderNote),
secretMetadata: ResourceMetadataSchema.optional(),
secretReminderRepeatDays: z
.number()

View File

@ -22,18 +22,19 @@ import {
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
import { ValidateDatabricksConnectionCredentialsSchema } from "@app/services/app-connection/databricks";
import { databricksConnectionService } from "@app/services/app-connection/databricks/databricks-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
import { databricksConnectionService } from "./databricks/databricks-connection-service";
import { ValidateGcpConnectionCredentialsSchema } from "./gcp";
import { gcpConnectionService } from "./gcp/gcp-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@ -369,6 +370,7 @@ export const appConnectionServiceFactory = ({
listAvailableAppConnectionsForUser,
github: githubConnectionService(connectAppConnectionById),
gcp: gcpConnectionService(connectAppConnectionById),
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService)
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
aws: awsConnectionService(connectAppConnectionById)
};
};

View File

@ -1,3 +1,4 @@
import { AWSRegion } from "@app/services/app-connection/app-connection-enums";
import {
TAwsConnection,
TAwsConnectionConfig,
@ -16,6 +17,7 @@ import {
TGitHubConnectionInput,
TValidateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
TAzureAppConfigurationConnection,
@ -73,3 +75,9 @@ export type TValidateAppConnectionCredentials =
| TValidateAzureKeyVaultConnectionCredentials
| TValidateAzureAppConfigurationConnectionCredentials
| TValidateDatabricksConnectionCredentials;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
region: AWSRegion;
destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager;
};

View File

@ -0,0 +1,88 @@
import AWS from "aws-sdk";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TListAwsConnectionKmsKeys } from "@app/services/app-connection/app-connection-types";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TAwsConnection>;
const listAwsKmsKeys = async (
appConnection: TAwsConnection,
{ region, destination }: Pick<TListAwsConnectionKmsKeys, "region" | "destination">
) => {
const { credentials } = await getAwsConnectionConfig(appConnection, region);
const awsKms = new AWS.KMS({
credentials,
region
});
const aliasEntries: AWS.KMS.AliasList = [];
let aliasMarker: string | undefined;
do {
// eslint-disable-next-line no-await-in-loop
const response = await awsKms.listAliases({ Limit: 100, Marker: aliasMarker }).promise();
aliasEntries.push(...(response.Aliases || []));
aliasMarker = response.NextMarker;
} while (aliasMarker);
const keyMetadataRecord: Record<string, AWS.KMS.KeyMetadata | undefined> = {};
for await (const aliasEntry of aliasEntries) {
if (aliasEntry.TargetKeyId) {
const keyDescription = await awsKms.describeKey({ KeyId: aliasEntry.TargetKeyId }).promise();
keyMetadataRecord[aliasEntry.TargetKeyId] = keyDescription.KeyMetadata;
}
}
const validAliasEntries = aliasEntries.filter((aliasEntry) => {
if (!aliasEntry.TargetKeyId) return false;
if (destination === SecretSync.AWSParameterStore && aliasEntry.AliasName === "alias/aws/ssm") return true;
if (destination === SecretSync.AWSSecretsManager && aliasEntry.AliasName === "alias/aws/secretsmanager")
return true;
if (aliasEntry.AliasName?.includes("alias/aws/")) return false;
const keyMetadata = keyMetadataRecord[aliasEntry.TargetKeyId];
if (!keyMetadata || keyMetadata.KeyUsage !== "ENCRYPT_DECRYPT" || keyMetadata.KeySpec !== "SYMMETRIC_DEFAULT")
return false;
return true;
});
const kmsKeys = validAliasEntries.map((aliasEntry) => {
return {
id: aliasEntry.TargetKeyId!,
alias: aliasEntry.AliasName!
};
});
return kmsKeys;
};
export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listKmsKeys = async (
{ connectionId, region, destination }: TListAwsConnectionKmsKeys,
actor: OrgServiceActor
) => {
const appConnection = await getAppConnection(AppConnection.AWS, connectionId, actor);
const kmsKeys = await listAwsKmsKeys(appConnection, { region, destination });
return kmsKeys;
};
return {
listKmsKeys
};
};

View File

@ -134,7 +134,15 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => {
* Return list of names of apps for Vercel integration
* This is re-used for getting custom environments for Vercel
*/
export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => {
export const getAppsVercel = async ({
accessToken,
teamId,
includeCustomEnvironments
}: {
teamId?: string | null;
accessToken: string;
includeCustomEnvironments?: boolean;
}) => {
const apps: Array<{ name: string; appId: string; customEnvironments: Array<{ slug: string; id: string }> }> = [];
const limit = "20";
@ -145,12 +153,6 @@ export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string |
projects: {
name: string;
id: string;
customEnvironments?: {
id: string;
type: string;
description: string;
slug: string;
}[];
}[];
pagination: {
count: number;
@ -159,6 +161,20 @@ export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string |
};
}
const getProjectCustomEnvironments = async (projectId: string) => {
const { data } = await request.get<{ environments: { id: string; slug: string }[] }>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
return data.environments;
};
while (hasMorePages) {
const params: { [key: string]: string } = {
limit
@ -180,17 +196,38 @@ export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string |
}
});
data.projects.forEach((a) => {
apps.push({
name: a.name,
appId: a.id,
customEnvironments:
a.customEnvironments?.map((env) => ({
slug: env.slug,
id: env.id
})) ?? []
if (includeCustomEnvironments) {
const projectsWithCustomEnvironments = await Promise.all(
data.projects.map(async (a) => {
const customEnvironments = await getProjectCustomEnvironments(a.id);
return {
...a,
customEnvironments
};
})
);
projectsWithCustomEnvironments.forEach((a) => {
apps.push({
name: a.name,
appId: a.id,
customEnvironments:
a.customEnvironments?.map((env) => ({
slug: env.slug,
id: env.id
})) ?? []
});
});
});
} else {
data.projects.forEach((a) => {
apps.push({
name: a.name,
appId: a.id,
customEnvironments: []
});
});
}
next = data.pagination.next;

View File

@ -1851,6 +1851,7 @@ export const integrationAuthServiceFactory = ({
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const vercelApps = await getAppsVercel({
includeCustomEnvironments: true,
accessToken,
teamId
});

View File

@ -2257,7 +2257,9 @@ const syncSecretsFlyio = async ({
}
`;
await request.post(
type TFlyioErrors = { message: string }[];
const setSecretsResp = await request.post<{ errors?: TFlyioErrors }>(
IntegrationUrls.FLYIO_API_URL,
{
query: SetSecrets,
@ -2279,6 +2281,10 @@ const syncSecretsFlyio = async ({
}
);
if (setSecretsResp.data.errors?.length) {
throw new Error(JSON.stringify(setSecretsResp.data.errors));
}
// get secrets
interface FlyioSecret {
name: string;

View File

@ -20,7 +20,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
secretDAL: Pick<TSecretDALFactory, "pruneSecretReminders">;
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets">;
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets" | "pruneExpiredSecretRequests">;
queueService: TQueueServiceFactory;
};
@ -45,6 +45,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
await identityAccessTokenDAL.removeExpiredTokens();
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
await secretSharingDAL.pruneExpiredSharedSecrets();
await secretSharingDAL.pruneExpiredSecretRequests();
await snapshotDAL.pruneExcessSnapshots();
await secretVersionDAL.pruneExcessVersions();
await secretVersionV2DAL.pruneExcessVersions();

View File

@ -50,7 +50,8 @@ export const secretFolderServiceFactory = ({
actorOrgId,
name,
environment,
path: secretPath
path: secretPath,
description
}: TCreateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@ -121,7 +122,10 @@ export const secretFolderServiceFactory = ({
}
}
const doc = await folderDAL.create({ name, envId: env.id, version: 1, parentId: parentFolderId }, tx);
const doc = await folderDAL.create(
{ name, envId: env.id, version: 1, parentId: parentFolderId, description },
tx
);
await folderVersionDAL.create(
{
name: doc.name,
@ -170,7 +174,7 @@ export const secretFolderServiceFactory = ({
const result = await folderDAL.transaction(async (tx) =>
Promise.all(
folders.map(async (newFolder) => {
const { environment, path: secretPath, id, name } = newFolder;
const { environment, path: secretPath, id, name, description } = newFolder;
const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
if (!parentFolder) {
@ -217,7 +221,7 @@ export const secretFolderServiceFactory = ({
const [doc] = await folderDAL.update(
{ envId: env.id, id: folder.id, parentId: parentFolder.id },
{ name },
{ name, description },
tx
);
await folderVersionDAL.create(
@ -259,7 +263,8 @@ export const secretFolderServiceFactory = ({
name,
environment,
path: secretPath,
id
id,
description
}: TUpdateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@ -312,7 +317,7 @@ export const secretFolderServiceFactory = ({
const newFolder = await folderDAL.transaction(async (tx) => {
const [doc] = await folderDAL.update(
{ envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false },
{ name },
{ name, description },
tx
);
await folderVersionDAL.create(

View File

@ -9,6 +9,7 @@ export type TCreateFolderDTO = {
environment: string;
path: string;
name: string;
description?: string | null;
} & TProjectPermission;
export type TUpdateFolderDTO = {
@ -16,6 +17,7 @@ export type TUpdateFolderDTO = {
path: string;
id: string;
name: string;
description?: string | null;
} & TProjectPermission;
export type TUpdateManyFoldersDTO = {
@ -25,6 +27,7 @@ export type TUpdateManyFoldersDTO = {
path: string;
id: string;
name: string;
description?: string | null;
}[];
} & Omit<TProjectPermission, "projectId">;

View File

@ -2,17 +2,61 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TSecretSharing } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { DatabaseError, NotFoundError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
import { SecretSharingType } from "./secret-sharing-types";
export type TSecretSharingDALFactory = ReturnType<typeof secretSharingDALFactory>;
export const secretSharingDALFactory = (db: TDbClient) => {
const sharedSecretOrm = ormify(db, TableName.SecretSharing);
const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => {
const getSecretRequestById = async (id: string) => {
const repDb = db.replicaNode();
const secretRequest = await repDb(TableName.SecretSharing)
.leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SecretSharing}.orgId`)
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretSharing}.userId`)
.where(`${TableName.SecretSharing}.id`, id)
.where(`${TableName.SecretSharing}.type`, SecretSharingType.Request)
.select(
repDb.ref("name").withSchema(TableName.Organization).as("orgName"),
repDb.ref("firstName").withSchema(TableName.Users).as("requesterFirstName"),
repDb.ref("lastName").withSchema(TableName.Users).as("requesterLastName"),
repDb.ref("username").withSchema(TableName.Users).as("requesterUsername")
)
.select(selectAllTableCols(TableName.SecretSharing))
.first();
if (!secretRequest) {
throw new NotFoundError({
message: `Secret request with ID '${id}' not found`
});
}
return {
...secretRequest,
requester: {
organizationName: secretRequest.orgName,
firstName: secretRequest.requesterFirstName,
lastName: secretRequest.requesterLastName,
username: secretRequest.requesterUsername
}
};
};
const countAllUserOrgSharedSecrets = async ({
orgId,
userId,
type
}: {
orgId: string;
userId: string;
type: SecretSharingType;
}) => {
try {
interface CountResult {
count: string;
@ -22,6 +66,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
.replicaNode()(TableName.SecretSharing)
.where(`${TableName.SecretSharing}.orgId`, orgId)
.where(`${TableName.SecretSharing}.userId`, userId)
.where(`${TableName.SecretSharing}.type`, type)
.count("*")
.first();
@ -38,6 +83,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
const docs = await (tx || db)(TableName.SecretSharing)
.where("expiresAt", "<", today)
.andWhere("encryptedValue", "<>", "")
.andWhere("type", SecretSharingType.Share)
.update({
encryptedValue: "",
tag: "",
@ -50,6 +96,26 @@ export const secretSharingDALFactory = (db: TDbClient) => {
}
};
const pruneExpiredSecretRequests = async (tx?: Knex) => {
logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests started`);
try {
const today = new Date();
const docs = await (tx || db)(TableName.SecretSharing)
.whereNotNull("expiresAt")
.andWhere("expiresAt", "<", today)
.andWhere("encryptedSecret", null)
.andWhere("type", SecretSharingType.Request)
.delete();
logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests completed`);
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "pruneExpiredSecretRequests" });
}
};
const findActiveSharedSecrets = async (filters: Partial<TSecretSharing>, tx?: Knex) => {
try {
const now = new Date();
@ -57,6 +123,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
.where(filters)
.andWhere("expiresAt", ">", now)
.andWhere("encryptedValue", "<>", "")
.andWhere("type", SecretSharingType.Share)
.select(selectAllTableCols(TableName.SecretSharing))
.orderBy("expiresAt", "asc");
} catch (error) {
@ -86,7 +153,9 @@ export const secretSharingDALFactory = (db: TDbClient) => {
...sharedSecretOrm,
countAllUserOrgSharedSecrets,
pruneExpiredSharedSecrets,
pruneExpiredSecretRequests,
softDeleteById,
findActiveSharedSecrets
findActiveSharedSecrets,
getSecretRequestById
};
};

View File

@ -4,26 +4,36 @@ import bcrypt from "bcrypt";
import { TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
import {
SecretSharingType,
TCreatePublicSharedSecretDTO,
TCreateSecretRequestDTO,
TCreateSharedSecretDTO,
TDeleteSharedSecretDTO,
TGetActiveSharedSecretByIdDTO,
TGetSharedSecretsDTO
TGetSecretRequestByIdDTO,
TGetSharedSecretsDTO,
TRevealSecretRequestValueDTO,
TSetSecretRequestValueDTO
} from "./secret-sharing-types";
type TSecretSharingServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
secretSharingDAL: TSecretSharingDALFactory;
orgDAL: TOrgDALFactory;
userDAL: TUserDALFactory;
kmsService: TKmsServiceFactory;
smtpService: TSmtpService;
};
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
@ -32,8 +42,29 @@ export const secretSharingServiceFactory = ({
permissionService,
secretSharingDAL,
orgDAL,
kmsService
kmsService,
smtpService,
userDAL
}: TSecretSharingServiceFactoryDep) => {
const $validateSharedSecretExpiry = (expiresAt: string) => {
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
const fiveMins = 5 * 60 * 1000;
if (expiryTime - currentTime < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
const createSharedSecret = async ({
actor,
actorId,
@ -49,25 +80,13 @@ export const secretSharingServiceFactory = ({
}: TCreateSharedSecretDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
$validateSharedSecretExpiry(expiresAt);
if (secretValue.length > 10_000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex");
@ -80,6 +99,7 @@ export const secretSharingServiceFactory = ({
encryptedValue: null,
encryptedSecret,
name,
type: SecretSharingType.Share,
password: hashedPassword,
expiresAt: new Date(expiresAt),
expiresAfterViews,
@ -93,6 +113,191 @@ export const secretSharingServiceFactory = ({
return { id: idToReturn };
};
const createSecretRequest = async ({
actor,
accessType,
expiresAt,
name,
actorId,
orgId,
actorAuthMethod,
actorOrgId
}: TCreateSecretRequestDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
const newSecretRequest = await secretSharingDAL.create({
type: SecretSharingType.Request,
userId: actorId,
orgId,
name,
encryptedSecret: null,
accessType,
expiresAt: new Date(expiresAt)
});
return { id: newSecretRequest.id };
};
const revealSecretRequestValue = async ({
id,
actor,
actorId,
actorOrgId,
orgId,
actorAuthMethod
}: TRevealSecretRequestValueDTO) => {
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) {
throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" });
}
if (!secretRequest.encryptedSecret) {
throw new BadRequestError({ message: "Secret request has no value set" });
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const decryptedSecret = decryptWithRoot(secretRequest.encryptedSecret);
return { ...secretRequest, secretValue: decryptedSecret.toString() };
};
const getSecretRequestById = async ({
id,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TGetSecretRequestByIdDTO) => {
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
if (!secretRequest.orgId) {
throw new BadRequestError({ message: "No organization ID present on secret request" });
}
if (!actorOrgId) {
throw new UnauthorizedError();
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
secretRequest.orgId,
actorAuthMethod,
actorOrgId
);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
}
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
throw new ForbiddenRequestError({
message: "Access denied: Secret request has expired"
});
}
return {
...secretRequest,
isSecretValueSet: Boolean(secretRequest.encryptedSecret)
};
};
const setSecretRequestValue = async ({
id,
actor,
actorId,
actorAuthMethod,
actorOrgId,
secretValue
}: TSetSecretRequestValueDTO) => {
const appCfg = getConfig();
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
let respondentUsername: string | undefined;
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
if (!secretRequest.orgId) {
throw new BadRequestError({ message: "No organization ID present on secret request" });
}
if (!actorOrgId) {
throw new UnauthorizedError();
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
secretRequest.orgId,
actorAuthMethod,
actorOrgId
);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
const user = await userDAL.findById(actorId);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
respondentUsername = user.username;
}
if (secretRequest.encryptedSecret) {
throw new BadRequestError({ message: "Secret request already has a value set" });
}
if (secretValue.length > 10_000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
throw new ForbiddenRequestError({
message: "Access denied: Secret request has expired"
});
}
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const request = await secretSharingDAL.transaction(async (tx) => {
const updatedRequest = await secretSharingDAL.updateById(id, { encryptedSecret }, tx);
await smtpService.sendMail({
recipients: [secretRequest.requesterUsername],
subjectLine: "Secret Request Completed",
substitutions: {
name: secretRequest.name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/organization/secret-sharing?selectedTab=request-secret`
},
template: SmtpTemplates.SecretRequestCompleted
});
return updatedRequest;
});
return request;
};
const createPublicSharedSecret = async ({
password,
secretValue,
@ -100,17 +305,7 @@ export const secretSharingServiceFactory = ({
expiresAfterViews,
accessType
}: TCreatePublicSharedSecretDTO) => {
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" });
}
$validateSharedSecretExpiry(expiresAt);
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
@ -123,6 +318,7 @@ export const secretSharingServiceFactory = ({
encryptedValue: null,
iv: null,
tag: null,
type: SecretSharingType.Share,
encryptedSecret,
password: hashedPassword,
expiresAt: new Date(expiresAt),
@ -139,7 +335,8 @@ export const secretSharingServiceFactory = ({
actorAuthMethod,
actorOrgId,
offset,
limit
limit,
type
}: TGetSharedSecretsDTO) => {
if (!actorOrgId) throw new ForbiddenRequestError();
@ -155,14 +352,16 @@ export const secretSharingServiceFactory = ({
const secrets = await secretSharingDAL.find(
{
userId: actorId,
orgId: actorOrgId
orgId: actorOrgId,
type
},
{ offset, limit, sort: [["createdAt", "desc"]] }
);
const count = await secretSharingDAL.countAllUserOrgSharedSecrets({
orgId: actorOrgId,
userId: actorId
userId: actorId,
type
});
return {
@ -189,9 +388,11 @@ export const secretSharingServiceFactory = ({
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
type: SecretSharingType.Share,
hashedHex
})
: await secretSharingDAL.findOne({
type: SecretSharingType.Share,
identifier: Buffer.from(sharedSecretId, "base64url").toString("hex")
});
@ -256,7 +457,7 @@ export const secretSharingServiceFactory = ({
secret: {
...sharedSecret,
...(decryptedSecretValue && {
secretValue: Buffer.from(decryptedSecretValue).toString()
secretValue: decryptedSecretValue.toString()
}),
orgName:
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
@ -272,11 +473,17 @@ export const secretSharingServiceFactory = ({
if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" });
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findById(sharedSecretId)
: await secretSharingDAL.findOne({ identifier: sharedSecretId });
? await secretSharingDAL.findOne({ id: sharedSecretId, type: deleteSharedSecretInput.type })
: await secretSharingDAL.findOne({ identifier: sharedSecretId, type: deleteSharedSecretInput.type });
if (sharedSecret.orgId && sharedSecret.orgId !== orgId)
if (sharedSecret.userId !== actorId) {
throw new ForbiddenRequestError({
message: "User does not have permission to delete shared secret"
});
}
if (sharedSecret.orgId && sharedSecret.orgId !== orgId) {
throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" });
}
const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId);
@ -288,6 +495,11 @@ export const secretSharingServiceFactory = ({
createPublicSharedSecret,
getSharedSecrets,
deleteSharedSecretById,
getSharedSecretById
getSharedSecretById,
createSecretRequest,
getSecretRequestById,
setSecretRequestValue,
revealSecretRequestValue
};
};

View File

@ -1,8 +1,14 @@
import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types";
import { SecretSharingAccessType, TGenericPermission, TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export enum SecretSharingType {
Share = "share",
Request = "request"
}
export type TGetSharedSecretsDTO = {
type: SecretSharingType;
offset: number;
limit: number;
} & TGenericPermission;
@ -39,6 +45,26 @@ export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
export type TCreateSecretRequestDTO = {
name?: string;
accessType: SecretSharingAccessType;
expiresAt: string;
} & TOrgPermission;
export type TRevealSecretRequestValueDTO = {
id: string;
} & TOrgPermission;
export type TGetSecretRequestByIdDTO = {
id: string;
} & Omit<TOrgPermission, "orgId">;
export type TSetSecretRequestValueDTO = {
id: string;
secretValue: string;
} & Omit<TOrgPermission, "orgId">;
export type TDeleteSharedSecretDTO = {
sharedSecretId: string;
type: SecretSharingType;
} & TSharedSecretPermission;

View File

@ -7,6 +7,8 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types";
type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>;
type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>;
const MAX_RETRIES = 5;
const BATCH_SIZE = 10;
@ -80,6 +82,129 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
return awsParameterStoreSecretsRecord;
};
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => {
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
let hasNext = true;
let nextToken: string | undefined;
let attempt = 0;
while (hasNext) {
try {
// eslint-disable-next-line no-await-in-loop
const parameters = await ssm
.describeParameters({
MaxResults: 10,
NextToken: nextToken,
ParameterFilters: [
{
Key: "Path",
Option: "OneLevel",
Values: [path]
}
]
})
.promise();
attempt = 0;
if (parameters.Parameters) {
parameters.Parameters.forEach((parameter) => {
if (parameter.Name) {
// no leading slash if path is '/'
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreMetadataRecord[secKey] = parameter;
}
});
}
hasNext = Boolean(parameters.NextToken);
nextToken = parameters.NextToken;
} catch (e) {
if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
attempt += 1;
// eslint-disable-next-line no-await-in-loop
await sleep();
// eslint-disable-next-line no-continue
continue;
}
throw e;
}
}
return awsParameterStoreMetadataRecord;
};
const getParameterStoreTagsRecord = async (
ssm: AWS.SSM,
awsParameterStoreSecretsRecord: TAWSParameterStoreRecord,
needsTagsPermissions: boolean
): Promise<{ shouldManageTags: boolean; awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord }> => {
const awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord = {};
for await (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
const [key, parameter] = entry;
if (!parameter.Name) {
// eslint-disable-next-line no-continue
continue;
}
try {
const tags = await ssm
.listTagsForResource({
ResourceType: "Parameter",
ResourceId: parameter.Name
})
.promise();
awsParameterStoreTagsRecord[key] = Object.fromEntries(tags.TagList?.map((tag) => [tag.Key, tag.Value]) ?? []);
} catch (e) {
// users aren't required to provide tag permissions to use sync so we handle gracefully if unauthorized
// and they aren't trying to configure tags
if ((e as AWSError).code === "AccessDeniedException") {
if (!needsTagsPermissions) {
return { shouldManageTags: false, awsParameterStoreTagsRecord: {} };
}
throw new SecretSyncError({
message:
"IAM role has inadequate permissions to manage resource tags. Ensure the following polices are present: ssm:ListTagsForResource, ssm:AddTagsToResource, and ssm:RemoveTagsFromResource",
shouldRetry: false
});
}
throw e;
}
}
return { shouldManageTags: true, awsParameterStoreTagsRecord };
};
const processParameterTags = ({
syncTagsRecord,
awsTagsRecord
}: {
syncTagsRecord: Record<string, string>;
awsTagsRecord: Record<string, string>;
}) => {
const tagsToAdd: AWS.SSM.TagList = [];
const tagKeysToRemove: string[] = [];
for (const syncEntry of Object.entries(syncTagsRecord)) {
const [syncKey, syncValue] = syncEntry;
if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey])
tagsToAdd.push({ Key: syncKey, Value: syncValue });
}
for (const awsKey of Object.keys(awsTagsRecord)) {
if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey);
}
return { tagsToAdd, tagKeysToRemove };
};
const putParameter = async (
ssm: AWS.SSM,
params: AWS.SSM.PutParameterRequest,
@ -98,6 +223,42 @@ const putParameter = async (
}
};
const addTagsToParameter = async (
ssm: AWS.SSM,
params: Omit<AWS.SSM.AddTagsToResourceRequest, "ResourceType">,
attempt = 0
): Promise<AWS.SSM.AddTagsToResourceResult> => {
try {
return await ssm.addTagsToResource({ ...params, ResourceType: "Parameter" }).promise();
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return addTagsToParameter(ssm, params, attempt + 1);
}
throw error;
}
};
const removeTagsFromParameter = async (
ssm: AWS.SSM,
params: Omit<AWS.SSM.RemoveTagsFromResourceRequest, "ResourceType">,
attempt = 0
): Promise<AWS.SSM.RemoveTagsFromResourceResult> => {
try {
return await ssm.removeTagsFromResource({ ...params, ResourceType: "Parameter" }).promise();
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return removeTagsFromParameter(ssm, params, attempt + 1);
}
throw error;
}
};
const deleteParametersBatch = async (
ssm: AWS.SSM,
parameters: AWS.SSM.Parameter[],
@ -132,35 +293,92 @@ const deleteParametersBatch = async (
export const AwsParameterStoreSyncFns = {
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions } = secretSync;
const ssm = await getSSM(secretSync);
// TODO(scott): KMS Key ID, Tags
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path);
// skip empty values (not allowed by AWS) or secrets that haven't changed
if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) {
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
ssm,
awsParameterStoreSecretsRecord,
Boolean(syncOptions.tags?.length || syncOptions.syncSecretMetadataAsTags)
);
const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []);
for await (const entry of Object.entries(secretMap)) {
const [key, { value, secretMetadata }] = entry;
// skip empty values (not allowed by AWS)
if (!value) {
// eslint-disable-next-line no-continue
continue;
}
try {
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
const keyId = syncOptions.keyId ?? "alias/aws/ssm";
// create parameter or update if changed
if (
!(key in awsParameterStoreSecretsRecord) ||
value !== awsParameterStoreSecretsRecord[key].Value ||
keyId !== awsParameterStoreMetadataRecord[key]?.KeyId
) {
try {
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true,
KeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (shouldManageTags) {
const { tagsToAdd, tagKeysToRemove } = processParameterTags({
syncTagsRecord: {
// configured sync tags take preference over secret metadata
...(syncOptions.syncSecretMetadataAsTags &&
Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])),
...syncTagsRecord
},
awsTagsRecord: awsParameterStoreTagsRecord[key] ?? {}
});
if (tagsToAdd.length) {
try {
await addTagsToParameter(ssm, {
ResourceId: `${destinationConfig.path}${key}`,
Tags: tagsToAdd
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTagsFromParameter(ssm, {
ResourceId: `${destinationConfig.path}${key}`,
TagKeys: tagKeysToRemove
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
}
}

View File

@ -8,6 +8,7 @@ import {
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const AwsParameterStoreSyncDestinationConfigSchema = z.object({
region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.region),
@ -20,19 +21,68 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({
.describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.path)
});
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({
const AwsParameterStoreSyncOptionsSchema = z.object({
keyId: z
.string()
.regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID")
.min(1, "Invalid KMS Key ID")
.max(256, "Invalid KMS Key ID")
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.keyId),
tags: z
.object({
key: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Resource tag key required")
.max(128, "Resource tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Resource tag value cannot exceed 256 characters")
})
.array()
.max(50)
.refine((items) => new Set(items.map((item) => item.key)).size === items.length, {
message: "Resource tag keys must be unique"
})
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.tags),
syncSecretMetadataAsTags: z
.boolean()
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.syncSecretMetadataAsTags)
});
const AwsParameterStoreSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional()
});

View File

@ -1,16 +1,28 @@
import { UntagResourceCommandOutput } from "@aws-sdk/client-kms";
import {
BatchGetSecretValueCommand,
CreateSecretCommand,
CreateSecretCommandInput,
DeleteSecretCommand,
DeleteSecretResponse,
DescribeSecretCommand,
DescribeSecretCommandInput,
ListSecretsCommand,
SecretsManagerClient,
TagResourceCommand,
TagResourceCommandOutput,
UntagResourceCommand,
UpdateSecretCommand,
UpdateSecretCommandInput
} from "@aws-sdk/client-secrets-manager";
import { AWSError } from "aws-sdk";
import { CreateSecretResponse, SecretListEntry, SecretValueEntry } from "aws-sdk/clients/secretsmanager";
import {
CreateSecretResponse,
DescribeSecretResponse,
SecretListEntry,
SecretValueEntry,
Tag
} from "aws-sdk/clients/secretsmanager";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums";
@ -21,6 +33,7 @@ import { TAwsSecretsManagerSyncWithCredentials } from "./aws-secrets-manager-syn
type TAwsSecretsRecord = Record<string, SecretListEntry>;
type TAwsSecretValuesRecord = Record<string, SecretValueEntry>;
type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>;
const MAX_RETRIES = 5;
const BATCH_SIZE = 20;
@ -135,6 +148,46 @@ const getSecretValuesRecord = async (
return awsSecretValuesRecord;
};
const describeSecret = async (
client: SecretsManagerClient,
input: DescribeSecretCommandInput,
attempt = 0
): Promise<DescribeSecretResponse> => {
try {
return await client.send(new DescribeSecretCommand(input));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return describeSecret(client, input, attempt + 1);
}
throw error;
}
};
const getSecretDescriptionsRecord = async (
client: SecretsManagerClient,
awsSecretsRecord: TAwsSecretsRecord
): Promise<TAwsSecretDescriptionsRecord> => {
const awsSecretDescriptionsRecord: TAwsSecretValuesRecord = {};
for await (const secretKey of Object.keys(awsSecretsRecord)) {
try {
awsSecretDescriptionsRecord[secretKey] = await describeSecret(client, {
SecretId: secretKey
});
} catch (error) {
throw new SecretSyncError({
secretKey,
error
});
}
}
return awsSecretDescriptionsRecord;
};
const createSecret = async (
client: SecretsManagerClient,
input: CreateSecretCommandInput,
@ -189,9 +242,71 @@ const deleteSecret = async (
}
};
const addTags = async (
client: SecretsManagerClient,
secretKey: string,
tags: Tag[],
attempt = 0
): Promise<TagResourceCommandOutput> => {
try {
return await client.send(new TagResourceCommand({ SecretId: secretKey, Tags: tags }));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return addTags(client, secretKey, tags, attempt + 1);
}
throw error;
}
};
const removeTags = async (
client: SecretsManagerClient,
secretKey: string,
tagKeys: string[],
attempt = 0
): Promise<UntagResourceCommandOutput> => {
try {
return await client.send(new UntagResourceCommand({ SecretId: secretKey, TagKeys: tagKeys }));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return removeTags(client, secretKey, tagKeys, attempt + 1);
}
throw error;
}
};
const processTags = ({
syncTagsRecord,
awsTagsRecord
}: {
syncTagsRecord: Record<string, string>;
awsTagsRecord: Record<string, string>;
}) => {
const tagsToAdd: Tag[] = [];
const tagKeysToRemove: string[] = [];
for (const syncEntry of Object.entries(syncTagsRecord)) {
const [syncKey, syncValue] = syncEntry;
if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey])
tagsToAdd.push({ Key: syncKey, Value: syncValue });
}
for (const awsKey of Object.keys(awsTagsRecord)) {
if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey);
}
return { tagsToAdd, tagKeysToRemove };
};
export const AwsSecretsManagerSyncFns = {
syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions } = secretSync;
const client = await getSecretsManagerClient(secretSync);
@ -199,9 +314,15 @@ export const AwsSecretsManagerSyncFns = {
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
const awsDescriptionsRecord = await getSecretDescriptionsRecord(client, awsSecretsRecord);
const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []);
const keyId = syncOptions.keyId ?? "alias/aws/secretsmanager";
if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) {
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
const [key, { value, secretMetadata }] = entry;
// skip secrets that don't have a value set
if (!value) {
@ -211,15 +332,26 @@ export const AwsSecretsManagerSyncFns = {
if (awsSecretsRecord[key]) {
// skip secrets that haven't changed
if (awsValuesRecord[key]?.SecretString === value) {
// eslint-disable-next-line no-continue
continue;
if (awsValuesRecord[key]?.SecretString !== value || keyId !== awsDescriptionsRecord[key]?.KmsKeyId) {
try {
await updateSecret(client, {
SecretId: key,
SecretString: value,
KmsKeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
} else {
try {
await updateSecret(client, {
SecretId: key,
SecretString: value
await createSecret(client, {
Name: key,
SecretString: value,
KmsKeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
@ -227,12 +359,34 @@ export const AwsSecretsManagerSyncFns = {
secretKey: key
});
}
} else {
}
const { tagsToAdd, tagKeysToRemove } = processTags({
syncTagsRecord: {
// configured sync tags take preference over secret metadata
...(syncOptions.syncSecretMetadataAsTags &&
Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])),
...syncTagsRecord
},
awsTagsRecord: Object.fromEntries(
awsDescriptionsRecord[key]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? []
)
});
if (tagsToAdd.length) {
try {
await createSecret(client, {
Name: key,
SecretString: value
await addTags(client, key, tagsToAdd);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTags(client, key, tagKeysToRemove);
} catch (error) {
throw new SecretSyncError({
error,
@ -261,17 +415,48 @@ export const AwsSecretsManagerSyncFns = {
Object.fromEntries(Object.entries(secretMap).map(([key, secretData]) => [key, secretData.value]))
);
if (awsValuesRecord[destinationConfig.secretName]) {
if (awsSecretsRecord[destinationConfig.secretName]) {
await updateSecret(client, {
SecretId: destinationConfig.secretName,
SecretString: secretValue
SecretString: secretValue,
KmsKeyId: keyId
});
} else {
await createSecret(client, {
Name: destinationConfig.secretName,
SecretString: secretValue
SecretString: secretValue,
KmsKeyId: keyId
});
}
const { tagsToAdd, tagKeysToRemove } = processTags({
syncTagsRecord,
awsTagsRecord: Object.fromEntries(
awsDescriptionsRecord[destinationConfig.secretName]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? []
)
});
if (tagsToAdd.length) {
try {
await addTags(client, destinationConfig.secretName, tagsToAdd);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: destinationConfig.secretName
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTags(client, destinationConfig.secretName, tagKeysToRemove);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: destinationConfig.secretName
});
}
}
}
},
getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise<TSecretMap> => {

View File

@ -9,6 +9,7 @@ import {
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const AwsSecretsManagerSyncDestinationConfigSchema = z
.discriminatedUnion("mappingBehavior", [
@ -38,22 +39,95 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z
})
);
export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema(SecretSync.AWSSecretsManager).extend({
const AwsSecretsManagerSyncOptionsSchema = z.object({
keyId: z
.string()
.regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID")
.min(1, "Invalid KMS Key ID")
.max(256, "Invalid KMS Key ID")
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.keyId),
tags: z
.object({
key: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Tag key required")
.max(128, "Tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Tag value cannot exceed 256 characters")
})
.array()
.max(50)
.refine((items) => new Set(items.map((item) => item.key)).size === items.length, {
message: "Tag keys must be unique"
})
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.tags),
syncSecretMetadataAsTags: z
.boolean()
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.syncSecretMetadataAsTags)
});
const AwsSecretsManagerSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema(
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
).extend({
destination: z.literal(SecretSync.AWSSecretsManager),
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
});
export const CreateAwsSecretsManagerSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AWSSecretsManager
).extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
});
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
)
.extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
})
.superRefine((sync, ctx) => {
if (
sync.destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne &&
sync.syncOptions.syncSecretMetadataAsTags
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.'
});
}
});
export const UpdateAwsSecretsManagerSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AWSSecretsManager
).extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional()
});
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
)
.extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional()
})
.superRefine((sync, ctx) => {
if (
sync.destinationConfig?.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne &&
sync.syncOptions.syncSecretMetadataAsTags
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.'
});
}
});
export const AwsSecretsManagerSyncListItemSchema = z.object({
name: z.literal("AWS Secrets Manager"),

View File

@ -233,6 +233,7 @@ export const secretSyncQueueFactory = ({
}
secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
secretMap[secretKey].secretMetadata = secret.secretMetadata;
})
);
@ -258,7 +259,8 @@ export const secretSyncQueueFactory = ({
secretMap[importedSecret.key] = {
skipMultilineEncoding: importedSecret.skipMultilineEncoding,
comment: importedSecret.secretComment,
value: importedSecret.secretValue || ""
value: importedSecret.secretValue || "",
secretMetadata: importedSecret.secretMetadata
};
}
}

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import { AnyZodObject, z } from "zod";
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
import { SecretSyncs } from "@app/lib/api-docs";
@ -8,34 +8,45 @@ import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) =>
z.object({
initialSyncBehavior: (options.canImportSecrets
const BaseSyncOptionsSchema = <T extends AnyZodObject | undefined = undefined>({
destination,
syncOptionsConfig: { canImportSecrets },
merge,
isUpdateSchema
}: {
destination: SecretSync;
syncOptionsConfig: TSyncOptionsConfig;
merge?: T;
isUpdateSchema?: boolean;
}) => {
const baseSchema = z.object({
initialSyncBehavior: (canImportSecrets
? z.nativeEnum(SecretSyncInitialSyncBehavior)
: z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination)
).describe(SecretSyncs.SYNC_OPTIONS(secretSync).initialSyncBehavior)
// prependPrefix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX),
// appendSuffix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX)
).describe(SecretSyncs.SYNC_OPTIONS(destination).initialSyncBehavior)
});
export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
const schema = merge ? baseSchema.merge(merge) : baseSchema;
return (
isUpdateSchema
? schema.describe(SecretSyncs.UPDATE(destination).syncOptions).optional()
: schema.describe(SecretSyncs.CREATE(destination).syncOptions)
) as T extends AnyZodObject ? z.ZodObject<z.objectUtil.MergeShapes<typeof schema.shape, T["shape"]>> : typeof schema;
};
export const BaseSecretSyncSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
SecretSyncsSchema.omit({
destination: true,
destinationConfig: true,
syncOptions: true
}).extend({
// destination needs to be on the extended object for type differentiation
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig),
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge }),
// join properties
projectId: z.string(),
connection: z.object({
@ -47,7 +58,11 @@ export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?
folder: z.object({ id: z.string(), path: z.string() }).nullable()
});
export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
export const GenericCreateSecretSyncFieldsSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name),
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId),
@ -66,10 +81,14 @@ export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syn
.transform(removeTrailingSlash)
.describe(SecretSyncs.CREATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions)
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge })
});
export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
export const GenericUpdateSecretSyncFieldsSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(),
connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(),
@ -90,7 +109,5 @@ export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syn
.optional()
.describe(SecretSyncs.UPDATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig)
.optional()
.describe(SecretSyncs.UPDATE(destination).syncOptions)
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge, isUpdateSchema: true })
});

View File

@ -2,6 +2,7 @@ import { Job } from "bullmq";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import {
TAwsSecretsManagerSync,
TAwsSecretsManagerSyncInput,
@ -197,5 +198,10 @@ export type TSendSecretSyncFailedNotificationsJobDTO = Job<
export type TSecretMap = Record<
string,
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
{
value: string;
comment?: string;
skipMultilineEncoding?: boolean | null | undefined;
secretMetadata?: ResourceMetadataDTO;
}
>;

View File

@ -39,7 +39,8 @@ export enum SmtpTemplates {
SecretSyncFailed = "secretSyncFailed.handlebars",
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
ExternalImportFailed = "externalImportFailed.handlebars",
ExternalImportStarted = "externalImportStarted.handlebars"
ExternalImportStarted = "externalImportStarted.handlebars",
SecretRequestCompleted = "secretRequestCompleted.handlebars"
}
export enum SmtpHost {

View File

@ -0,0 +1,33 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Secret Request Completed</title>
</head>
<body>
<h2>Infisical</h2>
<h2>A secret has been shared with you</h2>
{{#if name}}
<p>Secret request name: {{name}}</p>
{{/if}}
{{#if respondentUsername}}
<p>Shared by: {{respondentUsername}}</p>
{{/if}}
<br />
<br/>
<p>
You can access the secret by clicking the link below.
</p>
<p>
<a href="{{secretRequestUrl}}">Access Secret</a>
</p>
{{emailFooter}}
</body>
</html>

View File

@ -291,6 +291,15 @@ export const superAdminServiceFactory = ({
return user;
};
const grantServerAdminAccessToUser = async (userId: string) => {
if (!licenseService.onPremFeatures?.instanceUserManagement) {
throw new BadRequestError({
message: "Failed to grant server admin access to user due to plan restriction. Upgrade to Infisical's Pro plan."
});
}
await userDAL.updateById(userId, { superAdmin: true });
};
const getAdminSlackConfig = async () => {
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
@ -381,6 +390,7 @@ export const superAdminServiceFactory = ({
deleteUser,
getAdminSlackConfig,
updateRootEncryptionStrategy,
getConfiguredEncryptionStrategies
getConfiguredEncryptionStrategies,
grantServerAdminAccessToUser
};
};

View File

@ -13,7 +13,9 @@ export enum PostHogEventTypes {
IntegrationCreated = "Integration Created",
MachineIdentityCreated = "Machine Identity Created",
UserOrgInvitation = "User Org Invitation",
TelemetryInstanceStats = "Self Hosted Instance Stats"
TelemetryInstanceStats = "Self Hosted Instance Stats",
SecretRequestCreated = "Secret Request Created",
SecretRequestDeleted = "Secret Request Deleted"
}
export type TSecretModifiedEvent = {
@ -120,6 +122,23 @@ export type TTelemetryInstanceStatsEvent = {
};
};
export type TSecretRequestCreatedEvent = {
event: PostHogEventTypes.SecretRequestCreated;
properties: {
secretRequestId: string;
organizationId: string;
secretRequestName?: string;
};
};
export type TSecretRequestDeletedEvent = {
event: PostHogEventTypes.SecretRequestDeleted;
properties: {
secretRequestId: string;
organizationId: string;
};
};
export type TPostHogEvent = { distinctId: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
@ -130,4 +149,6 @@ export type TPostHogEvent = { distinctId: string } & (
| TIntegrationCreatedEvent
| TProjectCreateEvent
| TTelemetryInstanceStatsEvent
| TSecretRequestCreatedEvent
| TSecretRequestDeletedEvent
);

View File

@ -0,0 +1,8 @@
public_ip: 127.0.0.1
auth_secret: changeThisOnProduction
realm: infisical.org
# set port 5349 for tls
# port: 5349
# tls_private_key_path: /full-path
# tls_ca_path: /full-path
# tls_cert_path: /full-path

View File

@ -1,6 +1,8 @@
module github.com/Infisical/infisical-merge
go 1.21
go 1.23.0
toolchain go1.23.5
require (
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
@ -18,14 +20,17 @@ require (
github.com/muesli/reflow v0.3.0
github.com/muesli/roff v0.1.0
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9
github.com/pion/logging v0.2.3
github.com/pion/turn/v4 v4.0.0
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a
github.com/quic-go/quic-go v0.50.0
github.com/rs/cors v1.11.0
github.com/rs/zerolog v1.26.1
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.8.1
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.31.0
golang.org/x/term v0.27.0
golang.org/x/crypto v0.35.0
golang.org/x/term v0.29.0
gopkg.in/yaml.v2 v2.4.0
)
@ -56,13 +61,15 @@ require (
github.com/dvsekhvalnov/jose2go v1.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/errors v0.20.2 // indirect
github.com/go-openapi/strfmt v0.21.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
@ -80,7 +87,12 @@ require (
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.22.2 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pion/dtls/v3 v3.0.4 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
@ -88,6 +100,7 @@ require (
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
go.mongodb.org/mongo-driver v1.10.0 // indirect
go.opencensus.io v0.24.0 // indirect
@ -96,17 +109,21 @@ require (
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/net v0.33.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/api v0.188.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect
google.golang.org/grpc v1.64.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -144,8 +144,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/errors v0.20.2 h1:dxy7PGTqEh94zj2E3h1cUmQQWiM1+aeCROfAr02EmK8=
@ -154,6 +154,8 @@ github.com/go-openapi/strfmt v0.21.3 h1:xwhj5X6CjXEZZHMWy1zKJxvW9AfHC9pkyUjLvHtK
github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -222,6 +224,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
@ -342,11 +346,27 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 h1:lL+y4Xv20pVlCGyLzNHRC0I0rIHhIL1lTvHizoS/dU8=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -357,6 +377,8 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a h1:Ey0XWvrg6u6hyIn1Kd/jCCmL+bMv9El81tvuGBbxZGg=
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a/go.mod h1:oa2sAs9tGai3VldabTV0eWejt/O4/OOD7azP8GaikqU=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/quic-go v0.50.0 h1:3H/ld1pa3CYhkcc20TPIyG1bNsdhn9qZBGN3b9/UyUo=
github.com/quic-go/quic-go v0.50.0/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@ -410,6 +432,8 @@ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
@ -447,6 +471,8 @@ go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8p
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -458,8 +484,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -470,6 +496,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -495,6 +523,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -533,8 +563,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -560,8 +590,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -610,11 +640,11 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -624,8 +654,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -683,6 +713,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -797,8 +829,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -544,3 +544,59 @@ func CallUpdateRawSecretsV3(httpClient *resty.Client, request UpdateRawSecretByN
return nil
}
func CallRegisterGatewayIdentityV1(httpClient *resty.Client) (*GetRelayCredentialsResponseV1, error) {
var resBody GetRelayCredentialsResponseV1
response, err := httpClient.
R().
SetResult(&resBody).
SetHeader("User-Agent", USER_AGENT).
Post(fmt.Sprintf("%v/v1/gateways/register-identity", config.INFISICAL_URL))
if err != nil {
return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unable to complete api request [err=%w]", err)
}
if response.IsError() {
return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String())
}
return &resBody, nil
}
func CallExchangeRelayCertV1(httpClient *resty.Client, request ExchangeRelayCertRequestV1) (*ExchangeRelayCertResponseV1, error) {
var resBody ExchangeRelayCertResponseV1
response, err := httpClient.
R().
SetResult(&resBody).
SetBody(request).
SetHeader("User-Agent", USER_AGENT).
Post(fmt.Sprintf("%v/v1/gateways/exchange-cert", config.INFISICAL_URL))
if err != nil {
return nil, fmt.Errorf("CallExchangeRelayCertV1: Unable to complete api request [err=%w]", err)
}
if response.IsError() {
return nil, fmt.Errorf("CallExchangeRelayCertV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String())
}
return &resBody, nil
}
func CallGatewayHeartBeatV1(httpClient *resty.Client) error {
response, err := httpClient.
R().
SetHeader("User-Agent", USER_AGENT).
Post(fmt.Sprintf("%v/v1/gateways/heartbeat", config.INFISICAL_URL))
if err != nil {
return fmt.Errorf("CallGatewayHeartBeatV1: Unable to complete api request [err=%w]", err)
}
if response.IsError() {
return fmt.Errorf("CallGatewayHeartBeatV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String())
}
return nil
}

View File

@ -629,3 +629,22 @@ type GetRawSecretV3ByNameResponse struct {
} `json:"secret"`
ETag string
}
type GetRelayCredentialsResponseV1 struct {
TurnServerUsername string `json:"turnServerUsername"`
TurnServerPassword string `json:"turnServerPassword"`
TurnServerRealm string `json:"turnServerRealm"`
TurnServerAddress string `json:"turnServerAddress"`
InfisicalStaticIp string `json:"infisicalStaticIp"`
}
type ExchangeRelayCertRequestV1 struct {
RelayAddress string `json:"relayAddress"`
}
type ExchangeRelayCertResponseV1 struct {
SerialNumber string `json:"serialNumber"`
PrivateKey string `json:"privateKey"`
Certificate string `json:"certificate"`
CertificateChain string `json:"certificateChain"`
}

151
cli/packages/cmd/gateway.go Normal file
View File

@ -0,0 +1,151 @@
package cmd
import (
// "fmt"
// "github.com/Infisical/infisical-merge/packages/api"
// "github.com/Infisical/infisical-merge/packages/models"
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/Infisical/infisical-merge/packages/gateway"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/rs/zerolog/log"
// "github.com/Infisical/infisical-merge/packages/visualize"
// "github.com/rs/zerolog/log"
// "github.com/go-resty/resty/v2"
"github.com/posthog/posthog-go"
"github.com/spf13/cobra"
)
var gatewayCmd = &cobra.Command{
Example: `infisical gateway`,
Short: "Used to infisical gateway",
Use: "gateway",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
if token == nil {
util.HandleError(fmt.Errorf("Token not found"))
}
Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION))
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sigStopCh := make(chan bool, 1)
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
go func() {
<-sigCh
close(sigStopCh)
cancel()
// If we get a second signal, force exit
<-sigCh
log.Warn().Msgf("Force exit triggered")
os.Exit(1)
}()
// Main gateway retry loop with proper context handling
retryTicker := time.NewTicker(5 * time.Second)
defer retryTicker.Stop()
for {
if ctx.Err() != nil {
log.Info().Msg("Shutting down gateway")
return
}
gatewayInstance, err := gateway.NewGateway(token.Token)
if err != nil {
util.HandleError(err)
}
if err = gatewayInstance.ConnectWithRelay(); err != nil {
if ctx.Err() != nil {
log.Info().Msg("Shutting down gateway")
return
}
log.Error().Msgf("Gateway connection error with relay: %s", err)
log.Info().Msg("Retrying connection in 5 seconds...")
select {
case <-retryTicker.C:
continue
case <-ctx.Done():
log.Info().Msg("Shutting down gateway")
return
}
}
err = gatewayInstance.Listen(ctx)
if ctx.Err() != nil {
log.Info().Msg("Gateway shutdown complete")
return
}
log.Error().Msgf("Gateway listen error: %s", err)
log.Info().Msg("Retrying connection in 5 seconds...")
select {
case <-retryTicker.C:
continue
case <-ctx.Done():
log.Info().Msg("Shutting down gateway")
return
}
}
},
}
var gatewayRelayCmd = &cobra.Command{
Example: `infisical gateway relay`,
Short: "Used to run infisical gateway relay",
Use: "relay",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
relayConfigFilePath, err := cmd.Flags().GetString("config")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
if relayConfigFilePath == "" {
util.HandleError(fmt.Errorf("Missing config file"))
}
gatewayRelay, err := gateway.NewGatewayRelay(relayConfigFilePath)
if err != nil {
util.HandleError(err, "Failed to initialize gateway")
}
err = gatewayRelay.Run()
if err != nil {
util.HandleError(err, "Failed to start gateway")
}
},
}
func init() {
gatewayCmd.SetHelpFunc(func(command *cobra.Command, strings []string) {
command.Flags().MarkHidden("domain")
command.Parent().HelpFunc()(command, strings)
})
gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path")
gatewayCmd.AddCommand(gatewayRelayCmd)
rootCmd.AddCommand(gatewayCmd)
}

View File

@ -0,0 +1,142 @@
package gateway
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"strings"
"sync"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog/log"
)
func handleConnection(ctx context.Context, quicConn quic.Connection) {
log.Info().Msgf("New connection from: %s", quicConn.RemoteAddr().String())
// Use WaitGroup to track all streams
var wg sync.WaitGroup
for {
// Accept the first stream, which we'll use for commands
stream, err := quicConn.AcceptStream(ctx)
if err != nil {
log.Printf("Failed to accept QUIC stream: %v", err)
break
}
wg.Add(1)
go func(stream quic.Stream) {
defer wg.Done()
defer stream.Close()
handleStream(stream, quicConn)
}(stream)
}
wg.Wait()
log.Printf("All streams closed for connection: %s", quicConn.RemoteAddr().String())
}
func handleStream(stream quic.Stream, quicConn quic.Connection) {
streamID := stream.StreamID()
log.Printf("New stream %d from: %s", streamID, quicConn.RemoteAddr().String())
// Use buffered reader for better handling of fragmented data
reader := bufio.NewReader(stream)
defer stream.Close()
for {
msg, err := reader.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return
}
log.Error().Msgf("Error reading command: %s", err)
return
}
cmd := bytes.ToUpper(bytes.TrimSpace(bytes.Split(msg, []byte(" "))[0]))
args := bytes.TrimSpace(bytes.TrimPrefix(msg, cmd))
switch string(cmd) {
case "FORWARD-TCP":
proxyAddress := string(bytes.Split(args, []byte(" "))[0])
destTarget, err := net.Dial("tcp", proxyAddress)
if err != nil {
log.Error().Msgf("Failed to connect to target: %v", err)
return
}
defer destTarget.Close()
log.Info().Msgf("Starting secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String())
// Handle buffered data
buffered := reader.Buffered()
if buffered > 0 {
bufferedData := make([]byte, buffered)
_, err := reader.Read(bufferedData)
if err != nil {
log.Error().Msgf("Error reading buffered data: %v", err)
return
}
if _, err = destTarget.Write(bufferedData); err != nil {
log.Error().Msgf("Error writing buffered data: %v", err)
return
}
}
CopyDataFromQuicToTcp(stream, destTarget)
log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String())
return
case "PING":
if _, err := stream.Write([]byte("PONG\n")); err != nil {
log.Error().Msgf("Error writing PONG response: %v", err)
}
return
default:
log.Error().Msgf("Unknown command: %s", string(cmd))
return
}
}
}
type CloseWrite interface {
CloseWrite() error
}
func CopyDataFromQuicToTcp(quicStream quic.Stream, tcpConn net.Conn) {
// Create a WaitGroup to wait for both copy operations
var wg sync.WaitGroup
wg.Add(2)
// Start copying from QUIC stream to TCP
go func() {
defer wg.Done()
if _, err := io.Copy(tcpConn, quicStream); err != nil {
log.Error().Msgf("Error copying quic->postgres: %v", err)
}
if e, ok := tcpConn.(CloseWrite); ok {
log.Debug().Msg("Closing TCP write end")
e.CloseWrite()
} else {
log.Debug().Msg("TCP connection does not support CloseWrite")
}
}()
// Start copying from TCP to QUIC stream
go func() {
defer wg.Done()
if _, err := io.Copy(quicStream, tcpConn); err != nil {
log.Debug().Msgf("Error copying postgres->quic: %v", err)
}
// Close the write side of the QUIC stream
if err := quicStream.Close(); err != nil && !strings.Contains(err.Error(), "close called for canceled stream") {
log.Error().Msgf("Error closing QUIC stream write: %v", err)
}
}()
// Wait for both copies to complete
wg.Wait()
}

View File

@ -0,0 +1,363 @@
package gateway
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
"github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/systemd"
"github.com/go-resty/resty/v2"
"github.com/pion/logging"
"github.com/pion/turn/v4"
"github.com/rs/zerolog/log"
"github.com/quic-go/quic-go"
)
type GatewayConfig struct {
TurnServerUsername string
TurnServerPassword string
TurnServerAddress string
InfisicalStaticIp string
SerialNumber string
PrivateKey string
Certificate string
CertificateChain string
}
type Gateway struct {
httpClient *resty.Client
config *GatewayConfig
client *turn.Client
}
func NewGateway(identityToken string) (Gateway, error) {
httpClient := resty.New()
httpClient.SetAuthToken(identityToken)
return Gateway{
httpClient: httpClient,
config: &GatewayConfig{},
}, nil
}
func (g *Gateway) ConnectWithRelay() error {
relayDetails, err := api.CallRegisterGatewayIdentityV1(g.httpClient)
if err != nil {
return err
}
relayAddress, relayPort := strings.Split(relayDetails.TurnServerAddress, ":")[0], strings.Split(relayDetails.TurnServerAddress, ":")[1]
var conn net.Conn
// Dial TURN Server
if relayPort == "5349" {
log.Info().Msgf("Provided relay port %s. Using TLS", relayPort)
conn, err = tls.Dial("tcp", relayDetails.TurnServerAddress, &tls.Config{
ServerName: relayAddress,
})
} else {
log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort)
peerAddr, errPeer := net.ResolveTCPAddr("tcp", relayDetails.TurnServerAddress)
if errPeer != nil {
return fmt.Errorf("Failed to parse turn server address: %w", err)
}
conn, err = net.DialTCP("tcp", nil, peerAddr)
}
if err != nil {
return fmt.Errorf("Failed to connect with relay server: %w", err)
}
// Start a new TURN Client and wrap our net.Conn in a STUNConn
// This allows us to simulate datagram based communication over a net.Conn
logger := logging.NewDefaultLoggerFactory()
if os.Getenv("LOG_LEVEL") == "debug" {
logger.DefaultLogLevel = logging.LogLevelDebug
}
cfg := &turn.ClientConfig{
STUNServerAddr: relayDetails.TurnServerAddress,
TURNServerAddr: relayDetails.TurnServerAddress,
Conn: turn.NewSTUNConn(conn),
Username: relayDetails.TurnServerUsername,
Password: relayDetails.TurnServerPassword,
Realm: relayDetails.TurnServerRealm,
LoggerFactory: logger,
}
client, err := turn.NewClient(cfg)
if err != nil {
return fmt.Errorf("Failed to create relay client: %w", err)
}
g.config = &GatewayConfig{
TurnServerUsername: relayDetails.TurnServerUsername,
TurnServerPassword: relayDetails.TurnServerPassword,
TurnServerAddress: relayDetails.TurnServerAddress,
InfisicalStaticIp: relayDetails.InfisicalStaticIp,
}
g.client = client
return nil
}
func (g *Gateway) Listen(ctx context.Context) error {
defer g.client.Close()
err := g.client.Listen()
if err != nil {
return fmt.Errorf("Failed to listen to relay server: %w", err)
}
log.Info().Msg("Connected with relay")
// Allocate a relay socket on the TURN server. On success, it
// will return a net.PacketConn which represents the remote
// socket.
relayUdpConnection, err := g.client.Allocate()
if err != nil {
return fmt.Errorf("Failed to allocate relay connection: %w", err)
}
log.Info().Msg(relayUdpConnection.LocalAddr().String())
defer func() {
if closeErr := relayUdpConnection.Close(); closeErr != nil {
log.Error().Msgf("Failed to close connection: %s", closeErr)
}
}()
gatewayCert, err := api.CallExchangeRelayCertV1(g.httpClient, api.ExchangeRelayCertRequestV1{
RelayAddress: relayUdpConnection.LocalAddr().String(),
})
if err != nil {
return err
}
g.config.SerialNumber = gatewayCert.SerialNumber
g.config.PrivateKey = gatewayCert.PrivateKey
g.config.Certificate = gatewayCert.Certificate
g.config.CertificateChain = gatewayCert.CertificateChain
errCh := make(chan error, 1)
shutdownCh := make(chan bool, 1)
if err = g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil {
return err
}
g.registerHeartBeat(ctx, errCh)
cert, err := tls.X509KeyPair([]byte(gatewayCert.Certificate), []byte(gatewayCert.PrivateKey))
if err != nil {
return fmt.Errorf("failed to parse cert: %w", err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(gatewayCert.CertificateChain))
// Setup QUIC server
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
NextProtos: []string{"infisical-gateway"},
}
// Setup QUIC listener on the relayConn
quicConfig := &quic.Config{
EnableDatagrams: true,
MaxIdleTimeout: 10 * time.Second,
KeepAlivePeriod: 2 * time.Second,
}
g.registerRelayIsActive(ctx, relayUdpConnection.LocalAddr().String(), errCh)
quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig)
if err != nil {
return fmt.Errorf("Failed to listen for QUIC: %w", err)
}
defer quicListener.Close()
log.Printf("Listener started on %s", quicListener.Addr())
log.Info().Msg("Gateway started successfully")
var wg sync.WaitGroup
go func() {
for {
select {
case <-ctx.Done():
return
case <-shutdownCh:
return
default:
// Accept new relay connection
quicConn, err := quicListener.Accept(context.Background())
if err != nil {
log.Printf("Failed to accept QUIC connection: %v", err)
continue
}
tlsState := quicConn.ConnectionState().TLS
if len(tlsState.PeerCertificates) > 0 {
organizationUnit := tlsState.PeerCertificates[0].Subject.OrganizationalUnit
commonName := tlsState.PeerCertificates[0].Subject.CommonName
if organizationUnit[0] != "gateway-client" || commonName != "cloud" {
errMsg := fmt.Sprintf("Client certificate verification failed. Received %s, %s", organizationUnit, commonName)
log.Error().Msg(errMsg)
quicConn.CloseWithError(1, errMsg)
continue
}
}
// Handle the connection in a goroutine
wg.Add(1)
go func(c quic.Connection) {
defer wg.Done()
defer c.CloseWithError(0, "connection closed")
// Monitor parent context to close this connection when needed
go func() {
select {
case <-ctx.Done():
c.CloseWithError(0, "connection closed") // Force close connection when context is canceled
case <-shutdownCh:
c.CloseWithError(0, "connection closed") // Force close connection when accepting loop is done
}
}()
handleConnection(ctx, c)
}(quicConn)
}
}
}()
// make this compatiable with systemd notify mode
systemd.SdNotify(false, systemd.SdNotifyReady)
select {
case <-ctx.Done():
log.Info().Msg("Shutting down gateway...")
case err = <-errCh:
log.Error().Err(err).Msg("Gateway error occurred")
}
// Signal the accept loop to stop
close(shutdownCh)
// Set a timeout for waiting on connections to close
waitCh := make(chan struct{})
go func() {
wg.Wait()
close(waitCh)
}()
select {
case <-waitCh:
// All connections closed normally
case <-time.After(5 * time.Second):
log.Warn().Msg("Timeout waiting for connections to close gracefully")
}
return err
}
func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
go func() {
for {
if err := api.CallGatewayHeartBeatV1(g.httpClient); err != nil {
errCh <- err
} else {
log.Info().Msg("Gateway is reachable by Infisical")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func (g *Gateway) createPermissionForStaticIps(staticIps string) error {
if staticIps == "" {
return fmt.Errorf("Missing Infisical static ips for permission")
}
splittedIps := strings.Split(staticIps, ",")
resolvedIps := make([]net.Addr, 0)
for _, ip := range splittedIps {
ip = strings.TrimSpace(ip)
if ip == "" {
continue
}
// if port not specific allow all port
if !strings.Contains(ip, ":") {
ip = ip + ":0"
}
peerAddr, err := net.ResolveUDPAddr("udp", ip)
if err != nil {
return fmt.Errorf("Failed to resolve static ip for permission: %w", err)
}
resolvedIps = append(resolvedIps, peerAddr)
}
if err := g.client.CreatePermission(resolvedIps...); err != nil {
return fmt.Errorf("Failed to set ip permission: %w", err)
}
return nil
}
func (g *Gateway) registerRelayIsActive(ctx context.Context, relayAddress string, errCh chan error) error {
ticker := time.NewTicker(10 * time.Second)
maxFailures := 3
failures := 0
go func() {
time.Sleep(2 * time.Second)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Configure TLS to skip verification
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
NextProtos: []string{"infisical-gateway"},
}
quicConfig := &quic.Config{
EnableDatagrams: true,
}
func() {
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
conn, err := quic.DialAddr(checkCtx, relayAddress, tlsConfig, quicConfig)
if err != nil {
failures++
log.Warn().Err(err).Int("failures", failures).Msg("Relay connection check failed")
if failures >= maxFailures {
errCh <- fmt.Errorf("relay connection check failed: %w", err)
}
}
if conn != nil {
conn.CloseWithError(0, "closed")
}
}()
}
}
}()
return nil
}

View File

@ -0,0 +1,187 @@
package gateway
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"os"
"os/signal"
"runtime"
"strconv"
"syscall"
udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener"
"github.com/Infisical/infisical-merge/packages/systemd"
"github.com/pion/logging"
"github.com/pion/turn/v4"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
)
var (
errMissingTlsCert = errors.New("Missing TLS files")
)
type GatewayRelay struct {
Config *GatewayRelayConfig
}
type GatewayRelayConfig struct {
PublicIP string `yaml:"public_ip"`
Port int `yaml:"port"`
Realm string `yaml:"realm"`
AuthSecret string `yaml:"auth_secret"`
RelayMinPort uint16 `yaml:"relay_min_port"`
RelayMaxPort uint16 `yaml:"relay_max_port"`
TlsCertPath string `yaml:"tls_cert_path"`
TlsPrivateKeyPath string `yaml:"tls_private_key_path"`
TlsCaPath string `yaml:"tls_ca_path"`
tls tls.Certificate
tlsCa string
isTlsEnabled bool
}
func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) {
cfgFile, err := os.ReadFile(configFilePath)
if err != nil {
return nil, err
}
var cfg GatewayRelayConfig
if err := yaml.Unmarshal(cfgFile, &cfg); err != nil {
return nil, err
}
if cfg.PublicIP == "" {
return nil, fmt.Errorf("Missing public ip")
}
if cfg.AuthSecret == "" {
return nil, fmt.Errorf("Missing auth secret")
}
if cfg.Realm == "" {
cfg.Realm = "infisical.org"
}
if cfg.RelayMinPort == 0 {
cfg.RelayMinPort = 49152
}
if cfg.RelayMaxPort == 0 {
cfg.RelayMaxPort = 65535
}
if cfg.Port == 0 {
cfg.Port = 3478
} else if cfg.Port == 5349 {
if cfg.TlsCertPath == "" || cfg.TlsPrivateKeyPath == "" {
return nil, errMissingTlsCert
}
cert, err := tls.LoadX509KeyPair(cfg.TlsCertPath, cfg.TlsPrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("Failed to read load server tls key pair: %w", err)
}
if cfg.TlsCaPath != "" {
ca, err := os.ReadFile(cfg.TlsCaPath)
if err != nil {
return nil, fmt.Errorf("Failed to read tls ca: %w", err)
}
cfg.tlsCa = string(ca)
}
cfg.tls = cert
cfg.isTlsEnabled = true
}
return &GatewayRelay{
Config: &cfg,
}, nil
}
func (g *GatewayRelay) Run() error {
addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
if err != nil {
return fmt.Errorf("Failed to parse server address: %s", err)
}
// NewLongTermAuthHandler takes a pion.LeveledLogger. This allows you to intercept messages
// and process them yourself.
logger := logging.NewDefaultLeveledLoggerForScope("lt-creds", logging.LogLevelTrace, os.Stdout)
// Create `numThreads` UDP listeners to pass into pion/turn
// pion/turn itself doesn't allocate any UDP sockets, but lets the user pass them in
// this allows us to add logging, storage or modify inbound/outbound traffic
// UDP listeners share the same local address:port with setting SO_REUSEPORT and the kernel
// will load-balance received packets per the IP 5-tuple
listenerConfig := udplistener.SetupListenerConfig()
publicIP := g.Config.PublicIP
relayAddressGenerator := &turn.RelayAddressGeneratorPortRange{
RelayAddress: net.ParseIP(publicIP), // Claim that we are listening on IP passed by user
Address: "0.0.0.0", // But actually be listening on every interface
MinPort: g.Config.RelayMinPort,
MaxPort: g.Config.RelayMaxPort,
}
threadNum := runtime.NumCPU()
listenerConfigs := make([]turn.ListenerConfig, threadNum)
var connAddress string
for i := 0; i < threadNum; i++ {
conn, listErr := listenerConfig.Listen(context.Background(), addr.Network(), addr.String())
if listErr != nil {
return fmt.Errorf("Failed to allocate TCP listener at %s:%s %s", addr.Network(), addr.String(), listErr)
}
listenerConfigs[i] = turn.ListenerConfig{
RelayAddressGenerator: relayAddressGenerator,
}
if g.Config.isTlsEnabled {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{
Certificates: []tls.Certificate{g.Config.tls},
ClientCAs: caCertPool,
})
} else {
listenerConfigs[i].Listener = conn
}
connAddress = conn.Addr().String()
}
loggerF := logging.NewDefaultLoggerFactory()
loggerF.DefaultLogLevel = logging.LogLevelDebug
server, err := turn.NewServer(turn.ServerConfig{
Realm: g.Config.Realm,
AuthHandler: turn.LongTermTURNRESTAuthHandler(g.Config.AuthSecret, logger),
// PacketConnConfigs is a list of UDP Listeners and the configuration around them
ListenerConfigs: listenerConfigs,
LoggerFactory: loggerF,
})
if err != nil {
return fmt.Errorf("Failed to start server: %w", err)
}
log.Info().Msgf("Relay listening on %s\n", connAddress)
// make this compatiable with systemd notify mode
systemd.SdNotify(false, systemd.SdNotifyReady)
// Block until user sends SIGINT or SIGTERM
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
if err = server.Close(); err != nil {
return fmt.Errorf("Failed to close server: %w", err)
}
return nil
}

View File

@ -0,0 +1,26 @@
//go:build !windows
// +build !windows
package udplistener
import (
"net"
"syscall"
"golang.org/x/sys/unix"
// other imports
)
func SetupListenerConfig() *net.ListenConfig {
return &net.ListenConfig{
Control: func(network, address string, conn syscall.RawConn) error {
var operr error
if err := conn.Control(func(fd uintptr) {
operr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
}); err != nil {
return err
}
return operr
},
}
}

Some files were not shown because too many files have changed in this diff Show More