Compare commits

..

118 Commits

Author SHA1 Message Date
Maidul Islam
ac7ac79463 add to nav bar 2025-04-23 16:00:30 -07:00
ArshBallagan
317b15157d Update google-cloud-run.mdx 2025-04-23 10:44:39 -07:00
ArshBallagan
2e34167a24 Update google-cloud-run.mdx 2025-04-23 10:29:13 -07:00
ArshBallagan
bc1cce62ab Adding more architecture detail to the Cloud Run document 2025-04-22 18:50:45 -07:00
ArshBallagan
60d0bc827c Update google-cloud-run.mdx 2025-04-18 12:37:18 -07:00
ArshBallagan
6e9651d188 Adding a guide on deploying Infisical using Google Cloud Run 2025-04-18 11:35:59 -07:00
Scott Wilson
233740e029 Merge pull request #3429 from Infisical/windmill-connection-and-sync
Feature: Windmill Connection and Sync
2025-04-17 17:53:19 -07:00
Daniel Hougaard
767fdc645f Merge branch 'heads/main' into windmill-connection-and-sync 2025-04-18 04:44:32 +04:00
Daniel Hougaard
c477703dda Merge pull request #3406 from juhnny5/patch-1
Typo correction in the go-sdk example
2025-04-18 03:46:45 +04:00
Scott Wilson
923d639c40 Merge pull request #3445 from Infisical/vercel-connection-validation-fix
Fix: Vercel Connection Validation API Call
2025-04-17 14:20:23 -07:00
Scott Wilson
7655dc7f3c chore: remove unused type 2025-04-17 12:22:02 -07:00
Scott Wilson
6c6c4db92c fix: use a non-team based api call for validation 2025-04-17 12:20:02 -07:00
Maidul Islam
8cf125ed32 Merge pull request #3444 from Infisical/default-project-delete-protection-false
Improvement: Set Project Delete Protection to False by Default
2025-04-17 12:21:46 -04:00
Scott Wilson
886cc9a113 improvement: set project delete protection to false by default 2025-04-17 09:16:36 -07:00
Daniel Hougaard
e1016f0a8b Merge pull request #3441 from Infisical/revert-3439-daniel/remove-docs
Revert "fix: removed legacy sdk's"
2025-04-17 07:05:30 +04:00
Daniel Hougaard
9c0a5f0bd4 fix: deprecation notices 2025-04-17 07:03:20 +04:00
Daniel Hougaard
7facd0e89e Revert "fix: removed legacy sdk's" 2025-04-17 05:52:07 +04:00
Scott Wilson
3afe2552d5 documentation: fix grammar 2025-04-16 15:59:18 -07:00
Scott Wilson
1fdb695240 deconflict merge 2025-04-16 15:40:18 -07:00
Scott Wilson
d9bd1ac878 improvements: address feedback 2025-04-16 15:28:29 -07:00
Maidul Islam
ee185cbe47 Merge pull request #3425 from akhilmhdh/feat/aws-cf-invalidate
Added aws cf invalidation on cli deployment pipeline
2025-04-16 17:22:10 -04:00
Maidul Islam
abc2f3808e Merge pull request #3438 from akhilmhdh/doc/sql-change
Updated doc on db permission change
2025-04-16 17:15:57 -04:00
Maidul Islam
733440a7b5 update docs for pg permissions 2025-04-16 17:15:11 -04:00
Maidul Islam
1ef3525917 Merge pull request #3439 from Infisical/daniel/remove-docs
fix: removed legacy sdk's
2025-04-16 16:42:58 -04:00
Daniel Hougaard
6664add428 fix: removed legacy sdk's 2025-04-17 00:41:29 +04:00
Daniel Hougaard
242e8fd2c6 Merge pull request #3424 from Infisical/misc/allow-org-admins-to-bypass-sso-enforcement
misc: allow org admins to bypass sso enforcement
2025-04-17 00:22:18 +04:00
Sheen Capadngan
1137247e69 misc: addressed feedback 2025-04-17 04:00:02 +08:00
=
32b951f6e9 doc: updated doc on db permission change 2025-04-17 01:09:23 +05:30
carlosmonastyrski
6f5fe053cd Merge pull request #3422 from Infisical/feat/addProjectDeletionProtection
Add project delete protection
2025-04-16 16:30:46 -03:00
Scott Wilson
875ec6a24e fix: lowercase workspace for url 2025-04-16 12:08:22 -07:00
carlosmonastyrski
17233e6a6f Merge pull request #3437 from Infisical/feat/addDocsOnSamlModal
Add SAML doc links to Org Settings
2025-04-16 14:20:28 -03:00
carlosmonastyrski
0dd06c1d66 Merge pull request #3419 from Infisical/feat/notifyOnServiceTokenExpiration
Add notification on Service Token expiration
2025-04-16 14:20:09 -03:00
Sheen Capadngan
fc2e5d18b7 misc: displayed full admin login url 2025-04-17 00:52:24 +08:00
Sheen
ae1ee25687 Merge pull request #3436 from Infisical/misc/made-jwt-signature-alg-configurable-for-oidc
misc: made jwt signature alg configurable for oidc
2025-04-17 00:47:16 +08:00
Sheen Capadngan
5d0bbce12d misc: added admin login url to tooltip 2025-04-17 00:41:42 +08:00
Sheen Capadngan
8c87c40467 misc: only bypass when from admin login 2025-04-17 00:33:07 +08:00
Sheen Capadngan
a9dab557d9 misc: correct labels 2025-04-17 00:06:27 +08:00
Sheen Capadngan
76c3f1c152 misc: made bypass opt-in 2025-04-16 23:58:20 +08:00
Scott Wilson
4650ba9fdd Merge pull request #3397 from Infisical/auth0-connection-and-secret-rotation
Feature: Auth0 Connection and Client Secret Rotation
2025-04-16 08:19:50 -07:00
carlosmonastyrski
73dea6a0be Merge branch 'main' into feat/addProjectDeletionProtection 2025-04-16 10:00:23 -03:00
carlosmonastyrski
e7742afcd3 Merge pull request #3434 from Infisical/fix/improveRandomValueGeneratorUI
Improve random value generator modal UI
2025-04-16 09:58:07 -03:00
carlosmonastyrski
7d3dd765ad Add SAML doc links to Org Settings 2025-04-16 09:52:58 -03:00
Sheen
927eb0407d misc: update documentation 2025-04-16 12:33:22 +00:00
Sheen Capadngan
17ddb79def misc: made jwt signature alg configurable for oidc 2025-04-16 20:20:37 +08:00
carlosmonastyrski
5ef5a5a107 Improve random value generator modal UI 2025-04-16 08:04:41 -03:00
carlosmonastyrski
9ae0880f50 Improve random value generator modal UI 2025-04-16 07:07:37 -03:00
Akhil Mohan
3814c65f38 Merge pull request #3433 from akhilmhdh/fix/permission
feat: dashboard failing on failing check
2025-04-16 13:00:43 +05:30
Akhil Mohan
3fa98e2a8d Merge pull request #3428 from x032205/cli-secrets-folders-get-path
Fixed `v1/folders` API backward compatibility with `directory` parameter
2025-04-16 12:49:02 +05:30
=
c6b21491db feat: dashboard failing on failing check 2025-04-16 12:37:39 +05:30
Scott Wilson
357381b0d6 feature: windmill connection and sync 2025-04-15 19:10:13 -07:00
carlosmonastyrski
82af77c480 Add hasDeleteProtection to update endpoint 2025-04-15 22:37:53 -03:00
x
b2fae5c439 fixed backward compatibility with --directory flag on every v1/folders endpoint 2025-04-15 18:46:08 -04:00
x
f16e96759f fixed --path not working with infisical secrets folders get 2025-04-15 18:42:01 -04:00
Scott Wilson
5eb9a1a667 improvement: add doc additions for single credential rotations 2025-04-15 15:07:39 -07:00
Scott Wilson
03ad6f822a merge deconflict 2025-04-15 14:32:21 -07:00
Scott Wilson
98447e9402 improvements: address feedback 2025-04-15 14:22:41 -07:00
Sheen
0f7e8585dc Merge pull request #3391 from Infisical/feat/add-metadata-based-permissions-for-dynamic-secret
feat: add metadata based permissions for dynamic secret
2025-04-16 04:28:52 +08:00
carlosmonastyrski
8568d1f6fe Merge pull request #3426 from Infisical/fix/addConfusedDeputyProblemOnAWSDocs
Add confused deputy problem to AWS assume role docs
2025-04-15 17:03:04 -03:00
carlosmonastyrski
27198869d8 Confused Deputy Attacks docs improvement 2025-04-15 16:55:05 -03:00
Sheen
dd0880825b doc: added reference to admin login portal 2025-04-15 19:50:49 +00:00
Maidul Islam
f27050a1c3 Merge pull request #3421 from x032205/san-size-limit
Increase Certificate Alternative Names (SAN) Character Limit to 4096
2025-04-15 15:29:58 -04:00
Sheen Capadngan
785173747f misc: introduce admin login portal 2025-04-16 03:28:04 +08:00
carlosmonastyrski
d33b06dd8a Add confused deputy problem to AWS assume role docs 2025-04-15 15:41:13 -03:00
=
9a6e27d4be feat: added aws cf invalidation on cli deployment pipeline 2025-04-15 23:41:06 +05:30
Sheen Capadngan
d0db5c00e8 misc: allow org admins to bypass sso enforcement 2025-04-16 01:35:39 +08:00
Scott Wilson
9475c1671e Merge pull request #3418 from Infisical/allow-internal-ip-connection-env-var
Feature: Add General Env Var for Allowing Internal IP Connections
2025-04-15 08:26:00 -07:00
Sheen
0f710b1ccc misc: updated documentation 2025-04-15 15:01:10 +00:00
Sheen Capadngan
71c55d5a53 misc: addressed review comments 2025-04-15 22:42:38 +08:00
Akhil Mohan
32bca651df Merge pull request #3423 from Infisical/fix/getFolderIsImportedByThrow
Avoid throwing on getFolderIsImportedBy no folder found
2025-04-15 18:51:51 +05:30
carlosmonastyrski
82533f49ca Avoid throwing on getFolderIsImportedBy no folder found 2025-04-15 10:19:41 -03:00
carlosmonastyrski
1d8c513da1 Improve invalidateQueries for useToggleDeleteProjectProtection 2025-04-15 08:07:21 -03:00
x
b08b53b77d increase certificate altnames character limit to 4096 2025-04-15 00:40:28 -04:00
Daniel Hougaard
862ed4f4e7 Merge pull request #3411 from Infisical/daniel/kms-signing-docs
docs(kms): KMS sign/verify docs
2025-04-15 05:39:21 +04:00
Daniel Hougaard
7b9254d09a Merge pull request #3358 from Infisical/daniel/go-sdk-kms-docs
docs(sdk): go sdk kms docs
2025-04-15 05:30:48 +04:00
Daniel Hougaard
c6305045e3 Revert "fix(docs): rename isDigest to preDigested"
This reverts commit 2642f7501d.
2025-04-15 05:28:41 +04:00
Daniel Hougaard
24bf9f7a2a Revert "fix: rename IsDigest to IsPreDigested"
This reverts commit 8d4fa0bdb9.
2025-04-15 05:24:39 +04:00
carlosmonastyrski
101c056f43 Add project delete protection 2025-04-14 21:41:46 -03:00
Daniel Hougaard
8d4fa0bdb9 fix: rename IsDigest to IsPreDigested 2025-04-15 03:51:30 +04:00
Daniel Hougaard
2642f7501d fix(docs): rename isDigest to preDigested 2025-04-15 03:49:29 +04:00
Scott Wilson
68ba807b43 Merge pull request #3417 from Infisical/rollback-rotation-v1-deprecation
Improvement: Rollback Secret V1 Create Deprecation
2025-04-14 15:28:23 -07:00
Scott Wilson
499ff3635b feature: add general env var for allowing internal ip connections and update relevant docs 2025-04-14 14:04:26 -07:00
carlosmonastyrski
78fc8a693d Merge pull request #3356 from Infisical/feat/showWarningOnImportedSecretDeletion
Add warning on secret deletions where it's being imported by another folder
2025-04-14 17:37:16 -03:00
Scott Wilson
78687984b7 Merge pull request #3404 from Infisical/native-integrations-ui-deprecation-for-sync-parity
Improvement: Native Integration Deprecation Details and Sync Redirect
2025-04-14 13:29:56 -07:00
Scott Wilson
25d3fb6a8c improvements: address feedback 2025-04-14 13:22:25 -07:00
carlosmonastyrski
31a4bcafbe Merge branch 'main' into feat/showWarningOnImportedSecretDeletion 2025-04-14 15:30:41 -03:00
carlosmonastyrski
ac8b3aca60 Merge pull request #3415 from Infisical/feat/addBackstagePluginsDocs
Add Backstage Plugins docs
2025-04-14 15:18:20 -03:00
carlosmonastyrski
4ea0cc62e3 Change External Integrations to Others 2025-04-14 15:07:16 -03:00
Scott Wilson
9d0020fa4e improvement: rollback deprecate all secret rotation v1 create, update UI to only prevent pg/mssql 2025-04-14 10:50:45 -07:00
carlosmonastyrski
f2fead7a51 Add Backstage Plugins docs 2025-04-14 14:15:42 -03:00
carlosmonastyrski
3c58bf890d Merge branch 'main' into feat/showWarningOnImportedSecretDeletion 2025-04-14 09:04:47 -03:00
carlosmonastyrski
dc219b8e9f Fix edge case for referenced secrets batch delete and empty message 2025-04-14 08:54:43 -03:00
Daniel Hougaard
f1e30fd06b requested changes 2025-04-14 01:42:05 +04:00
Daniel Hougaard
e339b81bf1 docs(kms): signing documentation 2025-04-13 23:19:06 +04:00
Daniel Hougaard
b9bfe19b64 feat(kms/signing): better error handling 2025-04-13 23:17:50 +04:00
Julien Briault
fa030417ef Typo correction in the go-sdk example 2025-04-12 10:15:04 +02:00
Scott Wilson
8bfbae1037 chore: remove outdated comment 2025-04-11 19:38:47 -07:00
Scott Wilson
d00b34663e improvement: native integration legacy details and sync redirects 2025-04-11 19:34:33 -07:00
Scott Wilson
581e4b35f9 rebase 2025-04-11 12:25:26 -07:00
Sheen Capadngan
f33a777fae misc: updated form declaration for consistency 2025-04-12 02:04:49 +08:00
Sheen Capadngan
8a870131e9 misc: updated missing tx 2025-04-12 02:02:41 +08:00
Sheen Capadngan
d97057b43b misc: address metadata type 2025-04-12 01:50:05 +08:00
Sheen Capadngan
19b0cd9735 feat: update dynamic secret permissioning 2025-04-12 01:47:33 +08:00
carlosmonastyrski
1ec87fae75 Add referenced secret delete warning to overview page 2025-04-11 14:00:31 -03:00
carlosmonastyrski
aec131543f Add referenced secret delete warning to batch delete modal inside env 2025-04-11 10:12:41 -03:00
carlosmonastyrski
aeaa5babab Improve referenced secret deletion message logic 2025-04-11 09:29:01 -03:00
Sheen Capadngan
07898414a3 feat: add metadata based permissions for dynamic secret 2025-04-11 00:20:02 +08:00
carlosmonastyrski
f15b30ff85 Improve referenced secret deletion message component 2025-04-10 13:08:52 -03:00
carlosmonastyrski
8ee2b54182 Improve referenced secret deletion message component 2025-04-10 10:24:02 -03:00
carlosmonastyrski
b121ec891f UI changes on reference secret warning 2025-04-09 17:36:57 -03:00
carlosmonastyrski
ab566bcbe4 Merge branch 'main' into feat/showWarningOnImportedSecretDeletion 2025-04-09 15:39:43 -03:00
Daniel Hougaard
041d585f19 Update go.mdx 2025-04-09 02:11:43 +04:00
carlosmonastyrski
224b167000 Improve delete referenced secret warning message 2025-04-04 11:22:45 -03:00
Daniel Hougaard
e1a11c37e3 docs(sdk): go sdk kms docs 2025-04-04 06:02:47 +04:00
carlosmonastyrski
15130a433c UI improvements on secret deletion warning 2025-04-03 17:40:08 -03:00
carlosmonastyrski
a0bf03b2ae UI improvements on secret deletion warning 2025-04-03 16:00:41 -03:00
carlosmonastyrski
4d8598a019 Fix lint issue 2025-04-02 19:06:27 -03:00
carlosmonastyrski
a9da2d6241 Truncate folder name on warning message 2025-04-02 19:02:24 -03:00
carlosmonastyrski
4420985669 Add warning on secret deletions where it's being imported by another folder 2025-04-02 18:58:34 -03:00
358 changed files with 7604 additions and 1911 deletions

View File

@@ -145,3 +145,9 @@ jobs:
INFISICAL_CLI_REPO_SIGNING_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_SIGNING_KEY_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.INFISICAL_CLI_REPO_AWS_SECRET_ACCESS_KEY }}
- name: Invalidate Cloudfront cache
run: aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths '/deb/dists/stable/*'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.INFISICAL_CLI_REPO_AWS_SECRET_ACCESS_KEY }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.INFISICAL_CLI_REPO_CLOUDFRONT_DISTRIBUTION_ID }}

View File

@@ -22,3 +22,5 @@ frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredent
frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:28
frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65
frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26
docs/documentation/platform/kms/overview.mdx:generic-api-key:281
docs/documentation/platform/kms/overview.mdx:generic-api-key:344

View File

@@ -50,7 +50,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus
- **[Dashboard](https://infisical.com/docs/documentation/platform/project)**: Manage secrets across projects and environments (e.g. development, production, etc.) through a user-friendly interface.
- **[Native Integrations](https://infisical.com/docs/integrations/overview)**: Sync secrets to platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and use tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more.
- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)**: Keep track of every secret and project state; roll back when needed.
- **[Secret Rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview)**: Rotate secrets at regular intervals for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/secret-rotation/postgres), [MySQL](https://infisical.com/docs/documentation/platform/secret-rotation/mysql), [AWS IAM](https://infisical.com/docs/documentation/platform/secret-rotation/aws-iam), and more.
- **[Secret Rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview)**: Rotate secrets at regular intervals for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/secret-rotation/postgres-credentials), [MySQL](https://infisical.com/docs/documentation/platform/secret-rotation/mysql), [AWS IAM](https://infisical.com/docs/documentation/platform/secret-rotation/aws-iam), and more.
- **[Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview)**: Generate ephemeral secrets on-demand for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/postgresql), [MySQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mysql), [RabbitMQ](https://infisical.com/docs/documentation/platform/dynamic-secrets/rabbit-mq), and more.
- **[Secret Scanning and Leak Prevention](https://infisical.com/docs/cli/scanning-overview)**: Prevent secrets from leaking to git.
- **[Infisical Kubernetes Operator](https://infisical.com/docs/documentation/getting-started/kubernetes)**: Deliver secrets to your Kubernetes workloads and automatically reload deployments.

View File

@@ -0,0 +1,20 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId"))) {
await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => {
tb.uuid("dynamicSecretId");
tb.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId")) {
await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => {
tb.dropColumn("dynamicSecretId");
});
}
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn(TableName.Project, "hasDeleteProtection");
if (!hasCol) {
await knex.schema.alterTable(TableName.Project, (t) => {
t.boolean("hasDeleteProtection").defaultTo(false);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn(TableName.Project, "hasDeleteProtection");
if (hasCol) {
await knex.schema.alterTable(TableName.Project, (t) => {
t.dropColumn("hasDeleteProtection");
});
}
}

View File

@@ -0,0 +1,15 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.string("altNames", 4096).alter();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.string("altNames").alter(); // Defaults to varchar(255)
});
}

View File

@@ -0,0 +1,15 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => {
t.string("altNames", 4096).alter();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => {
t.string("altNames").alter(); // Defaults to varchar(255)
});
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { OIDCJWTSignatureAlgorithm } from "@app/ee/services/oidc/oidc-config-types";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.OidcConfig, "jwtSignatureAlgorithm"))) {
await knex.schema.alterTable(TableName.OidcConfig, (t) => {
t.string("jwtSignatureAlgorithm").defaultTo(OIDCJWTSignatureAlgorithm.RS256).notNullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.OidcConfig, "jwtSignatureAlgorithm")) {
await knex.schema.alterTable(TableName.OidcConfig, (t) => {
t.dropColumn("jwtSignatureAlgorithm");
});
}
}

View File

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

View File

@@ -30,9 +30,10 @@ export const OidcConfigsSchema = z.object({
updatedAt: z.date(),
orgId: z.string().uuid(),
lastUsed: z.date().nullable().optional(),
manageGroupMemberships: z.boolean().default(false),
encryptedOidcClientId: zodBuffer,
encryptedOidcClientSecret: zodBuffer
encryptedOidcClientSecret: zodBuffer,
manageGroupMemberships: z.boolean().default(false),
jwtSignatureAlgorithm: z.string().default("RS256")
});
export type TOidcConfigs = z.infer<typeof OidcConfigsSchema>;

View File

@@ -26,7 +26,8 @@ export const OrganizationsSchema = z.object({
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
privilegeUpgradeInitiatedAt: z.date().nullable().optional()
privilegeUpgradeInitiatedAt: z.date().nullable().optional(),
bypassOrgAuthEnabled: z.boolean().default(false)
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -26,7 +26,8 @@ export const ProjectsSchema = z.object({
kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(),
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false)
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(true).nullable().optional()
});
export type TProjects = z.infer<typeof ProjectsSchema>;

View File

@@ -16,7 +16,8 @@ export const ResourceMetadataSchema = z.object({
identityId: z.string().uuid().nullable().optional(),
secretId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
dynamicSecretId: z.string().uuid().nullable().optional()
});
export type TResourceMetadata = z.infer<typeof ResourceMetadataSchema>;

View File

@@ -11,6 +11,7 @@ import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -48,7 +49,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
.nullable(),
path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash),
environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1),
name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name)
name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name),
metadata: ResourceMetadataSchema.optional()
}),
response: {
200: z.object({
@@ -143,7 +145,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
.nullable(),
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional()
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(),
metadata: ResourceMetadataSchema.optional()
})
}),
response: {
@@ -238,6 +241,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
name: req.params.name,
...req.query
});
return { dynamicSecret: dynamicSecretCfg };
}
});

View File

@@ -12,7 +12,7 @@ import RedisStore from "connect-redis";
import { z } from "zod";
import { OidcConfigsSchema } from "@app/db/schemas";
import { OIDCConfigurationType } from "@app/ee/services/oidc/oidc-config-types";
import { OIDCConfigurationType, OIDCJWTSignatureAlgorithm } from "@app/ee/services/oidc/oidc-config-types";
import { getConfig } from "@app/lib/config/env";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -30,7 +30,8 @@ const SanitizedOidcConfigSchema = OidcConfigsSchema.pick({
orgId: true,
isActive: true,
allowedEmailDomains: true,
manageGroupMemberships: true
manageGroupMemberships: true,
jwtSignatureAlgorithm: true
});
export const registerOidcRouter = async (server: FastifyZodProvider) => {
@@ -170,7 +171,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => {
isActive: true,
orgId: true,
allowedEmailDomains: true,
manageGroupMemberships: true
manageGroupMemberships: true,
jwtSignatureAlgorithm: true
}).extend({
clientId: z.string(),
clientSecret: z.string()
@@ -225,7 +227,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => {
clientId: z.string().trim(),
clientSecret: z.string().trim(),
isActive: z.boolean(),
manageGroupMemberships: z.boolean().optional()
manageGroupMemberships: z.boolean().optional(),
jwtSignatureAlgorithm: z.nativeEnum(OIDCJWTSignatureAlgorithm).optional()
})
.partial()
.merge(z.object({ orgSlug: z.string() })),
@@ -292,7 +295,11 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => {
clientSecret: z.string().trim(),
isActive: z.boolean(),
orgSlug: z.string().trim(),
manageGroupMemberships: z.boolean().optional().default(false)
manageGroupMemberships: z.boolean().optional().default(false),
jwtSignatureAlgorithm: z
.nativeEnum(OIDCJWTSignatureAlgorithm)
.optional()
.default(OIDCJWTSignatureAlgorithm.RS256)
})
.superRefine((data, ctx) => {
if (data.configurationType === OIDCConfigurationType.CUSTOM) {

View File

@@ -23,7 +23,8 @@ export const registerSecretRotationProviderRouter = async (server: FastifyZodPro
title: z.string(),
image: z.string().optional(),
description: z.string().optional(),
template: z.any()
template: z.any(),
isDeprecated: z.boolean().optional()
})
.array()
})

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import { SecretRotationOutputsSchema, SecretRotationsSchema } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -41,10 +40,16 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) =
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => {
throw new BadRequestError({
message: `This version of Secret Rotations has been deprecated. Please see docs for new version.`
handler: async (req) => {
const secretRotation = await server.services.secretRotation.createRotation({
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
...req.body,
projectId: req.body.workspaceId
});
return { secretRotation };
}
});

View File

@@ -0,0 +1,19 @@
import {
Auth0ClientSecretRotationGeneratedCredentialsSchema,
Auth0ClientSecretRotationSchema,
CreateAuth0ClientSecretRotationSchema,
UpdateAuth0ClientSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/auth0-client-secret";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
export const registerAuth0ClientSecretRotationRouter = async (server: FastifyZodProvider) =>
registerSecretRotationEndpoints({
type: SecretRotation.Auth0ClientSecret,
server,
responseSchema: Auth0ClientSecretRotationSchema,
createSchema: CreateAuth0ClientSecretRotationSchema,
updateSchema: UpdateAuth0ClientSecretRotationSchema,
generatedCredentialsSchema: Auth0ClientSecretRotationGeneratedCredentialsSchema
});

View File

@@ -1,5 +1,6 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router";
import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router";
import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router";
@@ -10,5 +11,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record<
(server: FastifyZodProvider) => Promise<void>
> = {
[SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter,
[SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter
[SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter,
[SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter
};

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret";
import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
@@ -11,7 +12,8 @@ import { AuthMode } from "@app/services/auth/auth-type";
const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [
PostgresCredentialsRotationListItemSchema,
MsSqlCredentialsRotationListItemSchema
MsSqlCredentialsRotationListItemSchema,
Auth0ClientSecretRotationListItemSchema
]);
export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => {

View File

@@ -78,10 +78,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const plan = await licenseService.getPlan(actorOrgId);
if (!plan?.dynamicSecret) {
@@ -102,6 +98,15 @@ export const dynamicSecretLeaseServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder with path '${path}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id);
if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT)
throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` });
@@ -159,10 +164,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
@@ -187,7 +188,25 @@ export const dynamicSecretLeaseServiceFactory = ({
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
}
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret;
const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse(
secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString()
@@ -239,10 +258,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
@@ -259,7 +274,25 @@ export const dynamicSecretLeaseServiceFactory = ({
if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id)
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret;
const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse(
secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString()
@@ -309,10 +342,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder)
@@ -326,6 +355,15 @@ export const dynamicSecretLeaseServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder with path '${path}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
return dynamicSecretLeases;
};
@@ -352,10 +390,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' not found` });
@@ -364,6 +398,25 @@ export const dynamicSecretLeaseServiceFactory = ({
if (!dynamicSecretLease)
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
return dynamicSecretLease;
};

View File

@@ -1,9 +1,17 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TableName, TDynamicSecrets } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import {
buildFindFilter,
ormify,
prependTableNameToFindFilter,
selectAllTableCols,
sqlNestRelationships,
TFindFilter,
TFindOpt
} from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
@@ -12,6 +20,86 @@ export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory
export const dynamicSecretDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.DynamicSecret);
const findOne = async (filter: TFindFilter<TDynamicSecrets>, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.select(selectAllTableCols(TableName.DynamicSecret))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.where(prependTableNameToFindFilter(TableName.DynamicSecret, filter));
const docs = sqlNestRelationships({
data: await query,
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs[0];
};
const findWithMetadata = async (
filter: TFindFilter<TDynamicSecrets>,
{ offset, limit, sort, tx }: TFindOpt<TDynamicSecrets> = {}
) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.select(selectAllTableCols(TableName.DynamicSecret))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter));
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const docs = sqlNestRelationships({
data: await query,
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs;
};
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
const listDynamicSecretsByFolderIds = async (
{
@@ -39,18 +127,27 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`);
}
})
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`)
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(
selectAllTableCols(TableName.DynamicSecret),
db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`)
db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`),
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection);
let queryWithLimit;
if (limit) {
const rankOffset = offset + 1;
return await (tx || db)
queryWithLimit = (tx || db.replicaNode())
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
@@ -58,7 +155,22 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
.andWhere("w.rank", "<", rankOffset + limit);
}
const dynamicSecrets = await query;
const dynamicSecrets = sqlNestRelationships({
data: await (queryWithLimit || query),
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return dynamicSecrets;
} catch (error) {
@@ -66,5 +178,5 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
}
};
return { ...orm, listDynamicSecretsByFolderIds };
return { ...orm, listDynamicSecretsByFolderIds, findOne, findWithMetadata };
};

View File

@@ -42,7 +42,7 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) =
inputHostIps.push(...resolvedIps);
}
if (!isGateway && !appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP) {
if (!isGateway && !(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) {
const isInternalIp = inputHostIps.some((el) => isPrivateIp(el));
if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" });
}

View File

@@ -12,6 +12,7 @@ import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
@@ -46,6 +47,7 @@ type TDynamicSecretServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
projectGatewayDAL: Pick<TProjectGatewayDALFactory, "findOne">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
};
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
@@ -60,7 +62,8 @@ export const dynamicSecretServiceFactory = ({
dynamicSecretQueueService,
projectDAL,
kmsService,
projectGatewayDAL
projectGatewayDAL,
resourceMetadataDAL
}: TDynamicSecretServiceFactoryDep) => {
const create = async ({
path,
@@ -73,7 +76,8 @@ export const dynamicSecretServiceFactory = ({
projectSlug,
actorOrgId,
defaultTTL,
actorAuthMethod
actorAuthMethod,
metadata
}: TCreateDynamicSecretDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -87,9 +91,10 @@ export const dynamicSecretServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path, metadata })
);
const plan = await licenseService.getPlan(actorOrgId);
@@ -131,16 +136,36 @@ export const dynamicSecretServiceFactory = ({
projectId
});
const dynamicSecretCfg = await dynamicSecretDAL.create({
type: provider.type,
version: 1,
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob,
maxTTL,
defaultTTL,
folderId: folder.id,
name,
projectGatewayId: selectedGatewayId
const dynamicSecretCfg = await dynamicSecretDAL.transaction(async (tx) => {
const cfg = await dynamicSecretDAL.create(
{
type: provider.type,
version: 1,
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob,
maxTTL,
defaultTTL,
folderId: folder.id,
name,
projectGatewayId: selectedGatewayId
},
tx
);
if (metadata) {
await resourceMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
key,
value,
dynamicSecretId: cfg.id,
orgId: actorOrgId
})),
tx
);
}
return cfg;
});
return dynamicSecretCfg;
};
@@ -156,7 +181,8 @@ export const dynamicSecretServiceFactory = ({
actorId,
newName,
actorOrgId,
actorAuthMethod
actorAuthMethod,
metadata
}: TUpdateDynamicSecretDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -171,10 +197,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const plan = await licenseService.getPlan(actorOrgId);
if (!plan?.dynamicSecret) {
@@ -193,6 +215,27 @@ export const dynamicSecretServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found`
});
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
if (metadata) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata
})
);
}
if (newName) {
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id });
if (existingDynamicSecret)
@@ -231,14 +274,41 @@ export const dynamicSecretServiceFactory = ({
const isConnected = await selectedProvider.validateConnection(newInput);
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) }).cipherTextBlob,
maxTTL,
defaultTTL,
name: newName ?? name,
status: null,
statusDetails: null,
projectGatewayId: selectedGatewayId
const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => {
const cfg = await dynamicSecretDAL.updateById(
dynamicSecretCfg.id,
{
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) })
.cipherTextBlob,
maxTTL,
defaultTTL,
name: newName ?? name,
status: null,
projectGatewayId: selectedGatewayId
},
tx
);
if (metadata) {
await resourceMetadataDAL.delete(
{
dynamicSecretId: cfg.id
},
tx
);
await resourceMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
key,
value,
dynamicSecretId: cfg.id,
orgId: actorOrgId
})),
tx
);
}
return cfg;
});
return updatedDynamicCfg;
@@ -268,10 +338,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder)
@@ -282,6 +348,15 @@ export const dynamicSecretServiceFactory = ({
throw new NotFoundError({ message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` });
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
// when not forced we check with the external system to first remove the things
// we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system
@@ -329,14 +404,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder)
@@ -346,6 +413,25 @@ export const dynamicSecretServiceFactory = ({
if (!dynamicSecretCfg) {
throw new NotFoundError({ message: `Dynamic secret with name '${name} in folder '${path}' not found` });
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
@@ -356,6 +442,7 @@ export const dynamicSecretServiceFactory = ({
) as object;
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object;
return { ...dynamicSecretCfg, inputs: providerInputs };
};
@@ -426,7 +513,7 @@ export const dynamicSecretServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
ProjectPermissionSub.DynamicSecrets
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -473,16 +560,12 @@ export const dynamicSecretServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder)
throw new NotFoundError({ message: `Folder with path '${path}' in environment '${environmentSlug}' not found` });
const dynamicSecretCfg = await dynamicSecretDAL.find(
const dynamicSecretCfg = await dynamicSecretDAL.findWithMetadata(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{
limit,
@@ -490,7 +573,17 @@ export const dynamicSecretServiceFactory = ({
sort: orderBy ? [[orderBy, orderDirection]] : undefined
}
);
return dynamicSecretCfg;
return dynamicSecretCfg.filter((dynamicSecret) => {
return permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecret.metadata
})
);
});
};
const listDynamicSecretsByFolderIds = async (
@@ -542,24 +635,14 @@ export const dynamicSecretServiceFactory = ({
isInternal,
...params
}: TListDynamicSecretsMultiEnvDTO) => {
if (!isInternal) {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
)
);
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length)
@@ -572,7 +655,16 @@ export const dynamicSecretServiceFactory = ({
...params
});
return dynamicSecretCfg;
return dynamicSecretCfg.filter((dynamicSecret) => {
return permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: dynamicSecret.environment,
secretPath: path,
metadata: dynamicSecret.metadata
})
);
});
};
const fetchAzureEntraIdUsers = async ({

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { DynamicSecretProviderSchema } from "./providers/models";
@@ -20,6 +21,7 @@ export type TCreateDynamicSecretDTO = {
environmentSlug: string;
name: string;
projectSlug: string;
metadata?: ResourceMetadataDTO;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateDynamicSecretDTO = {
@@ -31,6 +33,7 @@ export type TUpdateDynamicSecretDTO = {
environmentSlug: string;
inputs?: TProvider["inputs"];
projectSlug: string;
metadata?: ResourceMetadataDTO;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteDynamicSecretDTO = {

View File

@@ -165,7 +165,8 @@ export const oidcConfigServiceFactory = ({
allowedEmailDomains: oidcCfg.allowedEmailDomains,
clientId,
clientSecret,
manageGroupMemberships: oidcCfg.manageGroupMemberships
manageGroupMemberships: oidcCfg.manageGroupMemberships,
jwtSignatureAlgorithm: oidcCfg.jwtSignatureAlgorithm
};
};
@@ -481,7 +482,8 @@ export const oidcConfigServiceFactory = ({
userinfoEndpoint,
clientId,
clientSecret,
manageGroupMemberships
manageGroupMemberships,
jwtSignatureAlgorithm
}: TUpdateOidcCfgDTO) => {
const org = await orgDAL.findOne({
slug: orgSlug
@@ -536,7 +538,8 @@ export const oidcConfigServiceFactory = ({
jwksUri,
isActive,
lastUsed: null,
manageGroupMemberships
manageGroupMemberships,
jwtSignatureAlgorithm
};
if (clientId !== undefined) {
@@ -569,7 +572,8 @@ export const oidcConfigServiceFactory = ({
userinfoEndpoint,
clientId,
clientSecret,
manageGroupMemberships
manageGroupMemberships,
jwtSignatureAlgorithm
}: TCreateOidcCfgDTO) => {
const org = await orgDAL.findOne({
slug: orgSlug
@@ -613,6 +617,7 @@ export const oidcConfigServiceFactory = ({
userinfoEndpoint,
orgId: org.id,
manageGroupMemberships,
jwtSignatureAlgorithm,
encryptedOidcClientId: encryptor({ plainText: Buffer.from(clientId) }).cipherTextBlob,
encryptedOidcClientSecret: encryptor({ plainText: Buffer.from(clientSecret) }).cipherTextBlob
});
@@ -676,7 +681,8 @@ export const oidcConfigServiceFactory = ({
const client = new issuer.Client({
client_id: oidcCfg.clientId,
client_secret: oidcCfg.clientSecret,
redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`]
redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`],
id_token_signed_response_alg: oidcCfg.jwtSignatureAlgorithm
});
const strategy = new OpenIdStrategy(

View File

@@ -5,6 +5,12 @@ export enum OIDCConfigurationType {
DISCOVERY_URL = "discoveryURL"
}
export enum OIDCJWTSignatureAlgorithm {
RS256 = "RS256",
HS256 = "HS256",
RS512 = "RS512"
}
export type TOidcLoginDTO = {
externalId: string;
email: string;
@@ -40,6 +46,7 @@ export type TCreateOidcCfgDTO = {
isActive: boolean;
orgSlug: string;
manageGroupMemberships: boolean;
jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm;
} & TGenericPermission;
export type TUpdateOidcCfgDTO = Partial<{
@@ -56,5 +63,6 @@ export type TUpdateOidcCfgDTO = Partial<{
isActive: boolean;
orgSlug: string;
manageGroupMemberships: boolean;
jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm;
}> &
TGenericPermission;

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { TDbClient } from "@app/db";
import {
IdentityProjectMembershipRoleSchema,
OrgMembershipRole,
OrgMembershipsSchema,
TableName,
TProjectRoles,
@@ -53,6 +54,7 @@ export const permissionDALFactory = (db: TDbClient) => {
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("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"),
db.ref("groupName").withSchema("userGroups"),
@@ -71,6 +73,7 @@ export const permissionDALFactory = (db: TDbClient) => {
OrgMembershipsSchema.extend({
permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(),
bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean()
}).parse(el),
@@ -571,6 +574,11 @@ export const permissionDALFactory = (db: TDbClient) => {
})
.join<TProjects>(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId]))
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.join(TableName.OrgMembership, (qb) => {
void qb
.on(`${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.andOn(`${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`);
})
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`)
@@ -670,6 +678,8 @@ export const permissionDALFactory = (db: TDbClient) => {
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("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project).as("projectType"),
db.ref("id").withSchema(TableName.Project).as("projectId"),
@@ -683,6 +693,7 @@ export const permissionDALFactory = (db: TDbClient) => {
orgId,
username,
orgAuthEnforced,
orgRole,
membershipId,
groupMembershipId,
membershipCreatedAt,
@@ -690,10 +701,12 @@ export const permissionDALFactory = (db: TDbClient) => {
groupMembershipUpdatedAt,
membershipUpdatedAt,
projectType,
shouldUseNewPrivilegeSystem
shouldUseNewPrivilegeSystem,
bypassOrgAuthEnabled
}) => ({
orgId,
orgAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId,
projectId,
username,
@@ -701,7 +714,8 @@ export const permissionDALFactory = (db: TDbClient) => {
id: membershipId || groupMembershipId,
createdAt: membershipCreatedAt || groupMembershipCreatedAt,
updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt,
shouldUseNewPrivilegeSystem
shouldUseNewPrivilegeSystem,
bypassOrgAuthEnabled
}),
childrenMapper: [
{

View File

@@ -2,7 +2,7 @@
import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability";
import { z } from "zod";
import { TOrganizations } from "@app/db/schemas";
import { OrgMembershipRole, TOrganizations } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type";
@@ -118,11 +118,20 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
].includes(actorAuthMethod);
}
function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"]) {
function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole
) {
if (actorAuthMethod === undefined) {
throw new UnauthorizedError({ name: "No auth method defined" });
}
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return;
}
if (
isOrgSsoEnforced &&
actorAuthMethod !== null &&

View File

@@ -139,7 +139,12 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, membership.orgAuthEnforced);
validateOrgSSO(
authMethod,
membership.orgAuthEnforced,
membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole
);
const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat(
membership?.groups?.map(({ role, customRolePermission }) => ({
@@ -226,7 +231,12 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, userProjectPermission.orgAuthEnforced);
validateOrgSSO(
authMethod,
userProjectPermission.orgAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole
);
if (actionProjectType !== ActionProjectType.Any && actionProjectType !== userProjectPermission.projectType) {
throw new BadRequestError({

View File

@@ -155,6 +155,10 @@ export type SecretFolderSubjectFields = {
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
metadata?: {
key: string;
value: string;
}[];
};
export type SecretImportSubjectFields = {
@@ -284,6 +288,42 @@ const SecretConditionV1Schema = z
})
.partial();
const DynamicSecretConditionV2Schema = z
.object({
environment: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
]),
secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA,
metadata: z.object({
[PermissionConditionOperators.$ELEMENTMATCH]: z
.object({
key: z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial(),
value: z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
})
.partial()
})
})
.partial();
const SecretConditionV2Schema = z
.object({
environment: z.union([
@@ -581,7 +621,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionDynamicSecretActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
conditions: DynamicSecretConditionV2Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),

View File

@@ -0,0 +1,15 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
name: "Auth0 Client Secret",
type: SecretRotation.Auth0ClientSecret,
connection: AppConnection.Auth0,
template: {
secretsMapping: {
clientId: "AUTH0_CLIENT_ID",
clientSecret: "AUTH0_CLIENT_SECRET"
}
}
};

View File

@@ -0,0 +1,104 @@
import {
TAuth0ClientSecretRotationGeneratedCredentials,
TAuth0ClientSecretRotationWithConnection
} from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-types";
import {
TRotationFactory,
TRotationFactoryGetSecretsPayload,
TRotationFactoryIssueCredentials,
TRotationFactoryRevokeCredentials,
TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { request } from "@app/lib/config/request";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { getAuth0ConnectionAccessToken } from "@app/services/app-connection/auth0";
import { generatePassword } from "../shared/utils";
export const auth0ClientSecretRotationFactory: TRotationFactory<
TAuth0ClientSecretRotationWithConnection,
TAuth0ClientSecretRotationGeneratedCredentials
> = (secretRotation, appConnectionDAL, kmsService) => {
const {
connection,
parameters: { clientId },
secretsMapping
} = secretRotation;
const $rotateClientSecret = async () => {
const accessToken = await getAuth0ConnectionAccessToken(connection, appConnectionDAL, kmsService);
const { audience } = connection.credentials;
await blockLocalAndPrivateIpAddresses(audience);
const clientSecret = generatePassword();
await request.request({
method: "PATCH",
url: `${audience}clients/${clientId}`,
headers: { authorization: `Bearer ${accessToken}` },
data: {
client_secret: clientSecret
}
});
return { clientId, clientSecret };
};
const issueCredentials: TRotationFactoryIssueCredentials<TAuth0ClientSecretRotationGeneratedCredentials> = async (
callback
) => {
const credentials = await $rotateClientSecret();
return callback(credentials);
};
const revokeCredentials: TRotationFactoryRevokeCredentials<TAuth0ClientSecretRotationGeneratedCredentials> = async (
_,
callback
) => {
const accessToken = await getAuth0ConnectionAccessToken(connection, appConnectionDAL, kmsService);
const { audience } = connection.credentials;
await blockLocalAndPrivateIpAddresses(audience);
// we just trigger an auth0 rotation to negate our credentials
await request.request({
method: "POST",
url: `${audience}clients/${clientId}/rotate-secret`,
headers: { authorization: `Bearer ${accessToken}` }
});
return callback();
};
const rotateCredentials: TRotationFactoryRotateCredentials<TAuth0ClientSecretRotationGeneratedCredentials> = async (
_,
callback
) => {
const credentials = await $rotateClientSecret();
return callback(credentials);
};
const getSecretsPayload: TRotationFactoryGetSecretsPayload<TAuth0ClientSecretRotationGeneratedCredentials> = (
generatedCredentials
) => {
const secrets = [
{
key: secretsMapping.clientId,
value: generatedCredentials.clientId
},
{
key: secretsMapping.clientSecret,
value: generatedCredentials.clientSecret
}
];
return secrets;
};
return {
issueCredentials,
revokeCredentials,
rotateCredentials,
getSecretsPayload
};
};

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
BaseCreateSecretRotationSchema,
BaseSecretRotationSchema,
BaseUpdateSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import { SecretRotations } from "@app/lib/api-docs";
import { SecretNameSchema } from "@app/server/lib/schemas";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const Auth0ClientSecretRotationGeneratedCredentialsSchema = z
.object({
clientId: z.string(),
clientSecret: z.string()
})
.array()
.min(1)
.max(2);
const Auth0ClientSecretRotationParametersSchema = z.object({
clientId: z
.string()
.trim()
.min(1, "Client ID Required")
.describe(SecretRotations.PARAMETERS.AUTH0_CLIENT_SECRET.clientId)
});
const Auth0ClientSecretRotationSecretsMappingSchema = z.object({
clientId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AUTH0_CLIENT_SECRET.clientId),
clientSecret: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AUTH0_CLIENT_SECRET.clientSecret)
});
export const Auth0ClientSecretRotationTemplateSchema = z.object({
secretsMapping: z.object({
clientId: z.string(),
clientSecret: z.string()
})
});
export const Auth0ClientSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.Auth0ClientSecret).extend({
type: z.literal(SecretRotation.Auth0ClientSecret),
parameters: Auth0ClientSecretRotationParametersSchema,
secretsMapping: Auth0ClientSecretRotationSecretsMappingSchema
});
export const CreateAuth0ClientSecretRotationSchema = BaseCreateSecretRotationSchema(
SecretRotation.Auth0ClientSecret
).extend({
parameters: Auth0ClientSecretRotationParametersSchema,
secretsMapping: Auth0ClientSecretRotationSecretsMappingSchema
});
export const UpdateAuth0ClientSecretRotationSchema = BaseUpdateSecretRotationSchema(
SecretRotation.Auth0ClientSecret
).extend({
parameters: Auth0ClientSecretRotationParametersSchema.optional(),
secretsMapping: Auth0ClientSecretRotationSecretsMappingSchema.optional()
});
export const Auth0ClientSecretRotationListItemSchema = z.object({
name: z.literal("Auth0 Client Secret"),
connection: z.literal(AppConnection.Auth0),
type: z.literal(SecretRotation.Auth0ClientSecret),
template: Auth0ClientSecretRotationTemplateSchema
});

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
import { TAuth0Connection } from "@app/services/app-connection/auth0";
import {
Auth0ClientSecretRotationGeneratedCredentialsSchema,
Auth0ClientSecretRotationListItemSchema,
Auth0ClientSecretRotationSchema,
CreateAuth0ClientSecretRotationSchema
} from "./auth0-client-secret-rotation-schemas";
export type TAuth0ClientSecretRotation = z.infer<typeof Auth0ClientSecretRotationSchema>;
export type TAuth0ClientSecretRotationInput = z.infer<typeof CreateAuth0ClientSecretRotationSchema>;
export type TAuth0ClientSecretRotationListItem = z.infer<typeof Auth0ClientSecretRotationListItemSchema>;
export type TAuth0ClientSecretRotationWithConnection = TAuth0ClientSecretRotation & {
connection: TAuth0Connection;
};
export type TAuth0ClientSecretRotationGeneratedCredentials = z.infer<
typeof Auth0ClientSecretRotationGeneratedCredentialsSchema
>;

View File

@@ -0,0 +1,3 @@
export * from "./auth0-client-secret-rotation-constants";
export * from "./auth0-client-secret-rotation-schemas";
export * from "./auth0-client-secret-rotation-types";

View File

@@ -1,6 +1,7 @@
export enum SecretRotation {
PostgresCredentials = "postgres-credentials",
MsSqlCredentials = "mssql-credentials"
MsSqlCredentials = "mssql-credentials",
Auth0ClientSecret = "auth0-client-secret"
}
export enum SecretRotationStatus {

View File

@@ -3,6 +3,7 @@ import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
@@ -16,7 +17,8 @@ import {
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
[SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION,
[SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION
[SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION,
[SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION
};
export const listSecretRotationOptions = () => {

View File

@@ -3,10 +3,12 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
export const SECRET_ROTATION_NAME_MAP: Record<SecretRotation, string> = {
[SecretRotation.PostgresCredentials]: "PostgreSQL Credentials",
[SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials"
[SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials",
[SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret"
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql,
[SecretRotation.Auth0ClientSecret]: AppConnection.Auth0
};

View File

@@ -13,6 +13,7 @@ import {
ProjectPermissionSecretRotationActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns";
import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
calculateNextRotationAt,
@@ -41,6 +42,7 @@ import {
TRotationFactory,
TSecretRotationRotateGeneratedCredentials,
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2Raw,
TSecretRotationV2WithConnection,
TUpdateSecretRotationV2DTO
@@ -53,6 +55,7 @@ import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { QueueJobs, TQueueServiceFactory } from "@app/queue";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { ActorType } from "@app/services/auth/auth-type";
@@ -97,15 +100,21 @@ export type TSecretRotationV2ServiceFactoryDep = {
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
queueService: Pick<TQueueServiceFactory, "queuePg">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
};
export type TSecretRotationV2ServiceFactory = ReturnType<typeof secretRotationV2ServiceFactory>;
const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactory> = {
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory,
[SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory
type TRotationFactoryImplementation = TRotationFactory<
TSecretRotationV2WithConnection,
TSecretRotationV2GeneratedCredentials
>;
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
[SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
[SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation
};
export const secretRotationV2ServiceFactory = ({
@@ -125,7 +134,8 @@ export const secretRotationV2ServiceFactory = ({
secretQueueService,
snapshotService,
keyStore,
queueService
queueService,
appConnectionDAL
}: TSecretRotationV2ServiceFactoryDep) => {
const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => {
const appCfg = getConfig();
@@ -429,11 +439,15 @@ export const secretRotationV2ServiceFactory = ({
// validates permission to connect and app is valid for rotation type
const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]({
parameters: payload.parameters,
secretsMapping,
connection
} as TSecretRotationV2WithConnection);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type](
{
parameters: payload.parameters,
secretsMapping,
connection
} as TSecretRotationV2WithConnection,
appConnectionDAL,
kmsService
);
try {
const currentTime = new Date();
@@ -441,7 +455,7 @@ export const secretRotationV2ServiceFactory = ({
// callback structure to support transactional rollback when possible
const secretRotation = await rotationFactory.issueCredentials(async (newCredentials) => {
const encryptedGeneratedCredentials = await encryptSecretRotationCredentials({
generatedCredentials: [newCredentials],
generatedCredentials: [newCredentials] as TSecretRotationV2GeneratedCredentials,
projectId,
kmsService
});
@@ -740,32 +754,37 @@ export const secretRotationV2ServiceFactory = ({
message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
});
const deleteTransaction = secretRotationV2DAL.transaction(async (tx) => {
if (deleteSecrets) {
await fnSecretBulkDelete({
secretDAL: secretV2BridgeDAL,
secretQueueService,
inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({
secretKey,
type: SecretType.Shared
})),
projectId,
folderId,
actorId: actor.id, // not actually used since rotated secrets are shared
tx
});
}
const deleteTransaction = async () =>
secretRotationV2DAL.transaction(async (tx) => {
if (deleteSecrets) {
await fnSecretBulkDelete({
secretDAL: secretV2BridgeDAL,
secretQueueService,
inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({
secretKey,
type: SecretType.Shared
})),
projectId,
folderId,
actorId: actor.id, // not actually used since rotated secrets are shared
tx
});
}
return secretRotationV2DAL.deleteById(rotationId, tx);
});
return secretRotationV2DAL.deleteById(rotationId, tx);
});
if (revokeGeneratedCredentials) {
const appConnection = await decryptAppConnection(connection, kmsService);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type]({
...secretRotation,
connection: appConnection
} as TSecretRotationV2WithConnection);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type](
{
...secretRotation,
connection: appConnection
} as TSecretRotationV2WithConnection,
appConnectionDAL,
kmsService
);
const generatedCredentials = await decryptSecretRotationCredentials({
encryptedGeneratedCredentials,
@@ -773,9 +792,9 @@ export const secretRotationV2ServiceFactory = ({
kmsService
});
await rotationFactory.revokeCredentials(generatedCredentials, async () => deleteTransaction);
await rotationFactory.revokeCredentials(generatedCredentials, deleteTransaction);
} else {
await deleteTransaction;
await deleteTransaction();
}
if (deleteSecrets) {
@@ -840,10 +859,14 @@ export const secretRotationV2ServiceFactory = ({
const inactiveCredentials = generatedCredentials[inactiveIndex];
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]({
...secretRotation,
connection: appConnection
} as TSecretRotationV2WithConnection);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation](
{
...secretRotation,
connection: appConnection
} as TSecretRotationV2WithConnection,
appConnectionDAL,
kmsService
);
const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => {
const updatedCredentials = [...generatedCredentials];
@@ -851,7 +874,7 @@ export const secretRotationV2ServiceFactory = ({
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
projectId,
generatedCredentials: updatedCredentials,
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
kmsService
});

View File

@@ -1,8 +1,17 @@
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
import { OrderByDirection } from "@app/lib/types";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import {
TAuth0ClientSecretRotation,
TAuth0ClientSecretRotationGeneratedCredentials,
TAuth0ClientSecretRotationInput,
TAuth0ClientSecretRotationListItem,
TAuth0ClientSecretRotationWithConnection
} from "./auth0-client-secret";
import {
TMsSqlCredentialsRotation,
TMsSqlCredentialsRotationInput,
@@ -18,17 +27,26 @@ import {
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation } from "./secret-rotation-v2-enums";
export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation;
export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation;
export type TSecretRotationV2WithConnection =
| TPostgresCredentialsRotationWithConnection
| TMsSqlCredentialsRotationWithConnection;
| TMsSqlCredentialsRotationWithConnection
| TAuth0ClientSecretRotationWithConnection;
export type TSecretRotationV2GeneratedCredentials = TSqlCredentialsRotationGeneratedCredentials;
export type TSecretRotationV2GeneratedCredentials =
| TSqlCredentialsRotationGeneratedCredentials
| TAuth0ClientSecretRotationGeneratedCredentials;
export type TSecretRotationV2Input = TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput;
export type TSecretRotationV2Input =
| TPostgresCredentialsRotationInput
| TMsSqlCredentialsRotationInput
| TAuth0ClientSecretRotationInput;
export type TSecretRotationV2ListItem = TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem;
export type TSecretRotationV2ListItem =
| TPostgresCredentialsRotationListItem
| TMsSqlCredentialsRotationListItem
| TAuth0ClientSecretRotationListItem;
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
@@ -129,27 +147,34 @@ export type TSecretRotationSendNotificationJobPayload = {
// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the
// third party credential changes (when supported), preventing credentials getting out of sync
export type TRotationFactoryIssueCredentials = (
callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise<TSecretRotationV2Raw>
export type TRotationFactoryIssueCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRevokeCredentials = (
generatedCredentials: TSecretRotationV2GeneratedCredentials,
export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
generatedCredentials: T,
callback: () => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRotateCredentials = (
credentialsToRevoke: TSecretRotationV2GeneratedCredentials[number] | undefined,
callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise<TSecretRotationV2Raw>
export type TRotationFactoryRotateCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
credentialsToRevoke: T[number] | undefined,
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryGetSecretsPayload = (
generatedCredentials: TSecretRotationV2GeneratedCredentials[number]
export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2GeneratedCredentials> = (
generatedCredentials: T[number]
) => { key: string; value: string }[];
export type TRotationFactory = (secretRotation: TSecretRotationV2WithConnection) => {
issueCredentials: TRotationFactoryIssueCredentials;
revokeCredentials: TRotationFactoryRevokeCredentials;
rotateCredentials: TRotationFactoryRotateCredentials;
getSecretsPayload: TRotationFactoryGetSecretsPayload;
export type TRotationFactory<
T extends TSecretRotationV2WithConnection,
C extends TSecretRotationV2GeneratedCredentials
> = (
secretRotation: T,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
issueCredentials: TRotationFactoryIssueCredentials<C>;
revokeCredentials: TRotationFactoryRevokeCredentials<C>;
rotateCredentials: TRotationFactoryRotateCredentials<C>;
getSecretsPayload: TRotationFactoryGetSecretsPayload<C>;
};

View File

@@ -1,9 +1,11 @@
import { z } from "zod";
import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret";
import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
export const SecretRotationV2Schema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema
MsSqlCredentialsRotationSchema,
Auth0ClientSecretRotationSchema
]);

View File

@@ -1,6 +1,5 @@
import { randomInt } from "crypto";
import {
TRotationFactory,
TRotationFactoryGetSecretsPayload,
TRotationFactoryIssueCredentials,
TRotationFactoryRevokeCredentials,
@@ -8,94 +7,12 @@ import {
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql";
import { generatePassword } from "../utils";
import {
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationWithConnection
} from "./sql-credentials-rotation-types";
const DEFAULT_PASSWORD_REQUIREMENTS = {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
const generatePassword = () => {
try {
const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: allowedSymbols || "-_.~!*"
};
const parts: string[] = [];
if (required.lowercase > 0) {
parts.push(
...Array(required.lowercase)
.fill(0)
.map(() => chars.lowercase[randomInt(chars.lowercase.length)])
);
}
if (required.uppercase > 0) {
parts.push(
...Array(required.uppercase)
.fill(0)
.map(() => chars.uppercase[randomInt(chars.uppercase.length)])
);
}
if (required.digits > 0) {
parts.push(
...Array(required.digits)
.fill(0)
.map(() => chars.digits[randomInt(chars.digits.length)])
);
}
if (required.symbols > 0) {
parts.push(
...Array(required.symbols)
.fill(0)
.map(() => chars.symbols[randomInt(chars.symbols.length)])
);
}
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
const remainingLength = Math.max(length - requiredTotal, 0);
const allowedChars = Object.entries(chars)
.filter(([key]) => required[key as keyof typeof required] > 0)
.map(([, value]) => value)
.join("");
parts.push(
...Array(remainingLength)
.fill(0)
.map(() => allowedChars[randomInt(allowedChars.length)])
);
// shuffle the array to mix up the characters
for (let i = parts.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join("");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to generate password: ${message}`);
}
};
const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGeneratedCredentials) => {
const error = e as Error;
@@ -110,7 +27,10 @@ const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGenerat
return redactedMessage;
};
export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRotationWithConnection) => {
export const sqlCredentialsRotationFactory: TRotationFactory<
TSqlCredentialsRotationWithConnection,
TSqlCredentialsRotationGeneratedCredentials
> = (secretRotation) => {
const {
connection,
parameters: { username1, username2 },
@@ -118,7 +38,7 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
secretsMapping
} = secretRotation;
const validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => {
const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => {
const client = await getSqlConnectionClient({
...connection,
credentials: {
@@ -136,7 +56,9 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
}
};
const issueCredentials: TRotationFactoryIssueCredentials = async (callback) => {
const issueCredentials: TRotationFactoryIssueCredentials<TSqlCredentialsRotationGeneratedCredentials> = async (
callback
) => {
const client = await getSqlConnectionClient(connection);
// For SQL, since we get existing users, we change both their passwords
@@ -159,13 +81,16 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
}
for await (const credentials of credentialsSet) {
await validateCredentials(credentials);
await $validateCredentials(credentials);
}
return callback(credentialsSet[0]);
};
const revokeCredentials: TRotationFactoryRevokeCredentials = async (credentialsToRevoke, callback) => {
const revokeCredentials: TRotationFactoryRevokeCredentials<TSqlCredentialsRotationGeneratedCredentials> = async (
credentialsToRevoke,
callback
) => {
const client = await getSqlConnectionClient(connection);
const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() }));
@@ -186,7 +111,10 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
return callback();
};
const rotateCredentials: TRotationFactoryRotateCredentials = async (_, callback) => {
const rotateCredentials: TRotationFactoryRotateCredentials<TSqlCredentialsRotationGeneratedCredentials> = async (
_,
callback
) => {
const client = await getSqlConnectionClient(connection);
// generate new password for the next active user
@@ -200,12 +128,14 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
await client.destroy();
}
await validateCredentials(credentials);
await $validateCredentials(credentials);
return callback(credentials);
};
const getSecretsPayload: TRotationFactoryGetSecretsPayload = (generatedCredentials) => {
const getSecretsPayload: TRotationFactoryGetSecretsPayload<TSqlCredentialsRotationGeneratedCredentials> = (
generatedCredentials
) => {
const { username, password } = secretsMapping;
const secrets = [
@@ -226,7 +156,6 @@ export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRot
issueCredentials,
revokeCredentials,
rotateCredentials,
getSecretsPayload,
validateCredentials
getSecretsPayload
};
};

View File

@@ -0,0 +1,84 @@
import { randomInt } from "crypto";
const DEFAULT_PASSWORD_REQUIREMENTS = {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
export const generatePassword = () => {
try {
const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: allowedSymbols || "-_.~!*"
};
const parts: string[] = [];
if (required.lowercase > 0) {
parts.push(
...Array(required.lowercase)
.fill(0)
.map(() => chars.lowercase[randomInt(chars.lowercase.length)])
);
}
if (required.uppercase > 0) {
parts.push(
...Array(required.uppercase)
.fill(0)
.map(() => chars.uppercase[randomInt(chars.uppercase.length)])
);
}
if (required.digits > 0) {
parts.push(
...Array(required.digits)
.fill(0)
.map(() => chars.digits[randomInt(chars.digits.length)])
);
}
if (required.symbols > 0) {
parts.push(
...Array(required.symbols)
.fill(0)
.map(() => chars.symbols[randomInt(chars.symbols.length)])
);
}
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
const remainingLength = Math.max(length - requiredTotal, 0);
const allowedChars = Object.entries(chars)
.filter(([key]) => required[key as keyof typeof required] > 0)
.map(([, value]) => value)
.join("");
parts.push(
...Array(remainingLength)
.fill(0)
.map(() => allowedChars[randomInt(allowedChars.length)])
);
// shuffle the array to mix up the characters
for (let i = parts.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join("");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to generate password: ${message}`);
}
};

View File

@@ -127,6 +127,13 @@ export const secretRotationServiceFactory = ({
});
if (selectedSecrets.length !== Object.values(outputs).length)
throw new NotFoundError({ message: `Secrets not found in folder with ID '${folder.id}'` });
const rotatedSecrets = selectedSecrets.filter(({ isRotatedSecret }) => isRotatedSecret);
if (rotatedSecrets.length)
throw new BadRequestError({
message: `Selected secrets are already used for rotation: ${rotatedSecrets
.map((secret) => secret.key)
.join(", ")}`
});
} else {
const selectedSecrets = await secretDAL.find({
folderId: folder.id,

View File

@@ -18,7 +18,8 @@ export const rotationTemplates: TSecretRotationProviderTemplate[] = [
title: "PostgreSQL",
image: "postgres.png",
description: "Rotate PostgreSQL/CockroachDB user credentials",
template: POSTGRES_TEMPLATE
template: POSTGRES_TEMPLATE,
isDeprecated: true
},
{
name: "mysql",
@@ -32,7 +33,8 @@ export const rotationTemplates: TSecretRotationProviderTemplate[] = [
title: "Microsoft SQL Server",
image: "mssqlserver.png",
description: "Rotate Microsoft SQL server user credentials",
template: MSSQL_TEMPLATE
template: MSSQL_TEMPLATE,
isDeprecated: true
},
{
name: "aws-iam",

View File

@@ -50,6 +50,7 @@ export type TSecretRotationProviderTemplate = {
image?: string;
description?: string;
template: THttpProviderTemplate | TDbProviderTemplate | TAwsProviderTemplate;
isDeprecated?: boolean;
};
export type THttpProviderTemplate = {

View File

@@ -478,7 +478,8 @@ export const PROJECTS = {
name: "The new name of the project.",
projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the organization)"
slug: "An optional slug for the project. (must be unique within the organization)",
hasDeleteProtection: "Enable or disable delete protection for the project."
},
GET_KEY: {
workspaceId: "The ID of the project to get the key from."
@@ -1782,6 +1783,12 @@ export const AppConnections = {
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.`
}),
CREDENTIALS: {
AUTH0_CONNECTION: {
domain: "The domain of the Auth0 instance to connect to.",
clientId: "Your Auth0 application's Client ID.",
clientSecret: "Your Auth0 application's Client Secret.",
audience: "The unique identifier of the target API you want to access."
},
SQL_CONNECTION: {
host: "The hostname of the database server.",
port: "The port number of the database.",
@@ -1801,6 +1808,10 @@ export const AppConnections = {
CAMUNDA: {
clientId: "The client ID used to authenticate with Camunda.",
clientSecret: "The client secret used to authenticate with Camunda."
},
WINDMILL: {
instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).",
accessToken: "The access token to use to connect with Windmill."
}
}
};
@@ -1936,6 +1947,10 @@ export const SecretSyncs = {
env: "The ID of the Vercel environment to sync secrets to.",
branch: "The branch to sync preview secrets to.",
teamId: "The ID of the Vercel team to sync secrets to."
},
WINDMILL: {
workspace: "The Windmill workspace to sync secrets to.",
path: "The Windmill workspace path to sync secrets to."
}
}
};
@@ -1997,12 +2012,19 @@ export const SecretRotations = {
"The username of the first login to rotate passwords for. This user must already exists in your database.",
username2:
"The username of the second login to rotate passwords for. This user must already exists in your database."
},
AUTH0_CLIENT_SECRET: {
clientId: "The client ID of the Auth0 Application to rotate the client secret for."
}
},
SECRETS_MAPPING: {
SQL_CREDENTIALS: {
username: "The name of the secret that the active username will be mapped to.",
password: "The name of the secret that the generated password will be mapped to."
},
AUTH0_CLIENT_SECRET: {
clientId: "The name of the secret that the client ID will be mapped to.",
clientSecret: "The name of the secret that the rotated client secret will be mapped to."
}
}
};

View File

@@ -24,5 +24,6 @@ export enum PermissionConditionOperators {
$IN = "$in",
$EQ = "$eq",
$NEQ = "$ne",
$GLOB = "$glob"
$GLOB = "$glob",
$ELEMENTMATCH = "$elemMatch"
}

View File

@@ -197,6 +197,7 @@ const envSchema = z
/* ----------------------------------------------------------------------------- */
/* App Connections ----------------------------------------------------------------------------- */
ALLOW_INTERNAL_IP_CONNECTIONS: zodStrBool.default("false"),
// aws
INF_APP_CONNECTION_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()),

View File

@@ -118,7 +118,12 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi
}
};
const $signRsaDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => {
const $signRsaDigest = async (
digest: Buffer,
privateKey: Buffer,
hashAlgorithm: SupportedHashAlgorithm,
signingAlgorithm: SigningAlgorithm
) => {
const tempDir = await createTemporaryDirectory("kms-rsa-sign");
const digestPath = path.join(tempDir, "digest.bin");
const sigPath = path.join(tempDir, "signature.bin");
@@ -164,12 +169,22 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi
}
return signature;
} catch (err) {
logger.error(err, "KMS: Failed to sign RSA digest");
throw new BadRequestError({
message: `Failed to sign RSA digest with ${signingAlgorithm} due to signing error. Ensure that your digest is hashed with ${hashAlgorithm.toUpperCase()}.`
});
} finally {
await cleanTemporaryDirectory(tempDir);
}
};
const $signEccDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => {
const $signEccDigest = async (
digest: Buffer,
privateKey: Buffer,
hashAlgorithm: SupportedHashAlgorithm,
signingAlgorithm: SigningAlgorithm
) => {
const tempDir = await createTemporaryDirectory("ecc-sign");
const digestPath = path.join(tempDir, "digest.bin");
const keyPath = path.join(tempDir, "key.pem");
@@ -216,6 +231,11 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi
}
return signature;
} catch (err) {
logger.error(err, "KMS: Failed to sign ECC digest");
throw new BadRequestError({
message: `Failed to sign ECC digest with ${signingAlgorithm} due to signing error. Ensure that your digest is hashed with ${hashAlgorithm.toUpperCase()}.`
});
} finally {
await cleanTemporaryDirectory(tempDir);
}
@@ -329,7 +349,12 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi
const signDigestFunctionsMap: Record<
AsymmetricKeyAlgorithm,
(data: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => Promise<Buffer>
(
data: Buffer,
privateKey: Buffer,
hashAlgorithm: SupportedHashAlgorithm,
signingAlgorithm: SigningAlgorithm
) => Promise<Buffer>
> = {
[AsymmetricKeyAlgorithm.ECC_NIST_P256]: $signEccDigest,
[AsymmetricKeyAlgorithm.RSA_4096]: $signRsaDigest
@@ -360,7 +385,7 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi
});
}
const signature = await signFunction(data, privateKey, hashAlgorithm);
const signature = await signFunction(data, privateKey, hashAlgorithm, signingAlgorithm);
return signature;
}

View File

@@ -2,10 +2,16 @@ import dns from "node:dns/promises";
import { isIPv4 } from "net";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "../errors";
import { isPrivateIp } from "../ip/ipRange";
export const blockLocalAndPrivateIpAddresses = async (url: string) => {
const appCfg = getConfig();
if (appCfg.isDevelopmentMode) return;
const validUrl = new URL(url);
const inputHostIps: string[] = [];
if (isIPv4(validUrl.host)) {
@@ -18,7 +24,8 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => {
inputHostIps.push(...resolvedIps);
}
const isInternalIp = inputHostIps.some((el) => isPrivateIp(el));
if (isInternalIp) throw new BadRequestError({ message: "Local IPs not allowed as URL" });
if (isInternalIp && !appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)
throw new BadRequestError({ message: "Local IPs not allowed as URL" });
};
type FQDNOptions = {

View File

@@ -1392,7 +1392,8 @@ export const registerRoutes = async (
permissionService,
licenseService,
kmsService,
projectGatewayDAL
projectGatewayDAL,
resourceMetadataDAL
});
const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({
@@ -1550,7 +1551,8 @@ export const registerRoutes = async (
resourceMetadataDAL,
snapshotService,
secretQueueService,
queueService
queueService,
appConnectionDAL
});
await secretRotationV2QueueServiceFactory({

View File

@@ -11,6 +11,7 @@ import {
UsersSchema
} from "@app/db/schemas";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
import { UnpackedPermissionSchema } from "./sanitizedSchema/permission";
@@ -232,7 +233,11 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
inputIV: true,
inputTag: true,
algorithm: true
});
}).merge(
z.object({
metadata: ResourceMetadataSchema.optional()
})
);
export const SanitizedAuditLogStreamSchema = z.object({
id: z.string(),
@@ -255,7 +260,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
upgradeStatus: true,
pitVersionLimit: true,
kmsCertificateKeyId: true,
auditLogsRetentionDays: true
auditLogsRetentionDays: true,
hasDeleteProtection: true
});
export const SanitizedTagSchema = SecretTagsSchema.pick({

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { Auth0ConnectionListItemSchema, SanitizedAuth0ConnectionSchema } from "@app/services/app-connection/auth0";
import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws";
import {
AzureAppConfigurationConnectionListItemSchema,
@@ -36,6 +37,10 @@ import {
TerraformCloudConnectionListItemSchema
} from "@app/services/app-connection/terraform-cloud";
import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel";
import {
SanitizedWindmillConnectionSchema,
WindmillConnectionListItemSchema
} from "@app/services/app-connection/windmill";
import { AuthMode } from "@app/services/auth/auth-type";
// can't use discriminated due to multiple schemas for certain apps
@@ -51,7 +56,9 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedVercelConnectionSchema.options,
...SanitizedPostgresConnectionSchema.options,
...SanitizedMsSqlConnectionSchema.options,
...SanitizedCamundaConnectionSchema.options
...SanitizedCamundaConnectionSchema.options,
...SanitizedWindmillConnectionSchema.options,
...SanitizedAuth0ConnectionSchema.options
]);
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
@@ -66,7 +73,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
VercelConnectionListItemSchema,
PostgresConnectionListItemSchema,
MsSqlConnectionListItemSchema,
CamundaConnectionListItemSchema
CamundaConnectionListItemSchema,
WindmillConnectionListItemSchema,
Auth0ConnectionListItemSchema
]);
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateAuth0ConnectionSchema,
SanitizedAuth0ConnectionSchema,
UpdateAuth0ConnectionSchema
} from "@app/services/app-connection/auth0";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerAuth0ConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Auth0,
server,
sanitizedResponseSchema: SanitizedAuth0ConnectionSchema,
createSchema: CreateAuth0ConnectionSchema,
updateSchema: UpdateAuth0ConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/clients`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z.object({
clients: z.object({ name: z.string(), id: z.string() }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const clients = await server.services.appConnection.auth0.listClients(connectionId, req.permission);
return { clients };
}
});
};

View File

@@ -1,3 +1,4 @@
import { registerAuth0ConnectionRouter } from "@app/server/routes/v1/app-connection-routers/auth0-connection-router";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { registerAwsConnectionRouter } from "./aws-connection-router";
@@ -12,6 +13,7 @@ import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router";
import { registerVercelConnectionRouter } from "./vercel-connection-router";
import { registerWindmillConnectionRouter } from "./windmill-connection-router";
export * from "./app-connection-router";
@@ -28,5 +30,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.Vercel]: registerVercelConnectionRouter,
[AppConnection.Postgres]: registerPostgresConnectionRouter,
[AppConnection.MsSql]: registerMsSqlConnectionRouter,
[AppConnection.Camunda]: registerCamundaConnectionRouter
[AppConnection.Camunda]: registerCamundaConnectionRouter,
[AppConnection.Windmill]: registerWindmillConnectionRouter,
[AppConnection.Auth0]: registerAuth0ConnectionRouter
};

View File

@@ -0,0 +1,53 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateWindmillConnectionSchema,
SanitizedWindmillConnectionSchema,
UpdateWindmillConnectionSchema
} from "@app/services/app-connection/windmill";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerWindmillConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Windmill,
server,
sanitizedResponseSchema: SanitizedWindmillConnectionSchema,
createSchema: CreateWindmillConnectionSchema,
updateSchema: UpdateWindmillConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/workspaces`,
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 workspaces = await server.services.appConnection.windmill.listWorkspaces(connectionId, req.permission);
return workspaces;
}
});
};

View File

@@ -1,13 +1,9 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ForbiddenError } from "@casl/ability";
import { z } from "zod";
import { ActionProjectType, SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas";
import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import {
ProjectPermissionDynamicSecretActions,
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
@@ -142,6 +138,34 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
})
.array()
.optional(),
importedByEnvs: z
.object({
environment: z.string(),
importedBy: z
.object({
environment: z.object({
name: z.string(),
slug: z.string()
}),
folders: z
.object({
name: z.string(),
isImported: z.boolean(),
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
})
.array()
.optional()
})
.array()
})
.array()
.optional()
})
.array()
.optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
@@ -289,24 +313,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalCount: totalFolderCount ?? 0
};
const { permission } = await server.services.permission.getProjectPermission({
actor: req.permission.type,
actorId: req.permission.id,
projectId,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
actionProjectType: ActionProjectType.SecretManager
});
const allowedDynamicSecretEnvironments = // filter envs user has access to
environments.filter((environment) =>
permission.can(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
)
);
if (includeDynamicSecrets && allowedDynamicSecretEnvironments.length) {
if (includeDynamicSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
actor: req.permission.type,
@@ -315,7 +322,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
actorOrgId: req.permission.orgId,
projectId,
search,
environmentSlugs: allowedDynamicSecretEnvironments,
environmentSlugs: environments,
path: secretPath,
isInternal: true
});
@@ -330,7 +337,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
search,
orderBy,
orderDirection,
environmentSlugs: allowedDynamicSecretEnvironments,
environmentSlugs: environments,
path: secretPath,
limit: remainingLimit,
offset: adjustedOffset,
@@ -471,6 +478,28 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
const importedByEnvs = [];
for await (const environment of environments) {
const importedBy = await server.services.secretImport.getFolderIsImportedBy({
path: secretPath,
environment,
projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secrets: secrets?.filter((s) => s.environment === environment)
});
if (importedBy) {
importedByEnvs.push({
environment,
importedBy
});
}
}
return {
folders,
dynamicSecrets,
@@ -482,6 +511,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalImportCount,
totalSecretCount,
totalSecretRotationCount,
importedByEnvs,
totalCount:
(totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) +
@@ -575,6 +605,28 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
importedBy: z
.object({
environment: z.object({
name: z.string(),
slug: z.string()
}),
folders: z
.object({
name: z.string(),
isImported: z.boolean(),
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
})
.array()
.optional()
})
.array()
})
.array()
.optional(),
totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
@@ -835,6 +887,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
const importedBy = await server.services.secretImport.getFolderIsImportedBy({
path: secretPath,
environment,
projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secrets
});
if (secrets?.length || secretRotations?.length) {
const secretCount =
(secrets?.length ?? 0) +
@@ -880,6 +943,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalDynamicSecretCount,
totalSecretCount,
totalSecretRotationCount,
importedBy,
totalCount:
(totalImportCount ?? 0) +
(totalFolderCount ?? 0) +

View File

@@ -31,6 +31,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
.object({
name: z.string(),
slug: z.string(),
syncSlug: z.string().optional(),
clientSlug: z.string().optional(),
image: z.string(),
isAvailable: z.boolean().optional(),

View File

@@ -31,7 +31,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
200: z.object({
organizations: sanitizedOrganizationSchema
.extend({
orgAuthMethod: z.string()
orgAuthMethod: z.string(),
userRole: z.string()
})
.array()
})
@@ -259,7 +260,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
enforceMfa: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional(),
allowSecretSharingOutsideOrganization: z.boolean().optional()
allowSecretSharingOutsideOrganization: z.boolean().optional(),
bypassOrgAuthEnabled: z.boolean().optional()
}),
response: {
200: z.object({

View File

@@ -312,6 +312,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
.optional()
.describe(PROJECTS.UPDATE.projectDescription),
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization),
hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection),
slug: z
.string()
.trim()
@@ -340,6 +341,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
name: req.body.name,
description: req.body.description,
autoCapitalization: req.body.autoCapitalization,
hasDeleteProtection: req.body.hasDeleteProtection,
slug: req.body.slug
},
actorAuthMethod: req.permission.authMethod,
@@ -390,6 +392,43 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/:workspaceId/delete-protection",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
body: z.object({
hasDeleteProtection: z.boolean()
}),
response: {
200: z.object({
message: z.string(),
workspace: SanitizedProjectSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const workspace = await server.services.project.toggleDeleteProtection({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
hasDeleteProtection: req.body.hasDeleteProtection
});
return {
message: "Successfully changed workspace settings",
workspace
};
}
});
server.route({
method: "PUT",
url: "/:workspaceSlug/version-limit",

View File

@@ -39,17 +39,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if path is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.path),
.describe(FOLDERS.CREATE.path)
.optional(),
// backward compatiability with cli
directory: z
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if directory is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.directory),
.describe(FOLDERS.CREATE.directory)
.optional(),
description: z.string().optional().nullable().describe(FOLDERS.CREATE.description)
}),
response: {
@@ -60,7 +62,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const path = req.body.path || req.body.directory;
const path = req.body.path || req.body.directory || "/";
const folder = await server.services.folder.createFolder({
actorId: req.permission.id,
actor: req.permission.type,
@@ -120,17 +122,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if path is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.path),
.describe(FOLDERS.UPDATE.path)
.optional(),
// backward compatiability with cli
directory: z
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if directory is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.directory),
.describe(FOLDERS.UPDATE.directory)
.optional(),
description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description)
}),
response: {
@@ -141,7 +145,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const path = req.body.path || req.body.directory;
const path = req.body.path || req.body.directory || "/";
const { folder, old } = await server.services.folder.updateFolder({
actorId: req.permission.id,
actor: req.permission.type,
@@ -271,17 +275,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if path is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.DELETE.path),
.describe(FOLDERS.DELETE.path)
.optional(),
// keep this here as cli need directory
directory: z
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if directory is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.DELETE.directory)
.optional()
}),
response: {
200: z.object({
@@ -291,7 +297,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const path = req.body.path || req.body.directory;
const path = req.body.path || req.body.directory || "/";
const folder = await server.services.folder.deleteFolder({
actorId: req.permission.id,
actor: req.permission.type,
@@ -339,18 +345,18 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
path: z
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if path is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.LIST.path),
.describe(FOLDERS.LIST.path)
.optional(),
// backward compatiability with cli
directory: z
.string()
.trim()
.default("/")
.transform(prefixWithSlash)
.transform(prefixWithSlash) // Transformations get skipped if directory is undefined
.transform(removeTrailingSlash)
.describe(FOLDERS.LIST.directory),
.describe(FOLDERS.LIST.directory)
.optional(),
recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive)
}),
response: {
@@ -363,7 +369,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const path = req.query.path || req.query.directory;
const path = req.query.path || req.query.directory || "/";
const folders = await server.services.folder.getFolders({
actorId: req.permission.id,
actor: req.permission.type,

View File

@@ -11,6 +11,7 @@ import { registerGitHubSyncRouter } from "./github-sync-router";
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router";
import { registerVercelSyncRouter } from "./vercel-sync-router";
import { registerWindmillSyncRouter } from "./windmill-sync-router";
export * from "./secret-sync-router";
@@ -25,5 +26,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
[SecretSync.Humanitec]: registerHumanitecSyncRouter,
[SecretSync.TerraformCloud]: registerTerraformCloudSyncRouter,
[SecretSync.Camunda]: registerCamundaSyncRouter,
[SecretSync.Vercel]: registerVercelSyncRouter
[SecretSync.Vercel]: registerVercelSyncRouter,
[SecretSync.Windmill]: registerWindmillSyncRouter
};

View File

@@ -25,6 +25,7 @@ import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
import { TerraformCloudSyncListItemSchema, TerraformCloudSyncSchema } from "@app/services/secret-sync/terraform-cloud";
import { VercelSyncListItemSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel";
import { WindmillSyncListItemSchema, WindmillSyncSchema } from "@app/services/secret-sync/windmill";
const SecretSyncSchema = z.discriminatedUnion("destination", [
AwsParameterStoreSyncSchema,
@@ -37,7 +38,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
HumanitecSyncSchema,
TerraformCloudSyncSchema,
CamundaSyncSchema,
VercelSyncSchema
VercelSyncSchema,
WindmillSyncSchema
]);
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
@@ -51,7 +53,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
HumanitecSyncListItemSchema,
TerraformCloudSyncListItemSchema,
CamundaSyncListItemSchema,
VercelSyncListItemSchema
VercelSyncListItemSchema,
WindmillSyncListItemSchema
]);
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {

View File

@@ -0,0 +1,17 @@
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
CreateWindmillSyncSchema,
UpdateWindmillSyncSchema,
WindmillSyncSchema
} from "@app/services/secret-sync/windmill";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerWindmillSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Windmill,
server,
responseSchema: WindmillSyncSchema,
createSchema: CreateWindmillSyncSchema,
updateSchema: UpdateWindmillSyncSchema
});

View File

@@ -303,7 +303,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
body: z.object({
name: z.string().trim().optional().describe(PROJECTS.UPDATE.name),
description: z.string().trim().optional().describe(PROJECTS.UPDATE.projectDescription),
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization)
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization),
hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection)
}),
response: {
200: SanitizedProjectSchema
@@ -321,7 +322,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
update: {
name: req.body.name,
description: req.body.description,
autoCapitalization: req.body.autoCapitalization
autoCapitalization: req.body.autoCapitalization,
hasDeleteProtection: req.body.hasDeleteProtection
},
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,

View File

@@ -10,7 +10,9 @@ export enum AppConnection {
Vercel = "vercel",
Postgres = "postgres",
MsSql = "mssql",
Camunda = "camunda"
Camunda = "camunda",
Windmill = "windmill",
Auth0 = "auth0"
}
export enum AWSRegion {

View File

@@ -16,6 +16,7 @@ import {
TAppConnectionCredentialsValidator,
TAppConnectionTransitionCredentialsToPlatform
} from "./app-connection-types";
import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0";
import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws";
import {
AzureAppConfigurationConnectionMethod,
@@ -49,6 +50,11 @@ import {
} from "./terraform-cloud";
import { VercelConnectionMethod } from "./vercel";
import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns";
import {
getWindmillConnectionListItem,
validateWindmillConnectionCredentials,
WindmillConnectionMethod
} from "./windmill";
export const listAppConnectionOptions = () => {
return [
@@ -63,7 +69,9 @@ export const listAppConnectionOptions = () => {
getVercelConnectionListItem(),
getPostgresConnectionListItem(),
getMsSqlConnectionListItem(),
getCamundaConnectionListItem()
getCamundaConnectionListItem(),
getWindmillConnectionListItem(),
getAuth0ConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -109,25 +117,29 @@ export const decryptAppConnectionCredentials = async ({
return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"];
};
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureAppConfiguration]:
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator
};
export const validateAppConnectionCredentials = async (
appConnection: TAppConnectionConfig
): Promise<TAppConnection["credentials"]> => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
): Promise<TAppConnection["credentials"]> => {
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureAppConfiguration]:
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator
};
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
};
export const getAppConnectionMethodName = (method: TAppConnection["method"]) => {
switch (method) {
@@ -154,6 +166,10 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return "Username & Password";
case WindmillConnectionMethod.AccessToken:
return "Access Token";
case Auth0ConnectionMethod.ClientCredentials:
return "Client Credentials";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -196,5 +212,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported,
[AppConnection.Camunda]: platformManagedCredentialsNotSupported,
[AppConnection.Vercel]: platformManagedCredentialsNotSupported
[AppConnection.Vercel]: platformManagedCredentialsNotSupported,
[AppConnection.Windmill]: platformManagedCredentialsNotSupported,
[AppConnection.Auth0]: platformManagedCredentialsNotSupported
};

View File

@@ -12,5 +12,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Vercel]: "Vercel",
[AppConnection.Postgres]: "PostgreSQL",
[AppConnection.MsSql]: "Microsoft SQL Server",
[AppConnection.Camunda]: "Camunda"
[AppConnection.Camunda]: "Camunda",
[AppConnection.Windmill]: "Windmill",
[AppConnection.Auth0]: "Auth0"
};

View File

@@ -14,6 +14,7 @@ import {
TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM,
validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns";
import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
@@ -27,6 +28,7 @@ import {
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentialsSchema
} from "./app-connection-types";
import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
@@ -47,6 +49,8 @@ import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-c
import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service";
import { ValidateVercelConnectionCredentialsSchema } from "./vercel";
import { vercelConnectionService } from "./vercel/vercel-connection-service";
import { ValidateWindmillConnectionCredentialsSchema } from "./windmill";
import { windmillConnectionService } from "./windmill/windmill-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@@ -68,7 +72,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Vercel]: ValidateVercelConnectionCredentialsSchema,
[AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema,
[AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema,
[AppConnection.Camunda]: ValidateCamundaConnectionCredentialsSchema
[AppConnection.Camunda]: ValidateCamundaConnectionCredentialsSchema,
[AppConnection.Windmill]: ValidateWindmillConnectionCredentialsSchema,
[AppConnection.Auth0]: ValidateAuth0ConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -442,6 +448,8 @@ export const appConnectionServiceFactory = ({
humanitec: humanitecConnectionService(connectAppConnectionById),
terraformCloud: terraformCloudConnectionService(connectAppConnectionById),
camunda: camundaConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
vercel: vercelConnectionService(connectAppConnectionById)
vercel: vercelConnectionService(connectAppConnectionById),
windmill: windmillConnectionService(connectAppConnectionById),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService)
};
};

View File

@@ -3,6 +3,12 @@ import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sq
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { AWSRegion } from "./app-connection-enums";
import {
TAuth0Connection,
TAuth0ConnectionConfig,
TAuth0ConnectionInput,
TValidateAuth0ConnectionCredentialsSchema
} from "./auth0";
import {
TAwsConnection,
TAwsConnectionConfig,
@@ -69,6 +75,12 @@ import {
TVercelConnectionConfig,
TVercelConnectionInput
} from "./vercel";
import {
TValidateWindmillConnectionCredentialsSchema,
TWindmillConnection,
TWindmillConnectionConfig,
TWindmillConnectionInput
} from "./windmill";
export type TAppConnection = { id: string } & (
| TAwsConnection
@@ -83,6 +95,8 @@ export type TAppConnection = { id: string } & (
| TPostgresConnection
| TMsSqlConnection
| TCamundaConnection
| TWindmillConnection
| TAuth0Connection
);
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
@@ -102,6 +116,8 @@ export type TAppConnectionInput = { id: string } & (
| TPostgresConnectionInput
| TMsSqlConnectionInput
| TCamundaConnectionInput
| TWindmillConnectionInput
| TAuth0ConnectionInput
);
export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput;
@@ -126,7 +142,9 @@ export type TAppConnectionConfig =
| TTerraformCloudConnectionConfig
| TVercelConnectionConfig
| TSqlConnectionConfig
| TCamundaConnectionConfig;
| TCamundaConnectionConfig
| TWindmillConnectionConfig
| TAuth0ConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
| TValidateAwsConnectionCredentialsSchema
@@ -140,7 +158,9 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateMsSqlConnectionCredentialsSchema
| TValidateCamundaConnectionCredentialsSchema
| TValidateTerraformCloudConnectionCredentialsSchema
| TValidateVercelConnectionCredentialsSchema;
| TValidateVercelConnectionCredentialsSchema
| TValidateWindmillConnectionCredentialsSchema
| TValidateAuth0ConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
connectionId: string;

View File

@@ -0,0 +1,3 @@
export enum Auth0ConnectionMethod {
ClientCredentials = "client-credentials"
}

View File

@@ -0,0 +1,97 @@
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { Auth0ConnectionMethod } from "./auth0-connection-enums";
import { TAuth0AccessTokenResponse, TAuth0Connection, TAuth0ConnectionConfig } from "./auth0-connection-types";
export const getAuth0ConnectionListItem = () => {
return {
name: "Auth0" as const,
app: AppConnection.Auth0 as const,
methods: Object.values(Auth0ConnectionMethod) as [Auth0ConnectionMethod.ClientCredentials]
};
};
const authorizeAuth0Connection = async ({
clientId,
clientSecret,
domain,
audience
}: TAuth0ConnectionConfig["credentials"]) => {
const instanceUrl = domain.startsWith("http") ? domain : `https://${domain}`;
await blockLocalAndPrivateIpAddresses(instanceUrl);
const { data } = await request.request<TAuth0AccessTokenResponse>({
method: "POST",
url: `${removeTrailingSlash(instanceUrl)}/oauth/token`,
headers: { "content-type": "application/x-www-form-urlencoded" },
data: new URLSearchParams({
grant_type: "client_credentials", // this will need to be resolved if we support methods other than client credentials
client_id: clientId,
client_secret: clientSecret,
audience
})
});
if (data.token_type !== "Bearer") {
throw new Error(`Unhandled token type: ${data.token_type}`);
}
return {
accessToken: data.access_token,
// cap token lifespan to 10 minutes
expiresAt: Math.min(data.expires_in * 1000, 600000) + Date.now()
};
};
export const getAuth0ConnectionAccessToken = async (
{ id, orgId, credentials }: TAuth0Connection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const { expiresAt, accessToken } = credentials;
// get new token if expired or less than 5 minutes until expiry
if (Date.now() < expiresAt - 300000) {
return accessToken;
}
const authData = await authorizeAuth0Connection(credentials);
const updatedCredentials: TAuth0Connection["credentials"] = {
...credentials,
...authData
};
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
});
await appConnectionDAL.updateById(id, { encryptedCredentials });
return authData.accessToken;
};
export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0ConnectionConfig) => {
try {
const { accessToken, expiresAt } = await authorizeAuth0Connection(credentials);
return {
...credentials,
accessToken,
expiresAt
};
} catch (e: unknown) {
throw new BadRequestError({
message: (e as Error).message ?? `Unable to validate connection: verify credentials`
});
}
};

View File

@@ -0,0 +1,94 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { Auth0ConnectionMethod } from "./auth0-connection-enums";
export const Auth0ConnectionClientCredentialsInputCredentialsSchema = z.object({
domain: z.string().trim().min(1, "Domain required").describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.domain),
clientId: z
.string()
.trim()
.min(1, "Client ID required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.clientId),
clientSecret: z
.string()
.trim()
.min(1, "Client Secret required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.clientSecret),
audience: z
.string()
.trim()
.url()
.min(1, "Audience required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.audience)
});
const Auth0ConnectionClientCredentialsOutputCredentialsSchema = z
.object({
accessToken: z.string(),
expiresAt: z.number()
})
.merge(Auth0ConnectionClientCredentialsInputCredentialsSchema);
const BaseAuth0ConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Auth0)
});
export const Auth0ConnectionSchema = z.intersection(
BaseAuth0ConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(Auth0ConnectionMethod.ClientCredentials),
credentials: Auth0ConnectionClientCredentialsOutputCredentialsSchema
})
])
);
export const SanitizedAuth0ConnectionSchema = z.discriminatedUnion("method", [
BaseAuth0ConnectionSchema.extend({
method: z.literal(Auth0ConnectionMethod.ClientCredentials),
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.pick({
domain: true,
clientId: true,
audience: true
})
})
]);
export const ValidateAuth0ConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(Auth0ConnectionMethod.ClientCredentials)
.describe(AppConnections.CREATE(AppConnection.Auth0).method),
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Auth0).credentials
)
})
]);
export const CreateAuth0ConnectionSchema = ValidateAuth0ConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Auth0)
);
export const UpdateAuth0ConnectionSchema = z
.object({
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Auth0).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Auth0));
export const Auth0ConnectionListItemSchema = z.object({
name: z.literal("Auth0"),
app: z.literal(AppConnection.Auth0),
// the below is preferable but currently breaks with our zod to json schema parser
// methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]),
methods: z.nativeEnum(Auth0ConnectionMethod).array()
});

View File

@@ -0,0 +1,71 @@
import { request } from "@app/lib/config/request";
import { OrgServiceActor } from "@app/lib/types";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { getAuth0ConnectionAccessToken } from "@app/services/app-connection/auth0/auth0-connection-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAuth0Connection, TAuth0ListClient, TAuth0ListClientsResponse } from "./auth0-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TAuth0Connection>;
const listAuth0Clients = async (
appConnection: TAuth0Connection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const accessToken = await getAuth0ConnectionAccessToken(appConnection, appConnectionDAL, kmsService);
const { audience, clientId: connectionClientId } = appConnection.credentials;
await blockLocalAndPrivateIpAddresses(audience);
const clients: TAuth0ListClient[] = [];
let hasMore = true;
let page = 0;
while (hasMore) {
// eslint-disable-next-line no-await-in-loop
const { data: clientsPage } = await request.get<TAuth0ListClientsResponse>(`${audience}clients`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
},
params: {
include_totals: true,
per_page: 100,
page
}
});
clients.push(...clientsPage.clients);
page += 1;
hasMore = clientsPage.total > clients.length;
}
return (
clients.filter((client) => client.client_id !== connectionClientId && client.name !== "All Applications") ?? []
);
};
export const auth0ConnectionService = (
getAppConnection: TGetAppConnectionFunc,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const listClients = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Auth0, connectionId, actor);
const clients = await listAuth0Clients(appConnection, appConnectionDAL, kmsService);
return clients.map((client) => ({ id: client.client_id, name: client.name }));
};
return {
listClients
};
};

View File

@@ -0,0 +1,39 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
Auth0ConnectionSchema,
CreateAuth0ConnectionSchema,
ValidateAuth0ConnectionCredentialsSchema
} from "./auth0-connection-schemas";
export type TAuth0Connection = z.infer<typeof Auth0ConnectionSchema>;
export type TAuth0ConnectionInput = z.infer<typeof CreateAuth0ConnectionSchema> & {
app: AppConnection.Auth0;
};
export type TValidateAuth0ConnectionCredentialsSchema = typeof ValidateAuth0ConnectionCredentialsSchema;
export type TAuth0ConnectionConfig = DiscriminativePick<TAuth0Connection, "method" | "app" | "credentials"> & {
orgId: string;
};
export type TAuth0AccessTokenResponse = {
access_token: string;
expires_in: number;
scope: string;
token_type: string;
};
export type TAuth0ListClient = {
name: string;
client_id: string;
};
export type TAuth0ListClientsResponse = {
total: number;
clients: TAuth0ListClient[];
};

View File

@@ -0,0 +1,4 @@
export * from "./auth0-connection-enums";
export * from "./auth0-connection-fns";
export * from "./auth0-connection-schemas";
export * from "./auth0-connection-types";

View File

@@ -1,6 +1,7 @@
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
@@ -26,6 +27,8 @@ const authorizeDatabricksConnection = async ({
clientSecret,
workspaceUrl
}: Pick<TDatabricksConnection["credentials"], "workspaceUrl" | "clientId" | "clientSecret">) => {
await blockLocalAndPrivateIpAddresses(workspaceUrl);
const { data } = await request.post<TAuthorizeDatabricksConnection>(
`${removeTrailingSlash(workspaceUrl)}/oidc/v1/token`,
"grant_type=client_credentials&scope=all-apis",

View File

@@ -16,7 +16,7 @@ export const DatabricksConnectionServicePrincipalInputCredentialsSchema = z.obje
workspaceUrl: z.string().trim().url().min(1, "Workspace URL required")
});
export const DatabricksConnectionServicePrincipalOutputCredentialsSchema = z
const DatabricksConnectionServicePrincipalOutputCredentialsSchema = z
.object({
accessToken: z.string(),
expiresAt: z.number()

View File

@@ -44,7 +44,7 @@ export const validateTerraformCloudConnectionCredentials = async (config: TTerra
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
message: "Unable to validate connection: verify credentials"
});
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-await-in-loop */
import { AxiosError, AxiosResponse } from "axios";
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
@@ -27,10 +27,8 @@ export const getVercelConnectionListItem = () => {
export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => {
const { credentials: inputCredentials } = config;
let response: AxiosResponse<VercelApp[]> | null = null;
try {
response = await request.get<VercelApp[]>(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, {
await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, {
headers: {
Authorization: `Bearer ${inputCredentials.apiToken}`
}
@@ -38,17 +36,14 @@ export const validateVercelConnectionCredentials = async (config: TVercelConnect
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Failed to validate credentials: ${
error.response?.data ? JSON.stringify(error.response?.data) : error.message || "Unknown error"
}`
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
});
}
if (!response?.data) {
throw new InternalServerError({
message: "Failed to get organizations: Response was empty"
message: `Unable to validate connection: ${(error as Error).message || "Verify credentials"}`
});
}

View File

@@ -0,0 +1,4 @@
export * from "./windmill-connection-enums";
export * from "./windmill-connection-fns";
export * from "./windmill-connection-schemas";
export * from "./windmill-connection-types";

View File

@@ -0,0 +1,3 @@
export enum WindmillConnectionMethod {
AccessToken = "access-token"
}

View File

@@ -0,0 +1,65 @@
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { WindmillConnectionMethod } from "./windmill-connection-enums";
import { TWindmillConnection, TWindmillConnectionConfig, TWindmillWorkspace } from "./windmill-connection-types";
export const getWindmillInstanceUrl = async (config: TWindmillConnectionConfig) => {
const instanceUrl = config.credentials.instanceUrl
? removeTrailingSlash(config.credentials.instanceUrl)
: "https://app.windmill.dev";
await blockLocalAndPrivateIpAddresses(instanceUrl);
return instanceUrl;
};
export const getWindmillConnectionListItem = () => {
return {
name: "Windmill" as const,
app: AppConnection.Windmill as const,
methods: Object.values(WindmillConnectionMethod) as [WindmillConnectionMethod.AccessToken]
};
};
export const validateWindmillConnectionCredentials = async (config: TWindmillConnectionConfig) => {
const instanceUrl = await getWindmillInstanceUrl(config);
const { accessToken } = config.credentials;
try {
await request.get(`${instanceUrl}/api/workspaces/list`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
return config.credentials;
};
export const listWindmillWorkspaces = async (appConnection: TWindmillConnection) => {
const instanceUrl = await getWindmillInstanceUrl(appConnection);
const { accessToken } = appConnection.credentials;
const resp = await request.get<TWindmillWorkspace[]>(`${instanceUrl}/api/workspaces/list`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
return resp.data.filter((workspace) => !workspace.deleted);
};

View File

@@ -0,0 +1,70 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { WindmillConnectionMethod } from "./windmill-connection-enums";
export const WindmillConnectionAccessTokenCredentialsSchema = z.object({
accessToken: z
.string()
.trim()
.min(1, "Access Token required")
.describe(AppConnections.CREDENTIALS.WINDMILL.accessToken),
instanceUrl: z
.string()
.trim()
.url("Invalid Instance URL")
.optional()
.describe(AppConnections.CREDENTIALS.WINDMILL.instanceUrl)
});
const BaseWindmillConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Windmill) });
export const WindmillConnectionSchema = BaseWindmillConnectionSchema.extend({
method: z.literal(WindmillConnectionMethod.AccessToken),
credentials: WindmillConnectionAccessTokenCredentialsSchema
});
export const SanitizedWindmillConnectionSchema = z.discriminatedUnion("method", [
BaseWindmillConnectionSchema.extend({
method: z.literal(WindmillConnectionMethod.AccessToken),
credentials: WindmillConnectionAccessTokenCredentialsSchema.pick({
instanceUrl: true
})
})
]);
export const ValidateWindmillConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(WindmillConnectionMethod.AccessToken)
.describe(AppConnections.CREATE(AppConnection.Windmill).method),
credentials: WindmillConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Windmill).credentials
)
})
]);
export const CreateWindmillConnectionSchema = ValidateWindmillConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Windmill)
);
export const UpdateWindmillConnectionSchema = z
.object({
credentials: WindmillConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Windmill).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Windmill));
export const WindmillConnectionListItemSchema = z.object({
name: z.literal("Windmill"),
app: z.literal(AppConnection.Windmill),
methods: z.nativeEnum(WindmillConnectionMethod).array()
});

View File

@@ -0,0 +1,28 @@
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listWindmillWorkspaces } from "./windmill-connection-fns";
import { TWindmillConnection } from "./windmill-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TWindmillConnection>;
export const windmillConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Windmill, connectionId, actor);
try {
const workspaces = await listWindmillWorkspaces(appConnection);
return workspaces;
} catch (error) {
return [];
}
};
return {
listWorkspaces
};
};

View File

@@ -0,0 +1,27 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateWindmillConnectionSchema,
ValidateWindmillConnectionCredentialsSchema,
WindmillConnectionSchema
} from "./windmill-connection-schemas";
export type TWindmillConnection = z.infer<typeof WindmillConnectionSchema>;
export type TWindmillConnectionInput = z.infer<typeof CreateWindmillConnectionSchema> & {
app: AppConnection.Windmill;
};
export type TValidateWindmillConnectionCredentialsSchema = typeof ValidateWindmillConnectionCredentialsSchema;
export type TWindmillConnectionConfig = DiscriminativePick<
TWindmillConnectionInput,
"method" | "app" | "credentials"
> & {
orgId: string;
};
export type TWindmillWorkspace = { id: string; name: string; deleted: boolean };

View File

@@ -2,7 +2,7 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Knex } from "knex";
import { TUsers, UserDeviceSchema } from "@app/db/schemas";
import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas";
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
@@ -174,20 +174,25 @@ export const authLoginServiceFactory = ({
const userEnc = await userDAL.findUserEncKeyByUsername({
username: email
});
const serverCfg = await getServerCfg();
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
}
if (
serverCfg.enabledLoginMethods &&
!serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) &&
!providerAuthToken
) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(userEnc.userId);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
}
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
@@ -573,28 +578,40 @@ export const authLoginServiceFactory = ({
switch (authMethod) {
case AuthMethod.GITHUB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GOOGLE: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GITLAB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}

View File

@@ -195,6 +195,7 @@ export const getIntegrationOptions = async () => {
{
name: "AWS Secrets Manager",
slug: "aws-secret-manager",
syncSlug: "aws-secrets-manager",
image: "Amazon Web Services.png",
isAvailable: true,
type: "custom",

View File

@@ -96,7 +96,9 @@ export const orgDALFactory = (db: TDbClient) => {
};
// special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
const findAllOrgsByUserId = async (
userId: string
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => {
try {
const org = (await db
.replicaNode()(TableName.OrgMembership)
@@ -117,6 +119,7 @@ export const orgDALFactory = (db: TDbClient) => {
);
})
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole"))
.select(
db.raw(`
CASE
@@ -125,7 +128,7 @@ export const orgDALFactory = (db: TDbClient) => {
ELSE ''
END as "orgAuthMethod"
`)
)) as (TOrganizations & { orgAuthMethod: string })[];
)) as (TOrganizations & { orgAuthMethod: string; userRole: string })[];
return org;
} catch (error) {

View File

@@ -16,5 +16,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
allowSecretSharingOutsideOrganization: true,
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true
privilegeUpgradeInitiatedAt: true,
bypassOrgAuthEnabled: true
});

View File

@@ -349,7 +349,8 @@ export const orgServiceFactory = ({
defaultMembershipRoleSlug,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -429,7 +430,8 @@ export const orgServiceFactory = ({
defaultMembershipRole,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;

View File

@@ -73,6 +73,7 @@ export type TUpdateOrgDTO = {
enforceMfa: boolean;
selectedMfaMethod: MfaMethod;
allowSecretSharingOutsideOrganization: boolean;
bypassOrgAuthEnabled: boolean;
}>;
} & TOrgPermission;

View File

@@ -86,6 +86,7 @@ import {
TProjectAccessRequestDTO,
TSearchProjectsDTO,
TToggleProjectAutoCapitalizationDTO,
TToggleProjectDeleteProtectionDTO,
TUpdateAuditLogsRetentionDTO,
TUpdateProjectDTO,
TUpdateProjectKmsDTO,
@@ -482,6 +483,12 @@ export const projectServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
if (project.hasDeleteProtection) {
throw new ForbiddenRequestError({
message: "Project delete protection is enabled"
});
}
const deletedProject = await projectDAL.transaction(async (tx) => {
// delete these so that project custom roles can be deleted in cascade effect
// direct deletion of project without these will cause fk error
@@ -616,6 +623,7 @@ export const projectServiceFactory = ({
description: update.description,
autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
slug: update.slug
});
@@ -648,6 +656,29 @@ export const projectServiceFactory = ({
return updatedProject;
};
const toggleDeleteProtection = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
hasDeleteProtection
}: TToggleProjectDeleteProtectionDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
const updatedProject = await projectDAL.updateById(projectId, { hasDeleteProtection });
return updatedProject;
};
const updateVersionLimit = async ({
actor,
actorId,
@@ -1499,6 +1530,7 @@ export const projectServiceFactory = ({
getProjectUpgradeStatus,
getAProject,
toggleAutoCapitalization,
toggleDeleteProtection,
updateName,
upgradeProject,
listProjectCas,

View File

@@ -1,11 +1,11 @@
import { Knex } from "knex";
import { ProjectType, TProjectKeys, SortDirection } from "@app/db/schemas";
import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas";
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
import { OrgServiceActor, TProjectPermission } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal";
import { OrgServiceActor, TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@@ -66,6 +66,10 @@ export type TToggleProjectAutoCapitalizationDTO = {
autoCapitalization: boolean;
} & TProjectPermission;
export type TToggleProjectDeleteProtectionDTO = {
hasDeleteProtection: boolean;
} & TProjectPermission;
export type TUpdateProjectVersionLimitDTO = {
pitVersionLimit: number;
workspaceSlug: string;
@@ -86,6 +90,7 @@ export type TUpdateProjectDTO = {
name?: string;
description?: string;
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
slug?: string;
};
} & Omit<TProjectPermission, "projectId">;

View File

@@ -5,6 +5,8 @@ import { TableName, TSecretImports } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { EnvironmentInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types";
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
export const secretImportDALFactory = (db: TDbClient) => {
@@ -169,6 +171,136 @@ export const secretImportDALFactory = (db: TDbClient) => {
}
};
const getFolderIsImportedBy = async (
secretPath: string,
environmentId: string,
environment: string,
projectId: string,
tx?: Knex
) => {
try {
const folderImports = await (tx || db.replicaNode())(TableName.SecretImport)
.where({ importPath: secretPath, importEnv: environmentId })
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId")
);
const secretReferences = await (tx || db.replicaNode())(TableName.SecretReferenceV2)
.where({ secretPath, environment })
.join(TableName.SecretV2, `${TableName.SecretReferenceV2}.secretId`, `${TableName.SecretV2}.id`)
.join(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.SecretFolder}.isReserved`, false)
.select(
db.ref("key").withSchema(TableName.SecretV2).as("secretId"),
db.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId"),
db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey")
);
const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({
envName,
envSlug,
folderName,
folderId
}));
const secretResults = secretReferences.map(
({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey }) => ({
envName,
envSlug,
secretId,
folderName,
folderId,
referencedSecretKey
})
);
type ResultItem = FolderResult | SecretResult;
const allResults: ResultItem[] = [...folderResults, ...secretResults];
type EnvFolderMap = {
[envName: string]: {
envSlug: string;
folders: {
[folderName: string]: {
secrets: {
secretId: string;
referencedSecretKey: string;
}[];
folderId: string;
folderImported: boolean;
};
};
};
};
const groupedByEnv = allResults.reduce<EnvFolderMap>((acc, item) => {
const env = item.envName;
const folder = item.folderName;
const { envSlug } = item;
const updatedAcc = { ...acc };
if (!updatedAcc[env]) {
updatedAcc[env] = {
envSlug,
folders: {}
};
}
if (!updatedAcc[env].folders[folder]) {
updatedAcc[env].folders[folder] = { secrets: [], folderId: item.folderId, folderImported: false };
}
if ("secretId" in item && item.secretId) {
updatedAcc[env].folders[folder].secrets = [
...updatedAcc[env].folders[folder].secrets,
{ secretId: item.secretId, referencedSecretKey: item.referencedSecretKey }
];
} else {
updatedAcc[env].folders[folder].folderImported = true;
}
return updatedAcc;
}, {});
const formattedResult: EnvironmentInfo[] = Object.keys(groupedByEnv).map((envName) => {
const envData = groupedByEnv[envName];
const folders: FolderInfo[] = Object.keys(envData.folders).map((folderName) => {
const folderData = envData.folders[folderName];
const hasSecrets = folderData.secrets.length > 0;
return {
folderName,
folderId: folderData.folderId,
folderImported: folderData.folderImported,
...(hasSecrets && { secrets: folderData.secrets })
};
});
return {
envName,
envSlug: envData.envSlug,
folders
};
});
return formattedResult;
} catch (error) {
throw new DatabaseError({ error, name: "GetSecretImportsAndReferences" });
}
};
return {
...secretImportOrm,
find,
@@ -176,6 +308,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
findByFolderIds,
findLastImportPosition,
updateAllPosition,
getProjectImportCount
getProjectImportCount,
getFolderIsImportedBy
};
};

View File

@@ -27,6 +27,7 @@ import { decryptSecretRaw } from "../secret/secret-fns";
import { TSecretQueueFactory } from "../secret/secret-queue";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { recursivelyGetSecretPaths } from "../secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretImportDALFactory } from "./secret-import-dal";
import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns";
import {
@@ -798,6 +799,140 @@ export const secretImportServiceFactory = ({
return secImportsArrays.flat();
};
const getFolderIsImportedBy = async ({
path: secretPath,
environment,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
secrets
}: TGetSecretImportsDTO & {
secrets: { secretKey: string; secretValue: string }[] | undefined;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
if (
permission.cannot(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
)
) {
return [];
}
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return [];
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId);
const deepPaths: { path: string; folderId: string }[] = [];
await Promise.all(
importedBy.map(async (el) => {
const envDeepPaths = await recursivelyGetSecretPaths({
folderDAL,
projectEnvDAL,
projectId,
environment: el.envSlug,
currentPath: "/"
});
deepPaths.push(...envDeepPaths);
})
);
const result = importedBy.map((el) => ({
environment: {
name: el.envName,
slug: el.envSlug
},
folders: el.folders.map((folderItem) => ({
folderId: folderItem.folderId,
isImported: folderItem.folderImported,
secrets: folderItem.secrets,
name: deepPaths.find((p) => p.folderId === folderItem.folderId)?.path || `...${folderItem.folderName}`
}))
}));
// Special case for same folder references as these do not have an entry on the references table
const locallyReferenced =
secrets
?.filter((secret) => {
return secrets.some(
(otherSecret) =>
otherSecret.secretKey !== secret.secretKey && secret.secretValue.includes(`\${${otherSecret.secretKey}}`)
);
})
.flatMap((secret) => {
return secrets
.filter(
(otherSecret) =>
otherSecret.secretKey !== secret.secretKey &&
secret.secretValue.includes(`\${${otherSecret.secretKey}}`)
)
.map((otherSecret) => ({
secretId: secret.secretKey,
referencedSecretKey: otherSecret.secretKey
}));
}) || [];
if (locallyReferenced.length > 0) {
const existingEnvIndex = result.findIndex((item) => item.environment.slug === environment);
if (existingEnvIndex >= 0) {
const existingFolderIndex = result[existingEnvIndex].folders.findIndex(
(folderItem) => folderItem.name === secretPath
);
if (existingFolderIndex >= 0) {
if (!result[existingEnvIndex].folders[existingFolderIndex].secrets) {
result[existingEnvIndex].folders[existingFolderIndex].secrets = [];
}
const existingSecrets = result[existingEnvIndex].folders[existingFolderIndex].secrets || [];
locallyReferenced.forEach((ref) => {
if (
!existingSecrets.some(
(s) => s.secretId === ref.secretId && s.referencedSecretKey === ref.referencedSecretKey
)
) {
existingSecrets.push(ref);
}
});
} else {
result[existingEnvIndex].folders.push({
folderId: folder.id,
isImported: false,
secrets: locallyReferenced,
name: secretPath
});
}
} else {
result.push({
environment: {
slug: environment,
name: environment
},
folders: [
{
folderId: folder.id,
isImported: false,
secrets: locallyReferenced,
name: secretPath
}
]
});
}
}
return result;
};
return {
createImport,
updateImport,
@@ -810,6 +945,7 @@ export const secretImportServiceFactory = ({
getProjectImportCount,
fnSecretsFromImports,
getProjectImportMultiEnvCount,
getImportsMultiEnv
getImportsMultiEnv,
getFolderIsImportedBy
};
};

View File

@@ -45,3 +45,29 @@ export type TGetSecretsFromImportDTO = {
environment: string;
path: string;
} & TProjectPermission;
export type FolderResult = {
envName: string;
folderName: string;
folderId: string;
envSlug: string;
};
export type SecretResult = {
secretId: string;
referencedSecretKey: string;
} & FolderResult;
export type FolderInfo = {
folderName: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
folderId: string;
folderImported: boolean;
envSlug?: string;
};
export type EnvironmentInfo = {
envName: string;
envSlug: string;
folders: FolderInfo[];
};

View File

@@ -9,7 +9,8 @@ export enum SecretSync {
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
Camunda = "camunda",
Vercel = "vercel"
Vercel = "vercel",
Windmill = "windmill"
}
export enum SecretSyncInitialSyncBehavior {

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