Compare commits

...

167 Commits

Author SHA1 Message Date
Sid
25fc1ac06e Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-23 23:02:07 +05:30
Sid
de39548d6b Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-23 22:57:33 +05:30
sidwebworks
81756bb37a feat: merge sdk docs 2025-08-23 14:30:59 +05:30
Sheen
5c632db282 Merge pull request #4399 from Infisical/audit-log-transaction-fix
fix(audit-logs): move prune audit log transaction inside while loop
2025-08-23 12:17:14 +08:00
Sid
461deef0d5 feat: support render environment groups (#4327)
* feat: support env groups in render sync

* fix: update doc

* Update backend/src/services/app-connection/render/render-connection-service.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: pr changes

* fix: lint and type check

* fix: changes

* fix: remove secrets

* fix: MAX iterations in render sync

* fix: render sync review fields

* fix: pr changes

* fix: lint

* fix: changes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-19 16:11:51 +05:30
Scott Wilson
7748e03612 Merge pull request #4378 from Infisical/animation-for-commit-popover
improvement(frontend): make commit popover animated
2025-08-19 18:11:13 +08:00
github-actions[bot]
2389c64e69 Update Helm chart to version v0.10.2 (#4400)
Co-authored-by: sidwebworks <sidwebworks@users.noreply.github.com>
2025-08-19 14:58:28 +05:30
Scott Wilson
de5ad47f77 fix: move prune audit log transaction inside while loop 2025-08-19 16:16:26 +08:00
Daniel Hougaard
e0161cd06f Merge pull request #4379 from Infisical/daniel/google-sso-enforcement
feat(sso): enforce google SSO on org-level
2025-08-19 15:30:02 +08:00
Akhil Mohan
7c12fa3a4c Merge pull request #4397 from Infisical/fix/crd-issue
feat: resolved instant update in required
2025-08-19 12:28:22 +05:30
=
0af53e82da feat: nity fix 2025-08-19 12:24:03 +05:30
=
f0c080187e feat: resolved instant update in required 2025-08-19 12:14:32 +05:30
Sheen
47118bcf19 Merge pull request #4396 from Infisical/misc/optimize-partition-script
misc: optimize partition script
2025-08-19 14:41:59 +08:00
Akhil Mohan
bb1975491f Merge pull request #4321 from Infisical/sid/k8s-operator
feat: support `InstantUpdates` in k8s operator
2025-08-19 12:02:59 +05:30
Sheen Capadngan
28cc919ff7 misc: optimize partition script 2025-08-19 14:27:06 +08:00
Scott Wilson
5c21ac3182 Merge pull request #4392 from Infisical/fix-audit-log-prune-infinite-loop
fix(audit-logs): clear deleted audit logs on error to prevent infinite looping of audit log prune
2025-08-18 22:13:01 +08:00
sidwebworks
6204b181e7 fix: log message 2025-08-18 14:03:31 +05:30
Scott Wilson
06de9d06c9 fix: clear deleted audit logs on error to prevent infinite looping of audit log prune 2025-08-18 14:28:51 +08:00
Sheen
3cceec86c8 Merge pull request #4391 from Infisical/doc/monitoring-telemetry
doc: monitoring telemetry
2025-08-18 14:25:57 +08:00
Sheen Capadngan
ff043f990f doc: monitoring telemetry 2025-08-18 14:20:45 +08:00
Daniel Hougaard
9e177c1e45 Merge pull request #4389 from Infisical/daniel/check-out-no-org-check
fix(cli): failing tests
2025-08-18 10:41:20 +08:00
Daniel Hougaard
5aeb823c9e Update auth-router.ts 2025-08-18 09:53:08 +08:00
Vlad Matsiiako
ef6f79f7a6 Merge pull request #4387 from Infisical/secrets-missing-docs
Bring Back Missing Secrets Documentation
2025-08-16 22:28:39 +08:00
Tuan Dang
43752e1888 bring back missing secrets docs 2025-08-16 17:02:06 +07:00
Daniel Hougaard
d587e779f5 requested changes 2025-08-16 00:26:06 +04:00
sidwebworks
f9a9565630 fix: add default roles 2025-08-16 01:26:29 +05:30
sidwebworks
05ba0abadd fix: PR changes 2025-08-16 00:04:18 +05:30
sidwebworks
fff9a96204 fix: revert config 2025-08-15 19:51:29 +05:30
Scott Wilson
bd72129d8c Merge pull request #4384 from Infisical/aws-parameter-store-key-schema-path-fix
fix(aws-parameter-store-sync): handle keyschema with path segments for aws parameter store
2025-08-14 17:10:24 -07:00
carlosmonastyrski
bf10b2f58a Merge pull request #4385 from Infisical/fix/gitlabSelfHostingOauth
Fix Gitlab OAuth redirection issue with instanceUrls
2025-08-14 17:07:11 -07:00
Scott Wilson
d24f5a57a8 improvement: throw on keyschema leading slash 2025-08-14 16:59:26 -07:00
Carlos Monastyrski
166104e523 Fix Gitlab OAuth redirection issue with instanceUrls 2025-08-14 16:55:47 -07:00
Scott Wilson
a7847f177c improvements: address feedback 2025-08-14 16:25:00 -07:00
Scott Wilson
48e5f550e9 fix: handle keyschema with path segments for aws parameter store 2025-08-14 15:46:41 -07:00
carlosmonastyrski
4a4a7fd325 Merge pull request #4374 from Infisical/ENG-3518
Disable environments edit when a project has more environment than the allowed by the org plan
2025-08-14 12:24:27 -07:00
Carlos Monastyrski
91b8ed8015 Minor improvement on a math calculated field 2025-08-14 10:14:22 -07:00
carlosmonastyrski
6cf978b593 Merge pull request #4359 from Infisical/ENG-3483
Add machine identities to /organization endpoint
2025-08-14 10:10:54 -07:00
Akhil Mohan
68fbb399fc Merge pull request #4383 from Infisical/fix/audit-log-page
feat: added a min for integration audit log
2025-08-14 22:37:18 +05:30
=
97366f6e95 feat: added a min for integration audit log 2025-08-14 22:32:51 +05:30
Akhil Mohan
c83d4af7a3 Merge pull request #4382 from Infisical/fix/replicate-duplicate
fix: resolved replication failing for duplicate
2025-08-14 22:28:07 +05:30
sidwebworks
f78556c85f fix: context 2025-08-14 21:50:03 +05:30
sidwebworks
13aa380cac fix: PR changes 2025-08-14 21:43:49 +05:30
Scott Wilson
c35c937c63 Merge pull request #4380 from Infisical/role-descriptions
improvement(frontend): display role descriptions in invite user/add project membership filter selects
2025-08-14 08:45:03 -07:00
=
b10752acb5 fix: resolved replication failing for duplicate 2025-08-14 20:39:00 +05:30
Maidul Islam
eb9b75d930 Merge pull request #4360 from agabek-ov/patch-1
Fix typos in overview.mdx
2025-08-14 05:59:28 -07:00
Sid
f2a9a57c95 Update k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-14 17:07:09 +05:30
Sid
6384fa6dba Update k8-operator/config/samples/universalAuthIdentitySecret.yaml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-14 17:07:01 +05:30
sidwebworks
c34ec8de09 fix: operator changes 2025-08-14 17:05:10 +05:30
sidwebworks
ef8a7f1233 Merge branch 'main' of github.com:Infisical/infisical into sid/k8s-operator 2025-08-14 15:48:20 +05:30
Sid
273a7b9657 fix: update event permissions system (#4355)
* fix: update project permission types

* fix: permissions

* fix: PR changes

* chore: update docs

* fix:  greptile changes

* fix: secret imports router changes

* fix: type change

* fix: lint

* fix: type change

* fix: check plan type in publish

* fix: permissions

* fix: import change

* fix: lint fix
2025-08-14 13:47:59 +05:30
x032205
a3b6fa9a53 Merge pull request #4335 from Infisical/ENG-3420
feat(app-connection): Make GitHub paginated requests concurrent
2025-08-13 23:26:24 -07:00
Scott Wilson
f60dd528e8 improvement: display role descriptions in invite user/add project member modals 2025-08-13 21:03:24 -07:00
carlosmonastyrski
8ffef1da8e Merge pull request #4372 from Infisical/ENG-3366
Add Couchbase dynamic secrets feature
2025-08-13 20:36:23 -07:00
carlosmonastyrski
f352f98374 Merge pull request #4375 from Infisical/ENG-3516
Improve delete folder message
2025-08-13 20:16:35 -07:00
Carlos Monastyrski
91a76f50ca Address PR comments 2025-08-13 20:13:34 -07:00
Scott Wilson
ea4bb0a062 Merge pull request #4369 from Infisical/modify-access-requests
feature(access-requests): allow editing of access request by admin reviewers
2025-08-13 20:08:27 -07:00
Scott Wilson
3d6be7b1b2 Merge pull request #4377 from Infisical/tag-filter-ui-improvements
improvement(frontend): adjust environment view dropdown alignment, add applied tags display and info tooltip to clarify behavior
2025-08-13 20:04:43 -07:00
Daniel Hougaard
09db98db50 fix: typescript complaining 2025-08-14 06:58:45 +04:00
Daniel Hougaard
a37f1eb1f8 requested changes & frontend lint 2025-08-14 06:53:57 +04:00
Daniel Hougaard
2113abcfdc Update license-fns.ts 2025-08-14 06:15:25 +04:00
Daniel Hougaard
ea2707651c feat(sso): enforce google SSO on org-level 2025-08-14 06:13:24 +04:00
Carlos Monastyrski
12558e8614 Fix wording on message 2025-08-13 18:16:37 -07:00
Scott Wilson
b986ff9a21 improvement: adjust key 2025-08-13 17:51:14 -07:00
Scott Wilson
106833328b improvement: make commit popover animated 2025-08-13 17:48:44 -07:00
Scott Wilson
987f87e562 improvement: adjust environment view dropdown alignment, add applied tags display and info tooltip to clarify behavior 2025-08-13 17:14:54 -07:00
Carlos Monastyrski
4d06d5cbb0 Fix wording on message 2025-08-13 17:09:53 -07:00
Carlos Monastyrski
bad934de48 Improve delete folder message 2025-08-13 16:13:38 -07:00
Scott Wilson
90b93fbd15 improvements: address feedback 2025-08-13 16:03:48 -07:00
Carlos Monastyrski
c2db2a0bc7 Endpoint improvements 2025-08-13 14:20:59 -07:00
Daniel Hougaard
b0d24de008 Merge pull request #4373 from Infisical/daniel/remove-srp-from-admin-signup
fix(srp): remove srp flow from admin signup
2025-08-14 00:56:45 +04:00
Carlos Monastyrski
0473fb0ddb Disable environments edit when a project has more environment than the allowed by the org plan 2025-08-13 13:50:29 -07:00
Daniel Hougaard
4ccb5dc9b0 fix bootstrapping 2025-08-14 00:44:49 +04:00
Daniel Hougaard
930425d5dc fix(srp): remove srp flow from admin signup 2025-08-13 23:56:38 +04:00
Carlos Monastyrski
f77a53bd8e Undo license fns changes for testing 2025-08-13 11:48:07 -07:00
Carlos Monastyrski
4bd61e5607 Undo license fns changes for testing 2025-08-13 11:47:41 -07:00
Carlos Monastyrski
aa4dbfa073 Move identity org details to new endpoint 2025-08-13 11:43:28 -07:00
Carlos Monastyrski
b479406ba0 Address greptile suggestions 2025-08-13 11:23:55 -07:00
Carlos Monastyrski
7cf9d933da Add Couchbase dynamic secrets feature 2025-08-13 09:59:22 -07:00
Scott Wilson
ca2825ba95 Merge pull request #4367 from Infisical/ENG-3486
feat(docs): Suggest higher throughput quotas for AWS parameter store
2025-08-13 09:24:15 -07:00
Scott Wilson
b8fa4d5255 improvements: address feedback 2025-08-13 09:13:26 -07:00
Scott Wilson
0d3cb2d41a feature: allow editing of access request by admin reviewers 2025-08-12 23:25:31 -07:00
x032205
e0d19d7b65 feat(docs): Suggest higher throughput quotas for AWS parameter store
sync
2025-08-12 21:51:22 -07:00
Akhil Mohan
f5a0d8be78 Merge pull request #4361 from Infisical/feat/doc-api
feat: added api document for project router get id from slug
2025-08-13 10:19:13 +05:30
Maidul Islam
c7ae7be493 Update security.mdx 2025-08-12 20:04:26 -07:00
Carlos Monastyrski
18881749fd Improve error message 2025-08-12 19:10:45 -07:00
x032205
fa54c406dc Merge pull request #4365 from Infisical/ENG-3491
Add memo to availableConnections to fix infinite re-render issue
2025-08-12 16:21:41 -07:00
Daniel Hougaard
1a2eef3ba6 Merge pull request #4364 from Infisical/fix-update-approval-policy-form
Fix form issue
2025-08-13 03:19:48 +04:00
x032205
0c562150f5 Add memo to availableConnections to fix infinite re-render issue 2025-08-12 16:02:43 -07:00
Scott Wilson
6fde132804 Merge pull request #4362 from Infisical/revise-commit-ui-labels
improvement(frontend): adjust commit modal wording and icons and autofocus commit message
2025-08-12 15:50:39 -07:00
x032205
799721782a Fix type check 2025-08-12 15:42:21 -07:00
x032205
86d430f911 Fix form issue 2025-08-12 15:39:38 -07:00
Carlos Monastyrski
7c28ee844e Type fix 2025-08-12 14:30:46 -07:00
Scott Wilson
d5390fcafc fix: correct saving tense 2025-08-12 14:10:24 -07:00
Scott Wilson
1b40f5d475 improvement: adjust commit modal wording and icons and autofocus commit message 2025-08-12 14:08:17 -07:00
x032205
d71362ccc3 Merge pull request #4356 from Infisical/ENG-3477
feat(secret-import): CSV support (with a base for other matrix-based formats)
2025-08-12 13:07:11 -07:00
x032205
e4d90eb055 Fix empty file infinite load 2025-08-12 12:12:31 -07:00
Anuar Agabekov
55607a4886 Fix typos in overview.mdx 2025-08-12 23:26:02 +05:00
sidwebworks
97dac1da94 fix: v4 changes 2025-08-12 18:58:35 +05:30
sidwebworks
f9f989c8af Merge branch 'main' of github.com:Infisical/infisical into sid/k8s-operator 2025-08-12 16:49:27 +05:30
Carlos Monastyrski
385c75c543 Add machine identities to /organization endpoint 2025-08-11 21:23:05 -07:00
carlosmonastyrski
f16dca45d9 Merge pull request #4358 from Infisical/fix/stopDailyResourceCleanUp
Add stopRepeatableJob for removed bullMQ events that may still be on the queue
2025-08-11 19:28:56 -07:00
x032205
118c28df54 Merge pull request #4357 from Infisical/ENG-3463
feat(api): Return path for folder create, update, delete
2025-08-11 22:27:26 -04:00
Carlos Monastyrski
249b2933da Add stopRepeatableJob for removed bullMQ events that may still be on the queue 2025-08-11 19:18:46 -07:00
x032205
272336092d Fixed path return 2025-08-11 17:56:42 -07:00
x032205
6f05a6d82c feat(api): Return path for folder create, update, delete 2025-08-11 17:11:33 -07:00
x032205
84ebdb8503 Merge pull request #4336 from Infisical/ENG-3429
feat(access-policies): Allow policy limits on access request times
2025-08-11 19:14:04 -04:00
carlosmonastyrski
b464941fbc Merge pull request #4354 from Infisical/ENG-3494
Fix UI folder row issue with multiple onClick events triggered before the redirect occurs
2025-08-11 15:46:26 -07:00
Daniel Hougaard
77e8d8a86d Merge pull request #4317 from Infisical/daniel/rotation-tests
feat(e2e-tests): secret rotations
2025-08-12 02:42:47 +04:00
Daniel Hougaard
c61dd1ee6e Update .infisicalignore 2025-08-12 02:31:48 +04:00
Daniel Hougaard
9db8573e72 Merge branch 'heads/main' into daniel/rotation-tests 2025-08-12 02:31:40 +04:00
x032205
ce8653e908 Address reviews 2025-08-11 15:15:48 -07:00
Carlos Monastyrski
fd4cdc2769 Remove unnecessary try catch 2025-08-11 14:41:05 -07:00
carlosmonastyrski
90a1cc9330 Merge pull request #4352 from Infisical/ENG-3502
Move DailyResourceCleanUp to PGBoss
2025-08-11 14:33:13 -07:00
Daniel Hougaard
78bfd0922a Update run-backend-tests.yml 2025-08-12 01:20:29 +04:00
x032205
458dcd31c1 feat(secret-import): CSV support (with a base for other matrix-based
formats)
2025-08-11 14:09:04 -07:00
Daniel Hougaard
372537f0b6 updated env vars 2025-08-12 01:06:45 +04:00
Daniel Hougaard
e173ff3828 final fixes 2025-08-12 00:56:11 +04:00
Carlos Monastyrski
2baadf60d1 Fix UI folder row issue with multiple onClick events triggered before the redirect occurs 2025-08-11 13:44:22 -07:00
Daniel Hougaard
e13fc93bac fix(e2e-tests): oracle 19c rotation fix 2025-08-12 00:30:32 +04:00
Carlos Monastyrski
6b14fbcce2 Remove code block used for testing 2025-08-11 12:13:02 -07:00
Carlos Monastyrski
86fbe5cc24 Improve dailyResourceCleanUpQueue error message 2025-08-11 12:06:35 -07:00
Carlos Monastyrski
3f7862a345 Move DailyResourceCleanUp to PGBoss 2025-08-11 11:54:32 -07:00
Maidul Islam
9661458469 bring down entropy to 3.7 2025-08-11 11:48:24 -07:00
Maidul Islam
c7c1eb0f5f Merge pull request #4350 from Infisical/misc/added-entropy-check-for-params-secret-scanning
misc: added entropy check for params secret scanning
2025-08-11 11:34:24 -07:00
Daniel Hougaard
a1e48a1795 Merge pull request #4351 from Infisical/helm-update-v0.10.1
Update Helm chart to version v0.10.1
2025-08-11 21:17:10 +04:00
DanielHougaard
d14e80b771 Update Helm chart to version v0.10.1 2025-08-11 17:15:45 +00:00
Daniel Hougaard
0264d37d9b Merge pull request #4349 from Infisical/daniel/fix-duplicate-helm-labels
fix(k8s-operator): duplicate helm labels
2025-08-11 21:11:55 +04:00
Sheen Capadngan
11a1604e14 misc: added entropy check for params secret scanning 2025-08-12 01:04:33 +08:00
Daniel Hougaard
f788dee398 fix(k8s-operator): duplicate helm labels 2025-08-11 20:56:24 +04:00
Maidul Islam
88120ed45e Merge pull request #4348 from Infisical/fix/log-date-issue
feat: resolved audit log date issue in integration page
2025-08-11 07:10:28 -07:00
=
d6a377416d feat: resolved audit log date issue in integration page 2025-08-11 15:08:42 +05:30
Daniel Hougaard
368e00ea71 Update secret-rotation-v2-queue.ts 2025-08-08 20:16:41 +04:00
Daniel Hougaard
23237dd055 Update secret-rotation-v2-queue.ts 2025-08-08 19:52:38 +04:00
Daniel Hougaard
e10aec3170 Update docker-compose.e2e-dbs.yml 2025-08-08 18:42:03 +04:00
Daniel Hougaard
0b11dcd627 Update secret-rotations.spec.ts 2025-08-08 18:41:04 +04:00
x032205
d1e8ae3c98 Greptile review fixes 2025-08-07 23:25:36 -04:00
x032205
5c9243d691 feat(access-policies): Allow policy limits on access request times 2025-08-07 23:15:48 -04:00
Daniel Hougaard
35d1eabf49 Update run-backend-tests.yml 2025-08-08 06:07:04 +04:00
Daniel Hougaard
b6902160ce Update docker-compose.e2e-dbs.yml 2025-08-08 05:59:32 +04:00
Daniel Hougaard
fbfc51ee93 Update docker-compose.e2e-dbs.yml 2025-08-08 05:52:15 +04:00
Daniel Hougaard
9d92ffce95 Update docker-compose.e2e-dbs.yml 2025-08-08 05:21:49 +04:00
Daniel Hougaard
9193418f8b Update run-backend-tests.yml 2025-08-08 05:14:05 +04:00
x032205
352ef050c3 Update backend/src/services/app-connection/github/github-connection-fns.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-07 20:26:12 -04:00
x032205
b6b9fb6ef5 feat(app-connection): Make GitHub paginated requests concurrent 2025-08-07 20:20:48 -04:00
sidwebworks
02ee418763 fix: revert yaml 2025-08-07 10:41:48 +05:30
Daniel Hougaard
389e2e1fb7 Update 20250725144940_fix-secret-reminders-migration.ts 2025-08-07 00:42:37 +04:00
Daniel Hougaard
88fcbcadd4 feat(e2e-tests): secret rotations 2025-08-07 00:41:51 +04:00
sidwebworks
faca20c00c Merge branch 'main' of github.com:Infisical/infisical into sid/k8s-operator 2025-08-07 01:07:52 +05:30
sidwebworks
69c3687add fix: revert license fns 2025-08-07 01:05:47 +05:30
sidwebworks
1645534b54 fix: changes 2025-08-07 01:04:29 +05:30
sidwebworks
dca0b0c614 draft: k8s operator changes 2025-08-06 23:31:45 +05:30
Daniel Hougaard
1b32de5c5b Update license-fns.ts 2025-08-06 03:46:37 +04:00
Daniel Hougaard
522795871e Merge branch 'heads/main' into daniel/rotation-tests 2025-08-06 03:39:33 +04:00
Daniel Hougaard
5c63955fde requested changes 2025-08-06 03:39:08 +04:00
Daniel Hougaard
d7f3892b73 Update vitest.e2e.config.ts 2025-08-06 03:29:13 +04:00
Daniel Hougaard
33af2fb2b8 feaet(e2e-tests): secret rotation tests 2025-08-06 03:28:28 +04:00
sidwebworks
d3d0d44778 wip: sse working 2025-08-05 01:08:29 +05:30
sidwebworks
67abcbfe7a wip: k8s operator changes 2025-08-05 00:16:47 +05:30
sidwebworks
fc772e6b89 chore: remove recursive 2025-08-04 23:31:17 +05:30
sidwebworks
c8108ff49a feat: improve docs 2025-08-04 15:53:32 +05:30
sidwebworks
806165b9e9 fix: pr changes 2025-08-03 02:39:16 +05:30
sidwebworks
9fde0a5787 docs: content 2025-08-02 18:26:21 +05:30
Sid
9ee2581659 Update docs/docs.json 2025-08-01 17:19:12 +05:30
Sid
2deff0ef55 Update backend/src/lib/api-docs/constants.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-01 17:18:15 +05:30
Sid
4312378589 Update backend/src/lib/api-docs/constants.ts 2025-08-01 17:17:03 +05:30
sidwebworks
d749a9621f fix: make the conditions optional in casl check 2025-08-01 17:14:51 +05:30
sidwebworks
9686d14e7f feat: events docs 2025-08-01 17:14:37 +05:30
195 changed files with 10493 additions and 3298 deletions

View File

@@ -16,6 +16,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
docker system prune -af
- name: ☁️ Checkout source
uses: actions/checkout@v3
- uses: KengoTODA/actions-setup-docker-compose@v1
@@ -34,6 +44,8 @@ jobs:
working-directory: backend
- name: Start postgres and redis
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
- name: Start Secret Rotation testing databases
run: docker compose -f docker-compose.e2e-dbs.yml up -d --wait --wait-timeout 300
- name: Run unit test
run: npm run test:unit
working-directory: backend
@@ -41,6 +53,9 @@ jobs:
run: npm run test:e2e
working-directory: backend
env:
E2E_TEST_ORACLE_DB_19_HOST: ${{ secrets.E2E_TEST_ORACLE_DB_19_HOST }}
E2E_TEST_ORACLE_DB_19_USERNAME: ${{ secrets.E2E_TEST_ORACLE_DB_19_USERNAME }}
E2E_TEST_ORACLE_DB_19_PASSWORD: ${{ secrets.E2E_TEST_ORACLE_DB_19_PASSWORD }}
REDIS_URL: redis://172.17.0.1:6379
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
AUTH_SECRET: something-random

View File

@@ -50,3 +50,4 @@ docs/integrations/app-connections/zabbix.mdx:generic-api-key:91
docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123
docs/integrations/app-connections/railway.mdx:generic-api-key:156
.github/workflows/validate-db-schemas.yml:generic-api-key:21
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8

View File

@@ -1,34 +0,0 @@
import { TQueueServiceFactory } from "@app/queue";
export const mockQueue = (): TQueueServiceFactory => {
const queues: Record<string, unknown> = {};
const workers: Record<string, unknown> = {};
const job: Record<string, unknown> = {};
const events: Record<string, unknown> = {};
return {
queue: async (name, jobData) => {
job[name] = jobData;
},
queuePg: async () => {},
schedulePg: async () => {},
initialize: async () => {},
shutdown: async () => undefined,
stopRepeatableJob: async () => true,
start: (name, jobFn) => {
queues[name] = jobFn;
workers[name] = jobFn;
},
startPg: async () => {},
listen: (name, event) => {
events[name] = event;
},
getRepeatableJobs: async () => [],
getDelayedJobs: async () => [],
clearQueue: async () => {},
stopJobById: async () => {},
stopJobByIdPg: async () => {},
stopRepeatableJobByJobId: async () => true,
stopRepeatableJobByKey: async () => true
};
};

View File

@@ -0,0 +1,726 @@
/* eslint-disable no-promise-executor-return */
/* eslint-disable no-await-in-loop */
import knex from "knex";
import { v4 as uuidv4 } from "uuid";
import { seedData1 } from "@app/db/seed-data";
enum SecretRotationType {
OracleDb = "oracledb",
MySQL = "mysql",
Postgres = "postgres"
}
type TGenericSqlCredentials = {
host: string;
port: number;
username: string;
password: string;
database: string;
};
type TSecretMapping = {
username: string;
password: string;
};
type TDatabaseUserCredentials = {
username: string;
};
const formatSqlUsername = (username: string) => `${username}_${uuidv4().slice(0, 8).replace(/-/g, "").toUpperCase()}`;
const getSecretValue = async (secretKey: string) => {
const passwordSecret = await testServer.inject({
url: `/api/v3/secrets/raw/${secretKey}`,
method: "GET",
query: {
workspaceId: seedData1.projectV3.id,
environment: seedData1.environment.slug
},
headers: {
authorization: `Bearer ${jwtAuthToken}`
}
});
expect(passwordSecret.statusCode).toBe(200);
expect(passwordSecret.json().secret).toBeDefined();
const passwordSecretJson = JSON.parse(passwordSecret.payload);
return passwordSecretJson.secret.secretValue as string;
};
const deleteSecretRotation = async (id: string, type: SecretRotationType) => {
const res = await testServer.inject({
method: "DELETE",
query: {
deleteSecrets: "true",
revokeGeneratedCredentials: "true"
},
url: `/api/v2/secret-rotations/${type}-credentials/${id}`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
}
});
expect(res.statusCode).toBe(200);
};
const deleteAppConnection = async (id: string, type: SecretRotationType) => {
const res = await testServer.inject({
method: "DELETE",
url: `/api/v1/app-connections/${type}/${id}`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
}
});
expect(res.statusCode).toBe(200);
};
const createOracleDBAppConnection = async (credentials: TGenericSqlCredentials) => {
const createOracleDBAppConnectionReqBody = {
credentials: {
database: credentials.database,
host: credentials.host,
username: credentials.username,
password: credentials.password,
port: credentials.port,
sslEnabled: true,
sslRejectUnauthorized: true
},
name: `oracle-db-${uuidv4()}`,
description: "Test OracleDB App Connection",
gatewayId: null,
isPlatformManagedCredentials: false,
method: "username-and-password"
};
const res = await testServer.inject({
method: "POST",
url: `/api/v1/app-connections/oracledb`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createOracleDBAppConnectionReqBody
});
const json = JSON.parse(res.payload);
expect(res.statusCode).toBe(200);
expect(json.appConnection).toBeDefined();
return json.appConnection.id as string;
};
const createMySQLAppConnection = async (credentials: TGenericSqlCredentials) => {
const createMySQLAppConnectionReqBody = {
name: `mysql-test-${uuidv4()}`,
description: "test-mysql",
gatewayId: null,
method: "username-and-password",
credentials: {
host: credentials.host,
port: credentials.port,
database: credentials.database,
username: credentials.username,
password: credentials.password,
sslEnabled: false,
sslRejectUnauthorized: true
}
};
const res = await testServer.inject({
method: "POST",
url: `/api/v1/app-connections/mysql`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createMySQLAppConnectionReqBody
});
const json = JSON.parse(res.payload);
expect(res.statusCode).toBe(200);
expect(json.appConnection).toBeDefined();
return json.appConnection.id as string;
};
const createPostgresAppConnection = async (credentials: TGenericSqlCredentials) => {
const createPostgresAppConnectionReqBody = {
credentials: {
host: credentials.host,
port: credentials.port,
database: credentials.database,
username: credentials.username,
password: credentials.password,
sslEnabled: false,
sslRejectUnauthorized: true
},
name: `postgres-test-${uuidv4()}`,
description: "test-postgres",
gatewayId: null,
method: "username-and-password"
};
const res = await testServer.inject({
method: "POST",
url: `/api/v1/app-connections/postgres`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createPostgresAppConnectionReqBody
});
const json = JSON.parse(res.payload);
expect(res.statusCode).toBe(200);
expect(json.appConnection).toBeDefined();
return json.appConnection.id as string;
};
const createOracleInfisicalUsers = async (
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[]
) => {
const client = knex({
client: "oracledb",
connection: {
database: credentials.database,
port: credentials.port,
host: credentials.host,
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: 10000,
ssl: {
// @ts-expect-error - this is a valid property for the ssl object
sslServerDNMatch: true
}
}
});
for await (const { username } of userCredentials) {
// check if user exists, and if it does, don't create it
const existingUser = await client.raw(`SELECT * FROM all_users WHERE username = '${username}'`);
if (!existingUser.length) {
await client.raw(`CREATE USER ${username} IDENTIFIED BY "temporary_password"`);
}
await client.raw(`GRANT ALL PRIVILEGES TO ${username} WITH ADMIN OPTION`);
}
await client.destroy();
};
const createMySQLInfisicalUsers = async (
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[]
) => {
const client = knex({
client: "mysql2",
connection: {
database: credentials.database,
port: credentials.port,
host: credentials.host,
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: 10000
}
});
// Fix: Ensure root has GRANT OPTION privileges
try {
await client.raw("GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;");
await client.raw("FLUSH PRIVILEGES;");
} catch (error) {
// Ignore if already has privileges
}
for await (const { username } of userCredentials) {
// check if user exists, and if it does, dont create it
const existingUser = await client.raw(`SELECT * FROM mysql.user WHERE user = '${username}'`);
if (!existingUser[0].length) {
await client.raw(`CREATE USER '${username}'@'%' IDENTIFIED BY 'temporary_password';`);
}
await client.raw(`GRANT ALL PRIVILEGES ON \`${credentials.database}\`.* TO '${username}'@'%';`);
await client.raw("FLUSH PRIVILEGES;");
}
await client.destroy();
};
const createPostgresInfisicalUsers = async (
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[]
) => {
const client = knex({
client: "pg",
connection: {
database: credentials.database,
port: credentials.port,
host: credentials.host,
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: 10000
}
});
for await (const { username } of userCredentials) {
// check if user exists, and if it does, don't create it
const existingUser = await client.raw("SELECT * FROM pg_catalog.pg_user WHERE usename = ?", [username]);
if (!existingUser.rows.length) {
await client.raw(`CREATE USER "${username}" WITH PASSWORD 'temporary_password'`);
}
await client.raw("GRANT ALL PRIVILEGES ON DATABASE ?? TO ??", [credentials.database, username]);
}
await client.destroy();
};
const createOracleDBSecretRotation = async (
appConnectionId: string,
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[],
secretMapping: TSecretMapping
) => {
const now = new Date();
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
await createOracleInfisicalUsers(credentials, userCredentials);
const createOracleDBSecretRotationReqBody = {
parameters: userCredentials.reduce(
(acc, user, index) => {
acc[`username${index + 1}`] = user.username;
return acc;
},
{} as Record<string, string>
),
secretsMapping: {
username: secretMapping.username,
password: secretMapping.password
},
name: `test-oracle-${uuidv4()}`,
description: "Test OracleDB Secret Rotation",
secretPath: "/",
isAutoRotationEnabled: true,
rotationInterval: 5, // 5 seconds for testing
rotateAtUtc: {
hours: rotationTime.getUTCHours(),
minutes: rotationTime.getUTCMinutes()
},
connectionId: appConnectionId,
environment: seedData1.environment.slug,
projectId: seedData1.projectV3.id
};
const res = await testServer.inject({
method: "POST",
url: `/api/v2/secret-rotations/oracledb-credentials`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createOracleDBSecretRotationReqBody
});
expect(res.statusCode).toBe(200);
expect(res.json().secretRotation).toBeDefined();
return res;
};
const createMySQLSecretRotation = async (
appConnectionId: string,
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[],
secretMapping: TSecretMapping
) => {
const now = new Date();
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
await createMySQLInfisicalUsers(credentials, userCredentials);
const createMySQLSecretRotationReqBody = {
parameters: userCredentials.reduce(
(acc, user, index) => {
acc[`username${index + 1}`] = user.username;
return acc;
},
{} as Record<string, string>
),
secretsMapping: {
username: secretMapping.username,
password: secretMapping.password
},
name: `test-mysql-rotation-${uuidv4()}`,
description: "Test MySQL Secret Rotation",
secretPath: "/",
isAutoRotationEnabled: true,
rotationInterval: 5,
rotateAtUtc: {
hours: rotationTime.getUTCHours(),
minutes: rotationTime.getUTCMinutes()
},
connectionId: appConnectionId,
environment: seedData1.environment.slug,
projectId: seedData1.projectV3.id
};
const res = await testServer.inject({
method: "POST",
url: `/api/v2/secret-rotations/mysql-credentials`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createMySQLSecretRotationReqBody
});
expect(res.statusCode).toBe(200);
expect(res.json().secretRotation).toBeDefined();
return res;
};
const createPostgresSecretRotation = async (
appConnectionId: string,
credentials: TGenericSqlCredentials,
userCredentials: TDatabaseUserCredentials[],
secretMapping: TSecretMapping
) => {
const now = new Date();
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
await createPostgresInfisicalUsers(credentials, userCredentials);
const createPostgresSecretRotationReqBody = {
parameters: userCredentials.reduce(
(acc, user, index) => {
acc[`username${index + 1}`] = user.username;
return acc;
},
{} as Record<string, string>
),
secretsMapping: {
username: secretMapping.username,
password: secretMapping.password
},
name: `test-postgres-rotation-${uuidv4()}`,
description: "Test Postgres Secret Rotation",
secretPath: "/",
isAutoRotationEnabled: true,
rotationInterval: 5,
rotateAtUtc: {
hours: rotationTime.getUTCHours(),
minutes: rotationTime.getUTCMinutes()
},
connectionId: appConnectionId,
environment: seedData1.environment.slug,
projectId: seedData1.projectV3.id
};
const res = await testServer.inject({
method: "POST",
url: `/api/v2/secret-rotations/postgres-credentials`,
headers: {
authorization: `Bearer ${jwtAuthToken}`
},
body: createPostgresSecretRotationReqBody
});
expect(res.statusCode).toBe(200);
expect(res.json().secretRotation).toBeDefined();
return res;
};
describe("Secret Rotations", async () => {
const testCases = [
{
type: SecretRotationType.MySQL,
name: "MySQL (8.4.6) Secret Rotation",
dbCredentials: {
database: "mysql-test",
host: "127.0.0.1",
username: "root",
password: "mysql-test",
port: 3306
},
secretMapping: {
username: formatSqlUsername("MYSQL_USERNAME"),
password: formatSqlUsername("MYSQL_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("MYSQL_USER_1")
},
{
username: formatSqlUsername("MYSQL_USER_2")
}
]
},
{
type: SecretRotationType.MySQL,
name: "MySQL (8.0.29) Secret Rotation",
dbCredentials: {
database: "mysql-test",
host: "127.0.0.1",
username: "root",
password: "mysql-test",
port: 3307
},
secretMapping: {
username: formatSqlUsername("MYSQL_USERNAME"),
password: formatSqlUsername("MYSQL_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("MYSQL_USER_1")
},
{
username: formatSqlUsername("MYSQL_USER_2")
}
]
},
{
type: SecretRotationType.MySQL,
name: "MySQL (5.7.31) Secret Rotation",
dbCredentials: {
database: "mysql-test",
host: "127.0.0.1",
username: "root",
password: "mysql-test",
port: 3308
},
secretMapping: {
username: formatSqlUsername("MYSQL_USERNAME"),
password: formatSqlUsername("MYSQL_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("MYSQL_USER_1")
},
{
username: formatSqlUsername("MYSQL_USER_2")
}
]
},
{
type: SecretRotationType.OracleDb,
name: "OracleDB (23.8) Secret Rotation",
dbCredentials: {
database: "FREEPDB1",
host: "127.0.0.1",
username: "system",
password: "pdb-password",
port: 1521
},
secretMapping: {
username: formatSqlUsername("ORACLEDB_USERNAME"),
password: formatSqlUsername("ORACLEDB_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("INFISICAL_USER_1")
},
{
username: formatSqlUsername("INFISICAL_USER_2")
}
]
},
{
type: SecretRotationType.OracleDb,
name: "OracleDB (19.3) Secret Rotation",
skippable: true,
dbCredentials: {
password: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD!,
host: process.env.E2E_TEST_ORACLE_DB_19_HOST!,
username: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!,
port: 1521,
database: "ORCLPDB1"
},
secretMapping: {
username: formatSqlUsername("ORACLEDB_USERNAME"),
password: formatSqlUsername("ORACLEDB_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("INFISICAL_USER_1")
},
{
username: formatSqlUsername("INFISICAL_USER_2")
}
]
},
{
type: SecretRotationType.Postgres,
name: "Postgres (17) Secret Rotation",
dbCredentials: {
database: "postgres-test",
host: "127.0.0.1",
username: "postgres-test",
password: "postgres-test",
port: 5433
},
secretMapping: {
username: formatSqlUsername("POSTGRES_USERNAME"),
password: formatSqlUsername("POSTGRES_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("INFISICAL_USER_1")
},
{
username: formatSqlUsername("INFISICAL_USER_2")
}
]
},
{
type: SecretRotationType.Postgres,
name: "Postgres (16) Secret Rotation",
dbCredentials: {
database: "postgres-test",
host: "127.0.0.1",
username: "postgres-test",
password: "postgres-test",
port: 5434
},
secretMapping: {
username: formatSqlUsername("POSTGRES_USERNAME"),
password: formatSqlUsername("POSTGRES_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("INFISICAL_USER_1")
},
{
username: formatSqlUsername("INFISICAL_USER_2")
}
]
},
{
type: SecretRotationType.Postgres,
name: "Postgres (10.12) Secret Rotation",
dbCredentials: {
database: "postgres-test",
host: "127.0.0.1",
username: "postgres-test",
password: "postgres-test",
port: 5435
},
secretMapping: {
username: formatSqlUsername("POSTGRES_USERNAME"),
password: formatSqlUsername("POSTGRES_PASSWORD")
},
userCredentials: [
{
username: formatSqlUsername("INFISICAL_USER_1")
},
{
username: formatSqlUsername("INFISICAL_USER_2")
}
]
}
] as {
skippable?: boolean;
type: SecretRotationType;
name: string;
dbCredentials: TGenericSqlCredentials;
secretMapping: TSecretMapping;
userCredentials: TDatabaseUserCredentials[];
}[];
const createAppConnectionMap = {
[SecretRotationType.OracleDb]: createOracleDBAppConnection,
[SecretRotationType.MySQL]: createMySQLAppConnection,
[SecretRotationType.Postgres]: createPostgresAppConnection
};
const createRotationMap = {
[SecretRotationType.OracleDb]: createOracleDBSecretRotation,
[SecretRotationType.MySQL]: createMySQLSecretRotation,
[SecretRotationType.Postgres]: createPostgresSecretRotation
};
const appConnectionIds: { id: string; type: SecretRotationType }[] = [];
const secretRotationIds: { id: string; type: SecretRotationType }[] = [];
afterAll(async () => {
for (const { id, type } of secretRotationIds) {
await deleteSecretRotation(id, type);
}
for (const { id, type } of appConnectionIds) {
await deleteAppConnection(id, type);
}
});
testCases.forEach(({ skippable, dbCredentials, secretMapping, userCredentials, type, name }) => {
const shouldSkip = () => {
if (skippable) {
if (type === SecretRotationType.OracleDb) {
if (!process.env.E2E_TEST_ORACLE_DB_19_HOST) {
return true;
}
}
}
return false;
};
if (shouldSkip()) {
test.skip(`Skipping Secret Rotation for ${type} (${name}) because E2E_TEST_ORACLE_DB_19_HOST is not set`);
} else {
test.concurrent(
`Create secret rotation for ${name}`,
async () => {
const appConnectionId = await createAppConnectionMap[type](dbCredentials);
if (appConnectionId) {
appConnectionIds.push({ id: appConnectionId, type });
}
const res = await createRotationMap[type](appConnectionId, dbCredentials, userCredentials, secretMapping);
const resJson = JSON.parse(res.payload);
if (resJson.secretRotation) {
secretRotationIds.push({ id: resJson.secretRotation.id, type });
}
const startSecretValue = await getSecretValue(secretMapping.password);
expect(startSecretValue).toBeDefined();
let attempts = 0;
while (attempts < 60) {
const currentSecretValue = await getSecretValue(secretMapping.password);
if (currentSecretValue !== startSecretValue) {
break;
}
attempts += 1;
await new Promise((resolve) => setTimeout(resolve, 2_500));
}
if (attempts >= 60) {
throw new Error("Secret rotation failed to rotate after 60 attempts");
}
const finalSecretValue = await getSecretValue(secretMapping.password);
expect(finalSecretValue).not.toBe(startSecretValue);
},
{
timeout: 300_000
}
);
}
});
});

View File

@@ -18,6 +18,7 @@ import { keyStoreFactory } from "@app/keystore/keystore";
import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns";
import { buildRedisFromConfig } from "@app/lib/config/redis";
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { bootstrapCheck } from "@app/server/boot-strap-check";
dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true });
export default {
@@ -63,6 +64,8 @@ export default {
const queue = queueServiceFactory(envCfg, { dbConnectionUrl: envCfg.DB_CONNECTION_URI });
const keyStore = keyStoreFactory(envCfg);
await queue.initialize();
const hsmModule = initializeHsmModule(envCfg);
hsmModule.initialize();
@@ -78,9 +81,13 @@ export default {
envConfig: envCfg
});
await bootstrapCheck({ db });
// @ts-expect-error type
globalThis.testServer = server;
// @ts-expect-error type
globalThis.testQueue = queue;
// @ts-expect-error type
globalThis.testSuperAdminDAL = superAdminDAL;
// @ts-expect-error type
globalThis.jwtAuthToken = crypto.jwt().sign(
@@ -105,6 +112,8 @@ export default {
// custom setup
return {
async teardown() {
// @ts-expect-error type
await globalThis.testQueue.shutdown();
// @ts-expect-error type
await globalThis.testServer.close();
// @ts-expect-error type
@@ -112,7 +121,9 @@ export default {
// @ts-expect-error type
delete globalThis.testSuperAdminDAL;
// @ts-expect-error type
delete globalThis.jwtToken;
delete globalThis.jwtAuthToken;
// @ts-expect-error type
delete globalThis.testQueue;
// called after all tests with this env have been run
await db.migrate.rollback(
{

View File

@@ -148,6 +148,7 @@ declare module "fastify" {
interface Session {
callbackPort: string;
isAdminLogin: boolean;
orgSlug?: string;
}
interface FastifyRequest {

View File

@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
t.index("expiresAt");
t.index("orgId");
t.index("projectId");
t.index("eventType");
t.index("userAgentType");
t.index("actor");
});
console.log("Adding GIN indices...");
@@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise<void> => {
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
// create partitions 4 years ahead
const partitionMonths = 4 * 12;
// create partitions 20 years ahead
const partitionMonths = 20 * 12;
const partitionPromises: Promise<void>[] = [];
for (let x = 1; x <= partitionMonths; x += 1) {
partitionPromises.push(

View File

@@ -2,7 +2,7 @@
import { Knex } from "knex";
import { chunkArray } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { initLogger, logger } from "@app/lib/logger";
import { TableName } from "../schemas";
import { TReminders, TRemindersInsert } from "../schemas/reminders";
@@ -107,5 +107,6 @@ export async function up(knex: Knex): Promise<void> {
}
export async function down(): Promise<void> {
initLogger();
logger.info("Rollback not implemented for secret reminders fix migration");
}

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas/models";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod"))) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.string("maxTimePeriod").nullable(); // Ex: 1h - Null is permanent
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod")) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.dropColumn("maxTimePeriod");
});
}
}

View File

@@ -0,0 +1,38 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (!hasEditNoteCol || !hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (!hasEditedByUserId) {
t.uuid("editedByUserId").nullable();
t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
}
if (!hasEditNoteCol) {
t.string("editNote").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (hasEditNoteCol || hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (hasEditedByUserId) {
t.dropColumn("editedByUserId");
}
if (hasEditNoteCol) {
t.dropColumn("editNote");
}
});
}
}

View File

@@ -0,0 +1,39 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced";
const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed";
export async function up(knex: Knex): Promise<void> {
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
);
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
);
await knex.schema.alterTable(TableName.Organization, (table) => {
if (!hasGoogleSsoAuthEnforcedColumn)
table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable();
if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable();
});
}
export async function down(knex: Knex): Promise<void> {
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
);
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
);
await knex.schema.alterTable(TableName.Organization, (table) => {
if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME);
if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME);
});
}

View File

@@ -17,7 +17,8 @@ export const AccessApprovalPoliciesSchema = z.object({
updatedAt: z.date(),
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional(),
allowedSelfApprovals: z.boolean().default(true)
allowedSelfApprovals: z.boolean().default(true),
maxTimePeriod: z.string().nullable().optional()
});
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;

View File

@@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({
requestedByUserId: z.string().uuid(),
note: z.string().nullable().optional(),
privilegeDeletedAt: z.date().nullable().optional(),
status: z.string().default("pending")
status: z.string().default("pending"),
editedByUserId: z.string().uuid().nullable().optional(),
editNote: z.string().nullable().optional()
});
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;

View File

@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
maxSharedSecretViewLimit: z.number().nullable().optional()
maxSharedSecretViewLimit: z.number().nullable().optional(),
googleSsoAuthEnforced: z.boolean().default(false),
googleSsoAuthLastUsed: z.date().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -3,12 +3,32 @@ import { z } from "zod";
import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
import { EnforcementLevel } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
const maxTimePeriodSchema = z
.string()
.trim()
.nullish()
.transform((val, ctx) => {
if (val === undefined) return undefined;
if (!val || val === "permanent") return null;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
});
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
@@ -71,7 +91,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.optional(),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true)
allowedSelfApprovals: z.boolean().default(true),
maxTimePeriod: maxTimePeriodSchema
})
.refine(
(val) => Boolean(val.environment) || Boolean(val.environments),
@@ -124,7 +145,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.nullable()
.optional(),
bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array()
bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array(),
maxTimePeriod: z.string().nullable().optional()
})
.array()
.nullable()
@@ -233,7 +255,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
stepNumber: z.number().int()
})
.array()
.optional()
.optional(),
maxTimePeriod: maxTimePeriodSchema
}),
response: {
200: z.object({
@@ -314,7 +337,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
})
.array()
.nullable()
.optional()
.optional(),
maxTimePeriod: z.string().nullable().optional()
})
})
}

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
body: z.object({
permissions: z.any().array(),
isTemporary: z.boolean(),
temporaryRange: z.string().optional(),
temporaryRange: z
.string()
.optional()
.transform((val, ctx) => {
if (!val || val === "permanent") return undefined;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
note: z.string().max(255).optional()
}),
querystring: z.object({
@@ -128,7 +145,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
envId: z.string(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean()
allowedSelfApprovals: z.boolean(),
maxTimePeriod: z.string().nullable().optional()
}),
reviewers: z
.object({
@@ -189,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
return { review };
}
});
server.route({
url: "/:requestId",
method: "PATCH",
schema: {
params: z.object({
requestId: z.string().trim()
}),
body: z.object({
temporaryRange: z.string().transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
editNote: z.string().max(255)
}),
response: {
200: z.object({
approval: AccessApprovalRequestsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
temporaryRange: req.body.temporaryRange,
editNote: req.body.editNote,
requestId: req.params.requestId
});
return { approval: request };
}
});
};

View File

@@ -56,6 +56,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
projectId: string;
bypassers: (
| {
@@ -96,6 +97,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environments: {
id: string;
name: string;
@@ -141,6 +143,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
}
| undefined
>;

View File

@@ -100,7 +100,8 @@ export const accessApprovalPolicyServiceFactory = ({
environments,
enforcementLevel,
allowedSelfApprovals,
approvalsRequired
approvalsRequired,
maxTimePeriod
}) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -219,7 +220,8 @@ export const accessApprovalPolicyServiceFactory = ({
secretPath,
name,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
maxTimePeriod
},
tx
);
@@ -318,7 +320,8 @@ export const accessApprovalPolicyServiceFactory = ({
enforcementLevel,
allowedSelfApprovals,
approvalsRequired,
environments
environments,
maxTimePeriod
}: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
@@ -461,7 +464,8 @@ export const accessApprovalPolicyServiceFactory = ({
secretPath,
name,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
maxTimePeriod
},
tx
);

View File

@@ -41,6 +41,7 @@ export type TCreateAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
maxTimePeriod?: string | null;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = {
@@ -60,6 +61,7 @@ export type TUpdateAccessApprovalPolicy = {
allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
environments?: string[];
maxTimePeriod?: string | null;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = {
@@ -104,7 +106,8 @@ export interface TAccessApprovalPolicyServiceFactory {
environment,
enforcementLevel,
allowedSelfApprovals,
approvalsRequired
approvalsRequired,
maxTimePeriod
}: TCreateAccessApprovalPolicy) => Promise<{
environment: {
name: string;
@@ -135,6 +138,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
}>;
deleteAccessApprovalPolicy: ({
policyId,
@@ -159,6 +163,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: {
id: string;
name: string;
@@ -185,7 +190,8 @@ export interface TAccessApprovalPolicyServiceFactory {
enforcementLevel,
allowedSelfApprovals,
approvalsRequired,
environments
environments,
maxTimePeriod
}: TUpdateAccessApprovalPolicy) => Promise<{
environment: {
id: string;
@@ -208,6 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean;
secretPath?: string | null | undefined;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
}>;
getAccessApprovalPolicyByProjectSlug: ({
actorId,
@@ -242,6 +249,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: {
id: string;
name: string;
@@ -298,6 +306,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean;
secretPath: string;
deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: {
id: string;
name: string;

View File

@@ -63,6 +63,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
enforcementLevel: string;
allowedSelfApprovals: boolean;
deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
};
projectId: string;
environments: string[];
@@ -161,6 +162,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
allowedSelfApprovals: boolean;
envId: string;
deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
};
projectId: string;
environment: string;
@@ -297,7 +299,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
@@ -364,7 +367,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
enforcementLevel: doc.policyEnforcementLevel,
allowedSelfApprovals: doc.policyAllowedSelfApprovals,
envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt
deletedAt: doc.policyDeletedAt,
maxTimePeriod: doc.policyMaxTimePeriod
},
requestedByUser: {
userId: doc.requestedByUserId,
@@ -574,7 +578,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
tx.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
);
const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => {
@@ -595,7 +600,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
allowedSelfApprovals: el.policyAllowedSelfApprovals,
deletedAt: el.policyDeletedAt
deletedAt: el.policyDeletedAt,
maxTimePeriod: el.policyMaxTimePeriod
},
requestedByUser: {
userId: el.requestedByUserId,

View File

@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
accessApprovalRequestReviewerDAL: Pick<
TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction"
"create" | "find" | "findOne" | "transaction" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
@@ -156,6 +156,15 @@ export const accessApprovalRequestServiceFactory = ({
throw new BadRequestError({ message: "The policy linked to this request has been deleted" });
}
// Check if the requested time falls under policy.maxTimePeriod
if (policy.maxTimePeriod) {
if (!temporaryRange || ms(temporaryRange) > ms(policy.maxTimePeriod)) {
throw new BadRequestError({
message: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`
});
}
}
const approverIds: string[] = [];
const approverGroupIds: string[] = [];
@@ -292,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
return { request: approval };
};
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
temporaryRange,
actorId,
actor,
actorOrgId,
actorAuthMethod,
editNote,
requestId
}) => {
const cfg = getConfig();
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
if (!accessApprovalRequest) {
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
}
const { policy, requestedByUser } = accessApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this access request has been deleted."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: accessApprovalRequest.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
if (!membership) {
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
}
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
}
const project = await projectDAL.findById(accessApprovalRequest.projectId);
if (!project) {
throw new NotFoundError({
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
});
}
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
throw new BadRequestError({ message: "The request has been closed" });
}
const editedByUser = await userDAL.findById(actorId);
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
}
}
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
permissions: accessApprovalRequest.permissions
});
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
const approvalRequest = await accessApprovalRequestDAL.updateById(
requestId,
{
temporaryRange,
isTemporary: true,
editNote,
editedByUserId: actorId
},
tx
);
// reset review progress
await accessApprovalRequestReviewerDAL.delete(
{
requestId
},
tx
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
await triggerWorkflowIntegrationNotification({
input: {
notification: {
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
payload: {
projectName: project.name,
requesterFullName,
isTemporary: true,
requesterEmail: requestedByUser.email as string,
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
}
},
projectId: project.id
},
dependencies: {
projectDAL,
projectSlackConfigDAL,
kmsService,
microsoftTeamsService,
projectMicrosoftTeamsConfigDAL
}
});
await smtpService.sendMail({
recipients: policy.approvers
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
.map((approver) => approver.email!),
subjectLine: "Access Approval Request Updated",
substitutions: {
projectName: project.name,
requesterFullName,
requesterEmail: requestedByUser.email,
isTemporary: true,
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorFullName,
editorEmail: editedByUser.email
},
template: SmtpTemplates.AccessApprovalRequestUpdated
});
return approvalRequest;
});
return { request: approval };
};
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug,
authorUserId,
@@ -641,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
return {
createAccessApprovalRequest,
updateAccessApprovalRequest,
listApprovalRequests,
reviewAccessRequest,
getCount

View File

@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
note?: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalRequestDTO = {
requestId: string;
temporaryRange: string;
editNote: string;
} & Omit<TProjectPermission, "projectId">;
export type TListApprovalRequestsDTO = {
projectSlug: string;
authorUserId?: string;
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
privilegeDeletedAt?: Date | null | undefined;
};
}>;
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
request: {
status: string;
id: string;
createdAt: Date;
updatedAt: Date;
policyId: string;
isTemporary: boolean;
requestedByUserId: string;
privilegeId?: string | null | undefined;
requestedBy?: string | null | undefined;
temporaryRange?: string | null | undefined;
permissions?: unknown;
note?: string | null | undefined;
privilegeDeletedAt?: Date | null | undefined;
};
}>;
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
requests: {
policy: {
@@ -82,6 +105,7 @@ export interface TAccessApprovalRequestServiceFactory {
allowedSelfApprovals: boolean;
envId: string;
deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
};
projectId: string;
environment: string;

View File

@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
import { EventType, filterableSecretEvents } from "./audit-log-types";
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
pruneAuditLog: (tx?: knex.Knex) => Promise<void>;
pruneAuditLog: () => Promise<void>;
find: (
arg: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string | undefined;
@@ -41,6 +41,10 @@ type TFindQuery = {
offset?: number;
};
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
export const auditLogDALFactory = (db: TDbClient) => {
const auditLogOrm = ormify(db, TableName.AuditLog);
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
};
// delete all audit log that have expired
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => {
const runPrune = async (dbClient: knex.Knex) => {
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
const today = new Date();
let deletedAuditLogIds: { id: string }[] = [];
let numberOfRetryOnFailure = 0;
let isRetrying = false;
const today = new Date();
let deletedAuditLogIds: { id: string }[] = [];
let numberOfRetryOnFailure = 0;
let isRetrying = false;
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
do {
try {
// eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
do {
try {
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
const findExpiredLogSubQuery = trx(TableName.AuditLog)
.where("expiresAt", "<", today)
.where("createdAt", "<", today) // to use audit log partition
.orderBy(`${TableName.AuditLog}.createdAt`, "desc")
@@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
// eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await dbClient(TableName.AuditLog)
.whereIn("id", findExpiredLogSubQuery)
.del()
.returning("id");
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, "Failed to delete audit log on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
};
const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
if (tx) {
await runPrune(tx);
} else {
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
await runPrune(trx);
});
}
return results;
});
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
deletedAuditLogIds = [];
logger.error(error, "Failed to delete audit log on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
};
const create: TAuditLogDALFactory["create"] = async (tx) => {

View File

@@ -1,8 +1,6 @@
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import { ProjectType, SecretKeyEncoding } from "@app/db/schemas";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { TopicName, toPublishableEvent } from "@app/ee/services/event/types";
import { SecretKeyEncoding } from "@app/db/schemas";
import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography";
import { logger } from "@app/lib/logger";
@@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
eventBusService: TEventBusService;
};
export type TAuditLogQueueServiceFactory = {
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
queueService,
projectDAL,
licenseService,
auditLogStreamDAL,
eventBusService
auditLogStreamDAL
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
@@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({
)
);
}
const publishable = toPublishableEvent(event);
if (publishable) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: publishable.data
});
}
});
return {

View File

@@ -9,7 +9,7 @@ import { getDbConnectionHost } from "@app/lib/knex";
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
const appCfg = getConfig();
if (appCfg.isDevelopmentMode) return [host];
if (appCfg.isDevelopmentMode || appCfg.isTestMode) return [host];
if (isGateway) return [host];

View File

@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import axios from "axios";
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
type TCreateCouchbaseUser = {
name: string;
password: string;
access: {
privileges: string[];
resources: {
buckets: {
name: string;
scopes?: {
name: string;
collections?: string[];
}[];
}[];
};
}[];
};
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12);
}
return sanitized;
};
/**
* Normalizes bucket configuration to handle wildcard (*) access consistently.
*
* Key behaviors:
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
*
* @param buckets - Either a string or array of bucket configurations
* @returns Normalized bucket resources for Couchbase API
*/
const normalizeBucketConfiguration = (
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>
) => {
if (typeof buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names
const bucketNames = buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
if (bucketNames.includes("*") || buckets === "*") {
return [{ name: "*" }];
}
return bucketNames.map((bucketName) => ({ name: bucketName }));
}
// Array of bucket objects with scopes and collections
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
if (hasWildcardBucket) {
return [{ name: "*" }];
}
return buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
return sanitizeCouchbaseUsername(compiledUsername);
};
const generatePassword = (requirements?: PasswordRequirements): string => {
const {
length = 12,
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
} = requirements || {};
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const symbols = allowedSymbols;
let password = "";
let remaining = length;
// Add required characters
for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)];
remaining -= 1;
}
for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)];
remaining -= 1;
}
// Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)];
}
// Shuffle the password
return password
.split("")
.sort(() => crypto.randomInt(3) - 1)
.join("");
};
const couchbaseApiRequest = async (
method: string,
url: string,
apiKey: string,
data?: unknown
): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url);
try {
const response = await axios({
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: data || undefined,
timeout: 30000
});
return response.data as CouchbaseUserResponse;
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [apiKey]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
}
};
export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs;
};
const validateConnection = async (inputs: unknown): Promise<boolean> => {
try {
const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true;
} catch (error) {
throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const create = async ({
inputs,
usernameTemplate,
identity
}: {
inputs: unknown;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
const userData: TCreateCouchbaseUser = {
name: username,
password,
access: [
{
privileges: providerInputs.roles,
resources: {
buckets: bucketResources
}
}
]
};
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username;
return {
entityId: userUuid,
data: {
username,
password
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId };
};
const renew = async (_inputs: unknown, entityId: string) => {
// Couchbase Cloud API doesn't support renewing user credentials
// The user remains valid until explicitly deleted
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};

View File

@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search";
import { GcpIamProvider } from "./gcp-iam";
import { GithubProvider } from "./github";
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
[DynamicSecretProviders.Github]: GithubProvider()
[DynamicSecretProviders.Github]: GithubProvider(),
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
});

View File

@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
.describe("The private key generated for your GitHub App.")
});
export const DynamicSecretCouchbaseSchema = z.object({
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
orgId: z.string().trim().min(1).describe("Organization ID"),
projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster ID"),
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z
.union([
z
.string()
.trim()
.min(1)
.default("*")
.refine((val) => {
if (val.includes(",")) {
const buckets = val
.split(",")
.map((b) => b.trim())
.filter((b) => b.length > 0);
if (buckets.includes("*") && buckets.length > 1) {
return false;
}
}
return true;
}, "Cannot combine '*' with other bucket names"),
z
.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"),
scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
})
)
.optional()
.describe("Scopes within the bucket")
})
)
.refine((buckets) => {
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
if (hasWildcard && buckets.length > 1) {
return false;
}
return true;
}, "Cannot combine '*' bucket with other buckets")
])
.default("*")
.describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: z
.object({
length: z.number().min(8, "Password must be at least 8 characters").max(128),
required: z
.object({
lowercase: z.number().min(1, "At least 1 lowercase character required"),
uppercase: z.number().min(1, "At least 1 uppercase character required"),
digits: z.number().min(1, "At least 1 digit required"),
symbols: z.number().min(1, "At least 1 special character required")
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128;
}, "Sum of required characters cannot exceed 128"),
allowedSymbols: z
.string()
.refine((symbols) => {
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements for Couchbase"),
auth: z.object({
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
})
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam",
Github = "github"
Github = "github",
Couchbase = "couchbase"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema })
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
]);
export type TDynamicProviderFns = {

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { logger } from "@app/lib/logger";
import { EventSchema, TopicName } from "./types";
import { BusEventSchema, TopicName } from "./types";
export const eventBusFactory = (redis: Redis) => {
const publisher = redis.duplicate();
@@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => {
* @param topic - The topic to publish the event to.
* @param event - The event data to publish.
*/
const publish = async <T extends z.input<typeof EventSchema>>(topic: TopicName, event: T) => {
const publish = async <T extends z.input<typeof BusEventSchema>>(topic: TopicName, event: T) => {
const json = JSON.stringify(event);
return publisher.publish(topic, json, (err) => {
@@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => {
* @template T - The type of the event data, which should match the schema defined in EventSchema.
* @returns A function that can be called to unsubscribe from the event bus.
*/
const subscribe = <T extends z.infer<typeof EventSchema>>(fn: (data: T) => Promise<void> | void) => {
const subscribe = <T extends z.infer<typeof BusEventSchema>>(fn: (data: T) => Promise<void> | void) => {
// Not using async await cause redis client's `on` method does not expect async listeners.
const listener = (channel: string, message: string) => {
try {

View File

@@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger";
import { TEventBusService } from "./event-bus-service";
import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream";
import { EventData, RegisteredEvent, toBusEventName } from "./types";
import { BusEvent, RegisteredEvent } from "./types";
const AUTH_REFRESH_INTERVAL = 60 * 1000;
const HEART_BEAT_INTERVAL = 15 * 1000;
@@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => {
}
};
function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) {
const eventType = toBusEventName(event.data.eventType);
function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) {
const eventType = event.data.event;
const match = registered.find((r) => r.event === eventType);
if (!match) return;

View File

@@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore";
import { conditionsMatcher } from "@app/lib/casl";
import { logger } from "@app/lib/logger";
import { EventData, RegisteredEvent } from "./types";
import { BusEvent, RegisteredEvent } from "./types";
export const getServerSentEventsHeaders = () =>
({
@@ -55,7 +55,7 @@ export type EventStreamClient = {
id: string;
stream: Readable;
open: () => Promise<void>;
send: (data: EventMessage | EventData) => void;
send: (data: EventMessage | BusEvent) => void;
ping: () => Promise<void>;
refresh: () => Promise<void>;
close: () => void;
@@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
return {
subject: options.type,
action: "subscribe",
conditions: {
eventType: r.event,
...(hasConditions
? {
environment: r.conditions?.environmentSlug ?? "",
secretPath: { $glob: secretPath }
}
: {})
}
conditions: hasConditions
? {
environment: r.conditions?.environmentSlug ?? "",
secretPath: { $glob: secretPath }
}
: undefined
};
});
@@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
// We will manually push data to the stream
stream._read = () => {};
const send = (data: EventMessage | EventData) => {
const send = (data: EventMessage | BusEvent) => {
const chunk = serializeSseEvent(data);
if (!stream.push(chunk)) {
logger.debug("Backpressure detected: dropped manual event");
@@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
await redis.set(key, "1", "EX", 60);
stream.push("1");
send({ type: "ping" });
};
const close = () => {

View File

@@ -1,7 +1,8 @@
import { z } from "zod";
import { ProjectType } from "@app/db/schemas";
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ProjectPermissionSecretEventActions } from "../permission/project-permission";
export enum TopicName {
CoreServers = "infisical::core-servers"
@@ -10,84 +11,44 @@ export enum TopicName {
export enum BusEventName {
CreateSecret = "secret:create",
UpdateSecret = "secret:update",
DeleteSecret = "secret:delete"
DeleteSecret = "secret:delete",
ImportMutation = "secret:import-mutation"
}
type PublisableEventTypes =
| EventType.CREATE_SECRET
| EventType.CREATE_SECRETS
| EventType.DELETE_SECRET
| EventType.DELETE_SECRETS
| EventType.UPDATE_SECRETS
| EventType.UPDATE_SECRET;
export function toBusEventName(input: EventType) {
switch (input) {
case EventType.CREATE_SECRET:
case EventType.CREATE_SECRETS:
return BusEventName.CreateSecret;
case EventType.UPDATE_SECRET:
case EventType.UPDATE_SECRETS:
return BusEventName.UpdateSecret;
case EventType.DELETE_SECRET:
case EventType.DELETE_SECRETS:
return BusEventName.DeleteSecret;
default:
return null;
}
}
const isBulkEvent = (event: Event): event is Extract<Event, { metadata: { secrets: Array<unknown> } }> => {
return event.type.endsWith("-secrets"); // Feels so wrong
};
export const toPublishableEvent = (event: Event) => {
const name = toBusEventName(event.type);
if (!name) return null;
const e = event as Extract<Event, { type: PublisableEventTypes }>;
if (isBulkEvent(e)) {
return {
name,
isBulk: true,
data: {
eventType: e.type,
payload: e.metadata.secrets.map((s) => ({
environment: e.metadata.environment,
secretPath: e.metadata.secretPath,
...s
}))
}
} as const;
}
return {
name,
isBulk: false,
data: {
eventType: e.type,
payload: {
...e.metadata,
environment: e.metadata.environment
}
export const Mappings = {
BusEventToAction(input: BusEventName) {
switch (input) {
case BusEventName.CreateSecret:
return ProjectPermissionSecretEventActions.SubscribeCreated;
case BusEventName.DeleteSecret:
return ProjectPermissionSecretEventActions.SubscribeDeleted;
case BusEventName.ImportMutation:
return ProjectPermissionSecretEventActions.SubscribeImportMutations;
case BusEventName.UpdateSecret:
return ProjectPermissionSecretEventActions.SubscribeUpdated;
default:
throw new Error("Unknown bus event name");
}
} as const;
}
};
export const EventName = z.nativeEnum(BusEventName);
const EventSecretPayload = z.object({
secretPath: z.string().optional(),
secretId: z.string(),
secretPath: z.string().optional(),
secretKey: z.string(),
environment: z.string()
});
const EventImportMutationPayload = z.object({
secretPath: z.string(),
environment: z.string()
});
export type EventSecret = z.infer<typeof EventSecretPayload>;
export const EventSchema = z.object({
export const BusEventSchema = z.object({
datacontenttype: z.literal("application/json").optional().default("application/json"),
type: z.nativeEnum(ProjectType),
source: z.string(),
@@ -95,25 +56,38 @@ export const EventSchema = z.object({
.string()
.optional()
.default(() => new Date().toISOString()),
data: z.discriminatedUnion("eventType", [
data: z.discriminatedUnion("event", [
z.object({
specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]),
payload: EventSecretPayload
event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]),
payload: z.union([EventSecretPayload, EventSecretPayload.array()])
}),
z.object({
specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]),
payload: EventSecretPayload.array()
event: z.enum([BusEventName.ImportMutation]),
payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()])
})
// Add more event types as needed
])
});
export type EventData = z.infer<typeof EventSchema>;
export type BusEvent = z.infer<typeof BusEventSchema>;
type PublishableEventPayload = z.input<typeof BusEventSchema>["data"];
type PublishableSecretEvent = Extract<
PublishableEventPayload,
{ event: Exclude<BusEventName, BusEventName.ImportMutation> }
>["payload"];
export type PublishableEvent = {
created?: PublishableSecretEvent;
updated?: PublishableSecretEvent;
deleted?: PublishableSecretEvent;
importMutation?: Extract<PublishableEventPayload, { event: BusEventName.ImportMutation }>["payload"];
};
export const EventRegisterSchema = z.object({
event: EventName,
event: z.nativeEnum(BusEventName),
conditions: z
.object({
secretPath: z.string().optional().default("/"),

View File

@@ -31,7 +31,7 @@ export const getDefaultOnPremFeatures = () => {
caCrl: false,
sshHostGroups: false,
enterpriseSecretSyncs: false,
enterpriseAppConnections: false,
enterpriseAppConnections: true,
machineIdentityAuthTemplates: false
};
};

View File

@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
auditLogStreams: false,
auditLogStreamLimit: 3,
samlSSO: false,
enforceGoogleSSO: false,
hsm: false,
oidcSSO: false,
scim: false,

View File

@@ -47,6 +47,7 @@ export type TFeatureSet = {
auditLogStreamLimit: 3;
githubOrgSync: false;
samlSSO: false;
enforceGoogleSSO: false;
hsm: false;
oidcSSO: false;
secretAccessInsights: false;

View File

@@ -13,6 +13,7 @@ import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningConfigActions,
ProjectPermissionSecretScanningDataSourceActions,
@@ -161,8 +162,7 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete,
ProjectPermissionSecretActions.Subscribe
ProjectPermissionSecretActions.Delete
],
ProjectPermissionSub.Secrets
);
@@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretScanningConfigs
);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};
@@ -266,8 +276,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Delete,
ProjectPermissionSecretActions.Subscribe
ProjectPermissionSecretActions.Delete
],
ProjectPermissionSub.Secrets
);
@@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};
@@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};

View File

@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
projectFavorites?: string[] | null | undefined;
customRoleSlug?: string | null | undefined;
orgAuthEnforced?: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
} & {
groups: {
id: string;
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
}[];
orgId: string;
orgAuthEnforced: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
orgRole: OrgMembershipRole;
userId: string;
projectId: string;
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"),
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
OrgMembershipsSchema.extend({
permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(),
orgGoogleSsoAuthEnforced: z.boolean(),
bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean()
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project),
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
orgId,
username,
orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole,
membershipId,
groupMembershipId,
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
}) => ({
orgId,
orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId,
projectId,

View File

@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole
) {
@@ -128,10 +129,16 @@ function validateOrgSSO(
throw new UnauthorizedError({ name: "No auth method defined" });
}
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return;
}
// case: google sso is enforced, but the actor is not using google sso
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
}
// case: SAML SSO is enforced, but the actor is not using SAML SSO
if (
isOrgSsoEnforced &&
actorAuthMethod !== null &&

View File

@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
validateOrgSSO(
authMethod,
membership.orgAuthEnforced,
membership.orgGoogleSsoAuthEnforced,
membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole
);
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
validateOrgSSO(
authMethod,
userProjectPermission.orgAuthEnforced,
userProjectPermission.orgGoogleSsoAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole
);

View File

@@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions {
ReadValue = "readValue",
Create = "create",
Edit = "edit",
Delete = "delete",
Subscribe = "subscribe"
Delete = "delete"
}
export enum ProjectPermissionCmekActions {
@@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
Update = "update-configs"
}
export enum ProjectPermissionSecretEventActions {
SubscribeCreated = "subscribe-on-created",
SubscribeUpdated = "subscribe-on-updated",
SubscribeDeleted = "subscribe-on-deleted",
SubscribeImportMutations = "subscribe-on-import-mutations"
}
export enum ProjectPermissionSub {
Role = "role",
Member = "member",
@@ -197,7 +203,8 @@ export enum ProjectPermissionSub {
Kmip = "kmip",
SecretScanningDataSources = "secret-scanning-data-sources",
SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs"
SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events"
}
export type SecretSubjectFields = {
@@ -205,7 +212,13 @@ export type SecretSubjectFields = {
secretPath: string;
secretName?: string;
secretTags?: string[];
eventType?: string;
};
export type SecretEventSubjectFields = {
environment: string;
secretPath: string;
secretName?: string;
secretTags?: string[];
};
export type SecretFolderSubjectFields = {
@@ -344,7 +357,11 @@ export type ProjectPermissionSet =
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
| [
ProjectPermissionSecretEventActions,
ProjectPermissionSub.SecretEvents | (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe(
"Describe what action an entity can take."
),
conditions: SecretSyncConditionV2Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema
]);

View File

@@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({
if (!folder) {
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
}
const { secrets } = mergeStatus;
await secretQueueService.syncSecrets({
projectId,
orgId: actorOrgId,
secretPath: folder.path,
environmentSlug: folder.environmentSlug,
actorId,
actor
actor,
event: {
created: secrets.created.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
updated: secrets.updated.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
deleted: secrets.deleted.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
}))
}
});
if (isSoftEnforcement) {

View File

@@ -2,6 +2,7 @@ import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
@@ -13,9 +14,11 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials";
import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret";
import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
import {
TSecretRotationRotateSecretsJobPayload,
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2ListItem,
@@ -74,6 +77,10 @@ export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rota
const appCfg = getConfig();
if (appCfg.isRotationDevelopmentMode) {
if (appCfg.isTestMode) {
// if its test mode, it should always rotate
return new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // Current time + 1 year
}
return getNextUTCMinuteInterval(rotateAtUtc);
}
@@ -263,3 +270,51 @@ export const throwOnImmutableParameterUpdate = (
// do nothing
}
};
export const rotateSecretsFns = async ({
job,
secretRotationV2DAL,
secretRotationV2Service
}: {
job: {
data: TSecretRotationRotateSecretsJobPayload;
id: string;
retryCount: number;
retryLimit: number;
};
secretRotationV2DAL: Pick<TSecretRotationV2DALFactory, "findById">;
secretRotationV2Service: Pick<TSecretRotationV2ServiceFactory, "rotateGeneratedCredentials">;
}) => {
const { rotationId, queuedAt, isManualRotation } = job.data;
const { retryCount, retryLimit } = job;
const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
try {
const secretRotation = await secretRotationV2DAL.findById(rotationId);
if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
if (!secretRotation.isAutoRotationEnabled) {
logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
}
if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
// rotated since being queued, skip rotation
logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
return;
}
await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
jobId: job.id,
shouldSendNotification: true,
isFinalAttempt: retryCount === retryLimit,
isManualRotation
});
logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
} catch (error) {
logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
throw error;
}
};

View File

@@ -1,9 +1,12 @@
import { v4 as uuidv4 } from "uuid";
import { ProjectMembershipRole } from "@app/db/schemas";
import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
getNextUtcRotationInterval,
getSecretRotationRotateSecretJobOptions
getSecretRotationRotateSecretJobOptions,
rotateSecretsFns
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
@@ -63,14 +66,34 @@ export const secretRotationV2QueueServiceFactory = async ({
rotation.lastRotatedAt
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
);
await queueService.queuePg(
QueueJobs.SecretRotationV2RotateSecrets,
{
rotationId: rotation.id,
queuedAt: currentTime
},
getSecretRotationRotateSecretJobOptions(rotation)
);
const data = {
rotationId: rotation.id,
queuedAt: currentTime
} as TSecretRotationRotateSecretsJobPayload;
if (appCfg.isTestMode) {
logger.warn("secretRotationV2Queue: Manually rotating secrets for test mode");
await rotateSecretsFns({
job: {
id: uuidv4(),
data,
retryCount: 0,
retryLimit: 0
},
secretRotationV2DAL,
secretRotationV2Service
});
} else {
await queueService.queuePg(
QueueJobs.SecretRotationV2RotateSecrets,
{
rotationId: rotation.id,
queuedAt: currentTime
},
getSecretRotationRotateSecretJobOptions(rotation)
);
}
}
} catch (error) {
logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
@@ -87,38 +110,14 @@ export const secretRotationV2QueueServiceFactory = async ({
await queueService.startPg<QueueName.SecretRotationV2>(
QueueJobs.SecretRotationV2RotateSecrets,
async ([job]) => {
const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload;
const { retryCount, retryLimit } = job;
const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
try {
const secretRotation = await secretRotationV2DAL.findById(rotationId);
if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
if (!secretRotation.isAutoRotationEnabled) {
logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
}
if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
// rotated since being queued, skip rotation
logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
return;
}
await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
jobId: job.id,
shouldSendNotification: true,
isFinalAttempt: retryCount === retryLimit,
isManualRotation
});
logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
} catch (error) {
logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
throw error;
}
await rotateSecretsFns({
job: {
...job,
data: job.data as TSecretRotationRotateSecretsJobPayload
},
secretRotationV2DAL,
secretRotationV2Service
});
},
{
batchSize: 1,

View File

@@ -58,9 +58,9 @@ export function scanDirectory(inputPath: string, outputPath: string, configPath?
});
}
export function scanFile(inputPath: string): Promise<void> {
export function scanFile(inputPath: string, configPath?: string): Promise<void> {
return new Promise((resolve, reject) => {
const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git`;
const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git ${configPath ? `-c ${configPath}` : ""}`;
exec(command, (error) => {
if (error && error.code === 77) {
reject(error);
@@ -166,6 +166,20 @@ export const parseScanErrorMessage = (err: unknown): string => {
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
};
const generateSecretValuePolicyConfiguration = (entropy: number): string => `
# Extend default configuration to preserve existing rules
[extend]
useDefault = true
# Add custom high-entropy rule
[[rules]]
id = "high-entropy"
description = "Will scan for high entropy secrets"
regex = '''.*'''
entropy = ${entropy}
keywords = []
`;
export const scanSecretPolicyViolations = async (
projectId: string,
secretPath: string,
@@ -188,14 +202,25 @@ export const scanSecretPolicyViolations = async (
const tempFolder = await createTempFolder();
try {
const configPath = join(tempFolder, "infisical-scan.toml");
const secretPolicyConfiguration = generateSecretValuePolicyConfiguration(
appCfg.PARAMS_FOLDER_SECRET_DETECTION_ENTROPY
);
await writeTextToFile(configPath, secretPolicyConfiguration);
const scanPromises = secrets
.filter((secret) => !ignoreValues.includes(secret.secretValue))
.map(async (secret) => {
const secretFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
await writeTextToFile(secretFilePath, `${secret.secretKey}=${secret.secretValue}`);
const secretKeyValueFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
const secretValueOnlyFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
await writeTextToFile(secretKeyValueFilePath, `${secret.secretKey}=${secret.secretValue}`);
await writeTextToFile(secretValueOnlyFilePath, secret.secretValue);
try {
await scanFile(secretFilePath);
await scanFile(secretKeyValueFilePath);
await scanFile(secretValueOnlyFilePath, configPath);
} catch (error) {
throw new BadRequestError({
message: `Secret value detected in ${secret.secretKey}. Please add this instead to the designated secrets path in the project.`,

View File

@@ -2491,6 +2491,7 @@ export const SecretSyncs = {
},
RENDER: {
serviceId: "The ID of the Render service to sync secrets to.",
environmentGroupId: "The ID of the Render environment group to sync secrets to.",
scope: "The Render scope that secrets should be synced to.",
type: "The Render resource type to sync secrets to."
},

View File

@@ -79,6 +79,7 @@ const envSchema = z
QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All),
HTTPS_ENABLED: zodStrBool,
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
// smtp options
SMTP_HOST: zpStr(z.string().optional()),
SMTP_IGNORE_TLS: zodStrBool.default("false"),
@@ -215,6 +216,7 @@ const envSchema = z
return JSON.parse(val) as { secretPath: string; projectId: string }[];
})
),
PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7),
// HSM
HSM_LIB_PATH: zpStr(z.string().optional()),
@@ -346,7 +348,11 @@ const envSchema = z
isSmtpConfigured: Boolean(data.SMTP_HOST),
isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS),
isDevelopmentMode: data.NODE_ENV === "development",
isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE,
isTestMode: data.NODE_ENV === "test",
isRotationDevelopmentMode:
(data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
isDailyResourceCleanUpDevelopmentMode:
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE,
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()

View File

@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
if (slackConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
await sendSlackNotification({
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
}
if (microsoftTeamsConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
microsoftTeamsConfig.accessRequestChannels

View File

@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
export enum TriggerFeature {
SECRET_APPROVAL = "secret-approval",
ACCESS_REQUEST = "access-request"
ACCESS_REQUEST = "access-request",
ACCESS_REQUEST_UPDATED = "access-request-updated"
}
export type TNotification =
@@ -34,6 +35,22 @@ export type TNotification =
approvalUrl: string;
note?: string;
};
}
| {
type: TriggerFeature.ACCESS_REQUEST_UPDATED;
payload: {
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
projectName: string;
permissions: string[];
approvalUrl: string;
editNote?: string;
editorFullName?: string;
editorEmail?: string;
};
};
export type TTriggerWorkflowNotificationDTO = {

View File

@@ -560,8 +560,7 @@ export const registerRoutes = async (
queueService,
projectDAL,
licenseService,
auditLogStreamDAL,
eventBusService
auditLogStreamDAL
});
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
@@ -1121,7 +1120,9 @@ export const registerRoutes = async (
resourceMetadataDAL,
folderCommitService,
secretSyncQueue,
reminderService
reminderService,
eventBusService,
licenseService
});
const projectService = projectServiceFactory({
@@ -1972,7 +1973,7 @@ export const registerRoutes = async (
await telemetryQueue.startTelemetryCheck();
await telemetryQueue.startAggregatedEventsJob();
await dailyResourceCleanUp.startCleanUp();
await dailyResourceCleanUp.init();
await dailyReminderQueueService.startDailyRemindersJob();
await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts();

View File

@@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
email: z.string().email().trim(),
password: z.string().trim(),
firstName: z.string().trim(),
lastName: z.string().trim().optional(),
protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(),
publicKey: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
encryptedPrivateKeyIV: z.string().trim(),
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim()
lastName: z.string().trim().optional()
}),
response: {
200: z.object({

View File

@@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider)
return services;
}
});
server.route({
method: "GET",
url: `/:connectionId/environment-groups`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z
.object({
id: z.string(),
name: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission);
return groups;
}
});
};

View File

@@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: () => ({ message: "Authenticated" as const })
});

View File

@@ -5,8 +5,8 @@ import { z } from "zod";
import { ActionProjectType, ProjectType } from "@app/db/schemas";
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
import { EventRegisterSchema } from "@app/ee/services/event/types";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types";
import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
import { readLimit } from "@app/server/config/rateLimiter";
@@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => {
req.body.register.forEach((r) => {
const fields = {
environment: r.conditions?.environmentSlug ?? "",
secretPath: r.conditions?.secretPath ?? "/",
eventType: r.event
secretPath: r.conditions?.secretPath ?? "/"
};
const allowed = info.permission.can(
ProjectPermissionSecretActions.Subscribe,
subject(ProjectPermissionSub.Secrets, fields)
);
const action = Mappings.BusEventToAction(r.event);
const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields));
if (!allowed) {
throw new ForbiddenRequestError({
name: "PermissionDenied",
message: `You are not allowed to subscribe on secrets`,
message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`,
details: {
event: fields.eventType,
action,
environmentSlug: fields.environment,
secretPath: fields.secretPath
}

View File

@@ -478,4 +478,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
return { identityMemberships };
}
});
server.route({
method: "GET",
url: "/details",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
identityDetails: z.object({
organization: z.object({
id: z.string(),
name: z.string(),
slug: z.string()
})
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN], { requireOrg: false }),
handler: async (req) => {
const organization = await server.services.org.findIdentityOrganization(req.permission.id);
return { identityDetails: { organization } };
}
});
};

View File

@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
name: GenericResourceNameSchema.optional(),
slug: slugSchema({ max: 64 }).optional(),
authEnforced: z.boolean().optional(),
googleSsoAuthEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional(),
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
enforceMfa: z.boolean().optional(),

View File

@@ -45,7 +45,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.path)
.optional(),
// backward compatiability with cli
// backward compatibility with cli
directory: z
.string()
.trim()
@@ -58,7 +58,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
}),
response: {
200: z.object({
folder: SecretFoldersSchema
folder: SecretFoldersSchema.extend({
path: z.string()
})
})
}
},
@@ -130,7 +132,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.path)
.optional(),
// backward compatiability with cli
// backward compatibility with cli
directory: z
.string()
.trim()
@@ -143,7 +145,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
}),
response: {
200: z.object({
folder: SecretFoldersSchema
folder: SecretFoldersSchema.extend({
path: z.string()
})
})
}
},
@@ -359,7 +363,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash)
.describe(FOLDERS.LIST.path)
.optional(),
// backward compatiability with cli
// backward compatibility with cli
directory: z
.string()
.trim()

View File

@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
try {
// @ts-expect-error this is because this is express type and not fastify
const callbackPort = req.session.get("callbackPort");
// @ts-expect-error this is because this is express type and not fastify
const orgSlug = req.session.get("orgSlug");
const email = profile?.emails?.[0]?.value;
if (!email)
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE,
callbackPort
callbackPort,
orgSlug
});
cb(null, { isUserCompleted, providerAuthToken });
} catch (error) {
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
schema: {
querystring: z.object({
callback_port: z.string().optional(),
org_slug: z.string().optional(),
is_admin_login: z
.string()
.optional()
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
},
preValidation: [
async (req, res) => {
const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query;
const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query;
// ensure fresh session state per login attempt
await req.session.regenerate();
if (callbackPort) {
req.session.set("callbackPort", callbackPort);
}
if (orgSlug) {
req.session.set("orgSlug", orgSlug);
}
if (isAdminLogin) {
req.session.set("isAdminLogin", isAdminLogin);
}

View File

@@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
return token;
};
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
if (!linkHeader) return {};
const links: Record<string, string> = {};
const segments = linkHeader.split(",");
const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/);
for (const segment of segments) {
const match = re.exec(segment.trim());
if (match) {
const url = match[1];
const rel = match[2];
links[rel] = url;
}
}
return links;
};
function extractNextPageUrl(linkHeader: string | undefined): string | null {
if (!linkHeader) return null;
const links = linkHeader.split(",");
const nextLink = links.find((link) => link.includes('rel="next"'));
if (!nextLink) return null;
const match = new RE2(/<([^>]+)>/).exec(nextLink);
return match ? match[1] : null;
const links = parseGitHubLinkHeader(linkHeader);
return links.next || null;
}
export const makePaginatedGitHubRequest = async <T, R = T[]>(
@@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
const token =
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl);
initialUrlObj.searchParams.set("per_page", "100");
let results: T[] = [];
let i = 0;
const maxIterations = 1000;
while (url && i < 1000) {
// eslint-disable-next-line no-await-in-loop
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
url,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
// Make initial request to get link header
const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
url: initialUrlObj.toString(),
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
results = results.concat(firstPageItems);
url = extractNextPageUrl(response.headers.link as string | undefined);
i += 1;
const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
const lastPageUrl = linkHeader.last;
// If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last
if (lastPageUrl) {
const lastPageParam = new URL(lastPageUrl).searchParams.get("page");
const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1;
const pageRequests: Promise<AxiosResponse<R>>[] = [];
for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) {
const pageUrlObj = new URL(initialUrlObj.toString());
pageUrlObj.searchParams.set("page", pageNum.toString());
pageRequests.push(
requestWithGitHubGateway<R>(appConnection, gatewayService, {
url: pageUrlObj.toString(),
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
})
);
}
const responses = await Promise.all(pageRequests);
for (const response of responses) {
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
}
} else {
// Fallback in case last link isn't present
let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined);
let i = 1;
while (url && i < maxIterations) {
// eslint-disable-next-line no-await-in-loop
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
url,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
url = extractNextPageUrl(response.headers.link as string | undefined);
i += 1;
}
}
return results;

View File

@@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
import { AppConnection } from "../app-connection-enums";
import { RenderConnectionMethod } from "./render-connection-enums";
import {
TRawRenderEnvironmentGroup,
TRawRenderService,
TRenderConnection,
TRenderConnectionConfig,
TRenderEnvironmentGroup,
TRenderService
} from "./render-connection-types";
@@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
const perPage = 100;
let cursor;
let maxIterations = 10;
while (hasMorePages) {
if (maxIterations <= 0) break;
const res: TRawRenderService[] = (
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
params: new URLSearchParams({
@@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
} else {
cursor = res[res.length - 1].cursor;
}
maxIterations -= 1;
}
return services;
@@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect
return inputCredentials;
};
export const listRenderEnvironmentGroups = async (
appConnection: TRenderConnection
): Promise<TRenderEnvironmentGroup[]> => {
const {
credentials: { apiKey }
} = appConnection;
const groups: TRenderEnvironmentGroup[] = [];
let hasMorePages = true;
const perPage = 100;
let cursor;
let maxIterations = 10;
while (hasMorePages) {
if (maxIterations <= 0) break;
const res: TRawRenderEnvironmentGroup[] = (
await request.get<TRawRenderEnvironmentGroup[]>(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, {
params: new URLSearchParams({
...(cursor ? { cursor: String(cursor) } : {}),
limit: String(perPage)
}),
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
"Accept-Encoding": "application/json"
}
})
).data;
res.forEach((item) => {
groups.push({
name: item.envGroup.name,
id: item.envGroup.id
});
});
if (res.length < perPage) {
hasMorePages = false;
} else {
cursor = res[res.length - 1].cursor;
}
maxIterations -= 1;
}
return groups;
};

View File

@@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listRenderServices } from "./render-connection-fns";
import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns";
import { TRenderConnection } from "./render-connection-types";
type TGetAppConnectionFunc = (
@@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc)
}
};
const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor);
try {
const groups = await listRenderEnvironmentGroups(appConnection);
return groups;
} catch (error) {
logger.error(error, "Failed to list environment groups for Render connection");
return [];
}
};
return {
listServices
listServices,
listEnvironmentGroups
};
};

View File

@@ -33,3 +33,16 @@ export type TRawRenderService = {
name: string;
};
};
export type TRenderEnvironmentGroup = {
name: string;
id: string;
};
export type TRawRenderEnvironmentGroup = {
cursor: string;
envGroup: {
id: string;
name: string;
};
};

View File

@@ -448,15 +448,34 @@ export const authLoginServiceFactory = ({
// Check if the user actually has access to the specified organization.
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited");
const selectedOrg = await orgDAL.findById(organizationId);
if (!hasOrganizationMembership) {
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}`
});
}
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
if (!canBypass) {
throw new ForbiddenRequestError({
message: "Google SSO is enforced for this organization. Please use Google SSO to login.",
error: "GoogleSsoEnforced"
});
}
}
if (decodedToken.authMethod === AuthMethod.GOOGLE) {
await orgDAL.updateById(selectedOrg.id, {
googleSsoAuthLastUsed: new Date()
});
}
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
@@ -502,7 +521,8 @@ export const authLoginServiceFactory = ({
selectedOrg.authEnforced &&
selectedOrg.bypassOrgAuthEnabled &&
!isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC
decodedToken.authMethod !== AuthMethod.OIDC &&
decodedToken.authMethod !== AuthMethod.GOOGLE
) {
await auditLogService.createAuditLog({
orgId: organizationId,
@@ -705,7 +725,7 @@ export const authLoginServiceFactory = ({
/*
* OAuth2 login for google,github, and other oauth2 provider
* */
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => {
// akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserByUsername(email);
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
@@ -759,6 +779,8 @@ export const authLoginServiceFactory = ({
const appCfg = getConfig();
let orgId = "";
let orgName: undefined | string;
if (!user) {
// Create a new user based on oAuth
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
@@ -784,7 +806,6 @@ export const authLoginServiceFactory = ({
});
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
let orgId = "";
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
if (!defaultOrg) {
throw new BadRequestError({
@@ -824,11 +845,39 @@ export const authLoginServiceFactory = ({
}
}
if (!orgId && orgSlug) {
const org = await orgDAL.findOrgBySlug(orgSlug);
if (org) {
// checks for the membership and only sets the orgId / orgName if the user is a member of the specified org
const orgMembership = await orgDAL.findMembership({
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
[`${TableName.OrgMembership}.orgId` as "orgId"]: org.id,
[`${TableName.OrgMembership}.isActive` as "isActive"]: true,
[`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted
});
if (orgMembership) {
orgId = org.id;
orgName = org.name;
}
}
}
const isUserCompleted = user.isAccepted;
const providerAuthToken = crypto.jwt().sign(
{
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id,
...(orgId && orgSlug && orgName !== undefined
? {
organizationId: orgId,
organizationName: orgName,
organizationSlug: orgSlug
}
: {}),
username: user.username,
email: user.email,
isEmailVerified: user.isEmailVerified,

View File

@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
lastName?: string;
authMethod: AuthMethod;
callbackPort?: string;
orgSlug?: string;
};
export type TOauthTokenExchangeDTO = {

View File

@@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => {
};
}
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const adaptiveCard = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Updated access approval request pending for review",
weight: "Bolder",
size: "Large"
},
{
type: "TextBlock",
text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`,
wrap: true
},
{
type: "TextBlock",
text: `The following permissions are requested: ${payload.permissions.join(", ")}`,
wrap: true
},
payload.editNote
? {
type: "TextBlock",
text: `**Editor Note**: ${payload.editNote}`,
wrap: true
}
: null
].filter(Boolean),
actions: [
{
type: "Action.OpenUrl",
title: "View request in Infisical",
url: payload.approvalUrl
}
]
};
return {
adaptiveCard
};
}
default: {
throw new BadRequestError({
message: "Teams notification type not supported."

View File

@@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => {
}
};
const findIdentityOrganization = async (
identityId: string
): Promise<{ id: string; name: string; slug: string; role: string }> => {
try {
const org = await db
.replicaNode()(TableName.IdentityOrgMembership)
.where({ identityId })
.join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`)
.select(db.ref("id").withSchema(TableName.Organization).as("id"))
.select(db.ref("name").withSchema(TableName.Organization).as("name"))
.select(db.ref("slug").withSchema(TableName.Organization).as("slug"))
.select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role"));
return org?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find identity organization" });
}
};
return withTransaction(db, {
...orgOrm,
findOrgByProjectId,
@@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => {
updateMembershipById,
deleteMembershipById,
deleteMembershipsById,
updateMembership
updateMembership,
findIdentityOrganization
});
};

View File

@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
createdAt: true,
updatedAt: true,
authEnforced: true,
googleSsoAuthEnforced: true,
scimEnabled: true,
kmsDefaultKeyId: true,
defaultMembershipRole: true,

View File

@@ -198,6 +198,15 @@ export const orgServiceFactory = ({
// Filter out orgs where the membership object is an invitation
return orgs.filter((org) => org.userStatus !== "invited");
};
/*
* Get all organization an identity is part of
* */
const findIdentityOrganization = async (identityId: string) => {
const org = await orgDAL.findIdentityOrganization(identityId);
return org;
};
/*
* Get all workspace members
* */
@@ -355,6 +364,7 @@ export const orgServiceFactory = ({
name,
slug,
authEnforced,
googleSsoAuthEnforced,
scimEnabled,
defaultMembershipRoleSlug,
enforceMfa,
@@ -421,6 +431,21 @@ export const orgServiceFactory = ({
}
}
if (googleSsoAuthEnforced !== undefined) {
if (!plan.enforceGoogleSSO) {
throw new BadRequestError({
message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO."
});
}
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
}
if (authEnforced && googleSsoAuthEnforced) {
throw new BadRequestError({
message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time."
});
}
if (authEnforced) {
const samlCfg = await samlConfigDAL.findOne({
orgId,
@@ -451,6 +476,21 @@ export const orgServiceFactory = ({
}
}
if (googleSsoAuthEnforced) {
if (googleSsoAuthEnforced && currentOrg.authEnforced) {
throw new BadRequestError({
message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled."
});
}
if (!currentOrg.googleSsoAuthLastUsed) {
throw new BadRequestError({
message:
"Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization."
});
}
}
let defaultMembershipRole: string | undefined;
if (defaultMembershipRoleSlug) {
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
@@ -465,6 +505,7 @@ export const orgServiceFactory = ({
name,
slug: slug ? slugify(slug) : undefined,
authEnforced,
googleSsoAuthEnforced,
scimEnabled,
defaultMembershipRole,
enforceMfa,
@@ -1403,6 +1444,7 @@ export const orgServiceFactory = ({
findOrganizationById,
findAllOrgMembers,
findAllOrganizationOfUser,
findIdentityOrganization,
inviteUserToOrganization,
verifyUserToOrg,
updateOrg,

View File

@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
name: string;
slug: string;
authEnforced: boolean;
googleSsoAuthEnforced: boolean;
scimEnabled: boolean;
defaultMembershipRoleSlug: string;
enforceMfa: boolean;

View File

@@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({
}
}
const envs = await projectEnvDAL.find({ projectId });
const project = await projectDAL.findById(projectId);
const plan = await licenseService.getPlan(project.orgId);
if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) {
// case: limit imposed on number of environments allowed
// case: number of environments used exceeds the number of environments allowed
throw new BadRequestError({
message:
"Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments."
});
}
const env = await projectEnvDAL.transaction(async (tx) => {
if (position) {
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);

View File

@@ -1,5 +1,6 @@
import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
@@ -41,32 +42,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
serviceTokenService,
orgService
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
queueService.start(QueueName.DailyResourceCleanUp, async () => {
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
await identityAccessTokenDAL.removeExpiredTokens();
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
await secretSharingDAL.pruneExpiredSharedSecrets();
await secretSharingDAL.pruneExpiredSecretRequests();
await snapshotDAL.pruneExcessSnapshots();
await secretVersionDAL.pruneExcessVersions();
await secretVersionV2DAL.pruneExcessVersions();
await secretFolderVersionDAL.pruneExcessVersions();
await serviceTokenService.notifyExpiringTokens();
await orgService.notifyInvitedUsers();
await auditLogDAL.pruneAuditLog();
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
});
const appCfg = getConfig();
// we do a repeat cron job in utc timezone at 12 Midnight each day
const startCleanUp = async () => {
// TODO(akhilmhdh): remove later
if (appCfg.isDailyResourceCleanUpDevelopmentMode) {
logger.warn("Daily Resource Clean Up is in development mode.");
}
const init = async () => {
await queueService.stopRepeatableJob(
QueueName.AuditLogPrune,
QueueJobs.AuditLogPrune,
{ pattern: "0 0 * * *", utc: true },
QueueName.AuditLogPrune // just a job id
);
// clear previous job
await queueService.stopRepeatableJob(
QueueName.DailyResourceCleanUp,
QueueJobs.DailyResourceCleanUp,
@@ -74,18 +62,43 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
QueueName.DailyResourceCleanUp // just a job id
);
await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, {
delay: 5000,
jobId: QueueName.DailyResourceCleanUp,
repeat: { pattern: "0 0 * * *", utc: true }
});
await queueService.startPg<QueueName.DailyResourceCleanUp>(
QueueJobs.DailyResourceCleanUp,
async () => {
try {
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
await identityAccessTokenDAL.removeExpiredTokens();
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
await secretSharingDAL.pruneExpiredSharedSecrets();
await secretSharingDAL.pruneExpiredSecretRequests();
await snapshotDAL.pruneExcessSnapshots();
await secretVersionDAL.pruneExcessVersions();
await secretVersionV2DAL.pruneExcessVersions();
await secretFolderVersionDAL.pruneExcessVersions();
await serviceTokenService.notifyExpiringTokens();
await orgService.notifyInvitedUsers();
await auditLogDAL.pruneAuditLog();
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
} catch (error) {
logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 1
}
);
await queueService.schedulePg(
QueueJobs.DailyResourceCleanUp,
appCfg.isDailyResourceCleanUpDevelopmentMode ? "*/5 * * * *" : "0 0 * * *",
undefined,
{ tz: "UTC" }
);
};
queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => {
logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
});
return {
startCleanUp
init
};
};

View File

@@ -238,8 +238,16 @@ export const secretFolderServiceFactory = ({
return doc;
});
const [folderWithFullPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]);
if (!folderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${folder.id}'`
});
}
await snapshotService.performSnapshot(folder.parentId as string);
return folder;
return { ...folder, path: folderWithFullPath.path };
};
const updateManyFolders = async ({
@@ -496,8 +504,27 @@ export const secretFolderServiceFactory = ({
return doc;
});
const foldersWithFullPaths = await folderDAL.findSecretPathByFolderIds(projectId, [newFolder.id, folder.id]);
const newFolderWithFullPath = foldersWithFullPaths.find((f) => f?.id === newFolder.id);
if (!newFolderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${newFolder.id}'`
});
}
const folderWithFullPath = foldersWithFullPaths.find((f) => f?.id === folder.id);
if (!folderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${folder.id}'`
});
}
await snapshotService.performSnapshot(newFolder.parentId as string);
return { folder: newFolder, old: folder };
return {
folder: { ...newFolder, path: newFolderWithFullPath.path },
old: { ...folder, path: folderWithFullPath.path }
};
};
const $checkFolderPolicy = async ({

View File

@@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({
projectId,
environmentSlug: environment,
actorId,
actor
actor,
event: {
importMutation: {
secretPath,
environment
}
}
});
}
@@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({
projectId,
environmentSlug: environment,
actor,
actorId
actorId,
event: {
importMutation: {
secretPath,
environment
}
}
});
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);

View File

@@ -1,4 +1,5 @@
import AWS, { AWSError } from "aws-sdk";
import handlebars from "handlebars";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
@@ -34,18 +35,51 @@ const sleep = async () =>
setTimeout(resolve, 1000);
});
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => {
const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => {
if (!keySchema || !keySchema.includes("/")) return path;
if (keySchema.startsWith("/")) {
throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false });
}
const keySchemaSegments = handlebars
.compile(keySchema)({
environment,
secretKey: "{{secretKey}}"
})
.split("/");
const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1);
if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) {
throw new SecretSyncError({
message: "Key schema cannot contain '/' after {{secretKey}}",
shouldRetry: false
});
}
return `${path}${pathSegments.join("/")}/`;
};
const getParametersByPath = async (
ssm: AWS.SSM,
path: string,
keySchema: string | undefined,
environment: string
): Promise<TAWSParameterStoreRecord> => {
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
let hasNext = true;
let nextToken: string | undefined;
let attempt = 0;
const fullPath = getFullPath({ path, keySchema, environment });
while (hasNext) {
try {
// eslint-disable-next-line no-await-in-loop
const parameters = await ssm
.getParametersByPath({
Path: path,
Path: fullPath,
Recursive: false,
WithDecryption: true,
MaxResults: BATCH_SIZE,
@@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
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;
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreSecretsRecord[secKey] = parameter;
}
});
@@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
return awsParameterStoreSecretsRecord;
};
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => {
const getParameterMetadataByPath = async (
ssm: AWS.SSM,
path: string,
keySchema: string | undefined,
environment: string
): Promise<TAWSParameterStoreMetadataRecord> => {
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
let hasNext = true;
let nextToken: string | undefined;
let attempt = 0;
const fullPath = getFullPath({ path, keySchema, environment });
while (hasNext) {
try {
// eslint-disable-next-line no-await-in-loop
@@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
{
Key: "Path",
Option: "OneLevel",
Values: [path]
Values: [fullPath]
}
]
})
@@ -112,7 +153,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
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;
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreMetadataRecord[secKey] = parameter;
}
});
@@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = {
const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path);
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
ssm,
@@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = {
await deleteParametersBatch(ssm, parametersToDelete);
},
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions, environment } = secretSync;
const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
return Object.fromEntries(
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
);
},
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions, environment } = secretSync;
const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const parametersToDelete: AWS.SSM.Parameter[] = [];

View File

@@ -1,5 +1,6 @@
export enum RenderSyncScope {
Service = "service"
Service = "service",
EnvironmentGroup = "environment-group"
}
export enum RenderSyncType {

View File

@@ -1,11 +1,13 @@
/* eslint-disable no-await-in-loop */
import { isAxiosError } from "axios";
import { AxiosRequestConfig, isAxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { RenderSyncScope } from "./render-sync-enums";
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
const MAX_RETRIES = 5;
@@ -27,6 +29,80 @@ const makeRequestWithRetry = async <T>(requestFn: () => Promise<T>, attempt = 0)
}
};
async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) {
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
method: "GET",
headers: {
Authorization: `Bearer ${input.token}`,
Accept: "application/json"
}
};
switch (input.destination.scope) {
case RenderSyncScope.Service: {
req.url = `/services/${input.destination.serviceId}/env-vars`;
const allSecrets: TRenderSecret[] = [];
let cursor: string | undefined;
do {
// eslint-disable-next-line @typescript-eslint/no-loop-func
const { data } = await makeRequestWithRetry(() =>
request.request<
{
envVar: {
key: string;
value: string;
};
cursor: string;
}[]
>({
...req,
params: {
cursor
}
})
);
const secrets = data.map((item) => ({
key: item.envVar.key,
value: item.envVar.value
}));
allSecrets.push(...secrets);
if (data.length > 0 && data[data.length - 1]?.cursor) {
cursor = data[data.length - 1].cursor;
} else {
cursor = undefined;
}
} while (cursor);
return allSecrets;
}
case RenderSyncScope.EnvironmentGroup: {
req.url = `/env-groups/${input.destination.environmentGroupId}`;
const res = await makeRequestWithRetry(() =>
request.request<{
envVars: {
key: string;
value: string;
}[];
}>(req)
);
return res.data.envVars.map((item) => ({
key: item.key,
value: item.value
}));
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
}
const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => {
const {
destinationConfig,
@@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential
}
} = secretSync;
const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`;
const allSecrets: TRenderSecret[] = [];
let cursor: string | undefined;
const secrets = await getSecrets({
destination: destinationConfig,
token: apiKey
});
do {
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const { data } = await makeRequestWithRetry(() =>
request.get<
{
envVar: {
key: string;
value: string;
};
cursor: string;
}[]
>(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
})
);
const secrets = data.map((item) => ({
key: item.envVar.key,
value: item.envVar.value
}));
allSecrets.push(...secrets);
if (data.length > 0 && data[data.length - 1]?.cursor) {
cursor = data[data.length - 1].cursor;
} else {
cursor = undefined;
}
} while (cursor);
return allSecrets;
return secrets;
};
const batchUpdateEnvironmentSecrets = async (
@@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async (
}
} = secretSync;
await makeRequestWithRetry(() =>
request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
};
switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${destinationConfig.serviceId}/env-vars`,
data: envVars
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
for await (const variable of envVars) {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`,
data: {
value: variable.value
}
})
);
}
})
);
break;
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
};
const deleteEnvironmentSecret = async (
secretSync: TRenderSyncWithCredentials,
envVar: { key: string; value: string }
): Promise<void> => {
const {
destinationConfig,
connection: {
credentials: { apiKey }
}
} = secretSync;
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
};
switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}`
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}`
})
);
break;
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
};
const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
@@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
}
} = secretSync;
await makeRequestWithRetry(() =>
request.post(
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`,
{},
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
};
switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
method: "POST",
url: `/services/${destinationConfig.serviceId}/deploys`,
data: {}
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
const { data } = await request.request<{ serviceLinks: { id: string }[] }>({
...req,
method: "GET",
url: `/env-groups/${destinationConfig.environmentGroupId}`
});
for await (const link of data.serviceLinks) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${link.id}/deploys`,
data: {}
})
);
}
)
);
break;
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
};
export const RenderSyncFns = {
@@ -169,14 +321,15 @@ export const RenderSyncFns = {
const finalEnvVars: Array<{ key: string; value: string }> = [];
for (const renderSecret of renderSecrets) {
if (!(renderSecret.key in secretMap)) {
if (renderSecret.key in secretMap) {
finalEnvVars.push({
key: renderSecret.key,
value: renderSecret.value
});
}
}
await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars);
await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el)));
if (secretSync.syncOptions.autoRedeployServices) {
await redeployService(secretSync);

View File

@@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId),
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
}),
z.object({
scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
environmentGroupId: z
.string()
.min(1, "Environment Group ID is required")
.describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId),
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
})
]);

View File

@@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({
actorId,
actor,
projectId,
environmentSlug: folder.environment.slug
environmentSlug: folder.environment.slug,
event: {
created: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
});
}
@@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({
actor,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
environmentSlug: folder.environment.slug,
event: {
updated: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
});
}
@@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({
actor,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
environmentSlug: folder.environment.slug,
event: {
deleted: {
secretId: secretToDelete.id,
environment: folder.environment.slug,
secretKey: secretToDelete.key,
secretPath
}
}
});
}
@@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
environmentSlug: folder.environment.slug,
event: {
created: newSecrets.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
});
return newSecrets.map((el) => {
@@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath: el.path,
projectId,
orgId: actorOrgId,
environmentSlug: environment
environmentSlug: environment,
event: {
updated: updatedSecrets.map((sec) => ({
secretId: sec.id,
secretKey: sec.key,
secretPath: sec.secretPath,
environment
}))
}
})
: undefined
)
@@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
environmentSlug: folder.environment.slug,
event: {
deleted: secretsDeleted.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
});
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: destinationFolder.path,
environmentSlug: destinationFolder.environment.slug,
actorId,
actor
actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
});
}
@@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: sourceFolder.path,
environmentSlug: sourceFolder.environment.slug,
actorId,
actor
actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
});
}

View File

@@ -5,6 +5,7 @@ import { Knex } from "knex";
import {
ProjectMembershipRole,
ProjectType,
ProjectUpgradeStatus,
ProjectVersion,
SecretType,
@@ -12,6 +13,9 @@ import {
TSecretVersionsV2
} from "@app/db/schemas";
import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
@@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = {
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
eventBusService: TEventBusService;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TGetSecrets = {
@@ -172,7 +178,9 @@ export const secretQueueFactory = ({
resourceMetadataDAL,
secretSyncQueue,
folderCommitService,
reminderService
reminderService,
eventBusService,
licenseService
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@@ -534,17 +542,70 @@ export const secretQueueFactory = ({
});
};
const publishEvents = async (event: PublishableEvent) => {
if (event.created) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.CreateSecret,
payload: event.created
}
});
}
if (event.updated) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.UpdateSecret,
payload: event.updated
}
});
}
if (event.deleted) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.DeleteSecret,
payload: event.deleted
}
});
}
if (event.importMutation) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.ImportMutation,
payload: event.importMutation
}
});
}
};
const syncSecrets = async <T extends boolean = false>({
// seperate de-dupe queue for integration sync and replication sync
_deDupeQueue: deDupeQueue = {},
_depth: depth = 0,
_deDupeReplicationQueue: deDupeReplicationQueue = {},
event,
...dto
}: TSyncSecretsDTO<T>) => {
}: TSyncSecretsDTO<T> & { event?: PublishableEvent }) => {
logger.info(
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
);
const plan = await licenseService.getPlan(dto.orgId);
if (event && plan.eventSubscriptions) {
await publishEvents(event);
}
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
if (
!dto.excludeReplication
@@ -565,7 +626,7 @@ export const secretQueueFactory = ({
_deDupeQueue: deDupeQueue,
_deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth
} as TSyncSecretsDTO,
} as unknown as TSyncSecretsDTO,
{
removeOnFail: true,
removeOnComplete: true,
@@ -689,6 +750,7 @@ export const secretQueueFactory = ({
isManual,
projectId,
secretPath,
depth = 1,
deDupeQueue = {}
} = job.data as TIntegrationSyncPayload;
@@ -738,7 +800,13 @@ export const secretQueueFactory = ({
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
excludeReplication: true,
event: {
importMutation: {
secretPath: foldersGroupedById[folderId][0]?.path as string,
environment: foldersGroupedById[folderId][0]?.environmentSlug as string
}
}
})
)
);
@@ -791,7 +859,13 @@ export const secretQueueFactory = ({
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
excludeReplication: true,
event: {
importMutation: {
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string
}
}
})
)
);

View File

@@ -115,6 +115,44 @@ User Note: ${payload.note}`
payloadBlocks
};
}
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
The following permissions are requested: ${payload.permissions.join(", ")}
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.editNote
? `
Editor Note: ${payload.editNote}`
: ""
}`;
const payloadBlocks = [
{
type: "header",
text: {
type: "plain_text",
text: "Updated access approval request pending for review",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
}
}
];
return {
payloadMessage: messageBody,
payloadBlocks
};
}
default: {
throw new BadRequestError({
message: "Slack notification type not supported."

View File

@@ -0,0 +1,95 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface AccessApprovalRequestUpdatedTemplateProps
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
projectName: string;
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
expiresIn: string;
permissions: string[];
editNote: string;
editorFullName: string;
editorEmail: string;
approvalUrl: string;
}
export const AccessApprovalRequestUpdatedTemplate = ({
projectName,
siteUrl,
requesterFullName,
requesterEmail,
isTemporary,
secretPath,
environment,
expiresIn,
permissions,
editNote,
editorEmail,
editorFullName,
approvalUrl
}: AccessApprovalRequestUpdatedTemplateProps) => {
return (
<BaseEmailWrapper
title="Access Approval Request Update"
preview="An access approval request was updated and requires your review."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
An access approval request was updated and is pending your review for the project <strong>{projectName}</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
<strong>{editorFullName}</strong> (<BaseLink href={`mailto:${editorEmail}`}>{editorEmail}</BaseLink>) has
updated the access request submitted by <strong>{requesterFullName}</strong> (
<BaseLink href={`mailto:${requesterEmail}`}>{requesterEmail}</BaseLink>) for <strong>{secretPath}</strong> in
the <strong>{environment}</strong> environment.
</Text>
{isTemporary && (
<Text className="text-[14px] text-red-600 leading-[24px]">
<strong>This access will expire {expiresIn} after approval.</strong>
</Text>
)}
<Text className="text-[14px] leading-[24px] mb-[4px]">
<strong>The following permissions are requested:</strong>
</Text>
{permissions.map((permission) => (
<Text key={permission} className="text-[14px] my-[2px] leading-[24px]">
- {permission}
</Text>
))}
<Text className="text-[14px] text-slate-700 leading-[24px]">
<strong className="text-black">Editor Note:</strong> "{editNote}"
</Text>
</Section>
<Section className="text-center">
<BaseButton href={approvalUrl}>Review Request</BaseButton>
</Section>
</BaseEmailWrapper>
);
};
export default AccessApprovalRequestUpdatedTemplate;
AccessApprovalRequestUpdatedTemplate.PreviewProps = {
requesterFullName: "Abigail Williams",
requesterEmail: "abigail@infisical.com",
isTemporary: true,
secretPath: "/api/secrets",
environment: "Production",
siteUrl: "https://infisical.com",
projectName: "Example Project",
expiresIn: "1 day",
permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"],
editNote: "Too permissive, they only need 3 days",
editorEmail: "john@infisical.com",
editorFullName: "John Smith"
} as AccessApprovalRequestUpdatedTemplateProps;

View File

@@ -1,4 +1,5 @@
export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate";

View File

@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
import {
AccessApprovalRequestTemplate,
AccessApprovalRequestUpdatedTemplate,
EmailMfaTemplate,
EmailVerificationTemplate,
ExternalImportFailedTemplate,
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
EmailMfa = "emailMfa",
UnlockAccount = "unlockAccount",
AccessApprovalRequest = "accessApprovalRequest",
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,

View File

@@ -11,7 +11,6 @@ import {
validateOverrides
} from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
@@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({
return updatedServerCfg;
};
const adminSignUp = async ({
lastName,
firstName,
email,
salt,
password,
verifier,
publicKey,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
ip,
userAgent
}: TAdminSignUpDTO) => {
const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => {
const appCfg = getConfig();
const sanitizedEmail = email.trim().toLowerCase();
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
const privateKey = await getUserPrivateKey(password, {
encryptionVersion: 2,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
});
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
const userInfo = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create(
{
@@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({
);
const userEnc = await userDAL.createUserEncryption(
{
salt,
encryptionVersion: 2,
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
verifier,
userId: newUser.id,
hashedPassword,
serverEncryptedPrivateKey: ciphertext,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyEncoding: encoding
hashedPassword
},
tx
);
return { user: newUser, enc: userEnc };
});
@@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({
},
tx
);
const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password);
const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const userEnc = await userDAL.createUserEncryption(
{
userId: newUser.id,
encryptionVersion: 2,
protectedKey: encKeys.protectedKey,
protectedKeyIV: encKeys.protectedKeyIV,
protectedKeyTag: encKeys.protectedKeyTag,
publicKey: encKeys.publicKey,
encryptedPrivateKey: encKeys.encryptedPrivateKey,
iv: encKeys.encryptedPrivateKeyIV,
tag: encKeys.encryptedPrivateKeyTag,
salt: encKeys.salt,
verifier: encKeys.verifier,
serverEncryptedPrivateKeyEncoding: encoding,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKey: ciphertext
hashedPassword
},
tx
);

View File

@@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env";
export type TAdminSignUpDTO = {
email: string;
password: string;
publicKey: string;
salt: string;
lastName?: string;
verifier: string;
firstName: string;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
ip: string;
userAgent: string;
};

View File

@@ -5,7 +5,10 @@ export default defineConfig({
test: {
globals: true,
env: {
NODE_ENV: "test"
NODE_ENV: "test",
E2E_TEST_ORACLE_DB_19_HOST: process.env.E2E_TEST_ORACLE_DB_19_HOST!,
E2E_TEST_ORACLE_DB_19_USERNAME: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!,
E2E_TEST_ORACLE_DB_19_PASSWORD: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD!
},
environment: "./e2e-test/vitest-environment-knex.ts",
include: ["./e2e-test/**/*.spec.ts"],

157
docker-compose.e2e-dbs.yml Normal file
View File

@@ -0,0 +1,157 @@
version: '3.8'
services:
# Oracle Databases
oracle-db-23.8:
image: container-registry.oracle.com/database/free:23.8.0.0
container_name: oracle-db-23.8
ports:
- "1521:1521"
environment:
- ORACLE_PDB=pdb
- ORACLE_PWD=pdb-password
volumes:
- oracle-data-23.8:/opt/oracle/oradata
restart: unless-stopped
healthcheck:
test: ["CMD", "sqlplus", "-L", "system/pdb-password@//localhost:1521/FREEPDB1", "<<<", "SELECT 1 FROM DUAL;"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
# MySQL Databases
mysql-8.4.6:
image: mysql:8.4.6
container_name: mysql-8.4.6
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=mysql-test
- MYSQL_DATABASE=mysql-test
- MYSQL_ROOT_HOST=%
- MYSQL_USER=mysql-test
- MYSQL_PASSWORD=mysql-test
volumes:
- mysql-data-8.4.6:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
mysql-8.0.29:
image: mysql:8.0.29
container_name: mysql-8.0.28
ports:
- "3307:3306"
environment:
- MYSQL_ROOT_PASSWORD=mysql-test
- MYSQL_DATABASE=mysql-test
- MYSQL_ROOT_HOST=%
- MYSQL_USER=mysql-test
- MYSQL_PASSWORD=mysql-test
volumes:
- mysql-data-8.0.29:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
mysql-5.7.31:
image: mysql:5.7.31
container_name: mysql-5.7.31
platform: linux/amd64
ports:
- "3308:3306"
environment:
- MYSQL_ROOT_PASSWORD=mysql-test
- MYSQL_DATABASE=mysql-test
- MYSQL_ROOT_HOST=%
- MYSQL_USER=mysql-test
- MYSQL_PASSWORD=mysql-test
volumes:
- mysql-data-5.7.31:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
# PostgreSQL Databases
postgres-17:
image: postgres:17
platform: linux/amd64
container_name: postgres-17
ports:
- "5433:5432"
environment:
- POSTGRES_DB=postgres-test
- POSTGRES_USER=postgres-test
- POSTGRES_PASSWORD=postgres-test
volumes:
- postgres-data-17:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
postgres-16:
image: postgres:16
platform: linux/amd64
container_name: postgres-16
ports:
- "5434:5432"
environment:
- POSTGRES_DB=postgres-test
- POSTGRES_USER=postgres-test
- POSTGRES_PASSWORD=postgres-test
volumes:
- postgres-data-16:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
postgres-10.12:
image: postgres:10.12
platform: linux/amd64
container_name: postgres-10.12
ports:
- "5435:5432"
environment:
- POSTGRES_DB=postgres-test
- POSTGRES_USER=postgres-test
- POSTGRES_PASSWORD=postgres-test
volumes:
- postgres-data-10.12:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
volumes:
oracle-data-23.8:
mysql-data-8.4.6:
mysql-data-8.0.29:
mysql-data-5.7.31:
postgres-data-17:
postgres-data-16:
postgres-data-10.12:

View File

@@ -310,7 +310,8 @@
"self-hosting/guides/mongo-to-postgres",
"self-hosting/guides/custom-certificates",
"self-hosting/guides/automated-bootstrapping",
"self-hosting/guides/production-hardening"
"self-hosting/guides/production-hardening",
"self-hosting/guides/monitoring-telemetry"
]
},
{
@@ -416,6 +417,9 @@
"pages": [
"documentation/platform/secrets-mgmt/project",
"documentation/platform/folder",
"documentation/platform/secret-versioning",
"documentation/platform/pit-recovery",
"documentation/platform/secret-reference",
{
"group": "Secret Rotation",
"pages": [
@@ -439,6 +443,7 @@
"documentation/platform/dynamic-secrets/aws-iam",
"documentation/platform/dynamic-secrets/azure-entra-id",
"documentation/platform/dynamic-secrets/cassandra",
"documentation/platform/dynamic-secrets/couchbase",
"documentation/platform/dynamic-secrets/elastic-search",
"documentation/platform/dynamic-secrets/gcp-iam",
"documentation/platform/dynamic-secrets/github",
@@ -458,7 +463,8 @@
"documentation/platform/dynamic-secrets/kubernetes",
"documentation/platform/dynamic-secrets/vertica"
]
}
},
"documentation/platform/webhooks"
]
},
{

View File

@@ -0,0 +1,259 @@
---
title: "Couchbase"
description: "Learn how to dynamically generate Couchbase Database user credentials."
---
The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions.
## Prerequisite
Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key).
<Info>The API Key must have permission to manage database users in your Couchbase Cloud organization and project.</Info>
## Set up Dynamic Secrets with Couchbase
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select Couchbase">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="URL" type="string" required default="https://cloudapi.cloud.couchbase.com">
The Couchbase Cloud API URL
</ParamField>
<ParamField path="Organization ID" type="string" required>
Your Couchbase Cloud organization ID
</ParamField>
<ParamField path="Project ID" type="string" required>
Your Couchbase Cloud project ID
</ParamField>
<ParamField path="Cluster ID" type="string" required>
Your Couchbase Cloud cluster ID where users will be created
</ParamField>
<ParamField path="Roles" type="array" required>
Database credential roles to assign to the generated user. Available options:
- **read**: Read access to bucket data (alias for data_reader)
- **write**: Read and write access to bucket data (alias for data_writer)
</ParamField>
<ParamField path="Bucket Access" type="string" required default="*">
Specify bucket access configuration:
- Use `*` for access to all buckets
- Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets
- Use Advanced Bucket Configuration for granular scope and collection access
</ParamField>
<ParamField path="API Key" type="string" required>
Your Couchbase Cloud API Key for authentication
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png)
</Step>
<Step title="(Optional) Advanced Configuration">
![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png)
<ParamField path="Advanced Bucket Configuration" type="boolean" default="false">
Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections
</ParamField>
When Advanced Bucket Configuration is enabled, you can configure:
<ParamField path="Buckets" type="array">
List of buckets with optional scope and collection specifications:
- **Bucket Name**: Name of the bucket (e.g., travel-sample)
- **Scopes**: Optional array of scopes within the bucket
- **Scope Name**: Name of the scope (e.g., inventory, _default)
- **Collections**: Optional array of collection names within the scope
</ParamField>
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
Specifies a template for generating usernames. This field allows customization of how usernames are automatically created.
Allowed template variables are:
- `{{randomUsername}}`: Random username string
- `{{unixTimestamp}}`: Current Unix timestamp
- `{{identity.name}}`: Name of the identity that is generating the secret
- `{{random N}}`: Random string of N characters
Allowed template functions are:
- `truncate`: Truncates a string to a specified length
- `replace`: Replaces a substring with another value
Examples:
```
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
{{unixTimestamp}} // 17490641580
{{identity.name}} // testuser
{{random 5}} // x9k2m
{{truncate identity.name 4}} // test
{{replace identity.name 'user' 'replace'}} // testreplace
```
</ParamField>
<ParamField path="Password Configuration" type="object">
Optional password generation requirements for Couchbase users:
<ParamField path="Password Length" type="number" default="12" min="8" max="128">
Length of the generated password
</ParamField>
<ParamField path="Character Requirements" type="object">
Minimum required character counts:
- **Lowercase Count**: Minimum lowercase letters (default: 1)
- **Uppercase Count**: Minimum uppercase letters (default: 1)
- **Digit Count**: Minimum digits (default: 1)
- **Symbol Count**: Minimum special characters (default: 1)
</ParamField>
<ParamField path="Allowed Symbols" type="string" default="!@#$%^()_+-=[]{}:,?/~`">
Special characters allowed in passwords. Cannot contain: `< > ; . * & | £`
</ParamField>
<Info>
Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £`
</Info>
</ParamField>
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
<Note>
If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs.
</Note>
![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png)
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png)
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png)
</Step>
</Steps>
## Advanced Bucket Configuration Examples
The advanced bucket configuration allows you to specify granular access control:
### Example 1: Specific Bucket Access
```json
[
{
"name": "travel-sample"
}
]
```
### Example 2: Bucket with Specific Scopes
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory"
},
{
"name": "_default"
}
]
}
]
```
### Example 3: Bucket with Scopes and Collections
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory",
"collections": ["airport", "airline"]
},
{
"name": "_default",
"collections": ["users"]
}
]
}
]
```
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you to see the expiration time of the lease or delete a lease before its set time to live.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below.
![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>
## Couchbase Roles and Permissions
The Couchbase dynamic secret integration supports the following database credential roles:
- **read**: Provides read-only access to bucket data
- **write**: Provides read and write access to bucket data
<Note>
These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration.
</Note>
## Troubleshooting
### Common Issues
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users
2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key
3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write)
4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible

View File

@@ -1,6 +1,6 @@
---
title: "Delivering Secrets"
description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them."
title: "Fetching Secrets"
description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them."
---
Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them.

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -22,7 +22,7 @@ It can also automatically reload dependent Deployments resources whenever releva
## Install
The operator can be install via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications.
The operator can be installed via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications.
**Install the latest Helm repository**
```bash
@@ -229,9 +229,9 @@ The managed secret created by the operator will not be deleted when the operator
<Tabs>
<Tab title="Helm">
Install Infisical Helm repository
Uninstall Infisical Helm repository
```bash
helm uninstall <release name>
```
</Tab>
</Tabs>
</Tabs>

View File

@@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical."
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
<Note>
For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting.
</Note>
<Tabs>
<Tab title="Infisical UI">
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.

View File

@@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical."
![Configure Destination](/images/secret-syncs/render/render-sync-destination.png)
- **Render Connection**: The Render Connection to authenticate with.
- **Scope**: Select **Service**.
- **Service**: Choose the Render service you want to sync secrets to.
- **Scope**: Select **Service** or **Environment Group**.
- **Service**: Choose the Render service you want to sync secrets to.
- **Environment Group**: Choose the Render environment group you want to sync secrets to.
5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
![Configure Options](/images/secret-syncs/render/render-sync-options.png)

View File

@@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their
Supports conditions and permission inversion
| Action | Description | Notes |
| -------- | ------------------------------- | ----- |
| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. |
| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. |
| `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. |
| `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. |
| `create` | Add new secrets to the project | |
| `edit` | Modify existing secret values | |
| `delete` | Remove secrets from the project | |
| `create` | Add new secrets to the project | |
| `edit` | Modify existing secret values | |
| `delete` | Remove secrets from the project | |
#### Subject: `secret-folders`
@@ -169,6 +169,15 @@ Supports conditions and permission inversion
| `edit` | Modify secret imports |
| `delete` | Remove secret imports |
#### Subject: `secret-events`
| Action | Description |
| ------------------------------- | ------------------------------------------------------------- |
| `subscribe-on-created` | Subscribe to events when secrets are created |
| `subscribe-on-updated` | Subscribe to events when secrets are updated |
| `subscribe-on-deleted` | Subscribe to events when secrets are deleted |
| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports |
#### Subject: `secret-rollback`
| Action | Description |
@@ -178,10 +187,10 @@ Supports conditions and permission inversion
#### Subject: `commits`
| Action | Description |
| -------- | ---------------------------------- |
| `read` | View commits and changes across folders |
| `perform-rollback` | Roll back commits changes and restore folders to previous state|
| Action | Description |
| ------------------ | --------------------------------------------------------------- |
| `read` | View commits and changes across folders |
| `perform-rollback` | Roll back commits changes and restore folders to previous state |
#### Subject: `secret-approval`
@@ -197,14 +206,14 @@ Supports conditions and permission inversion
#### Subject: `secret-rotation`
Supports conditions and permission inversion
| Action | Description |
| Action | Description |
| ------------------------------ | ---------------------------------------------- |
| `read` | View secret rotation configurations |
| `read-generated-credentials` | View the generated credentials of a rotation |
| `create` | Set up secret rotation configurations |
| `edit` | Modify secret rotation configurations |
| `rotate-secrets` | Rotate the generated credentials of a rotation |
| `delete` | Remove secret rotation configurations |
| `read` | View secret rotation configurations |
| `read-generated-credentials` | View the generated credentials of a rotation |
| `create` | Set up secret rotation configurations |
| `edit` | Modify secret rotation configurations |
| `rotate-secrets` | Rotate the generated credentials of a rotation |
| `delete` | Remove secret rotation configurations |
#### Subject: `secret-syncs`
@@ -263,12 +272,12 @@ Supports conditions and permission inversion
#### Subject: `certificates`
| Action | Description |
| -------------------- | ----------------------------- |
| `read` | View certificates |
| `read-private-key` | Read certificate private key |
| `create` | Issue new certificates |
| `delete` | Revoke or remove certificates |
| Action | Description |
| ------------------ | ----------------------------- |
| `read` | View certificates |
| `read-private-key` | Read certificate private key |
| `create` | Issue new certificates |
| `delete` | Revoke or remove certificates |
#### Subject: `certificate-templates`
@@ -330,8 +339,8 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-data-sources`
| Action | Description |
| -------- | ---------------------------------------------------- |
| Action | Description |
| ---------------------------- | -------------------------------- |
| `read-data-sources` | View Data Sources |
| `create-data-sources` | Create new Data Sources |
| `edit-data-sources` | Modify Data Sources |
@@ -342,15 +351,14 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-findings`
| Action | Description |
| -------- | --------------------------------- |
| `read-findings` | View Secret Scanning Findings |
| `update-findings` | Update Secret Scanning Findings |
| Action | Description |
| ----------------- | ------------------------------- |
| `read-findings` | View Secret Scanning Findings |
| `update-findings` | Update Secret Scanning Findings |
#### Subject: `secret-scanning-configs`
| Action | Description |
| ---------------- | ------------------------------------------------ |
| `read-configs` | View Secret Scanning Project Configuration |
| `update-configs` | Update Secret Scanning Project Configuration |
| Action | Description |
| ---------------- | -------------------------------------------- |
| `read-configs` | View Secret Scanning Project Configuration |
| `update-configs` | Update Secret Scanning Project Configuration |

View File

@@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag
## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed.
To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed.
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage.
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues.
With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages.
## Penetration testing

View File

@@ -1,6 +1,315 @@
---
title: "Infisical C++ SDK"
sidebarTitle: "C++"
url: "https://github.com/Infisical/infisical-cpp-sdk/?tab=readme-ov-file#infisical-c-sdk"
icon: "/images/sdks/languages/cpp.svg"
---
---
A C++ library implementation for [Infisical](https://infisical.com).
## Compatible with C++ 17 and later
The Infisical C++ SDK is compatible with C++ 17 capable compilers. This implies GCC 8 or newer, and clang 3.8 or newer. Earlier versions of C++ are unsupported.
## Dependencies
- `cURL`: Used internally for crafting HTTP requests.
## CMake Installation
```bash
cmake_minimum_required(VERSION 3.14)
project(InfisicalTest)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_PREFIX_PATH ${CMAKE_BINARY_DIR})
find_package(OpenSSL REQUIRED)
include(FetchContent)
FetchContent_Declare(
infisical
GIT_REPOSITORY https://github.com/Infisical/infisical-cpp-sdk.git
GIT_TAG 1.0.0 # Replace with the desired version
)
FetchContent_MakeAvailable(infisical)
FetchContent_GetProperties(infisical)
# Example usage. This will differ based on your project structure.
add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE infisical OpenSSL::SSL OpenSSL::Crypto)
target_include_directories(my_app PRIVATE ${infisical_SOURCE_DIR}/include)
```
## Manual Installation
If you're unable to use the recommended CMake installation approach, you can choose to manually build the library and use it in your project.
```bash
mkdir build
cd build
cmake ..
make
```
## Quick-Start Example
Below you'll find an example that uses the Infisical SDK to fetch a secret with the key `API_KEY` using [Machine Identity Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth)
More examples can be found in the [/examples](https://github.com/Infisical/infisical-cpp-sdk/tree/main/examples) folder.
```cpp
#include <iostream>
#include <libinfisical/InfisicalClient.h>
int main() {
try {
Infisical::InfisicalClient client(
Infisical::ConfigBuilder()
.withHostUrl("https://app.infisical.com") // Optionally change this to your custom Infisical instance URL.
.withAuthentication(
Infisical::AuthenticationBuilder()
.withUniversalAuth("<machine-identity-universal-auth-client-id>", "<machine-identity-universal-auth-client-secret>")
.build())
.build());
const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder()
.withEnvironment("<env-slug>") // dev, staging, prod, etc
.withProjectId("<your-project-id>")
.withSecretKey("API_KEY")
.build();
const auto apiKeySecret = client.secrets().getSecret(getSecretOptions);
printf("Secret retrieved, [key=%s] [value=%s]\n", apiKeySecret.getSecretKey().c_str(), apiKeySecret.getSecretValue().c_str());
} catch (const Infisical::InfisicalError &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
```
## JSON Serialization
The SDK uses [nlohmann/json](https://github.com/nlohmann/json) internally to serialize/deserialize JSON data. This SDK makes no assumptions about which JSON library you use in your project, and you aren't constrained to `nlohmann/json` in any way. Data returned by the SDK is returned as a class, which exposes Getter methods for getting fields such as the secret value or secret key.
## Documentation
The Infisical C++ SDK follows a builder pattern for all types of input. Below is a detailed documentation of our currently support methods.
Everything related to the Infisical SDK lives inside the `Infisical` namespace.
### InfisicalClient Class
`InfisicalClient(Config &config)`
```cpp
Infisical::InfisicalClient client(
Infisical::ConfigBuilder()
.withHostUrl("https://app.infisical.com")
.withAuthentication(
Infisical::AuthenticationBuilder()
.withUniversalAuth(clientId, clientSecret)
.build())
.build());
```
Config is created through the `ConfigBuilder` class. See below for more details
### Config Class
`Config` defines the configuration of the Infisical Client itself, such as authentication.
```cpp
Infisical::Config config = Infisical::ConfigBuilder()
.withHostUrl("https://app.infisical.com")
.withAuthentication(
Infisical::AuthenticationBuilder()
.withUniversalAuth(clientId, clientSecret)
.build())
.build();
Infisical::InfisicalClient client(config);
```
- `withHostUrl(string)` _(optional)_: Specify a custom Infisical host URL, pointing to your Infisical instance. Defaults to `https://app.infisical.com`
- `withAuthentication(Infisical::Authentication)`: Configure the authentication that will be used by the SDK. See [Authentication Class](#authentication-class) for more details.
- `build()`: Returns the `Config` object with the options you configured.
### Authentication Class
```cpp
Infisical::Authentication auth = Infisical::AuthenticationBuilder()
.withUniversalAuth(clientId, clientSecret)
.build();
Infisical::Config config = Infisical::ConfigBuilder()
.withAuthentication(std::move(auth)) // Or use inline declaration
.build();
```
- `withUniversalAuth(string, string)`: Specify the Universal Auth Client ID and Client Secret that will be used for authentication.
- `build()`: Returns the `Authentication` object with the options you specified.
### TSecret Class
The `TSecret` class is the class that's returned by all secret methods (get/list/delete/update/create). It can come in the form of a `std::vector` or a single instance.
**Available getter methods:**
- `getId(): std::string`: Returns the ID of the secret.
- `getWorkspace(): std::string`: Returns the project ID of the secret.
- `getEnvironment(): std::string`: Returns the environment slug of the secret.
- `getVersion(): unsigned int`: Gets the version of the secret. By default this will always be the latest version unless specified otherwise with `withVersion()`
- `getType(): std::string`: Returns the type of the secret. Can only be `shared` or `personal`. Shared secrets are available to everyone with access to the secret. Personal secrets are personal overwrites of the secret, mainly intended for local development purposes.
- `getSecretKey(): std::string`: Returns the secret key.
- `getSecretValue(): std::string` Returns the secret value.
- `getRotationId(): std::string`: If the secret is a rotation secret, this will return the rotation ID of the secret. If it's a regular secret, this will return an empty string.
- `getSecretPath(): std::string`: Returns the secret path of the secret.
- `getSkipMultilineEncoding(): bool`: Returns whether or not skip multiline encoding is enabled for the secret or not.
`getIsRotatedSecret(): bool`: Returns wether or not the secret is a rotated secret. If `true`, then `getRotationId()` returns the ID of the rotation.
### Secrets
#### Create Secret
```cpp
const auto createSecretOptions = Infisical::Input::CreateSecretOptionsBuilder()
.withEnvironment("<env-slug>")
.withProjectId("<project-id>")
.withSecretKey("SECRET_KEY_TO_CREATE")
.withSecretValue("VALUE_TO_CREATE")
.withSecretComment("Secret comment to attach") // Optional
.withSecretPath("/path/where/to/create/secret") // Optional, defaults to /
.withTagIds({"tag-id-1", "tag-id-2"}) // Optional
.build();
const auto secret = client.secrets().createSecret(createSecretOptions);
```
**Parameters**:
- `withEnvironment(string)`: Specify the slug of the environment to create the secret in.
- `withProjectId(string)`: Specify the ID of the project to create the secret in.
- `withSecretPath(string)`: Specify the secret path to create the secret in. Defaults to `/`
- `withSecretKey(string)`: The secret key to be created.
- `withSecretValue(string)`: The value of the secret to create.
- `withSecretComment(string)` _(optional)_: Optionally add a comment to the secret.
- `withTagIds(std::vector<std::string>>)` _(optional)_: A list of ID's of tags to attach to the secret.
- `build()`: Returns the `CreateSecretOptions` class that can be passed into the `createSecret()` method.
**Returns**:
- Returns the created secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation.
#### Update Secret
```cpp
const auto updateSecretOptions = Infisical::Input::UpdateSecretOptionsBuilder()
.withEnvironment("<env-slug>")
.withProjectId("<project-id>")
.withSecretKey("<secret-key>")
.withNewSecretKey("<new-secret-key>") // Optional
.withSecretValue("<new-secret-value>") // Optional
.withSecretComment("Updated comment") // Optional
.withSecretReminderNote("Updated reminder note") // Optional
.withSecretReminderRepeatDays(1) // Optional
.withType("shared") // Optional
.withTagIds({"tag-id-3", "tag-id-4"}) // Optional
.build();
const auto updatedSecret = client.secrets().updateSecret(updateSecretOptions);
```
**Parameters**:
- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in.
- `withProjectId(string)`: Specify the ID of the project where the secret to update lives in.
- `withSecretPath(string)`: Specify the secret path of the secret to update. Defaults to `/`.
- `withType("shared" | "personal")`: _(optional)_: The type of secret to update. Defaults to `shared`.
- `withSecretKey(string)`: The key of the secret you wish to update.
- `withNewSecretKey(string)` _(optional)_: The new key of the secret you wish to update.
- `withSecretValue(string)` _(optional)_: The new value of the secret.
- `withSecretReminderNote(string)` _(optional)_: Update the secret reminder note attached to the secret.
- `withSecretReminderRepeatDays(unsigned int)` _(optional)_: Update the secret reminder repeat days attached to the secret.
- `withTagIds(std::vector<std::string>>)` _(optional)_: A list of ID's of tags to attach to the secret.
- `build()`: Returns the `UpdateSecretOptions` class that can be passed into the `updateSecret()` method.
**Returns**:
- Returns the updated secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation.
#### Get Secret
```cpp
const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder()
.withEnvironment("<env-slug>")
.withProjectId("<project-id>")
.withSecretKey("<secret-key>")
.withType("shared")
.withVersion(2)
.withExpandSecretReferences(true)
.build();
const auto secret = client.secrets().getSecret(getSecretOptions);
```
**Parameters**:
- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in.
- `withProjectId(string)`: Specify the ID of the project where the secret lives in.
- `withSecretPath(string)`: Specify the secret path of the secret to get. Defaults to `/`
- `withType("shared" | "personal")`: _(optional)_: The type of secret to get. Defaults to `shared`.
- `withSecretKey(string)`: The key of the secret to get.
- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`.
- `withVersion(unsigned int)` _(optional)_: Optionally fetch a specific version of the secret. If not defined, the latest version of the secret is returned.
- `build()`: Returns the `GetSecretOptions` class that can be passed into the `getSecret()` method.
**Returns**:
- Returns the secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation.
#### Delete Secret
```cpp
const auto deleteSecretOptions = Infisical::Input::DeleteSecretOptionsBuilder()
.withEnvironment("<env-slug>")
.withProjectId("<project-id>")
.withSecretKey("<secret-key>")
.withType("shared")
.withSecretPath("<secret-path>")
.build();
const auto deletedSecret = client.secrets().deleteSecret(deleteSecretOptions);
```
**Parameters**:
- `withEnvironment(string)`: Specify the slug of the environment where the secret to delete lives in.
- `withProjectId(string)`: Specify the ID of the project where the secret to delete lives in.
- `withSecretPath(string)`: Specify the secret path of the secret to delete. Defaults to `/`
- `withType("shared" | "personal")`: _(optional)_: The type of secret to delete. Defaults to `shared`.
- `withSecretKey(string)`: The key of the secret to delete.
- `build()` Returns the `DeleteSecretOptions` class that can be passed into the `deleteSecret()` method.
**Returns**:
- Returns the deleted secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation.
#### List Secrets
```cpp
const auto listSecretsOptions = Infisical::Input::ListSecretOptionsBuilder()
.withProjectId(projectId)
.withEnvironment(environment)
.withSecretPath("/")
.withRecursive(false)
.withAddSecretsToEnvironmentVariables(false)
.build();
const auto secrets = client.secrets().listSecrets(listSecretsOptions);
```
**Parameters**:
- `withEnvironment(string)`: Specify the slug of the environment to list secrets from.
- `withProjectId(string)`: Specify the ID of the project to fetch secrets from.
- `withSecretPath(string)`: Specify the secret path to fetch secrets from. Defaults to `/`
- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`.
- `withRecursive(bool)` _(optional)_: Wether or not to recursively fetch secrets from sub-folders. If set to true, all secrets from the secret path specified with `withSecretPath()` and downwards will be fetched.
- `withAddSecretsToEnvironmentVariables(bool)` _(optional)_: If set to true, the fetched secrets will be automatically set as environment variables, making them accessible with `std::getenv` or equivalent by secret key.
- `build()`: Returns the `ListSecretsOptions` class that can be passed into the `listSecrets()` method.
**Returns**:
- Returns the listed secrets as `std::vector<TSecret>`. Read more in the [TSecret Class](#tsecret-class) documentation.

View File

@@ -1,594 +1,358 @@
---
title: "Infisical .NET SDK"
sidebarTitle: ".NET"
url: "https://github.com/Infisical/infisical-dotnet-sdk?tab=readme-ov-file#infisical-net-sdk"
icon: "/images/sdks/languages/dotnet.svg"
---
{/*
If you're working with C#, the official [Infisical C# SDK](https://github.com/Infisical/sdk/tree/main/languages/csharp) package is the easiest way to fetch and work with secrets for your application.
- [Nuget Package](https://www.nuget.org/packages/Infisical.Sdk)
- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/csharp)
The Infisical .NET SDK provides a convenient way to interact with the Infisical API.
<Warning>
**Deprecation Notice**
## Installation
All versions prior to **2.3.9** should be considered deprecated and are no longer supported by Infisical. Please update to version **2.3.9** or newer. All changes are fully backwards compatible with older versions.
</Warning>
## Basic Usage
```cs
using Infisical.Sdk;
namespace Example
{
class Program
{
static void Main(string[] args)
{
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
UniversalAuth = new UniversalAuthMethod
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret"
}
}
};
var infisicalClient = new InfisicalClient(settings);
var getSecretOptions = new GetSecretOptions
{
SecretName = "TEST",
ProjectId = "PROJECT_ID",
Environment = "dev",
};
var secret = infisicalClient.GetSecret(getSecretOptions);
Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}");
}
}
}
```bash
dotnet add package Infisical.Sdk
```
This example demonstrates how to use the Infisical C# SDK in a C# application. The application retrieves a secret named `TEST` from the `dev` environment of the `PROJECT_ID` project.
## Getting Started (.NET)
<Warning>
We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best.
</Warning>
# Installation
```console
$ dotnet add package Infisical.Sdk
```
# Configuration
Import the SDK and create a client instance with your [Machine Identity](/platform/identities/universal-auth).
```cs
using Infisical.Sdk;
namespace Example
{
class Program
{
static void Main(string[] args)
{
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
UniversalAuth = new UniversalAuthMethod
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret"
}
}
};
var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use
}
}
}
```
### ClientSettings methods
<ParamField query="options" type="object">
<Expandable title="properties">
<ParamField query="ClientId" deprecated type="string" optional>
Your machine identity client ID.
</ParamField>
<ParamField query="ClientSecret" deprecated type="string" optional>
Your machine identity client secret.
</ParamField>
<ParamField query="AccessToken" deprecated type="string" optional>
An access token obtained from the machine identity login endpoint.
</ParamField>
<ParamField query="CacheTtl" type="number" default="300" optional>
Time-to-live (in seconds) for refreshing cached secrets.
If manually set to 0, caching will be disabled, this is not recommended.
</ParamField>
<ParamField query="SiteUrl" type="string" default="https://app.infisical.com" optional>
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</ParamField>
<ParamField query="SslCertificatePath" optional>
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
</ParamField>
<ParamField query="Auth" type="AuthenticationOptions">
The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField>
</Expandable>
</ParamField>
### Authentication
The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate.
#### Universal Auth
**Using environment variables**
- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID.
- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
UniversalAuth = new UniversalAuthMethod
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret"
}
}
};
namespace Example;
using Infisical.Sdk;
using Infisical.Sdk.Model;
public class Program {
public static void Main(string[] args) {
var settings = new InfisicalSdkSettingsBuilder()
.WithHostUri("http://localhost:8080") // Optional. Will default to https://app.infisical.com
.Build();
var infisicalClient = new InfisicalClient(settings);
```
#### GCP ID Token Auth
<Info>
Please note that this authentication method will only work if you're running your application on Google Cloud Platform.
Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method.
</Info>
var _ = infisicalClient.Auth().UniversalAuth().LoginAsync("<machine-identity-universal-auth-client-id>", "<machine-identity-universal-auth-client-secret>").Result;
**Using environment variables**
- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
var options = new ListSecretsOptions
{
GcpIdToken = new GcpIdTokenAuthMethod
{
IdentityId = "your-machine-identity-id",
}
}
};
var infisicalClient = new InfisicalClient(settings);
```
#### GCP IAM Auth
**Using environment variables**
- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID.
- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
GcpIam = new GcpIamAuthMethod
{
IdentityId = "your-machine-identity-id",
ServiceAccountKeyFilePath = "./path/to/your/service-account-key.json"
}
}
};
var infisicalClient = new InfisicalClient(settings);
```
#### AWS IAM Auth
<Info>
Please note that this authentication method will only work if you're running your application on AWS.
Please [read more](/documentation/platform/identities/aws-auth) about this authentication method.
</Info>
**Using environment variables**
- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
AwsIam = new AwsIamAuthMethod
{
IdentityId = "your-machine-identity-id",
}
}
};
var infisicalClient = new InfisicalClient(settings);
```
#### Azure Auth
<Info>
Please note that this authentication method will only work if you're running your application on Azure.
Please [read more](/documentation/platform/identities/azure-auth) about this authentication method.
</Info>
**Using environment variables**
- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
Azure = new AzureAuthMethod
{
IdentityId = "YOUR_IDENTITY_ID",
}
}
};
var infisicalClient = new InfisicalClient(settings);
```
#### Kubernetes Auth
<Info>
Please note that this authentication method will only work if you're running your application on Kubernetes.
Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method.
</Info>
**Using environment variables**
- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID.
- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`.
**Using the SDK directly**
```csharp
ClientSettings settings = new ClientSettings
{
Auth = new AuthenticationOptions
{
Kubernetes = new KubernetesAuthMethod
{
ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token", // Optional
IdentityId = "YOUR_IDENTITY_ID",
}
}
};
var infisicalClient = new InfisicalClient(settings);
```
### Caching
To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client.
## Working with Secrets
### client.ListSecrets(options)
```cs
var options = new ListSecretsOptions
{
ProjectId = "PROJECT_ID",
Environment = "dev",
Path = "/foo/bar",
AttachToProcessEnv = false,
};
var secrets = infisical.ListSecrets(options);
```
Retrieve all secrets within the Infisical project and environment that client is connected to
#### Parameters
<ParamField query="Parameters" type="object">
<Expandable title="properties">
<ParamField query="Environment" type="string" required>
The slug name (dev, prod, etc) of the environment from where secrets should be fetched from.
</ParamField>
<ParamField query="ProjectId" type="string">
The project ID where the secret lives in.
</ParamField>
<ParamField query="Path" type="string" optional>
The path from where secrets should be fetched from.
</ParamField>
<ParamField query="AttachToProcessEnv" type="boolean" default="false" optional>
Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`.
</ParamField>
<ParamField query="IncludeImports" type="boolean" default="false" optional>
Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference)
</ParamField>
<ParamField query="Recursive" type="boolean" default="false" optional>
Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching.
</ParamField>
<ParamField query="ExpandSecretReferences" type="boolean" default="true" optional>
Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference)
</ParamField>
</Expandable>
</ParamField>
### client.GetSecret(options)
```cs
var options = new GetSecretOptions
{
SecretName = "AAAA",
ProjectId = "659c781eb2d4fe3e307b77bd",
Environment = "dev",
SetSecretsAsEnvironmentVariables = true,
EnvironmentSlug = "<your-env-slug>",
SecretPath = "/",
ProjectId = "<your-project-id>",
};
var secret = infisical.GetSecret(options);
var secrets = infisicalClient.Secrets().ListAsync(options).Result;
if (secrets == null)
{
throw new Exception("Failed to fetch secrets, returned null response");
}
foreach (var secret in secrets)
{
Console.WriteLine($"{secret.SecretKey}: {secret.SecretValue}");
}
}
}
```
Retrieve a secret from Infisical.
## Getting Started (Visual Basic)
```vb
Imports Infisical.Sdk
Imports Infisical.Sdk.Model
By default, `GetSecret()` fetches and returns a shared secret.
#### Parameters
<ParamField query="Parameters" type="object" optional>
<Expandable title="properties">
<ParamField query="SecretName" type="string" required>
The key of the secret to retrieve.
</ParamField>
<ParamField query="ProjectId" type="string" required>
The project ID where the secret lives in.
</ParamField>
<ParamField query="Environment" type="string" required>
The slug name (dev, prod, etc) of the environment from where secrets should be fetched from.
</ParamField>
<ParamField query="Path" type="string" optional>
The path from where secret should be fetched from.
</ParamField>
<ParamField query="Type" type="string" optional>
The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared".
</ParamField>
<ParamField query="IncludeImports" type="boolean" optional>
Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference)
</ParamField>
<ParamField query="ExpandSecretReferences" type="boolean" optional>
Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference)
</ParamField>
Module Program
Sub Main(args As String())
Dim settings = New InfisicalSdkSettingsBuilder() _
.WithHostUri("https://app.infisical.com") _
.Build()
</Expandable>
</ParamField>
Dim infisicalClient As New InfisicalClient(settings)
### client.CreateSecret(options)
Dim authResult = infisicalClient.Auth().UniversalAuth() _
.LoginAsync("<machine-identity-universal-auth-client-id>", "machine-identity-universal-auth-client-secret").Result
```cs
var options = new CreateSecretOptions {
Environment = "dev",
ProjectId = "PROJECT_ID",
Dim options As New ListSecretsOptions With {
.SetSecretsAsEnvironmentVariables = True,
.EnvironmentSlug = "<your-env-slug>",
.SecretPath = "/",
.ProjectId = "<your-project-id>"
}
SecretName = "NEW_SECRET",
SecretValue = "NEW_SECRET_VALUE",
SecretComment = "This is a new secret",
};
Dim secrets = infisicalClient.Secrets().ListAsync(options).Result
var newSecret = infisical.CreateSecret(options);
For Each secret In secrets
Console.WriteLine(secret.SecretKey)
if Environment.GetEnvironmentVariable(secret.SecretKey) IsNot Nothing Then
Console.WriteLine("{0} found on environment variables", secret.SecretKey)
End If
Next
End Sub
End Module
```
Create a new secret in Infisical.
## Core Methods
#### Parameters
The SDK methods are organized into the following high-level categories:
<ParamField query="Parameters" type="object" optional>
<Expandable title="properties">
<ParamField query="SecretName" type="string" required>
The key of the secret to create.
</ParamField>
<ParamField query="SecretValue" type="string" required>
The value of the secret.
</ParamField>
<ParamField query="ProjectId" type="string" required>
The project ID where the secret lives in.
</ParamField>
<ParamField query="Environment" type="string" required>
The slug name (dev, prod, etc) of the environment from where secrets should be fetched from.
</ParamField>
<ParamField query="Path" type="string" optional>
The path from where secret should be created.
</ParamField>
<ParamField query="Type" type="string" optional>
The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared".
</ParamField>
</Expandable>
</ParamField>
1. `Auth()`: Handles authentication methods.
2. `Secrets()`: Manages CRUD operations for secrets.
3. `Pki()`: Programmatically interact with the Infisical PKI.
* `Subscribers()`: Manage PKI Subscribers.
### client.UpdateSecret(options)
### `Auth()`
The `Auth()` component provides methods for authentication:
### Universal Auth
#### Authenticating
```cs
var options = new UpdateSecretOptions {
Environment = "dev",
ProjectId = "PROJECT_ID",
SecretName = "SECRET_TO_UPDATE",
SecretValue = "NEW VALUE"
};
var updatedSecret = infisical.UpdateSecret(options);
var _ = await sdk.Auth().UniversalAuth().LoginAsync(
"CLIENT_ID",
"CLIENT_SECRET"
);
```
Update an existing secret in Infisical.
#### Parameters
**Parameters:**
- `clientId` (string): The client ID of your Machine Identity.
- `clientSecret` (string): The client secret of your Machine Identity.
<ParamField query="Parameters" type="object" optional>
<Expandable title="properties">
<ParamField query="SecretName" type="string" required>
The key of the secret to update.
</ParamField>
<ParamField query="SecretValue" type="string" required>
The new value of the secret.
</ParamField>
<ParamField query="ProjectId" type="string" required>
The project ID where the secret lives in.
</ParamField>
<ParamField query="Environment" type="string" required>
The slug name (dev, prod, etc) of the environment from where secrets should be fetched from.
</ParamField>
<ParamField query="Path" type="string" optional>
The path from where secret should be updated.
</ParamField>
<ParamField query="Type" type="string" optional>
The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared".
</ParamField>
</Expandable>
</ParamField>
### `Secrets()`
### client.DeleteSecret(options)
The `Secrets()` sub-class handles operations related to the Infisical secrets management product.
#### List Secrets
```cs
Task<Secret[]> ListAsync(ListSecretsOptions options);
throws InfisicalException
```
```csharp
var options = new ListSecretsOptions
{
SetSecretsAsEnvironmentVariables = true,
EnvironmentSlug = "dev",
SecretPath = "/test",
Recursive = true,
ExpandSecretReferences = true,
ProjectId = projectId,
ViewSecretValue = true,
};
Secret[] secrets = await sdk.Secrets().ListAsync(options);
```
**ListSecretsOptions:**
- `ProjectId` (string): The ID of your project.
- `EnvironmentSlug` (string): The environment in which to list secrets (e.g., "dev").
- `SecretPath` (string): The path to the secrets.
- `ExpandSecretReferences` (boolean): Whether to expand secret references.
- `Recursive` (boolean): Whether to list secrets recursively.
- `SetSecretsAsEnvironmentVariables` (boolean): Set the retrieved secrets as environment variables.
**Returns:**
- `Task<Secret[]>`: The response containing the list of secrets.
#### Create Secret
```cs
public Task<Secret> CreateAsync(CreateSecretOptions options);
throws InfisicalException
```
```cs
var options = new CreateSecretOptions
{
SecretName = "SECRET_NAME",
SecretValue = "SECRET_VALUE",
EnvironmentSlug = "<environment-slug>",
SecretPath = "/",
ProjectId = "<your-project-id>",
Metadata = new SecretMetadata[] {
new SecretMetadata {
Key = "metadata-key",
Value = "metadata-value"
}
}
};
Task<Secret> newSecret = await sdk.Secrets().CreateAsync(options);
```
**Parameters:**
- `SecretName` (string): The name of the secret to create
- `SecretValue` (string): The value of the secret.
- `ProjectId` (string): The ID of your project.
- `EnvironmentSlug` (string): The environment in which to create the secret.
- `SecretPath` (string, optional): The path to the secret.
- `Metadata` (object, optional): Attach metadata to the secret.
- `SecretComment` (string, optional): Attach a secret comment to the secret.
- `SecretReminderNote` (string, optional): Attach a secret reminder note to the secret.
- `SecretReminderRepeatDays` (int, optional): Set the reminder repeat days on the secret.
- `SkipMultilineEncoding` (bool, optional): Whether or not to skip multiline encoding for the secret's value. Defaults to `false`.
**Returns:**
- `Task<Secret>`: The created secret.
#### Update Secret
```cs
public Task<Secret> UpdateAsync(UpdateSecretOptions options);
throws InfisicalException
```
```cs
var updateSecretOptions = new UpdateSecretOptions
{
SecretName = "EXISTING_SECRET_NAME",
EnvironmentSlug = "<environment-slug>",
SecretPath = "/",
NewSecretName = "NEW_SECRET_NAME",
NewSecretValue = "new-secret-value",
ProjectId = "<project-id>",
};
Task<Secret> updatedSecret = await sdk.Secrets().UpdateAsync(updateSecretOptions);
```
**Parameters:**
- `SecretName` (string): The name of the secret to update.`
- `ProjectId` (string): The ID of your project.
- `EnvironmentSlug` (string): The environment in which to update the secret.
- `SecretPath` (string): The path to the secret.
- `NewSecretValue` (string, optional): The new value of the secret.
- `NewSecretName` (string, optional): A new name for the secret.
- `NewMetadata` (object, optional): New metadata to attach to the secret.
**Returns:**
- `Task<Secret>`: The updated secret.
#### Get Secret by Name
```cs
public Task<Secret> GetAsync(GetSecretOptions options);
throws InfisicalException
```
```cs
var getSecretOptions = new GetSecretOptions
{
SecretName = "SECRET_NAME",
EnvironmentSlug = "<environment-slug>",
SecretPath = "/",
ProjectId = "<project-id>",
};
Secret secret = await sdk.Secrets().GetAsync(getSecretOptions);
```
**Parameters:**
- `SecretName` (string): The name of the secret to get`
- `ProjectId` (string): The ID of your project.
- `EnvironmentSlug` (string): The environment in which to retrieve the secret.
- `SecretPath` (string): The path to the secret.
- `ExpandSecretReferences` (boolean, optional): Whether to expand secret references.
- `Type` (SecretType, optional): The type of secret to fetch. Defaults to `Shared`.
**Returns:**
- `Task<Secret>`: The fetched secret.
#### Delete Secret by Name
```cs
public Secret DeleteAsync(DeleteSecretOptions options);
throws InfisicalException
```
```cs
var options = new DeleteSecretOptions
{
Environment = "dev",
ProjectId = "PROJECT_ID",
SecretName = "NEW_SECRET",
SecretName = "SECRET_TO_DELETE",
EnvironmentSlug = "<environment-slug>",
SecretPath = "/",
ProjectId = "<project-id>",
};
var deletedSecret = infisical.DeleteSecret(options);
Secret deletedSecret = await sdk.Secrets().DeleteAsync(options);
```
Delete a secret in Infisical.
**Parameters:**
- `SecretName` (string): The name of the secret to delete.
- `ProjectId` (string): The ID of your project.
- `EnvironmentSlug` (string): The environment in which to delete the secret.
- `SecretPath` (string, optional): The path to the secret.
#### Parameters
**Returns:**
- `Task<Secret>`: The deleted secret.
<ParamField query="Parameters" type="object" optional>
<Expandable title="properties">
<ParamField query="SecretName" type="string">
The key of the secret to update.
</ParamField>
<ParamField query="ProjectId" type="string" required>
The project ID where the secret lives in.
</ParamField>
<ParamField query="Environment" type="string" required>
The slug name (dev, prod, etc) of the environment from where secrets should be fetched from.
</ParamField>
<ParamField query="Path" type="string" optional>
The path from where secret should be deleted.
</ParamField>
<ParamField query="Type" type="string" optional>
The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared".
</ParamField>
</Expandable>
</ParamField>
## Cryptography
### `Pki().Subscribers()`
### Create a symmetric key
The `Pki().Subscribers()` sub-class is used to programmatically interact with the Infisical PKI product line. Currently only issuing new certificates and retrieving the latest certificate bundle from a subscriber is supported. More widespread support for the PKI product is coming to the .NET SDK in the near future.
Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption.
#### Issue a new certificate
```cs
var key = infisical.CreateSymmetricKey();
public async Task<SubscriberIssuedCertificate> IssueCertificateAsync(IssueCertificateOptions options);
throws InfisicalException
```
#### Returns (string)
`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes.
### Encrypt symmetric
```cs
var options = new EncryptSymmetricOptions
var options = new IssueCertificateOptions
{
Plaintext = "Infisical is awesome!",
Key = key,
SubscriberName = "<subscriber-name>",
ProjectId = "<your-project-id>",
};
var encryptedData = infisical.EncryptSymmetric(options);
SubscriberIssuedCertificate newCertificate = await sdk.Pki().Subscribers().IssueCertificateAsync(options);
```
#### Parameters
**Parameters:**
- `SubscriberName` (string): The name of the subscriber to create a certificate for.
- `ProjectId` (string): The ID of PKI project.
<ParamField query="Parameters" type="object" required>
<Expandable title="properties">
<ParamField query="Plaintext" type="string">
The plaintext you want to encrypt.
</ParamField>
<ParamField query="Key" type="string" required>
The symmetric key to use for encryption.
</ParamField>
</Expandable>
</ParamField>
**Returns:**
- `Task<SubscriberIssuedCertificate>`: The newly issued certificate along with it's credentials for the specified subscriber.
#### Returns (object)
`Tag` (string): A base64-encoded, 128-bit authentication tag.
`Iv` (string): A base64-encoded, 96-bit initialization vector.
`CipherText` (string): A base64-encoded, encrypted ciphertext.
#### Retrieve latest certificate bundle
### Decrypt symmetric
```cs
var decryptOptions = new DecryptSymmetricOptions
{
Key = key,
Ciphertext = encryptedData.Ciphertext,
Iv = encryptedData.Iv,
Tag = encryptedData.Tag,
};
public async Task<CertificateBundle> RetrieveLatestCertificateBundleAsync(RetrieveLatestCertificateBundleOptions options)
var decryptedPlaintext = infisical.DecryptSymmetric(decryptOptions);
throws InfisicalException
```
#### Parameters
<ParamField query="Parameters" type="object" required>
<Expandable title="properties">
<ParamField query="Ciphertext" type="string">
The ciphertext you want to decrypt.
</ParamField>
<ParamField query="Key" type="string" required>
The symmetric key to use for encryption.
</ParamField>
<ParamField query="Iv" type="string" required>
The initialization vector to use for decryption.
</ParamField>
<ParamField query="Tag" type="string" required>
The authentication tag to use for decryption.
</ParamField>
</Expandable>
</ParamField>
```cs
var options = new RetrieveLatestCertificateBundleOptions
{
SubscriberName = "<subscriber-name>",
ProjectId = "<your-project-id>",
};
#### Returns (string)
`Plaintext` (string): The decrypted plaintext.
*/}
CertificateBundle latestCertificate = await sdk.Pki().Subscribers().RetrieveLatestCertificateBundleAsync(options);
```
**Parameters:**
- `SubscriberName` (string): The name of the subscriber to retrieve the latest certificate bundle for
- `ProjectId` (string): The ID of PKI project.
**Returns:**
- `Task<CertificateBundle>`: The latest certificate bundle for the specified subscriber.

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