diff --git a/.infisicalignore b/.infisicalignore index a88bdccbd..4ccf734b6 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 0edfdfb84..b9edf8b98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,7 +8,8 @@ RUN apt-get update && apt-get install -y \ python3 \ make \ g++ \ - openssh-client + openssh-client \ + openssl # Install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apt-get install -y \ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index adb5157f5..3435672e7 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y \ make \ g++ \ openssh-client \ + openssl \ curl \ pkg-config diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 05753995c..48f52f9e7 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -9,6 +9,7 @@ export const mockKeyStore = (): TKeyStoreFactory => { store[key] = value; return "OK"; }, + setExpiry: async () => 0, setItemWithExpiry: async (key, value) => { store[key] = value; return "OK"; diff --git a/backend/package-lock.json b/backend/package-lock.json index 47e9014ce..3cd03cd6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -132,7 +132,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", @@ -9753,11 +9753,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.5.tgz", - "integrity": "sha512-Uq2xbNq0chGg+/WQEU0LJTSs/1nKxz6u1iemLcGomkSnKokbW1fbLqc3HOqCf2JP7KjlL4QkS7oZZTrOQHQYgQ==", + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -20081,11 +20082,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/scim-patch/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" - }, "node_modules/scim2-parse-filter": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/scim2-parse-filter/-/scim2-parse-filter-0.2.10.tgz", @@ -22442,9 +22438,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/backend/package.json b/backend/package.json index c575722fd..66eddcc10 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,7 +89,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", diff --git a/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts new file mode 100644 index 000000000..591c5f1ec --- /dev/null +++ b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { KmsKeyUsage } from "@app/services/kms/kms-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasKeyUsageColumn = await knex.schema.hasColumn(TableName.KmsKey, "keyUsage"); + + if (!hasKeyUsageColumn) { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.string("keyUsage").notNullable().defaultTo(KmsKeyUsage.ENCRYPT_DECRYPT); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasKeyUsageColumn = await knex.schema.hasColumn(TableName.KmsKey, "keyUsage"); + + if (hasKeyUsageColumn) { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.dropColumn("keyUsage"); + }); + } +} diff --git a/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts b/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts new file mode 100644 index 000000000..46df5cf80 --- /dev/null +++ b/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + if (await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId")) { + await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => { + tb.dropColumn("dynamicSecretId"); + }); + } +} diff --git a/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts b/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts new file mode 100644 index 000000000..5703351a8 --- /dev/null +++ b/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames", 4096).alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames").alter(); // Defaults to varchar(255) + }); +} diff --git a/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts b/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts new file mode 100644 index 000000000..e412d612a --- /dev/null +++ b/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => { + t.string("altNames", 4096).alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => { + t.string("altNames").alter(); // Defaults to varchar(255) + }); +} diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index b56fab7bf..ccb779d57 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -16,7 +16,8 @@ export const KmsKeysSchema = z.object({ name: z.string(), createdAt: z.date(), updatedAt: z.date(), - projectId: z.string().nullable().optional() + projectId: z.string().nullable().optional(), + keyUsage: z.string().default("encrypt-decrypt") }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/resource-metadata.ts b/backend/src/db/schemas/resource-metadata.ts index f496b29db..442de66b6 100644 --- a/backend/src/db/schemas/resource-metadata.ts +++ b/backend/src/db/schemas/resource-metadata.ts @@ -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; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index b28d4b18d..fdaaf5932 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -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 }; } }); diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c9899c98e..9a1f4902c 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -2,7 +2,7 @@ import z from "zod"; import { KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -74,7 +74,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { schema: { description: "KMIP endpoint for creating managed objects", body: z.object({ - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyAlgorithm) }), response: { 200: KmsKeysSchema @@ -433,7 +433,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { body: z.object({ key: z.string(), name: z.string(), - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyAlgorithm) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index df5c61fe4..66bced3df 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -136,11 +136,12 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { url: "/login/error", method: "GET", handler: async (req, res) => { + const failureMessage = req.session.get("messages"); await req.session.destroy(); return res.status(500).send({ error: "Authentication error", - details: req.query + details: failureMessage ?? req.query }); } }); diff --git a/backend/src/ee/routes/v1/secret-rotation-provider-router.ts b/backend/src/ee/routes/v1/secret-rotation-provider-router.ts index 58419d3b7..e6a1ac72b 100644 --- a/backend/src/ee/routes/v1/secret-rotation-provider-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-provider-router.ts @@ -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() }) diff --git a/backend/src/ee/routes/v1/secret-rotation-router.ts b/backend/src/ee/routes/v1/secret-rotation-router.ts index 1efc2c8aa..936459fa1 100644 --- a/backend/src/ee/routes/v1/secret-rotation-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-router.ts @@ -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 }; } }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 996a90555..91464bc0b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -12,7 +12,8 @@ import { import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; @@ -255,6 +256,11 @@ export enum EventType { GET_CMEK = "get-cmek", CMEK_ENCRYPT = "cmek-encrypt", CMEK_DECRYPT = "cmek-decrypt", + CMEK_SIGN = "cmek-sign", + CMEK_VERIFY = "cmek-verify", + CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", + CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", + UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", GET_PROJECT_TEMPLATES = "get-project-templates", @@ -1997,7 +2003,7 @@ interface CreateCmekEvent { keyId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; }; } @@ -2045,6 +2051,39 @@ interface CmekDecryptEvent { }; } +interface CmekSignEvent { + type: EventType.CMEK_SIGN; + metadata: { + keyId: string; + signingAlgorithm: SigningAlgorithm; + signature: string; + }; +} + +interface CmekVerifyEvent { + type: EventType.CMEK_VERIFY; + metadata: { + keyId: string; + signingAlgorithm: SigningAlgorithm; + signature: string; + signatureValid: boolean; + }; +} + +interface CmekListSigningAlgorithmsEvent { + type: EventType.CMEK_LIST_SIGNING_ALGORITHMS; + metadata: { + keyId: string; + }; +} + +interface CmekGetPublicKeyEvent { + type: EventType.CMEK_GET_PUBLIC_KEY; + metadata: { + keyId: string; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -2639,6 +2678,10 @@ export type Event = | GetCmeksEvent | CmekEncryptEvent | CmekDecryptEvent + | CmekSignEvent + | CmekVerifyEvent + | CmekListSigningAlgorithmsEvent + | CmekGetPublicKeyEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 8feee1830..88f2d90f1 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -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; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts index e47d9102d..d7f78c3b1 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -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 { const orm = ormify(db, TableName.DynamicSecret); + const findOne = async (filter: TFindFilter, 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, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + 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[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 }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 4bd384bcf..05d492240 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -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" }); } diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 25ea21024..44c18b001 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -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; kmsService: Pick; projectGatewayDAL: Pick; + resourceMetadataDAL: Pick; }; export type TDynamicSecretServiceFactory = ReturnType; @@ -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 ({ diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 957d884c8..58fdc2143 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -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; export type TUpdateDynamicSecretDTO = { @@ -31,6 +33,7 @@ export type TUpdateDynamicSecretDTO = { environmentSlug: string; inputs?: TProvider["inputs"]; projectSlug: string; + metadata?: ResourceMetadataDTO; } & Omit; export type TDeleteDynamicSecretDTO = { diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index faaace343..49ac293ed 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -7,7 +7,7 @@ import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/er import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { KmsDataKey } from "@app/services/kms/kms-types"; +import { KmsDataKey, KmsKeyUsage } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -115,6 +115,7 @@ export const externalKmsServiceFactory = ({ { isReserved: false, description, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, name: kmsName, orgId: actorOrgId }, diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts index b3b61694b..bee1eb24b 100644 --- a/backend/src/ee/services/external-kms/providers/gcp-kms.ts +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -92,7 +92,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro plaintext: data }); if (!encryptedText[0].ciphertext) throw new Error("encryption failed"); - return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) }; + return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext as Uint8Array) }; }; const decrypt = async (encryptedBlob: Buffer) => { @@ -101,7 +101,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro ciphertext: encryptedBlob }); if (!decryptedText[0].plaintext) throw new Error("decryption failed"); - return { data: Buffer.from(decryptedText[0].plaintext) }; + return { data: Buffer.from(decryptedText[0].plaintext as Uint8Array) }; }; return { diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index d35d17a24..0ed4c5faf 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -258,7 +258,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const decrypt: { (encryptedBlob: Buffer, providedSession: pkcs11js.Handle): Promise; (encryptedBlob: Buffer): Promise; - } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle) => { + } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle): Promise => { if (!pkcs11 || !isInitialized) { throw new Error("PKCS#11 module is not initialized"); } @@ -309,10 +309,10 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon pkcs11.C_DecryptInit(sessionHandle, decryptMechanism, aesKey); - const tempBuffer = Buffer.alloc(encryptedData.length); + const tempBuffer: Buffer = Buffer.alloc(encryptedData.length); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const decryptedData = pkcs11.C_Decrypt(sessionHandle, encryptedData, tempBuffer); - // Create a new buffer from the decrypted data return Buffer.from(decryptedData); } catch (error) { logger.error(error, "HSM: Failed to perform decryption"); diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index 66c3a1d46..45f201498 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyUsage } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { OrgPermissionKmipActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -403,6 +404,7 @@ export const kmipOperationServiceFactory = ({ algorithm, isReserved: false, projectId, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, orgId: project.orgId }); diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index a259a79b8..81d0d8766 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -1,4 +1,4 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { OrderByDirection, TOrgPermission, TProjectPermission } from "@app/lib/types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; @@ -49,7 +49,7 @@ type KmipOperationBaseDTO = { } & Omit; export type TKmipCreateDTO = { - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; } & KmipOperationBaseDTO; export type TKmipGetDTO = { @@ -77,7 +77,7 @@ export type TKmipLocateDTO = KmipOperationBaseDTO; export type TKmipRegisterDTO = { name: string; key: string; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 153401900..b5cfadbeb 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -32,7 +32,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionDynamicSecretActions { @@ -153,6 +155,10 @@ export type SecretFolderSubjectFields = { export type DynamicSecretSubjectFields = { environment: string; secretPath: string; + metadata?: { + key: string; + value: string; + }[]; }; export type SecretImportSubjectFields = { @@ -282,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([ @@ -579,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() }), @@ -732,7 +774,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); @@ -935,7 +979,9 @@ const buildMemberPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 4a8e74532..2f340626b 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -113,7 +113,13 @@ type TSecretApprovalRequestServiceFactoryDep = { kmsService: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" | "find" + | "insertMany" + | "upsertSecretReferences" + | "findBySecretKeys" + | "bulkUpdate" + | "deleteMany" + | "find" + | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -864,6 +870,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); if (!folder) { diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 5fae2675d..480e80028 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -45,7 +45,14 @@ type TSecretReplicationServiceFactoryDep = { secretVersionDAL: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "find" | "findBySecretKeys" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction" + | "find" + | "findBySecretKeys" + | "insertMany" + | "bulkUpdate" + | "delete" + | "upsertSecretReferences" + | "transaction" + | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick< TSecretVersionV2DALFactory, @@ -260,6 +267,7 @@ export const secretReplicationServiceFactory = ({ const sourceLocalSecrets = await secretV2BridgeDAL.find({ folderId: folder.id, type: SecretType.Shared }); const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id }); const sourceImportedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports: sourceSecretImports, secretDAL: secretV2BridgeDAL, folderDAL, @@ -497,6 +505,7 @@ export const secretReplicationServiceFactory = ({ } }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretQueueService.syncSecrets({ projectId, orgId, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 9956916b7..a828acb32 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -91,7 +91,7 @@ export type TSecretRotationV2ServiceFactoryDep = { folderDAL: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" + "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -529,6 +529,7 @@ export const secretRotationV2ServiceFactory = ({ }); }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -665,6 +666,7 @@ export const secretRotationV2ServiceFactory = ({ }); if (secretsMappingUpdated) { + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -796,6 +798,7 @@ export const secretRotationV2ServiceFactory = ({ } if (deleteSecrets) { + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -958,6 +961,7 @@ export const secretRotationV2ServiceFactory = ({ } }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index ac8fcc9f2..2c6124348 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -48,7 +48,7 @@ type TSecretRotationQueueFactoryDep = { secretRotationDAL: TSecretRotationDALFactory; projectBotService: Pick; secretDAL: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick; secretVersionDAL: Pick; secretVersionV2BridgeDAL: Pick; telemetryService: Pick; @@ -339,6 +339,8 @@ export const secretRotationQueueFactory = ({ tx ); }); + + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId); } else { if (!botKey) throw new NotFoundError({ diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts index df7b86a0b..2364de79d 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -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, diff --git a/backend/src/ee/services/secret-rotation/templates/index.ts b/backend/src/ee/services/secret-rotation/templates/index.ts index 39774ae28..ce3ecd687 100644 --- a/backend/src/ee/services/secret-rotation/templates/index.ts +++ b/backend/src/ee/services/secret-rotation/templates/index.ts @@ -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", diff --git a/backend/src/ee/services/secret-rotation/templates/types.ts b/backend/src/ee/services/secret-rotation/templates/types.ts index 2adc40ba3..2ec998db7 100644 --- a/backend/src/ee/services/secret-rotation/templates/types.ts +++ b/backend/src/ee/services/secret-rotation/templates/types.ts @@ -50,6 +50,7 @@ export type TSecretRotationProviderTemplate = { image?: string; description?: string; template: THttpProviderTemplate | TDbProviderTemplate | TAwsProviderTemplate; + isDeprecated?: boolean; }; export type THttpProviderTemplate = { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8fef532f5..ac28e9ade 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -77,6 +77,8 @@ export const keyStoreFactory = (redisUrl: string) => { const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); + const waitTillReady = async ({ key, waitingCb, @@ -103,6 +105,7 @@ export const keyStoreFactory = (redisUrl: string) => { return { setItem, getItem, + setExpiry, setItemWithExpiry, deleteItem, incrementBy, diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index 1fe78cf7e..10b28ffec 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -10,6 +10,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { store[key] = value; return "OK"; }, + setExpiry: async () => 0, setItemWithExpiry: async (key, value) => { store[key] = value; return "OK"; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 20e6944e0..125564ab3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1672,7 +1672,8 @@ export const KMS = { projectId: "The ID of the project to create the key in.", name: "The name of the key to be created. Must be slug-friendly.", description: "An optional description of the key.", - encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key." + encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key.", + type: "The type of key to be created, either encrypt-decrypt or sign-verify, based on your intended use for the key." }, UPDATE_KEY: { keyId: "The ID of the key to be updated.", @@ -1705,6 +1706,28 @@ export const KMS = { DECRYPT: { keyId: "The ID of the key to decrypt the data with.", ciphertext: "The ciphertext to be decrypted (base64 encoded)." + }, + + LIST_SIGNING_ALGORITHMS: { + keyId: "The ID of the key to list the signing algorithms for. The key must be for signing and verifying." + }, + + GET_PUBLIC_KEY: { + keyId: "The ID of the key to get the public key for. The key must be for signing and verifying." + }, + + SIGN: { + keyId: "The ID of the key to sign the data with.", + data: "The data in string format to be signed (base64 encoded).", + isDigest: + "Whether the data is already digested or not. Please be aware that if you are passing a digest the algorithm used to create the digest must match the signing algorithm used to sign the digest.", + signingAlgorithm: "The algorithm to use when performing cryptographic operations with the key." + }, + VERIFY: { + keyId: "The ID of the key to verify the data with.", + data: "The data in string format to be verified (base64 encoded). For data larger than 4096 bytes you must first create a digest of the data and then pass the digest in the data parameter.", + signature: "The signature to be verified (base64 encoded).", + isDigest: "Whether the data is already digested or not." } }; @@ -1775,6 +1798,9 @@ export const AppConnections = { sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.", sslCertificate: "The SSL certificate to use for connection." }, + TERRAFORM_CLOUD: { + apiToken: "The API token to use to connect with Terraform Cloud." + }, VERCEL: { apiToken: "The API token used to authenticate with Vercel." }, @@ -1901,6 +1927,15 @@ export const SecretSyncs = { env: "The ID of the Humanitec environment to sync secrets to.", scope: "The Humanitec scope that secrets should be synced to." }, + TERRAFORM_CLOUD: { + org: "The ID of the Terraform Cloud org to sync secrets to.", + variableSetName: "The name of the Terraform Cloud Variable Set to sync secrets to.", + variableSetId: "The ID of the Terraform Cloud Variable Set to sync secrets to.", + workspaceName: "The name of the Terraform Cloud workspace to sync secrets to.", + workspaceId: "The ID of the Terraform Cloud workspace to sync secrets to.", + scope: "The Terraform Cloud scope that secrets should be synced to.", + category: "The Terraform Cloud category that secrets should be synced to." + }, VERCEL: { app: "The ID of the Vercel app to sync secrets to.", appName: "The name of the Vercel app to sync secrets to.", diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index 147d12ef7..7a3c05969 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -24,5 +24,6 @@ export enum PermissionConditionOperators { $IN = "$in", $EQ = "$eq", $NEQ = "$ne", - $GLOB = "$glob" + $GLOB = "$glob", + $ELEMENTMATCH = "$elemMatch" } diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 10ab16b97..907884433 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -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()), diff --git a/backend/src/lib/crypto/cache.ts b/backend/src/lib/crypto/cache.ts new file mode 100644 index 000000000..9f36d360b --- /dev/null +++ b/backend/src/lib/crypto/cache.ts @@ -0,0 +1,10 @@ +import crypto from "node:crypto"; + +export const generateCacheKeyFromData = (data: unknown) => + crypto + .createHash("md5") + .update(JSON.stringify(data)) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); diff --git a/backend/src/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts index 7bc16b470..718c8ad5e 100644 --- a/backend/src/lib/crypto/cipher/cipher.ts +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; +import { SymmetricKeyAlgorithm, TSymmetricEncryptionFns } from "./types"; const getIvLength = () => { return 12; @@ -10,7 +10,9 @@ const getTagLength = () => { return 16; }; -export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { +export const symmetricCipherService = ( + type: SymmetricKeyAlgorithm.AES_GCM_128 | SymmetricKeyAlgorithm.AES_GCM_256 +): TSymmetricEncryptionFns => { const IV_LENGTH = getIvLength(); const TAG_LENGTH = getTagLength(); diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts index 41dbcf639..27373a009 100644 --- a/backend/src/lib/crypto/cipher/index.ts +++ b/backend/src/lib/crypto/cipher/index.ts @@ -1,2 +1,2 @@ export { symmetricCipherService } from "./cipher"; -export { SymmetricEncryption } from "./types"; +export { AllowedEncryptionKeyAlgorithms, SymmetricKeyAlgorithm } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts index f490d6a66..e2f63ce5e 100644 --- a/backend/src/lib/crypto/cipher/types.ts +++ b/backend/src/lib/crypto/cipher/types.ts @@ -1,7 +1,18 @@ -export enum SymmetricEncryption { +import { z } from "zod"; + +import { AsymmetricKeyAlgorithm } from "../sign/types"; + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyAlgorithm { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } +export const SymmetricKeyAlgorithmEnum = z.enum(Object.values(SymmetricKeyAlgorithm) as [string, ...string[]]).options; + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyAlgorithm), + ...Object.values(AsymmetricKeyAlgorithm) +] as [string, ...string[]]).options; export type TSymmetricEncryptionFns = { encrypt: (text: Buffer, key: Buffer) => Buffer; diff --git a/backend/src/lib/crypto/sign/index.ts b/backend/src/lib/crypto/sign/index.ts new file mode 100644 index 000000000..5680cd27a --- /dev/null +++ b/backend/src/lib/crypto/sign/index.ts @@ -0,0 +1,2 @@ +export { signingService } from "./signing"; +export { AsymmetricKeyAlgorithm, SigningAlgorithm } from "./types"; diff --git a/backend/src/lib/crypto/sign/signing.ts b/backend/src/lib/crypto/sign/signing.ts new file mode 100644 index 000000000..66f36dc0f --- /dev/null +++ b/backend/src/lib/crypto/sign/signing.ts @@ -0,0 +1,564 @@ +import { execFile } from "child_process"; +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { promisify } from "util"; + +import { BadRequestError } from "@app/lib/errors"; +import { cleanTemporaryDirectory, createTemporaryDirectory, writeToTemporaryFile } from "@app/lib/files"; +import { logger } from "@app/lib/logger"; + +import { AsymmetricKeyAlgorithm, SigningAlgorithm, TAsymmetricSignVerifyFns } from "./types"; + +const execFileAsync = promisify(execFile); + +interface SigningParams { + hashAlgorithm: SupportedHashAlgorithm; + padding?: number; + saltLength?: number; +} + +enum SupportedHashAlgorithm { + SHA256 = "sha256", + SHA384 = "sha384", + SHA512 = "sha512" +} + +const COMMAND_TIMEOUT = 15_000; + +const SHA256_DIGEST_LENGTH = 32; +const SHA384_DIGEST_LENGTH = 48; +const SHA512_DIGEST_LENGTH = 64; + +/** + * Service for cryptographic signing and verification operations using asymmetric keys + * + * @param algorithm The key algorithm itself. The signing algorithm is supplied in the individual sign/verify functions. + * @returns Object with sign and verify functions + */ +export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSignVerifyFns => { + const $getSigningParams = (signingAlgorithm: SigningAlgorithm): SigningParams => { + switch (signingAlgorithm) { + // RSA PSS + case SigningAlgorithm.RSASSA_PSS_SHA_512: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA512, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA512_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_256: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA256, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA256_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_384: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA384, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA384_DIGEST_LENGTH + }; + + // RSA PKCS#1 v1.5 + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_512: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA512, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_384: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA384, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_256: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA256, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + + // ECDSA + case SigningAlgorithm.ECDSA_SHA_256: + return { hashAlgorithm: SupportedHashAlgorithm.SHA256 }; + case SigningAlgorithm.ECDSA_SHA_384: + return { hashAlgorithm: SupportedHashAlgorithm.SHA384 }; + case SigningAlgorithm.ECDSA_SHA_512: + return { hashAlgorithm: SupportedHashAlgorithm.SHA512 }; + + default: + throw new Error(`Unsupported signing algorithm: ${signingAlgorithm as string}`); + } + }; + + const $getEcCurveName = (keyAlgorithm: AsymmetricKeyAlgorithm): { full: string; short: string } => { + // We will support more in the future + switch (keyAlgorithm) { + case AsymmetricKeyAlgorithm.ECC_NIST_P256: + return { + full: "prime256v1", + short: "p256" + }; + default: + throw new Error(`Unsupported EC curve: ${keyAlgorithm}`); + } + }; + + const $validateAlgorithmWithKeyType = (signingAlgorithm: SigningAlgorithm) => { + const isRsaKey = algorithm.startsWith("RSA"); + const isEccKey = algorithm.startsWith("ECC"); + + const isRsaAlgorithm = signingAlgorithm.startsWith("RSASSA"); + const isEccAlgorithm = signingAlgorithm.startsWith("ECDSA"); + + if (isRsaKey && !isRsaAlgorithm) { + throw new BadRequestError({ message: `KMS RSA key cannot be used with ${signingAlgorithm}` }); + } + + if (isEccKey && !isEccAlgorithm) { + throw new BadRequestError({ message: `KMS ECC key cannot be used with ${signingAlgorithm}` }); + } + }; + + 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"); + const keyPath = path.join(tempDir, "key.pem"); + + try { + await writeToTemporaryFile(digestPath, digest); + await writeToTemporaryFile(keyPath, privateKey); + + const { stderr } = await execFileAsync( + "openssl", + [ + "pkeyutl", + "-sign", + "-in", + digestPath, + "-inkey", + keyPath, + "-pkeyopt", + `digest:${hashAlgorithm}`, + "-out", + sigPath + ], + { + maxBuffer: 10 * 1024 * 1024, + timeout: COMMAND_TIMEOUT + } + ); + + if (stderr) { + logger.error(stderr, "KMS: Failed to sign RSA digest"); + throw new BadRequestError({ + message: "Failed to sign RSA digest due to signing error" + }); + } + const signature = await fs.readFile(sigPath); + + if (!signature) { + throw new BadRequestError({ + message: + "No signature was created. Make sure you are using an appropriate signing algorithm that uses the same hashing algorithm as the one used to create the digest." + }); + } + + 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, + signingAlgorithm: SigningAlgorithm + ) => { + const tempDir = await createTemporaryDirectory("ecc-sign"); + const digestPath = path.join(tempDir, "digest.bin"); + const keyPath = path.join(tempDir, "key.pem"); + const sigPath = path.join(tempDir, "signature.bin"); + + try { + await writeToTemporaryFile(digestPath, digest); + await writeToTemporaryFile(keyPath, privateKey); + + const { stderr } = await execFileAsync( + "openssl", + [ + "pkeyutl", + "-sign", + "-in", + digestPath, + "-inkey", + keyPath, + "-pkeyopt", + `digest:${hashAlgorithm}`, + "-out", + sigPath + ], + { + maxBuffer: 10 * 1024 * 1024, + timeout: COMMAND_TIMEOUT + } + ); + + if (stderr) { + logger.error(stderr, "KMS: Failed to sign ECC digest"); + throw new BadRequestError({ + message: "Failed to sign ECC digest due to signing error" + }); + } + + const signature = await fs.readFile(sigPath); + + if (!signature) { + throw new BadRequestError({ + message: + "No signature was created. Make sure you are using an appropriate signing algorithm that uses the same hashing algorithm as the one used to create the digest." + }); + } + + 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); + } + }; + + const $verifyEccDigest = async ( + digest: Buffer, + signature: Buffer, + publicKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm + ) => { + const tempDir = await createTemporaryDirectory("ecc-signature-verification"); + const publicKeyFile = path.join(tempDir, "public-key.pem"); + const sigFile = path.join(tempDir, "signature.sig"); + const digestFile = path.join(tempDir, "digest.bin"); + + try { + await writeToTemporaryFile(publicKeyFile, publicKey); + await writeToTemporaryFile(sigFile, signature); + await writeToTemporaryFile(digestFile, digest); + + await execFileAsync( + "openssl", + [ + "pkeyutl", + "-verify", + "-in", + digestFile, + "-inkey", + publicKeyFile, + "-pubin", // Important for EC public keys + "-sigfile", + sigFile, + "-pkeyopt", + `digest:${hashAlgorithm}` + ], + { timeout: COMMAND_TIMEOUT } + ); + + return true; + } catch (error) { + const err = error as { stderr: string }; + + if ( + !err?.stderr?.toLowerCase()?.includes("signature verification failure") && + !err?.stderr?.toLowerCase()?.includes("bad signature") + ) { + logger.error(error, "KMS: Failed to verify ECC signature"); + } + return false; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const $verifyRsaDigest = async ( + digest: Buffer, + signature: Buffer, + publicKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm + ) => { + const tempDir = await createTemporaryDirectory("kms-signature-verification"); + const publicKeyFile = path.join(tempDir, "public-key.pub"); + const signatureFile = path.join(tempDir, "signature.sig"); + const digestFile = path.join(tempDir, "digest.bin"); + + try { + await writeToTemporaryFile(publicKeyFile, publicKey); + await writeToTemporaryFile(signatureFile, signature); + await writeToTemporaryFile(digestFile, digest); + + await execFileAsync( + "openssl", + [ + "pkeyutl", + "-verify", + "-in", + digestFile, + "-inkey", + publicKeyFile, + "-pubin", + "-sigfile", + signatureFile, + "-pkeyopt", + `digest:${hashAlgorithm}` + ], + { timeout: COMMAND_TIMEOUT } + ); + + // it'll throw if the verification was not successful + return true; + } catch (error) { + const err = error as { stdout: string }; + + if (!err?.stdout?.toLowerCase()?.includes("signature verification failure")) { + logger.error(error, "KMS: Failed to verify signature"); + } + return false; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const verifyDigestFunctionsMap: Record< + AsymmetricKeyAlgorithm, + (data: Buffer, signature: Buffer, publicKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => Promise + > = { + [AsymmetricKeyAlgorithm.ECC_NIST_P256]: $verifyEccDigest, + [AsymmetricKeyAlgorithm.RSA_4096]: $verifyRsaDigest + }; + + const signDigestFunctionsMap: Record< + AsymmetricKeyAlgorithm, + ( + data: Buffer, + privateKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm, + signingAlgorithm: SigningAlgorithm + ) => Promise + > = { + [AsymmetricKeyAlgorithm.ECC_NIST_P256]: $signEccDigest, + [AsymmetricKeyAlgorithm.RSA_4096]: $signRsaDigest + }; + + const sign = async ( + data: Buffer, + privateKey: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ): Promise => { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + if (isDigest) { + if (signingAlgorithm.startsWith("RSASSA_PSS")) { + throw new BadRequestError({ + message: "RSA PSS does not support digested input" + }); + } + + const signFunction = signDigestFunctionsMap[algorithm]; + + if (!signFunction) { + throw new BadRequestError({ + message: `Digested input is not supported for key algorithm ${algorithm}` + }); + } + + const signature = await signFunction(data, privateKey, hashAlgorithm, signingAlgorithm); + return signature; + } + + const privateKeyObject = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSA")) { + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + + return signer.sign({ + key: privateKeyObject, + padding, + ...(signingAlgorithm.includes("PSS") ? { saltLength } : {}) + }); + } + if (signingAlgorithm.startsWith("ECDSA")) { + // For ECDSA signatures + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + return signer.sign({ + key: privateKeyObject, + dsaEncoding: "der" + }); + } + throw new BadRequestError({ + message: `Signing algorithm ${signingAlgorithm} not implemented` + }); + }; + + const verify = async ( + data: Buffer, + signature: Buffer, + publicKey: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ): Promise => { + try { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + if (isDigest) { + if (signingAlgorithm.startsWith("RSASSA_PSS")) { + throw new BadRequestError({ + message: "RSA PSS does not support digested input" + }); + } + + const verifyFunction = verifyDigestFunctionsMap[algorithm]; + + if (!verifyFunction) { + throw new BadRequestError({ + message: `Digested input is not supported for key algorithm ${algorithm}` + }); + } + + const signatureValid = await verifyFunction(data, signature, publicKey, hashAlgorithm); + + return signatureValid; + } + + const publicKeyObject = crypto.createPublicKey({ + key: publicKey, + format: "der", + type: "spki" + }); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + + return verifier.verify( + { + key: publicKeyObject, + padding, + ...(signingAlgorithm.includes("PSS") ? { saltLength } : {}) + }, + signature + ); + } + // For ECDSA signatures + if (signingAlgorithm.startsWith("ECDSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + return verifier.verify( + { + key: publicKeyObject, + dsaEncoding: "der" + }, + signature + ); + } + throw new BadRequestError({ + message: `Verification for algorithm ${signingAlgorithm} not implemented` + }); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + logger.error(error, "KMS: Failed to verify signature"); + return false; + } + }; + + const generateAsymmetricPrivateKey = async () => { + const { privateKey } = await new Promise<{ privateKey: string }>((resolve, reject) => { + if (algorithm.startsWith("RSA")) { + crypto.generateKeyPair( + "rsa", + { + modulusLength: Number(algorithm.split("_")[1]), + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ privateKey: pk }); + } + } + ); + } else { + const { full: namedCurve } = $getEcCurveName(algorithm); + + crypto.generateKeyPair( + "ec", + { + namedCurve, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ + privateKey: pk + }); + } + } + ); + } + }); + + return Buffer.from(privateKey); + }; + + const getPublicKeyFromPrivateKey = (privateKey: Buffer) => { + const privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + const publicKey = crypto.createPublicKey(privateKeyObj).export({ + type: "spki", + format: "der" + }); + + return publicKey; + }; + + return { + sign, + verify, + generateAsymmetricPrivateKey, + getPublicKeyFromPrivateKey + }; +}; diff --git a/backend/src/lib/crypto/sign/types.ts b/backend/src/lib/crypto/sign/types.ts new file mode 100644 index 000000000..aa81b4057 --- /dev/null +++ b/backend/src/lib/crypto/sign/types.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export type TAsymmetricSignVerifyFns = { + sign: (data: Buffer, key: Buffer, signingAlgorithm: SigningAlgorithm, isDigest: boolean) => Promise; + verify: ( + data: Buffer, + signature: Buffer, + key: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ) => Promise; + generateAsymmetricPrivateKey: () => Promise; + getPublicKeyFromPrivateKey: (privateKey: Buffer) => Buffer; +}; + +// Supported asymmetric key types +export enum AsymmetricKeyAlgorithm { + RSA_4096 = "RSA_4096", + ECC_NIST_P256 = "ECC_NIST_P256" +} + +export const AsymmetricKeyAlgorithmEnum = z.enum( + Object.values(AsymmetricKeyAlgorithm) as [string, ...string[]] +).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + // These are NOT deterministic and include randomness. + // This means that the output signature is different each time for the same input. + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + + // RSA PKCS#1 v1.5 algorithms + // These are deterministic and the output is the same each time for the same input. + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + + // ECDSA algorithms + // None of these are deterministic and include randomness like RSA PSS. + ECDSA_SHA_512 = "ECDSA_SHA_512", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_256 = "ECDSA_SHA_256" +} diff --git a/backend/src/lib/files/files.ts b/backend/src/lib/files/files.ts new file mode 100644 index 000000000..063d71d09 --- /dev/null +++ b/backend/src/lib/files/files.ts @@ -0,0 +1,35 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { logger } from "@app/lib/logger"; + +const baseDir = path.join(os.tmpdir(), "infisical"); +const randomPath = () => `${crypto.randomBytes(32).toString("hex")}`; + +export const createTemporaryDirectory = async (name: string) => { + const tempDirPath = path.join(baseDir, `${name}-${randomPath()}`); + await fs.mkdir(tempDirPath, { recursive: true }); + + return tempDirPath; +}; + +export const removeTemporaryBaseDirectory = async () => { + await fs.rm(baseDir, { force: true, recursive: true }).catch((err) => { + logger.error(err, `Failed to remove temporary base directory [path=${baseDir}]`); + }); +}; + +export const cleanTemporaryDirectory = async (dirPath: string) => { + await fs.rm(dirPath, { recursive: true, force: true }).catch((err) => { + logger.error(err, `Failed to cleanup temporary directory [path=${dirPath}]`); + }); +}; + +export const writeToTemporaryFile = async (tempDirPath: string, data: string | Buffer) => { + await fs.writeFile(tempDirPath, data, { mode: 0o600 }).catch((err) => { + logger.error(err, `Failed to write to temporary file [path=${tempDirPath}]`); + throw err; + }); +}; diff --git a/backend/src/lib/files/index.ts b/backend/src/lib/files/index.ts new file mode 100644 index 000000000..b2cba4b62 --- /dev/null +++ b/backend/src/lib/files/index.ts @@ -0,0 +1 @@ +export * from "./files"; diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index b8b272017..9f063172f 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -41,6 +41,18 @@ export type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K; }[keyof T]; +export type BufferKeysToString = { + [K in keyof T]: T[K] extends Buffer + ? string + : T[K] extends Buffer | null + ? string | null + : T[K] extends Buffer | undefined + ? string | undefined + : T[K] extends Buffer | null | undefined + ? string | null | undefined + : T[K]; +}; + export type PickRequired = Pick>; export type DiscriminativePick = T extends unknown ? Pick : never; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index 6feab9036..fdf99e405 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -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 = { diff --git a/backend/src/main.ts b/backend/src/main.ts index d5c54991b..80d98abcf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,6 +9,7 @@ import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig } from "./lib/config/env"; +import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; @@ -21,6 +22,8 @@ const run = async () => { const logger = initLogger(); const envConfig = initEnvConfig(logger); + await removeTemporaryBaseDirectory(); + const db = initDbConnection({ dbConnectionUri: envConfig.DB_CONNECTION_URI, dbRootCert: envConfig.DB_ROOT_CERT, @@ -71,6 +74,7 @@ const run = async () => { process.on("SIGINT", async () => { await server.close(); await db.destroy(); + await removeTemporaryBaseDirectory(); hsmModule.finalize(); process.exit(0); }); @@ -79,6 +83,7 @@ const run = async () => { process.on("SIGTERM", async () => { await server.close(); await db.destroy(); + await removeTemporaryBaseDirectory(); hsmModule.finalize(); process.exit(0); }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c70cda5d2..a5aa66cb3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -315,7 +315,7 @@ export const registerRoutes = async ( const secretVersionTagDAL = secretVersionTagDALFactory(db); const secretBlindIndexDAL = secretBlindIndexDALFactory(db); - const secretV2BridgeDAL = secretV2BridgeDALFactory(db); + const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); const secretVersionTagV2BridgeDAL = secretVersionV2TagBridgeDALFactory(db); @@ -1391,7 +1391,8 @@ export const registerRoutes = async ( permissionService, licenseService, kmsService, - projectGatewayDAL + projectGatewayDAL, + resourceMetadataDAL }); const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 8bf6f7390..2a87cf7cf 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -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(), diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 452b50207..c773acc16 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -32,6 +32,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + SanitizedTerraformCloudConnectionSchema, + TerraformCloudConnectionListItemSchema +} from "@app/services/app-connection/terraform-cloud"; import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -44,6 +48,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedTerraformCloudConnectionSchema.options, ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, @@ -59,6 +64,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, + TerraformCloudConnectionListItemSchema, VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 2605fc76b..22bd5f717 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -11,6 +11,7 @@ import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -24,6 +25,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.TerraformCloud, + server, + sanitizedResponseSchema: SanitizedTerraformCloudConnectionSchema, + createSchema: CreateTerraformCloudConnectionSchema, + updateSchema: UpdateTerraformCloudConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + variableSets: z + .object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + global: z.boolean().optional() + }) + .array(), + workspaces: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const organizations: TTerraformCloudOrganization[] = + await server.services.appConnection.terraformCloud.listOrganizations(connectionId, req.permission); + + return organizations; + } + }); +}; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 7aecaee37..64f47f980 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -4,13 +4,15 @@ import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { KMS } from "@app/lib/api-docs"; import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { AllowedEncryptionKeyAlgorithms, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CmekOrderBy } from "@app/services/cmek/cmek-types"; +import { CmekOrderBy, TCmekKeyEncryptionAlgorithm } from "@app/services/cmek/cmek-types"; +import { KmsKeyUsage } from "@app/services/kms/kms-types"; const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); @@ -45,16 +47,46 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { }, schema: { description: "Create KMS key", - body: z.object({ - projectId: z.string().describe(KMS.CREATE_KEY.projectId), - name: keyNameSchema.describe(KMS.CREATE_KEY.name), - description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), - encryptionAlgorithm: z - .nativeEnum(SymmetricEncryption) - .optional() - .default(SymmetricEncryption.AES_GCM_256) - .describe(KMS.CREATE_KEY.encryptionAlgorithm) // eventually will support others - }), + body: z + .object({ + projectId: z.string().describe(KMS.CREATE_KEY.projectId), + name: keyNameSchema.describe(KMS.CREATE_KEY.name), + description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), + keyUsage: z + .nativeEnum(KmsKeyUsage) + .optional() + .default(KmsKeyUsage.ENCRYPT_DECRYPT) + .describe(KMS.CREATE_KEY.type), + encryptionAlgorithm: z + .enum(AllowedEncryptionKeyAlgorithms) + .optional() + .default(SymmetricKeyAlgorithm.AES_GCM_256) + .describe(KMS.CREATE_KEY.encryptionAlgorithm) + }) + .superRefine((data, ctx) => { + if ( + data.keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && + !Object.values(SymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as SymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid symmetric encryption algorithm. Valid options are: ${Object.values( + SymmetricKeyAlgorithm + ).join(", ")}` + }); + } + if ( + data.keyUsage === KmsKeyUsage.SIGN_VERIFY && + !Object.values(AsymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as AsymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid asymmetric sign-verify algorithm. Valid options are: ${Object.values( + AsymmetricKeyAlgorithm + ).join(", ")}` + }); + } + }), response: { 200: z.object({ key: CmekSchema @@ -64,12 +96,19 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { - body: { projectId, name, description, encryptionAlgorithm }, + body: { projectId, name, description, encryptionAlgorithm, keyUsage }, permission } = req; const cmek = await server.services.cmek.createCmek( - { orgId: permission.orgId, projectId, name, description, encryptionAlgorithm }, + { + orgId: permission.orgId, + projectId, + name, + description, + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm, + keyUsage + }, permission ); @@ -82,7 +121,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { keyId: cmek.id, name, description, - encryptionAlgorithm + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm } } }); @@ -126,7 +165,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId: cmek.projectId!, event: { type: EventType.UPDATE_CMEK, metadata: { @@ -169,7 +208,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId: cmek.projectId!, event: { type: EventType.DELETE_CMEK, metadata: { @@ -282,7 +321,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { - description: "Get KMS key by Name", + description: "Get KMS key by name", params: z.object({ keyName: slugSchema({ field: "Key name" }).describe(KMS.GET_KEY_BY_NAME.keyName) }), @@ -349,11 +388,11 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { permission } = req; - const ciphertext = await server.services.cmek.cmekEncrypt({ keyId, plaintext }, permission); + const { ciphertext, projectId } = await server.services.cmek.cmekEncrypt({ keyId, plaintext }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId, event: { type: EventType.CMEK_ENCRYPT, metadata: { @@ -366,6 +405,198 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/keys/:keyId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: + "Get the public key for a KMS key that is used for signing and verifying data. This endpoint is only available for asymmetric keys.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.GET_PUBLIC_KEY.keyId) + }), + response: { + 200: z.object({ + publicKey: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + permission + } = req; + + const { publicKey, projectId } = await server.services.cmek.getPublicKey({ keyId }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_GET_PUBLIC_KEY, + metadata: { + keyId + } + } + }); + + return { publicKey }; + } + }); + + server.route({ + method: "GET", + url: "/keys/:keyId/signing-algorithms", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all available signing algorithms for a KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.LIST_SIGNING_ALGORITHMS.keyId) + }), + response: { + 200: z.object({ + signingAlgorithms: z.array(z.nativeEnum(SigningAlgorithm)) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { keyId } = req.params; + + const { signingAlgorithms, projectId } = await server.services.cmek.listSigningAlgorithms( + { keyId }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_LIST_SIGNING_ALGORITHMS, + metadata: { + keyId + } + } + }); + + return { signingAlgorithms }; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/sign", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Sign data with a KMS key.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.SIGN.keyId) + }), + body: z.object({ + signingAlgorithm: z.nativeEnum(SigningAlgorithm), + isDigest: z.boolean().optional().default(false).describe(KMS.SIGN.isDigest), + data: base64Schema.describe(KMS.SIGN.data) + }), + response: { + 200: z.object({ + signature: z.string(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId: inputKeyId }, + body: { data, signingAlgorithm, isDigest }, + permission + } = req; + + const { projectId, ...result } = await server.services.cmek.cmekSign( + { keyId: inputKeyId, data, signingAlgorithm, isDigest }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_SIGN, + metadata: { + keyId: inputKeyId, + signingAlgorithm, + signature: result.signature + } + } + }); + return result; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/verify", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Verify data signatures with a KMS key.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.VERIFY.keyId) + }), + body: z.object({ + isDigest: z.boolean().optional().default(false).describe(KMS.VERIFY.isDigest), + data: base64Schema.describe(KMS.VERIFY.data), + signature: base64Schema.describe(KMS.VERIFY.signature), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }), + response: { + 200: z.object({ + signatureValid: z.boolean(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body: { data, signature, signingAlgorithm, isDigest }, + permission + } = req; + + const { projectId, ...result } = await server.services.cmek.cmekVerify( + { keyId, data, signature, signingAlgorithm, isDigest }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_VERIFY, + metadata: { + keyId, + signatureValid: result.signatureValid, + signingAlgorithm, + signature + } + } + }); + + return result; + } + }); + server.route({ method: "POST", url: "/keys/:keyId/decrypt", @@ -394,11 +625,11 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { permission } = req; - const plaintext = await server.services.cmek.cmekDecrypt({ keyId, ciphertext }, permission); + const { plaintext, projectId } = await server.services.cmek.cmekDecrypt({ keyId, ciphertext }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId, event: { type: EventType.CMEK_DECRYPT, metadata: { diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index bcb0a949a..5a52d4748 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -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) + diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 185738de6..bca11cbf3 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -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(), diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index ad087fdd1..07567124d 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,6 +9,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; 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"; export * from "./secret-sync-router"; @@ -22,6 +23,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.TerraformCloud, + server, + responseSchema: TerraformCloudSyncSchema, + createSchema: CreateTerraformCloudSyncSchema, + updateSchema: UpdateTerraformCloudSyncSchema + }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index a4570389f..a222ab172 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -108,7 +108,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName, + firstName: profile.displayName || profile.username || "", lastName: "", authMethod: AuthMethod.GITHUB, callbackPort @@ -145,7 +145,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName, + firstName: profile.displayName || profile.username || "", lastName: "", authMethod: AuthMethod.GITLAB, callbackPort diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 3252bca50..2c5a32c85 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 25b2a286d..de329faaa 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -43,6 +43,11 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { + getTerraformCloudConnectionListItem, + TerraformCloudConnectionMethod, + validateTerraformCloudConnectionCredentials +} from "./terraform-cloud"; import { VercelConnectionMethod } from "./vercel"; import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; @@ -55,6 +60,7 @@ export const listAppConnectionOptions = () => { getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), + getTerraformCloudConnectionListItem(), getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem(), @@ -121,6 +127,7 @@ export const validateAppConnectionCredentials = async ( [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 }; @@ -146,6 +153,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case CamundaConnectionMethod.ClientCredentials: return "Client Credentials"; case HumanitecConnectionMethod.ApiToken: + case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: @@ -193,6 +201,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 3847eef90..2e3f91dd6 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", + [AppConnection.TerraformCloud]: "Terraform Cloud", [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 4de9ee81e..6d1d53e93 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -45,6 +45,8 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; +import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; import { vercelConnectionService } from "./vercel/vercel-connection-service"; @@ -64,6 +66,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Terraform Cloud" as const, + app: AppConnection.TerraformCloud as const, + methods: Object.values(TerraformCloudConnectionMethod) as [TerraformCloudConnectionMethod.ApiToken] + }; +}; + +export const validateTerraformCloudConnectionCredentials = async (config: TTerraformCloudConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse<{ data: TTerraformCloudOrganization[] }> | null = null; + + try { + response = await request.get<{ data: TTerraformCloudOrganization[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations`, + { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } 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" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +export const listOrganizations = async ( + appConnection: TTerraformCloudConnection +): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const headers = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + }; + + const fetchAllPages = async (url: string): Promise => { + let results: T[] = []; + let nextUrl: string | null = url; + + while (nextUrl) { + // eslint-disable-next-line no-await-in-loop + const res: AxiosResponse<{ data: T[]; links?: { next?: string } }> = await request.get(nextUrl, { headers }); + results = results.concat(res.data.data); + nextUrl = res.data.links?.next || null; + } + + return results; + }; + + const orgEntities = await fetchAllPages<{ id: string; attributes: { name: string } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations` + ); + + const orgsWithVariableSetsAndWorkspaces: TTerraformCloudOrganization[] = []; + + const variableSetPromises = orgEntities.map((org) => + fetchAllPages<{ id: string; attributes: { name: string; description?: string; global?: boolean } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/varsets` + ).catch(() => []) + ); + + const workspacePromises = orgEntities.map((org) => + fetchAllPages<{ id: string; attributes: { name: string } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/workspaces` + ).catch(() => []) + ); + + const [variableSetResults, workspaceResults] = await Promise.all([ + Promise.all(variableSetPromises), + Promise.all(workspacePromises) + ]); + + for (let i = 0; i < orgEntities.length; i += 1) { + const org = orgEntities[i]; + const variableSetsData = variableSetResults[i]; + const workspacesData = workspaceResults[i]; + + const variableSets: TTerraformCloudVariableSet[] = variableSetsData.map((varSet) => ({ + id: varSet.id, + name: varSet.attributes.name, + description: varSet.attributes.description, + global: varSet.attributes.global + })); + + const workspaces: TTerraformCloudWorkspace[] = workspacesData.map((workspace) => ({ + id: workspace.id, + name: workspace.attributes.name + })); + + orgsWithVariableSetsAndWorkspaces.push({ + id: org.id, + name: org.attributes.name, + variableSets, + workspaces + }); + } + + return orgsWithVariableSetsAndWorkspaces; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts new file mode 100644 index 000000000..0d408ba4f --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts @@ -0,0 +1,60 @@ +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 { TerraformCloudConnectionMethod } from "./terraform-cloud-connection-enums"; + +export const TerraformCloudConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.TERRAFORM_CLOUD.apiToken) +}); + +const BaseTerraformCloudConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.TerraformCloud) +}); + +export const TerraformCloudConnectionSchema = BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.ApiToken), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTerraformCloudConnectionSchema = z.discriminatedUnion("method", [ + BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.ApiToken), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateTerraformCloudConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(TerraformCloudConnectionMethod.ApiToken) + .describe(AppConnections?.CREATE(AppConnection.TerraformCloud).method), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TerraformCloud).credentials + ) + }) +]); + +export const CreateTerraformCloudConnectionSchema = ValidateTerraformCloudConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TerraformCloud) +); + +export const UpdateTerraformCloudConnectionSchema = z + .object({ + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TerraformCloud).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TerraformCloud)); + +export const TerraformCloudConnectionListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + app: z.literal(AppConnection.TerraformCloud), + methods: z.nativeEnum(TerraformCloudConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts new file mode 100644 index 000000000..56d56492b --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOrganizations as getTerraformCloudOrganizations } from "./terraform-cloud-connection-fns"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const terraformCloudConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TerraformCloud, connectionId, actor); + try { + const organizations = await getTerraformCloudOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to establish connection with Terraform Cloud"); + return []; + } + }; + + return { + listOrganizations + }; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts new file mode 100644 index 000000000..cabcbb146 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts @@ -0,0 +1,45 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTerraformCloudConnectionSchema, + TerraformCloudConnectionSchema, + ValidateTerraformCloudConnectionCredentialsSchema +} from "./terraform-cloud-connection-schemas"; + +export type TTerraformCloudConnection = z.infer; + +export type TTerraformCloudConnectionInput = z.infer & { + app: AppConnection.TerraformCloud; +}; + +export type TValidateTerraformCloudConnectionCredentialsSchema = + typeof ValidateTerraformCloudConnectionCredentialsSchema; + +export type TTerraformCloudConnectionConfig = DiscriminativePick< + TTerraformCloudConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TTerraformCloudVariableSet = { + id: string; + name: string; + description?: string; + global?: boolean; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudOrganization = { + id: string; + name: string; + variableSets: TTerraformCloudVariableSet[]; + workspaces: TTerraformCloudWorkspace[]; +}; diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 5e74a5bac..b968a8951 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,12 +3,18 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { SigningAlgorithm } from "@app/lib/crypto/sign"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { TCmekDecryptDTO, TCmekEncryptDTO, + TCmekGetPublicKeyDTO, + TCmekKeyEncryptionAlgorithm, + TCmekListSigningAlgorithmsDTO, + TCmekSignDTO, + TCmekVerifyDTO, TCreateCmekDTO, TListCmeksByProjectIdDTO, TUpdabteCmekByIdDTO @@ -16,6 +22,7 @@ import { import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyUsage } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; type TCmekServiceFactoryDep = { @@ -221,7 +228,151 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj const { cipherTextBlob } = await encrypt({ plainText: Buffer.from(plaintext, "base64") }); - return cipherTextBlob.toString("base64"); + return { + ciphertext: cipherTextBlob.toString("base64"), + projectId: key.projectId + }; + }; + + const listSigningAlgorithms = async ({ keyId }: TCmekListSigningAlgorithmsDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + if (key.keyUsage !== KmsKeyUsage.SIGN_VERIFY) { + throw new BadRequestError({ message: `Key with ID '${keyId}' is not intended for signing` }); + } + + const encryptionAlgorithm = key.encryptionAlgorithm as TCmekKeyEncryptionAlgorithm; + + const algos = [ + { + keyAlgorithm: "rsa", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("rsa") + ) + }, + { + keyAlgorithm: "ecc", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("ecdsa") + ) + } + ]; + + const selectedAlgorithm = algos.find((algo) => encryptionAlgorithm.toLowerCase().startsWith(algo.keyAlgorithm)); + + if (!selectedAlgorithm) { + throw new BadRequestError({ message: `Unsupported encryption algorithm: ${encryptionAlgorithm}` }); + } + + return { signingAlgorithms: selectedAlgorithm.signingAlgorithms, projectId: key.projectId }; + }; + + const getPublicKey = async ({ keyId }: TCmekGetPublicKeyDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + const publicKey = await kmsService.getPublicKey({ kmsId: keyId }); + return { publicKey: publicKey.toString("base64"), projectId: key.projectId }; + }; + + const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Sign, ProjectPermissionSub.Cmek); + + const sign = await kmsService.signWithKmsKey({ kmsId: keyId }); + + const { signature, algorithm } = await sign({ data: Buffer.from(data, "base64"), signingAlgorithm, isDigest }); + + return { + signature: signature.toString("base64"), + keyId: key.id, + projectId: key.projectId, + signingAlgorithm: algorithm + }; + }; + + const cmekVerify = async ( + { keyId, data, signature, signingAlgorithm, isDigest }: TCmekVerifyDTO, + actor: OrgServiceActor + ) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Verify, ProjectPermissionSub.Cmek); + + const verify = await kmsService.verifyWithKmsKey({ kmsId: keyId, signingAlgorithm }); + + const { signatureValid, algorithm } = await verify({ + isDigest, + data: Buffer.from(data, "base64"), + signature: Buffer.from(signature, "base64") + }); + + return { + signatureValid, + keyId: key.id, + projectId: key.projectId, + signingAlgorithm: algorithm + }; }; const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: OrgServiceActor) => { @@ -248,7 +399,10 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj const plaintextBlob = await decrypt({ cipherTextBlob: Buffer.from(ciphertext, "base64") }); - return plaintextBlob.toString("base64"); + return { + plaintext: plaintextBlob.toString("base64"), + projectId: key.projectId + }; }; return { @@ -259,6 +413,10 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj cmekEncrypt, cmekDecrypt, findCmekById, - findCmekByName + findCmekByName, + cmekSign, + cmekVerify, + listSigningAlgorithms, + getPublicKey }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index b99ff1d6e..0421bce0e 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -1,12 +1,18 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; +import { KmsKeyUsage } from "../kms/kms-types"; + +export type TCmekKeyEncryptionAlgorithm = SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; + export type TCreateCmekDTO = { orgId: string; projectId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: TCmekKeyEncryptionAlgorithm; + keyUsage: KmsKeyUsage; }; export type TUpdabteCmekByIdDTO = { @@ -38,3 +44,26 @@ export type TCmekDecryptDTO = { export enum CmekOrderBy { Name = "name" } + +export type TCmekListSigningAlgorithmsDTO = { + keyId: string; +}; + +export type TCmekGetPublicKeyDTO = { + keyId: string; +}; + +export type TCmekSignDTO = { + keyId: string; + data: string; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + +export type TCmekVerifyDTO = { + keyId: string; + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 4b07245ac..18406f29a 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -50,7 +50,7 @@ const getIntegrationSecretsV2 = async ( } // process secrets in current folder - const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId: dto.folderId, projectId: dto.projectId }); secrets.forEach((secret) => { const secretKey = secret.key; @@ -63,6 +63,7 @@ const getIntegrationSecretsV2 = async ( // if no imports then return secrets in the current folder if (!secretImports.length) return content; const importedSecrets = await fnSecretsV2FromImports({ + projectId: dto.projectId, decryptor: dto.decryptor, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 7bd33d86b..e8ef72f7a 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -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", diff --git a/backend/src/services/kms/kms-fns.ts b/backend/src/services/kms/kms-fns.ts index 06395272b..8c7aa13dd 100644 --- a/backend/src/services/kms/kms-fns.ts +++ b/backend/src/services/kms/kms-fns.ts @@ -1,13 +1,55 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm } from "@app/lib/crypto/sign"; +import { BadRequestError } from "@app/lib/errors"; + +import { KmsKeyUsage } from "./kms-types"; export const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; -export const getByteLengthForAlgorithm = (encryptionAlgorithm: SymmetricEncryption) => { +export const getByteLengthForSymmetricEncryptionAlgorithm = (encryptionAlgorithm: SymmetricKeyAlgorithm) => { switch (encryptionAlgorithm) { - case SymmetricEncryption.AES_GCM_128: + case SymmetricKeyAlgorithm.AES_GCM_128: return 16; - case SymmetricEncryption.AES_GCM_256: + case SymmetricKeyAlgorithm.AES_GCM_256: default: return 32; } }; + +export const verifyKeyTypeAndAlgorithm = ( + keyUsage: KmsKeyUsage, + algorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm, + extra?: { + forceType?: KmsKeyUsage; + } +) => { + if (extra?.forceType && keyUsage !== extra.forceType) { + throw new BadRequestError({ + message: `Unsupported key type, expected ${extra.forceType} but got ${keyUsage}` + }); + } + + if (keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) { + if (!Object.values(SymmetricKeyAlgorithm).includes(algorithm as SymmetricKeyAlgorithm)) { + throw new BadRequestError({ + message: `Unsupported encryption algorithm for encrypt/decrypt key: ${algorithm as string}` + }); + } + + return true; + } + + if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + if (!Object.values(AsymmetricKeyAlgorithm).includes(algorithm as AsymmetricKeyAlgorithm)) { + throw new BadRequestError({ + message: `Unsupported sign/verify algorithm for sign/verify key: ${algorithm as string}` + }); + } + + return true; + } + + throw new BadRequestError({ + message: `Unsupported key type: ${keyUsage as string}` + }); +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index cfd64a89a..07ed90bef 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -15,12 +15,17 @@ import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { randomSecureBytes } from "@app/lib/crypto"; -import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { generateHash } from "@app/lib/crypto/encryption"; +import { AsymmetricKeyAlgorithm, signingService } from "@app/lib/crypto/sign"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { getByteLengthForAlgorithm, KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { + getByteLengthForSymmetricEncryptionAlgorithm, + KMS_ROOT_CONFIG_UUID, + verifyKeyTypeAndAlgorithm +} from "@app/services/kms/kms-fns"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -29,6 +34,7 @@ import { TKmsKeyDALFactory } from "./kms-key-dal"; import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; import { KmsDataKey, + KmsKeyUsage, KmsType, RootKeyEncryptionStrategy, TDecryptWithKeyDTO, @@ -38,8 +44,11 @@ import { TEncryptWithKmsDTO, TGenerateKMSDTO, TGetKeyMaterialDTO, + TGetPublicKeyDTO, TImportKeyMaterialDTO, - TUpdateProjectSecretManagerKmsKeyDTO + TSignWithKmsDTO, + TUpdateProjectSecretManagerKmsKeyDTO, + TVerifyWithKmsDTO } from "./kms-types"; type TKmsServiceFactoryDep = { @@ -83,19 +92,42 @@ export const kmsServiceFactory = ({ tx, name, projectId, - encryptionAlgorithm = SymmetricEncryption.AES_GCM_256, + encryptionAlgorithm = SymmetricKeyAlgorithm.AES_GCM_256, + keyUsage = KmsKeyUsage.ENCRYPT_DECRYPT, description }: TGenerateKMSDTO) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: ensure that the key type (sign/encrypt) and the encryption algorithm are compatible. + verifyKeyTypeAndAlgorithm(keyUsage, encryptionAlgorithm); - const kmsKeyMaterial = randomSecureBytes(getByteLengthForAlgorithm(encryptionAlgorithm)); + let kmsKeyMaterial: Buffer | null = null; + if (keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) { + kmsKeyMaterial = randomSecureBytes( + getByteLengthForSymmetricEncryptionAlgorithm(encryptionAlgorithm as SymmetricKeyAlgorithm) + ); + } else if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + const { generateAsymmetricPrivateKey, getPublicKeyFromPrivateKey } = signingService( + encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + kmsKeyMaterial = await generateAsymmetricPrivateKey(); + // daniel: safety check to ensure we're able to extract the public key from the private key before we proceed to key creation + getPublicKeyFromPrivateKey(kmsKeyMaterial); + } + + if (!kmsKeyMaterial) { + throw new BadRequestError({ + message: `Invalid KMS key type. No key material was created for key usage '${keyUsage}' using algorithm '${encryptionAlgorithm}'` + }); + } + + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { const kmsDoc = await kmsDAL.create( { name: sanitizedName, + keyUsage, orgId, isReserved, projectId, @@ -115,6 +147,7 @@ export const kmsServiceFactory = ({ ); return kmsDoc; }; + if (tx) return dbQuery(tx); const doc = await kmsDAL.transaction(async (tx2) => dbQuery(tx2)); return doc; @@ -134,7 +167,7 @@ export const kmsServiceFactory = ({ */ const encryptWithInputKey = async ({ key }: Omit) => { // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return ({ plainText }: Pick) => { const encryptedPlainTextBlob = cipher.encrypt(plainText, key); // Buffer#1 encrypted text + Buffer#2 version number @@ -149,7 +182,7 @@ export const kmsServiceFactory = ({ * This can be even later exposed directly as api for encryption as function */ const decryptWithInputKey = async ({ key }: Omit) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); @@ -227,7 +260,7 @@ export const kmsServiceFactory = ({ }; const encryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return (plainTextBuffer: Buffer) => { const encryptedBuffer = cipher.encrypt(plainTextBuffer, ROOT_ENCRYPTION_KEY); @@ -236,7 +269,7 @@ export const kmsServiceFactory = ({ }; const decryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return (cipherTextBuffer: Buffer) => { return cipher.decrypt(cipherTextBuffer, ROOT_ENCRYPTION_KEY); @@ -315,9 +348,14 @@ export const kmsServiceFactory = ({ }; } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.ENCRYPT_DECRYPT + }); + // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { @@ -345,19 +383,22 @@ export const kmsServiceFactory = ({ }); } - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return kmsKey; }; const importKeyMaterial = async ( - { key, algorithm, name, isReserved, projectId, orgId }: TImportKeyMaterialDTO, + { key, algorithm, name, isReserved, projectId, orgId, keyUsage }: TImportKeyMaterialDTO, tx?: Knex ) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: currently we only support imports for encrypt/decrypt keys + verifyKeyTypeAndAlgorithm(keyUsage, algorithm, { forceType: KmsKeyUsage.ENCRYPT_DECRYPT }); - const expectedByteLength = getByteLengthForAlgorithm(algorithm); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + + const expectedByteLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyAlgorithm); if (key.byteLength !== expectedByteLength) { throw new BadRequestError({ message: `Invalid key length for ${algorithm}. Expected ${expectedByteLength} bytes but got ${key.byteLength} bytes` @@ -370,6 +411,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, orgId, isReserved, projectId @@ -393,6 +435,74 @@ export const kmsServiceFactory = ({ return doc; }; + const getPublicKey = async ({ kmsId }: TGetPublicKeyDTO) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + return signingService(encryptionAlgorithm).getPublicKeyFromPrivateKey(kmsKey); + }; + + const signWithKmsKey = async ({ kmsId }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const { sign } = signingService(encryptionAlgorithm); + return async ({ + data, + signingAlgorithm, + isDigest + }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + const signature = await sign(data, kmsKey, signingAlgorithm, isDigest); + + return Promise.resolve({ signature, algorithm: signingAlgorithm }); + }; + }; + + const verifyWithKmsKey = async ({ + kmsId, + signingAlgorithm + }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const { verify, getPublicKeyFromPrivateKey } = signingService(encryptionAlgorithm); + return async ({ data, signature, isDigest }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + const publicKey = getPublicKeyFromPrivateKey(kmsKey); + const signatureValid = await verify(data, signature, publicKey, signingAlgorithm, isDigest); + return Promise.resolve({ signatureValid, algorithm: signingAlgorithm }); + }; + }; + const encryptWithKmsKey = async ({ kmsId }: Omit, tx?: Knex) => { const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId, tx); if (!kmsDoc) { @@ -453,9 +563,14 @@ export const kmsServiceFactory = ({ }; } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.ENCRYPT_DECRYPT + }); + // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); return ({ plainText }: Pick) => { const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); const encryptedPlainTextBlob = dataCipher.encrypt(plainText, kmsKey); @@ -729,7 +844,7 @@ export const kmsServiceFactory = ({ // case 2: root key is encrypted with software encryption if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); @@ -749,7 +864,7 @@ export const kmsServiceFactory = ({ } if (strategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.encrypt(plainKeyBuffer, encryptionKeyBuffer); @@ -765,7 +880,7 @@ export const kmsServiceFactory = ({ const createCipherPairWithDataKey = async (encryptionContext: TEncryptWithKmsDataKeyDTO, trx?: Knex) => { const dataKey = await $getDataKey(encryptionContext, trx); - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return { encryptor: ({ plainText }: Pick) => { @@ -966,6 +1081,7 @@ export const kmsServiceFactory = ({ const decryptedRootKey = await $decryptRootKey(kmsRootConfig); logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; }; @@ -1014,6 +1130,9 @@ export const kmsServiceFactory = ({ getKmsById, createCipherPairWithDataKey, getKeyMaterial, - importKeyMaterial + importKeyMaterial, + signWithKmsKey, + verifyWithKmsKey, + getPublicKey }; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 8be0b29fc..ca2401bb6 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,6 +1,7 @@ import { Knex } from "knex"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; export enum KmsDataKey { Organization, @@ -13,6 +14,11 @@ export enum KmsType { Internal = "internal" } +export enum KmsKeyUsage { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TEncryptWithKmsDataKeyDTO = | { type: KmsDataKey.Organization; orgId: string } | { type: KmsDataKey.SecretManager; projectId: string }; @@ -25,7 +31,8 @@ export type TEncryptWithKmsDataKeyDTO = export type TGenerateKMSDTO = { orgId: string; projectId?: string; - encryptionAlgorithm?: SymmetricEncryption; + encryptionAlgorithm?: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; + keyUsage?: KmsKeyUsage; isReserved?: boolean; name?: string; description?: string; @@ -37,6 +44,25 @@ export type TEncryptWithKmsDTO = { plainText: Buffer; }; +export type TGetPublicKeyDTO = { + kmsId: string; +}; + +export type TSignWithKmsDTO = { + kmsId: string; + data: Buffer; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + +export type TVerifyWithKmsDTO = { + kmsId: string; + data: Buffer; + signature: Buffer; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + export type TEncryptionWithKeyDTO = { key: Buffer; plainText: Buffer; @@ -67,9 +93,10 @@ export type TGetKeyMaterialDTO = { export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; name?: string; isReserved: boolean; projectId: string; orgId: string; + keyUsage: KmsKeyUsage; }; diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index da25f4d30..1a171aa2e 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -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; 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((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 }; }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index e5a450441..2056d5a2c 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -159,7 +159,8 @@ export const fnSecretsV2FromImports = async ({ decryptor, expandSecretReferences, hasSecretAccess, - viewSecretValue + viewSecretValue, + projectId }: { secretImports: (Omit & { importEnv: { id: string; slug: string; name: string }; @@ -176,6 +177,7 @@ export const fnSecretsV2FromImports = async ({ environment: string; }) => Promise; hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean; + projectId: string; }) => { const cyclicDetector = new Set(); const stack: { @@ -216,7 +218,8 @@ export const fnSecretsV2FromImports = async ({ type: SecretType.Shared }, { - sort: [["id", "asc"]] + sort: [["id", "asc"]], + useCache: { projectId } } ); const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index fdd326b5f..571ae8b73 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -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 { @@ -43,7 +44,7 @@ type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; folderDAL: TSecretFolderDALFactory; secretDAL: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick; projectBotService: Pick; projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; @@ -184,6 +185,7 @@ export const secretImportServiceFactory = ({ }); } + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return { ...secImport, importEnv }; }; @@ -281,6 +283,8 @@ export const secretImportServiceFactory = ({ ); return doc; }); + + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return { ...updatedSecImport, importEnv: importedEnv }; }; @@ -355,6 +359,7 @@ export const secretImportServiceFactory = ({ actorId }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return secImport; }; @@ -693,6 +698,7 @@ export const secretImportServiceFactory = ({ projectId }); const importedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports, folderDAL, viewSecretValue: true, @@ -793,6 +799,136 @@ 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 + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ); + + 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, @@ -805,6 +941,7 @@ export const secretImportServiceFactory = ({ getProjectImportCount, fnSecretsFromImports, getProjectImportMultiEnvCount, - getImportsMultiEnv + getImportsMultiEnv, + getFolderIsImportedBy }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 638e36cb1..e4490e715 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -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[]; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 70febfc0d..9349e2197 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -7,6 +7,7 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index ea22ebffc..a3efa4bc1 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -27,6 +27,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; const SECRET_SYNC_LIST_OPTIONS: Record = { @@ -38,6 +39,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, + [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION }; @@ -125,6 +127,8 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.Camunda: return camundaSyncFactory({ appConnectionDAL, @@ -176,6 +180,9 @@ export const SecretSyncFns = { case SecretSync.Humanitec: secretMap = await HumanitecSyncFns.getSecrets(secretSync); break; + case SecretSync.TerraformCloud: + secretMap = await TerraformCloudSyncFns.getSecrets(secretSync); + break; case SecretSync.Camunda: secretMap = await camundaSyncFactory({ appConnectionDAL, @@ -227,6 +234,8 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.Camunda: return camundaSyncFactory({ appConnectionDAL, diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 10e89ff03..661815814 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -10,6 +10,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", [SecretSync.Humanitec]: "Humanitec", + [SecretSync.TerraformCloud]: "Terraform Cloud", [SecretSync.Camunda]: "Camunda", [SecretSync.Vercel]: "Vercel" }; @@ -23,6 +24,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 8afcf6416..17257b8e2 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -213,7 +213,7 @@ export const secretSyncQueueFactory = ({ canExpandValue: () => true }); - const secrets = await secretV2BridgeDAL.findByFolderId(folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId, projectId }); await Promise.allSettled( secrets.map(async (secret) => { @@ -243,6 +243,7 @@ export const secretSyncQueueFactory = ({ if (secretImports.length) { const importedSecrets = await fnSecretsV2FromImports({ + projectId, decryptor: decryptSecretValue, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 091f91240..d3207918c 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -55,6 +55,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTerraformCloudSync, + TTerraformCloudSyncInput, + TTerraformCloudSyncListItem, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud"; import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; export type TSecretSync = @@ -66,6 +72,7 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync + | TTerraformCloudSync | TCamundaSync | TVercelSync; @@ -78,6 +85,7 @@ export type TSecretSyncWithCredentials = | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials | THumanitecSyncWithCredentials + | TTerraformCloudSyncWithCredentials | TCamundaSyncWithCredentials | TVercelSyncWithCredentials; @@ -90,6 +98,7 @@ export type TSecretSyncInput = | TAzureAppConfigurationSyncInput | TDatabricksSyncInput | THumanitecSyncInput + | TTerraformCloudSyncInput | TCamundaSyncInput | TVercelSyncInput; @@ -102,6 +111,7 @@ export type TSecretSyncListItem = | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem | THumanitecSyncListItem + | TTerraformCloudSyncListItem | TCamundaSyncListItem | TVercelSyncListItem; diff --git a/backend/src/services/secret-sync/terraform-cloud/index.ts b/backend/src/services/secret-sync/terraform-cloud/index.ts new file mode 100644 index 000000000..2df19747d --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/index.ts @@ -0,0 +1,5 @@ +export * from "./terraform-cloud-sync-constants"; +export * from "./terraform-cloud-sync-enums"; +export * from "./terraform-cloud-sync-fns"; +export * from "./terraform-cloud-sync-schemas"; +export * from "./terraform-cloud-sync-types"; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts new file mode 100644 index 000000000..edca7d304 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const TERRAFORM_CLOUD_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Terraform Cloud", + destination: SecretSync.TerraformCloud, + connection: AppConnection.TerraformCloud, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts new file mode 100644 index 000000000..cfd1daf2c --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts @@ -0,0 +1,9 @@ +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} + +export enum TerraformCloudSyncCategory { + Environment = "env", + Terraform = "terraform" +} diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts new file mode 100644 index 000000000..4cfd7ec05 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -0,0 +1,253 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosResponse } from "axios"; + +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; +import { TerraformCloudSyncScope } from "./terraform-cloud-sync-enums"; +import { + TerraformCloudApiResponse, + TerraformCloudApiVariable, + TerraformCloudVariable, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud-sync-types"; + +const getTerraformCloudVariables = async ( + secretSync: TTerraformCloudSyncWithCredentials +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url: string; + let source: TerraformCloudVariable["source"]; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars`; + source = "varset"; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars`; + source = "workspace"; + } + + const headers = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + }; + + const fetchAllPages = async (): Promise => { + let results: TerraformCloudApiVariable[] = []; + let nextUrl: string | null = url; + + while (nextUrl) { + const res: AxiosResponse> = await request.get< + TerraformCloudApiResponse + >(nextUrl, { + headers + }); + + if (res.data?.data) { + results = results.concat(res.data.data); + } + + nextUrl = res.data?.links?.next ?? null; + } + + return results; + }; + + const allVariableData = await fetchAllPages(); + + const variables: TerraformCloudVariable[] = allVariableData.map((variable) => ({ + id: variable.id, + key: variable.attributes.key, + value: variable.attributes.value || "", + sensitive: variable.attributes.sensitive, + description: variable.attributes.description || "", + category: variable.attributes.category, + source + })); + + return variables; +}; + +const deleteVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + variable: TerraformCloudVariable +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars/${variable.id}`; + } + + await request.delete(url, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +const createVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + key: string +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars`; + } + + await request.post( + url, + { + data: { + type: "vars", + attributes: { + key, + value: secretMap[key].value, + description: secretMap[key].comment || "", + category: secretSync.destinationConfig.category, + sensitive: true + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + variable: TerraformCloudVariable +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars/${variable.id}`; + } + + await request.patch( + url, + { + data: { + type: "vars", + id: variable.id, + attributes: { + value: secretMap[variable.key].value, + description: secretMap[variable.key].comment || "", + category: secretSync.destinationConfig.category + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +export const TerraformCloudSyncFns = { + syncSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + const terraformCloudVariablesMap = new Map( + terraformCloudVariables.map((v) => [v.key, v]) + ); + + const secretKeys = Object.keys(secretMap); + for (const key of secretKeys) { + const existingVariable = terraformCloudVariablesMap.get(key); + + if (!existingVariable) { + await createVariable(secretSync, secretMap, key); + } else { + await updateVariable(secretSync, secretMap, existingVariable); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for (const terraformCloudVariable of terraformCloudVariables) { + if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { + await deleteVariable(secretSync, terraformCloudVariable); + } + } + }, + + getSecrets: async (secretSync: TTerraformCloudSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + + for (const variable of terraformCloudVariables) { + if (Object.prototype.hasOwnProperty.call(secretMap, variable.key)) { + await deleteVariable(secretSync, variable); + } + } + } +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts new file mode 100644 index 000000000..359d7f4c5 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { + TerraformCloudSyncCategory, + TerraformCloudSyncScope +} from "@app/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums"; + +const TerraformCloudSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z + .literal(TerraformCloudSyncScope.VariableSet) + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + variableSetName: z + .string() + .min(1, "Variable set name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.variableSetName), + variableSetId: z + .string() + .min(1, "Variable set ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.variableSetId), + category: z.nativeEnum(TerraformCloudSyncCategory).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.category) + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + workspaceName: z + .string() + .min(1, "Workspace name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.workspaceName), + workspaceId: z + .string() + .min(1, "Workspace ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.workspaceId), + category: z.nativeEnum(TerraformCloudSyncCategory).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.category) + }) +]); + +const TerraformCloudSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const TerraformCloudSyncSchema = BaseSecretSyncSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const CreateTerraformCloudSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const UpdateTerraformCloudSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema.optional() +}); + +export const TerraformCloudSyncListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + connection: z.literal(AppConnection.TerraformCloud), + destination: z.literal(SecretSync.TerraformCloud), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts new file mode 100644 index 000000000..f68db0d51 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts @@ -0,0 +1,77 @@ +import z from "zod"; + +import { TTerraformCloudConnection } from "@app/services/app-connection/terraform-cloud"; + +import { + CreateTerraformCloudSyncSchema, + TerraformCloudSyncListItemSchema, + TerraformCloudSyncSchema +} from "./terraform-cloud-sync-schemas"; + +export type TTerraformCloudSyncListItem = z.infer; + +export type TTerraformCloudSync = z.infer; + +export type TTerraformCloudSyncInput = z.infer; + +export type TTerraformCloudSyncWithCredentials = TTerraformCloudSync & { + connection: TTerraformCloudConnection; +}; + +export type TerraformCloudApiVariable = { + id: string; + type: string; + attributes: { + key: string; + value: string | null; + sensitive: boolean; + category: "terraform" | "env"; + hcl: boolean; + description: string | null; + }; + relationships: { + workspace?: { + data: { + id: string; + type: string; + }; + }; + project?: { + data: { + id: string; + type: string; + }; + }; + }; +}; + +export type TerraformCloudVariable = { + id: string; + key: string; + value: string; + sensitive: boolean; + description: string; + category: "terraform" | "env"; + source: "varset" | "workspace"; +}; + +export type TerraformCloudApiResponse = { + data: T; + included?: unknown[]; + links?: { + self?: string; + first?: string; + prev?: string; + next?: string; + last?: string; + }; + meta?: { + pagination?: { + current_page: number; + prev_page: number | null; + next_page: number | null; + total_pages: number; + total_count: number; + }; + }; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 9bed01637..a9c909899 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -2,7 +2,10 @@ import { Knex } from "knex"; import { validate as uuidValidate } from "uuid"; import { TDbClient } from "@app/db"; -import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; +import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { generateCacheKeyFromData } from "@app/lib/crypto/cache"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { buildFindFilter, @@ -12,15 +15,67 @@ import { TFindFilter, TFindOpt } from "@app/lib/knex"; -import { OrderByDirection } from "@app/lib/types"; +import { BufferKeysToString, OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; -import { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import type { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; + +export const SecretDalCacheKeys = { + get productKey() { + const { INFISICAL_PLATFORM_VERSION } = getConfig(); + return `${ProjectType.SecretManager}:${INFISICAL_PLATFORM_VERSION || 0}`; + }, + getSecretDalVersion: (projectId: string) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${TableName.SecretV2}-dal-version`; + }, + findByFolderIds: ( + projectId: string, + version: number, + { useCache, tx, ...cacheKey }: Parameters[0] + ) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find-by-folder-ids:${generateCacheKeyFromData(cacheKey)}`; + }, + findByFolderId: ( + projectId: string, + version: number, + { useCache, tx, ...cacheKey }: Parameters[0] + ) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find-by-folder-id:${generateCacheKeyFromData(cacheKey)}`; + }, + find: (projectId: string, version: number, ...args: Parameters) => { + const [filter, opts] = args; + delete opts?.tx; + delete opts?.useCache; + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find:${generateCacheKeyFromData({ + filter, + opts + })}`; + } +}; export type TSecretV2BridgeDALFactory = ReturnType; +interface TSecretV2DalArg { + db: TDbClient; + keyStore: TKeyStoreFactory; +} -export const secretV2BridgeDALFactory = (db: TDbClient) => { +const SECRET_DAL_TTL = 5 * 60; +const SECRET_DAL_VERSION_TTL = 15 * 60; +const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; +export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const secretOrm = ormify(db, TableName.SecretV2); + const invalidateSecretCacheByProjectId = async (projectId: string) => { + const secretDalVersionKey = SecretDalCacheKeys.getSecretDalVersion(projectId); + await keyStore.incrementBy(secretDalVersionKey, 1); + await keyStore.setExpiry(secretDalVersionKey, SECRET_DAL_VERSION_TTL); + }; + const findOne = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretV2) @@ -73,8 +128,35 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter, + opts: TFindOpt & { useCache?: { projectId: string } } = {} + ) => { + const { offset, limit, sort, tx, useCache } = opts; try { + let secretDalVersion = 0; + if (useCache) { + const cachedSecretDalVersion = await keyStore.getItem( + SecretDalCacheKeys.getSecretDalVersion(useCache.projectId) + ); + secretDalVersion = Number(cachedSecretDalVersion || 0); + const cacheKey = SecretDalCacheKeys.find(useCache.projectId, secretDalVersion, filter, opts); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const query = (tx || db)(TableName.SecretV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter)) @@ -142,6 +224,23 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + + if (useCache) { + const cachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(cachedSecrets); + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.find(useCache.projectId, secretDalVersion, filter, opts), + SECRET_DAL_TTL, + cache + ); + } + } + return data; } catch (error) { throw new DatabaseError({ error, name: `${TableName.SecretV2}: Find` }); @@ -246,14 +345,43 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => { + const findByFolderId = async (dto: { + folderId: string; + userId?: string; + tx?: Knex; + projectId: string; + useCache?: boolean; + }) => { try { - // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + const { folderId, tx, projectId } = dto; + let { userId } = dto; + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo if (userId && !uuidValidate(userId)) { // eslint-disable-next-line userId = undefined; } + const cachedSecretDalVersion = await keyStore.getItem(SecretDalCacheKeys.getSecretDalVersion(projectId)); + const secretDalVersion = Number(cachedSecretDalVersion || 0); + + if (dto.useCache) { + const cacheKey = SecretDalCacheKeys.findByFolderId(projectId, secretDalVersion, dto); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const secs = await (tx || db.replicaNode())(TableName.SecretV2) .where({ folderId }) .where((bd) => { @@ -309,6 +437,22 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + if (dto.useCache) { + const newCachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(newCachedSecrets); + + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.findByFolderId(projectId, secretDalVersion, dto), + SECRET_DAL_TTL, + cache + ); + } + } return data; } catch (error) { throw new DatabaseError({ error, name: "get all secret" }); @@ -394,12 +538,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderIds = async ( - folderIds: string[], - userId?: string, - tx?: Knex, - filters?: TFindSecretsByFolderIdsFilter - ) => { + const findByFolderIds = async (dto: { + folderIds: string[]; + userId?: string; + tx?: Knex; + projectId: string; + filters?: TFindSecretsByFolderIdsFilter; + useCache?: boolean; + }) => { + const { folderIds, tx, filters, useCache, projectId } = dto; + let { userId } = dto; try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) if (userId && !uuidValidate(userId)) { @@ -407,6 +555,26 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId = undefined; } + const cachedSecretDalVersion = await keyStore.getItem(SecretDalCacheKeys.getSecretDalVersion(projectId)); + const secretDalVersion = Number(cachedSecretDalVersion || 0); + if (useCache) { + const cacheKey = SecretDalCacheKeys.findByFolderIds(projectId, secretDalVersion, dto); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const query = (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.folderId`, folderIds) .where((bd) => { @@ -532,6 +700,22 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + if (useCache) { + const cachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(cachedSecrets); + + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.findByFolderIds(projectId, secretDalVersion, dto), + SECRET_DAL_TTL, + cache + ); + } + } return data; } catch (error) { @@ -724,6 +908,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { findAllProjectSecretValues, countByFolderIds, findOne, - find + find, + invalidateSecretCacheByProjectId }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 4ab021510..3b35ab41a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -501,7 +501,7 @@ export const expandSecretReferencesFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return { value: "", tags: [] }; - const secrets = await secretDAL.findByFolderId(folder.id); + const secrets = await secretDAL.findByFolderId({ folderId: folder.id, projectId, useCache: true }); const decryptedSecret = secrets.reduce>((prev, secret) => { // eslint-disable-next-line no-param-reassign diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 509144e21..596ebb5a1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -331,6 +331,7 @@ export const secretV2BridgeServiceFactory = ({ return createdSecret; }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -539,6 +540,7 @@ export const secretV2BridgeServiceFactory = ({ projectId }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -647,6 +649,7 @@ export const secretV2BridgeServiceFactory = ({ }) ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -796,12 +799,14 @@ export const secretV2BridgeServiceFactory = ({ ) => { const groupedFolderMappings = groupBy(folderMappings, (folderMapping) => folderMapping.folderId); - const secrets = await secretDAL.findByFolderIds( - folderMappings.map((folderMapping) => folderMapping.folderId), + const secrets = await secretDAL.findByFolderIds({ + projectId, + folderIds: folderMappings.map((folderMapping) => folderMapping.folderId), userId, - undefined, - filters - ); + tx: undefined, + filters, + useCache: true + }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -952,12 +957,14 @@ export const secretV2BridgeServiceFactory = ({ const groupedPaths = groupBy(paths, (p) => p.folderId); - const secrets = await secretDAL.findByFolderIds( - paths.map((p) => p.folderId), - actorId, - undefined, - params - ); + const secrets = await secretDAL.findByFolderIds({ + projectId, + folderIds: paths.map((p) => p.folderId), + userId: actorId, + tx: undefined, + filters: params, + useCache: true + }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1087,6 +1094,7 @@ export const secretV2BridgeServiceFactory = ({ const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); const allowedImports = secretImports.filter(({ isReplication }) => !isReplication); const importedSecrets = await fnSecretsV2FromImports({ + projectId, viewSecretValue, secretImports: allowedImports, secretDAL, @@ -1304,6 +1312,7 @@ export const secretV2BridgeServiceFactory = ({ if (!secret && includeImports) { const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); const importedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports, viewSecretValue, secretDAL, @@ -1543,7 +1552,7 @@ export const secretV2BridgeServiceFactory = ({ tx }) ); - + await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, @@ -1883,6 +1892,7 @@ export const secretV2BridgeServiceFactory = ({ } }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined))); await Promise.allSettled( folders.map((el) => @@ -2014,6 +2024,7 @@ export const secretV2BridgeServiceFactory = ({ }) ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, @@ -2537,6 +2548,9 @@ export const secretV2BridgeServiceFactory = ({ } }); + if (isDestinationUpdated || isSourceUpdated) { + await secretDAL.invalidateSecretCacheByProjectId(projectId); + } if (isDestinationUpdated) { await snapshotService.performSnapshot(destinationFolder.id); await secretQueueService.syncSecrets({ @@ -2715,7 +2729,7 @@ export const secretV2BridgeServiceFactory = ({ generatePaths(folderMap).map(({ folderId, path }) => [folderId, path === "/" ? path : path.substring(1)]) ); - const secrets = await secretDAL.findByFolderIds(folders.map((f) => f.id)); + const secrets = await secretDAL.findByFolderIds({ folderIds: folders.map((f) => f.id), projectId, useCache: true }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 7b4ea1ee1..11149c605 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -279,6 +279,13 @@ export type TUpdateManySecretsFnFactory = { folderDAL: TSecretFolderDALFactory; }; +export type TFindByFolderIdDALDTO = { + folderId: string; + userId?: string; + tx?: Knex; + projectId: string; +}; + export type TUpdateManySecretsFn = { projectId: string; environment: string; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 60eec9cb1..0d36250fb 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -367,7 +367,7 @@ export const secretQueueFactory = ({ canExpandValue: () => true }); // process secrets in current folder - const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId: dto.folderId, projectId: dto.projectId }); await Promise.allSettled( secrets.map(async (secret) => { @@ -397,6 +397,7 @@ export const secretQueueFactory = ({ // if no imports then return secrets in the current folder if (!secretImports.length) return content; const importedSecrets = await fnSecretsV2FromImports({ + projectId: dto.projectId, decryptor: dto.decryptor, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx new file mode 100644 index 000000000..fb2dbdeaf --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/terraform-cloud/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx new file mode 100644 index 000000000..ad7d4a5d1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/terraform-cloud" +--- + + + Check out the configuration docs for [Terraform Cloud Connections](/integrations/app-connections/terraform-cloud) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx new file mode 100644 index 000000000..daa558f5a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/terraform-cloud/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx new file mode 100644 index 000000000..587cc8f11 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/terraform-cloud/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx new file mode 100644 index 000000000..722381605 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/terraform-cloud/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx new file mode 100644 index 000000000..831846155 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx new file mode 100644 index 000000000..b8f526a88 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/terraform-cloud/{connectionId}" +--- + + + Check out the configuration docs for [Terraform Cloud Connections](/integrations/app-connections/terraform-cloud) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/kms/keys/decrypt.mdx b/docs/api-reference/endpoints/kms/encryption/decrypt.mdx similarity index 100% rename from docs/api-reference/endpoints/kms/keys/decrypt.mdx rename to docs/api-reference/endpoints/kms/encryption/decrypt.mdx diff --git a/docs/api-reference/endpoints/kms/keys/encrypt.mdx b/docs/api-reference/endpoints/kms/encryption/encrypt.mdx similarity index 100% rename from docs/api-reference/endpoints/kms/keys/encrypt.mdx rename to docs/api-reference/endpoints/kms/encryption/encrypt.mdx diff --git a/docs/api-reference/endpoints/kms/signing/public-key.mdx b/docs/api-reference/endpoints/kms/signing/public-key.mdx new file mode 100644 index 000000000..4c8e1fda5 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/public-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve Public Key" +openapi: "GET /api/v1/kms/keys/{keyId}/public-key" +--- diff --git a/docs/api-reference/endpoints/kms/signing/sign.mdx b/docs/api-reference/endpoints/kms/signing/sign.mdx new file mode 100644 index 000000000..ebeca5924 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/sign.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign Data" +openapi: "POST /api/v1/kms/keys/{keyId}/sign" +--- diff --git a/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx b/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx new file mode 100644 index 000000000..0a09ef9e0 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx @@ -0,0 +1,4 @@ +--- +title: "List Signing Algorithms" +openapi: "GET /api/v1/kms/keys/{keyId}/signing-algorithms" +--- diff --git a/docs/api-reference/endpoints/kms/signing/verify.mdx b/docs/api-reference/endpoints/kms/signing/verify.mdx new file mode 100644 index 000000000..a76270fc3 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/verify.mdx @@ -0,0 +1,4 @@ +--- +title: "Verify Signature" +openapi: "POST /api/v1/kms/keys/{keyId}/verify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx new file mode 100644 index 000000000..491889e16 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx new file mode 100644 index 000000000..dfd3206f5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx new file mode 100644 index 000000000..c25888a53 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx new file mode 100644 index 000000000..5a1645866 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/terraform-cloud/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx new file mode 100644 index 000000000..0993c76ef --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx new file mode 100644 index 000000000..6f00362e3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/terraform-cloud/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx new file mode 100644 index 000000000..c71b68e48 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/terraform-cloud/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx new file mode 100644 index 000000000..759fcfc71 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index aca42c5c4..2a279ce90 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index da39c0a56..f88a88d35 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **MySQL**. diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index e8fa86028..c7b34bec9 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **Oracle**. @@ -62,7 +66,7 @@ Create a user with the required permission in your SQL instance. This user will A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png) diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index 5216d87af..feb81d6d6 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **PostgreSQL**. @@ -63,7 +67,7 @@ Create a user with the required permission in your SQL instance. This user will A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png) diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms/overview.mdx index 8991646cf..577373ab8 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms/overview.mdx @@ -30,7 +30,9 @@ The typical workflow for using Infisical KMS consists of the following steps: as via API. -## Guide to Encrypting Data +## Encryption + +### Guide to Encrypting Data In the following steps, we explore how to generate a key and use it to encrypt data. @@ -44,7 +46,8 @@ In the following steps, we explore how to generate a key and use it to encrypt d Specify your key details. Here's some guidance on each field: - Name: A slug-friendly name for the key. - - Type: The encryption algorithm associated with the key (e.g. `AES-GCM-256`). + - Key Usage: The type of key to create (e.g `Encrypt/Decrypt` for encryption, and `Sign/Verify` for signing). + - Algorithm: The encryption algorithm associated with the key (e.g. `AES-GCM-256`). - Description: An optional description of what the intended usage is for the key. ![kms add key modal](/images/platform/kms/infisical-kms/kms-add-key-modal.png) @@ -137,7 +140,7 @@ In the following steps, we explore how to generate a key and use it to encrypt d -## Guide to Decrypting Data +### Guide to Decrypting Data In the following steps, we explore how to use decrypt data using an existing key in Infisical KMS. @@ -193,6 +196,164 @@ In the following steps, we explore how to use decrypt data using an existing key +## Signing + +### Guide to Signing Data + +In the following steps, we explore how to generate a key and use it to sign data. + + + + + + Navigate to Project > Key Management and tap on the **Add Key** button. + ![kms add key button](/images/platform/kms/infisical-kms/kms-add-key.png) + + Specify your key details. Here's some guidance on each field: + + - Name: A slug-friendly name for the key. + - Key Usage: The type of key to create (e.g `Encrypt/Decrypt` for encryption, and `Sign/Verify` for signing). + - Algorithm: The signing algorithm associated with the key (e.g. `RSA_4096`). + - Description: An optional description of what the intended usage is for the key. + + ![kms add key modal](/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png) + + + + Once your key is generated, open the options menu for the newly created key and select sign data. + ![kms key options](/images/platform/kms/infisical-kms/signing/sign-options.png) + + Populate the text area with your data and tap on the Sign button. + ![kms sign data](/images/platform/kms/infisical-kms/signing/sign-data-modal.png) + + Make sure to select the appropriate signing algorithm that will be used to sign the data. + Supported signing algorithms are: + + **For RSA keys:** + - `RSASSA PSS SHA 512`: Not deterministic, and includes random salt. + - `RSASSA PSS SHA 384`: Not deterministic, and includes random salt. + - `RSASSA PSS SHA 256`: Not deterministic, and includes random salt. + - `RSASSA PKCS1 V1.5 SHA 512`: Deterministic, and does not include randomness. + - `RSASSA PKCS1 V1.5 SHA 384`: Deterministic, and does not include randomness. + - `RSASSA PKCS1 V1.5 SHA 256`: Deterministic, and does not include randomness. + + **For ECC keys:** + - `ECDSA SHA 512`: Not deterministic, and includes randomness. + - `ECDSA SHA 384`: Not deterministic, and includes randomness. + - `ECDSA SHA 256`: Not deterministic, and includes randomness. + + In this example, we'll use the `RSASSA PSS SHA 512` signing algorithm. + + + If your data is already Base64 encoded make sure to toggle the respective switch on to avoid + redundant encoding. + + + Copy and store the signature of your data. + ![kms signed data](/images/platform/kms/infisical-kms/signing/copy-signature.png) + + + + + + + To sign data, make an API request to the [Sign + Data](/api-reference/endpoints/kms/signing/sign) API endpoint, + specifying the key to use. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//sign \ + --header 'Content-Type: application/json' \ + --data '{ + "data": "SGVsbG8sIFdvcmxkIQ==", // base64 encoded data + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + }' + ``` + + ### Sample response + + ```bash Response + { + "signature": "JYuiBt1Ta9pbqFIW9Ou6qzBsFhjYbMJp9k4dP87ILrO+F2MPnp85g3nOlXK1ttZmRoGWsWnLNDRn9W3rf5VtkeaixPqUW/KvY/fM3CxdMyIV3BuxlGgDksjL8X34Eqkrz4CCPo9hjB5uT+rBCOxCgZqRbOdATPipAneUapI9npseNquEeh3jPklwviBix83PJHV9PW2t03AGGUXuMY55ZaFEIMv+IrI1WYdnPVIXDyIitYsS3y+/6KRfhVeTcPNJ5Rw+FE9y1eZzDEZtTNpxOfUT3QIoXmpZlYL4HbhRuJBZ+Yx54C7uPiUIN9U69XbyXt+Kkynykw2HPaagwuCZxiqCU5sFfLnrVbc3dmZxQcX2yRrs2gmFamzBx+uVbi648H4mb7WuE5UPTBjjA11jRsBjCY0YS2T4Vgfe1RlzlPQkZgjP/bnCCGDqXa3/VZAlZX1nTI51X995bPHBQI0rq2sNDlIXenwiAy1wJSITbSI8DbUx09Cr83xCEaYAE6R6PUfog/tbIUXi0VbrYsCVkAGCK446Wb1vW6q7HR8jrjXNwmXlqN9eLbSVWqdWj7N7fieeTYSrECtUaAjxtUYTIVsH2bfT6FOEM9gMWKffOpFowVzzr3B9bNQLIhnEEwebxBw947i4OcxyVIcEUuumWxoKvcbSPxzJ8v1M3SoBBh4=", // base64 encoded signature + "keyId": "62b2c14e-58af-4199-9842-02995c63edf9", + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + } + ``` + + + To sign predigested data, you can pass `"isDigest": true` in the request body. This requires the data to be a base64 encoded digest of the data you wish to sign. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + + + + + +### Guide to Verifying Data + +In the following steps, we explore how to verify data using an existing key in Infisical KMS. + + + + + + Navigate to Project > Key Management and open the options menu for the key used to sign the data + you want to verify. + ![kms key options](/images/platform/kms/infisical-kms/signing/sign-options.png) + + + + Paste your signature and data into the text areas and tap on the Verify button. + ![kms verify data](/images/platform/kms/infisical-kms/signing/verify-data-modal.png) + + Your verification result will be displayed and can be copied for use. + ![kms verified data](/images/platform/kms/infisical-kms/signing/signature-verified.png) + + If the signature is invalid, you'll see an error message indicating that the signature is invalid, and the "Signature Status" field will be `Invalid`. + + + + + + + To verify data, make an API request to the [Verify + Data](/api-reference/endpoints/kms/signing/verify) API endpoint, + specifying the key to use. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//verify \ + --header 'Content-Type: application/json' \ + --data '{ + "data": "SGVsbG8sIFdvcmxkIQ==", // base64 encoded data + "signature": "JYuiBt1Ta9pbqFIW9Ou6qzBsFhjYbMJp9k4dP87ILrO+F2MPnp85g3nOlXK1ttZmRoGWsWnLNDRn9W3rf5VtkeaixPqUW/KvY/fM3CxdMyIV3BuxlGgDksjL8X34Eqkrz4CCPo9hjB5uT+rBCOxCgZqRbOdATPipAneUapI9npseNquEeh3jPklwviBix83PJHV9PW2t03AGGUXuMY55ZaFEIMv+IrI1WYdnPVIXDyIitYsS3y+/6KRfhVeTcPNJ5Rw+FE9y1eZzDEZtTNpxOfUT3QIoXmpZlYL4HbhRuJBZ+Yx54C7uPiUIN9U69XbyXt+Kkynykw2HPaagwuCZxiqCU5sFfLnrVbc3dmZxQcX2yRrs2gmFamzBx+uVbi648H4mb7WuE5UPTBjjA11jRsBjCY0YS2T4Vgfe1RlzlPQkZgjP/bnCCGDqXa3/VZAlZX1nTI51X995bPHBQI0rq2sNDlIXenwiAy1wJSITbSI8DbUx09Cr83xCEaYAE6R6PUfog/tbIUXi0VbrYsCVkAGCK446Wb1vW6q7HR8jrjXNwmXlqN9eLbSVWqdWj7N7fieeTYSrECtUaAjxtUYTIVsH2bfT6FOEM9gMWKffOpFowVzzr3B9bNQLIhnEEwebxBw947i4OcxyVIcEUuumWxoKvcbSPxzJ8v1M3SoBBh4=", // base64 encoded signature + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + }' + ``` + + ### Sample response + + ```bash Response + { + "signatureValid": true, + "keyId": "62b2c14e-58af-4199-9842-02995c63edf9", + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + To verify predigested data, you can pass `"isDigest": true` in the request body. This requires the data to be a base64 encoded digest of the data you wish to verify. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + + + + + ## FAQ @@ -205,8 +366,76 @@ In the following steps, we explore how to use decrypt data using an existing key external sources. - Currently, Infisical only supports `AES-128-GCM` and `AES-256-GCM` for - encryption operations. We anticipate supporting more algorithms and - cryptographic operations in the coming months. + Currently Infisical supports 4 different key algorithms with different purposes: + + - `RSA_4096`: For signing and verifying data. + - `ECC_NIST_P256`: For signing and verifying data. + + - `AES-256-GCM`: For encryption and decryption operations. + - `AES-128-GCM`: For encryption and decryption operations. + + We anticipate to further expand our supported algorithms and support cryptographic operations in the future. + + + To sign and verify a digest using the Infisical KMS, you can use the `Sign` and `Verify` endpoints respectively. + You will need to pass `"isDigest": true` in the request body to indicate that you are signing or verifying a digest. + The data you are signing or verifying will need to be a base64 encoded digest of the data you wish to sign or verify. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + To create a SHA512 digest of your data, you can use the following command with OpenSSL: + ```bash + echo -n "Hello, World" | openssl dgst -sha512 -binary | openssl base64 + ``` + + ### Sample request for signing a digest + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//sign \ + --header 'Content-Type: application/json' \ + --data '{ + "data": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "isDigest": true + }' + ``` + + ### Sample response for signing a digest + + ```bash Response + { + "signature": , + "keyId": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + ### Sample request for verifying a digest + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//verify \ + --header 'Content-Type: application/json' \ + --data '{ + "data": , + "signature": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "isDigest": true + }' + ``` + + ### Sample response for verifying a digest + + ```bash Response + { + "signatureValid": true, + "keyId": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + + Please note that `RSA PSS` signing algorithms are not supported for digest signing and verification. Please use `RSA PKCS1 V1.5` signing algorithms for digest signing and verification, or `ECDSA` if you're using an ECC key. + diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png new file mode 100644 index 000000000..f80df4229 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png new file mode 100644 index 000000000..f8904957a Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png new file mode 100644 index 000000000..e8f0b9524 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png new file mode 100644 index 000000000..369067a31 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png new file mode 100644 index 000000000..348a4fc53 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png new file mode 100644 index 000000000..3637145b6 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png new file mode 100644 index 000000000..3293ccdac Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png differ diff --git a/docs/images/integrations/external/backstage/backstage-plugin-infisical.png b/docs/images/integrations/external/backstage/backstage-plugin-infisical.png new file mode 100644 index 000000000..5d7e6d350 Binary files /dev/null and b/docs/images/integrations/external/backstage/backstage-plugin-infisical.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png deleted file mode 100644 index 053873a9c..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png index 7f296a441..89994c55e 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png new file mode 100644 index 000000000..0e3f64e7c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png new file mode 100644 index 000000000..39fd4243b Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png new file mode 100644 index 000000000..97d7ca246 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/copy-signature.png b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png new file mode 100644 index 000000000..2644b358b Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png new file mode 100644 index 000000000..da8a01438 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-options.png b/docs/images/platform/kms/infisical-kms/signing/sign-options.png new file mode 100644 index 000000000..7129c1d5b Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/sign-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/signature-verified.png b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png new file mode 100644 index 000000000..70b7856b6 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png new file mode 100644 index 000000000..6d3683c9f Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png new file mode 100644 index 000000000..d6a89609e Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png new file mode 100644 index 000000000..bb2e2f095 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png new file mode 100644 index 000000000..b87c51494 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png new file mode 100644 index 000000000..7670bf2c1 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png new file mode 100644 index 000000000..def9cf1c0 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png new file mode 100644 index 000000000..f7b245771 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png new file mode 100644 index 000000000..7a7250f88 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index cb8a5bce0..ab2f4638f 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -56,7 +56,15 @@ Infisical supports two methods for connecting to AWS. 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. - 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + 4. (Recommended) Enable "Require external ID" and input your **Organization ID** to strengthen security and mitigate the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + + + When configuring an IAM Role that Infisical will assume, it’s highly recommended to enable the **"Require external ID"** option and specify your **Organization ID**. + + This precaution helps protect your AWS account against the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html), a potential security vulnerability where Infisical could be tricked into performing actions on your behalf by an unauthorized actor. + + Always enable "Require external ID" and use your Organization ID when setting up the IAM Role. + diff --git a/docs/integrations/app-connections/mssql.mdx b/docs/integrations/app-connections/mssql.mdx index 45082103b..7e940804d 100644 --- a/docs/integrations/app-connections/mssql.mdx +++ b/docs/integrations/app-connections/mssql.mdx @@ -51,6 +51,10 @@ Infisical supports connecting to Microsoft SQL Server using database principals. - `username` - The username of the login created in the steps above - `password` - The password of the login created in the steps above - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx index 523fc35a8..860e9ee3c 100644 --- a/docs/integrations/app-connections/postgres.mdx +++ b/docs/integrations/app-connections/postgres.mdx @@ -41,6 +41,10 @@ Infisical supports connecting to PostgreSQL using a database role. - `username` - The role name of the login created in the steps above - `password` - The role password of the login created in the steps above - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + diff --git a/docs/integrations/app-connections/terraform-cloud.mdx b/docs/integrations/app-connections/terraform-cloud.mdx new file mode 100644 index 000000000..02deb22cc --- /dev/null +++ b/docs/integrations/app-connections/terraform-cloud.mdx @@ -0,0 +1,83 @@ +--- +title: "Terraform Cloud Connection" +description: "Learn how to configure a Terraform Cloud Connection for Infisical." +--- + +Infisical supports connecting to Terraform Cloud using a service user. + +## Setup Terraform Cloud Connection in Infisical + + + + Navigate to the Terraform Cloud **Account Settings** tab. + ![Terraform Cloud Account Settings](/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png) + + + Move to the **Tokens** tab. + ![Terraform Cloud Tokens Tab](/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png) + + + Create the API token to be used by Infisical. + + If you configure an expiry date for your API token you will need to manually rotate to a new token prior to expiration to avoid integration downtime. + + ![Terraform Cloud Create API Token](/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png) + + + The API token will be displayed after creating it. Save the token in a secure location for later use in the following steps. + ![Terraform Cloud Copy API Token](/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png) + + + + + 1. Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Select the **Terraform Cloud Connection** option from the connection options modal. + ![Select Terraform Cloud Connection](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png) + 3. Fill out the Terraform Cloud Connection modal, here you will need to provide the API Token generated in the previous step. + ![Terraform Cloud Connection Modal](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png) + 4. Your **Terraform Cloud Connection** is now available for use. + ![Terraform Cloud Connection Created](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png) + + + To create an Terraform Cloud Connection, make an API request to the [Create Terraform Cloud + Connection](/api-reference/endpoints/app-connections/terraform-cloud/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/terraform-cloud \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-terraform-cloud-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-terraform-cloud-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "terraform-cloud", + "method": "api-token", + "credentials": { + "apiToken": "..." + } + } + } + ``` + + + + diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 80b35b8fc..d2bb36a0b 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -3,197 +3,6 @@ title: "AWS Parameter Store" description: "Learn how to sync secrets from Infisical to AWS Parameter Store." --- - - - Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - To connect your Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the AWS IAM Role for the integration. - - If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. - - The following steps are for instances not deployed on AWS - - - Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. - - - Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowAssumeAnyRole", - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::*:role/*" - } - ] - } - ``` - - - Obtain the AWS access key ID and secret access key for your IAM User by navigating to IAM > Users > [Your User] > Security credentials > Access keys. - - ![Access Key Step 1](../../images/integrations/aws/integrations-aws-access-key-1.png) - ![Access Key Step 2](../../images/integrations/aws/integrations-aws-access-key-2.png) - ![Access Key Step 3](../../images/integrations/aws/integrations-aws-access-key-3.png) - - - 1. Set the access key as **CLIENT_ID_AWS_INTEGRATION**. - 2. Set the secret key as **CLIENT_SECRET_AWS_INTEGRATION**. - - - - - - - 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. - ![IAM Role Creation](../../images/integrations/aws/integration-aws-iam-assume-role.png) - - 2. Select **AWS Account** as the **Trusted Entity Type**. - 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. - 4. Optionally, enable **Require external ID** and enter your **project ID** to further enhance security. - - - - ![IAM Role Permissions](../../images/integrations/aws/integration-aws-iam-assume-permission.png) - Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowSSMAccess", - "Effect": "Allow", - "Action": [ - "ssm:PutParameter", - "ssm:DeleteParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - "ssm:DescribeParameters", - "ssm:DeleteParameters", - "ssm:AddTagsToResource", // if you need to add tags to secrets - "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases", // if you need to specify the KMS key - "kms:Encrypt", // if you need to specify the KMS key - "kms:Decrypt" // if you need to specify the KMS key - ], - "Resource": "*" - } - ] - } - ``` - - - - ![Copy IAM Role ARN](../../images/integrations/aws/integration-aws-iam-assume-arn.png) - - - - 1. Navigate to your project's integrations tab in Infisical. - 2. Click on the **AWS Parameter Store** tile. - ![Select AWS Parameter Store](../../images/integrations.png) - - 3. Select the **AWS Assume Role** option. - ![Select Assume Role](../../images/integrations/aws/integration-aws-parameter-store-iam-assume-select.png) - - 4. Provide the **AWS IAM Role ARN** obtained from the previous step and press connect. - - - Select which Infisical environment secrets you want to sync to which AWS Parameter Store region and indicate the path for your secrets. Then, press create integration to start syncing secrets to AWS Parameter Store. - - ![integration create](../../images/integrations/aws/integrations-aws-parameter-store-create.png) - - - Infisical requires you to add a path for your secrets to be stored in AWS - Parameter Store and recommends setting the path structure to - `/[project_name]/[environment]/` according to best practices. This enables a - secret like `TEST` to be stored as `/[project_name]/[environment]/TEST` in AWS - Parameter Store. - - - - - - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to your IAM user permissions and add a permission policy to grant access to AWS Parameter Store. - - ![integration IAM 1](../../images/integrations/aws/integrations-aws-iam-1.png) - ![integration IAM 2](../../images/integrations/aws/integrations-aws-parameter-store-iam-2.png) - ![integrations IAM 3](../../images/integrations/aws/integrations-aws-parameter-store-iam-3.png) - - For enhanced security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowSSMAccess", - "Effect": "Allow", - "Action": [ - "ssm:PutParameter", - "ssm:DeleteParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - "ssm:DescribeParameters", - "ssm:DeleteParameters", - "ssm:AddTagsToResource", // if you need to add tags to secrets - "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases", // if you need to specify the KMS key - "kms:Encrypt", // if you need to specify the KMS key - "kms:Decrypt" // if you need to specify the KMS key - ], - "Resource": "*" - } - ] - } - ``` - - - - Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys - - ![access key 1](../../images/integrations/aws/integrations-aws-access-key-1.png) - ![access key 2](../../images/integrations/aws/integrations-aws-access-key-2.png) - ![access key 3](../../images/integrations/aws/integrations-aws-access-key-3.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the AWS Parameter Store tile and select Access Key as the authentication mode. Input your AWS access key ID and secret access key from the previous step. - - ![integration auth](../../images/integrations/aws/integrations-aws-parameter-store-auth.png) - - - - Select which Infisical environment secrets you want to sync to which AWS Parameter Store region and indicate the path for your secrets. Then, press create integration to start syncing secrets to AWS Parameter Store. - - ![integration create](../../images/integrations/aws/integrations-aws-parameter-store-create.png) - - - Infisical requires you to add a path for your secrets to be stored in AWS - Parameter Store and recommends setting the path structure to - `/[project_name]/[environment]/` according to best practices. This enables a - secret like `TEST` to be stored as `/[project_name]/[environment]/TEST` in AWS - Parameter Store. - - - - - - + + The AWS Parameter Store Native Integration will be deprecated in 2026. Please migrate to our new [AWS Parameter Store Sync](../secret-syncs/aws-parameter-store). + \ No newline at end of file diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index 1f3a0da1f..a56461998 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -3,269 +3,6 @@ title: "AWS Secrets Manager" description: "Learn how to sync secrets from Infisical to AWS Secrets Manager." --- - - -Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - To connect your Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the AWS IAM Role for the integration. - -If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. - -The following steps are for instances not deployed on AWS - - - - Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. - - - Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowAssumeAnyRole", - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::*:role/*" - } - ] -} -``` - - - Obtain the AWS access key ID and secret access key for your IAM User by navigating to IAM > Users > [Your User] > Security credentials > Access keys. - -![Access Key Step 1](../../images/integrations/aws/integrations-aws-access-key-1.png) -![Access Key Step 2](../../images/integrations/aws/integrations-aws-access-key-2.png) -![Access Key Step 3](../../images/integrations/aws/integrations-aws-access-key-3.png) - - - - 1. Set the access key as **CLIENT_ID_AWS_INTEGRATION**. - 2. Set the secret key as **CLIENT_SECRET_AWS_INTEGRATION**. - - - - - - - 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. - ![IAM Role Creation](../../images/integrations/aws/integration-aws-iam-assume-role.png) - - 2. Select **AWS Account** as the **Trusted Entity Type**. - 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. - 4. Optionally, enable **Require external ID** and enter your **project ID** to further enhance security. - - - - - ![IAM Role Permissions](../../images/integrations/aws/integration-aws-iam-assume-permission.png) - Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowSecretsManagerAccess", - "Effect": "Allow", - "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:CreateSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:DescribeSecret", - "secretsmanager:TagResource", - "secretsmanager:UntagResource", - "kms:ListKeys", - "kms:ListAliases", - "kms:Encrypt", - "kms:Decrypt" - ], - "Resource": "*" - } - ] - } - ``` - - - - - ![Copy IAM Role - ARN](../../images/integrations/aws/integration-aws-iam-assume-arn.png) - - - - 1. Navigate to your project's integrations tab in Infisical. - 2. Click on the **AWS Secrets Manager** tile. - ![Select AWS Secrets Manager](../../images/integrations.png) - - 3. Select the **AWS Assume Role** option. - ![Select Assume Role](../../images/integrations/aws/integration-aws-iam-assume-select.png) - - 4. Provide the **AWS IAM Role ARN** obtained from the previous step. - - - Select how you want to integration to work by specifying a number of parameters: - - - The environment in Infisical from which you want to sync secrets to AWS Secrets Manager. - - - The path within the preselected environment form which you want to sync secrets to AWS Secrets Manager. - - - The region that you want to integrate with in AWS Secrets Manager. - - - How you want the integration to map the secrets. The selected value could be either one to one or one to many. - - - The secret name/path in AWS into which you want to sync the secrets from Infisical. - - - ![integration create](../../images/integrations/aws/integrations-aws-secret-manager-create.png) - - Optionally, you can add tags or specify the encryption key of all the secrets created via this integration: - - - The sync mode for AWS tags. The supported options are `Secret Metadata` and `Custom`. If `Secret Metadata` is selected, - the metadata of the Infisical secrets are used as tags in AWS. If custom is selected, then the key/value of the **Secret Tag** field is used. `Secret Metadata` mode - is only supported for one-to-one integrations. - - - - The Key/Value of a tag that will be added to secrets in AWS. Please note that it is possible to add multiple tags via API. - - - The alias/ID of the AWS KMS key used for encryption. Please note that key should be enabled in order to work and the IAM user should have access to it. - - ![integration options](../../images/integrations/aws/integrations-aws-secret-manager-options.png) - - Then, press `Create Integration` to start syncing secrets to AWS Secrets Manager. - - - Infisical currently syncs environment variables to AWS Secrets Manager as - key-value pairs under one secret. We're actively exploring ways to help users - group environment variable key-pairs under multiple secrets for greater - control. - - - Please note that upon deleting secrets in Infisical, AWS Secrets Manager immediately makes the secrets inaccessible but only schedules them for deletion after at least 7 days. - - - - - - -Infisical will access your account using the provided AWS access key and secret key. - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Set up AWS and have/create an IAM user - - - - Navigate to your IAM user permissions and add a permission policy to grant access to AWS Secrets Manager. - - ![integration IAM 1](../../images/integrations/aws/integrations-aws-iam-1.png) - ![integration IAM 2](../../images/integrations/aws/integrations-aws-secret-manager-iam-2.png) - ![integrations IAM 3](../../images/integrations/aws/integrations-aws-secret-manager-iam-3.png) - - For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager for the IAM user that you can use: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowSecretsManagerAccess", - "Effect": "Allow", - "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:CreateSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:DescribeSecret", // if you need to add tags to secrets - "secretsmanager:TagResource", // if you need to add tags to secrets - "secretsmanager:UntagResource", // if you need to add tags to secrets - "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases", // if you need to specify the KMS key - "kms:Encrypt", // if you need to specify the KMS key - "kms:Decrypt" // if you need to specify the KMS key - ], - "Resource": "*" - } - ] - } - ``` - - - - Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys - - ![access key 1](../../images/integrations/aws/integrations-aws-access-key-1.png) - ![access key 2](../../images/integrations/aws/integrations-aws-access-key-2.png) - ![access key 3](../../images/integrations/aws/integrations-aws-access-key-3.png) - - 1. Navigate to your project's integrations tab in Infisical. - 2. Click on the **AWS Secrets Manager** tile. - ![Select AWS Secrets Manager](../../images/integrations.png) - - 3. Select the **Access Key** option for Authentication Mode. - ![Select Access Key](../../images/integrations/aws/integrations-aws-secret-manager-auth.png) - 4. Provide the **access key** and **secret key** for the AWS Iam User. - - - - Select how you want to integration to work by specifying a number of parameters: - - - The environment in Infisical from which you want to sync secrets to AWS Secrets Manager. - - - The path within the preselected environment form which you want to sync secrets to AWS Secrets Manager. - - - The region that you want to integrate with in AWS Secrets Manager. - - - How you want the integration to map the secrets. The selected value could be either one to one or one to many. - - - The secret name/path in AWS into which you want to sync the secrets from Infisical. - - - ![integration create](../../images/integrations/aws/integrations-aws-secret-manager-create.png) - - Optionally, you can add tags or specify the encryption key of all the secrets created via this integration: - - - The Key/Value of a tag that will be added to secrets in AWS. Please note that it is possible to add multiple tags via API. - - - The alias/ID of the AWS KMS key used for encryption. Please note that key should be enabled in order to work and the IAM user should have access to it. - - ![integration options](../../images/integrations/aws/integrations-aws-secret-manager-options.png) - - Then, press `Create Integration` to start syncing secrets to AWS Secrets Manager. - - - Infisical currently syncs environment variables to AWS Secrets Manager as - key-value pairs under one secret. We're actively exploring ways to help users - group environment variable key-pairs under multiple secrets for greater - control. - - - Please note that upon deleting secrets in Infisical, AWS Secrets Manager immediately makes the secrets inaccessible but only schedules them for deletion after at least 7 days. - - - - - - + + The AWS Secrets Manager Native Integration will be deprecated in 2026. Please migrate to our new [AWS Secrets Manager Sync](../secret-syncs/aws-secrets-manager). + \ No newline at end of file diff --git a/docs/integrations/cloud/azure-app-configuration.mdx b/docs/integrations/cloud/azure-app-configuration.mdx index a96bec7b2..4e7dfd94f 100644 --- a/docs/integrations/cloud/azure-app-configuration.mdx +++ b/docs/integrations/cloud/azure-app-configuration.mdx @@ -3,110 +3,6 @@ title: "Azure App Configuration" description: "How to sync secrets from Infisical to Azure App Configuration" --- - - - **Prerequisites:** - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com). - - Set up Azure and have an existing App Configuration instance. - - User setting up the integration on Infisical must have the `App Configuration Data Owner` role for the intended Azure App Configuration instance. - - Azure App Configuration instance must be reachable by Infisical. - - - - Navigate to your project's integrations tab - - ![integrations](../../images/integrations/azure-app-configuration/new-infisical-integration.png) - - Press on the Azure App Configuration tile and grant Infisical access to App Configuration. - - - Obtain the Azure App Configuration endpoint from the overview tab. - ![integrations](../../images/integrations/azure-app-configuration/azure-app-config-endpoint.png) - - Select which Infisical environment secrets you want to sync to your Azure App Configuration. Then, input your App Configuration instance endpoint. Optionally, you can define a prefix for your secrets which will be appended to the keys upon syncing. - - ![integrations](../../images/integrations/azure-app-configuration/create-integration-form.png) - - Press create integration to start syncing secrets to Azure App Configuration. - - - The Azure App Configuration integration requires the following permissions to be set on the user / service principal - for Infisical to sync secrets to Azure App Configuration: `Read Key-Value`, `Write Key-Value`, `Delete Key-Value`. - - Any role with these permissions would work such as the **App Configuration Data Owner** role. Alternatively, you can use the - **App Configuration Data Reader** role for read-only access or **App Configuration Data Contributor** role for read/write access. - - - - - - #### Azure references - When adding secrets in Infisical that reference Azure Key Vault secrets, Infisical will automatically sets the content type to `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` in Azure App Configuration. - The following reference formats are automatically detected when added on Infisical's side: - - `{ "uri": "https://my-key-vault.vault.azure.net/secrets/my-secret" }` - - `https://my-key-vault.vault.azure.net/secrets/my-secret` - - #### Azure Labels - You can sync secrets from Infisical to Azure with custom labels by enabling the `Use Labels` option during setup: - - **When enabled**: Secrets will be pushed to Azure with your specified label - - **When disabled**: Secrets will be pushed with an empty (null) label - - - If you have set the initial sync to `import` have behavior, the label selection affects which secrets are imported from Azure: - - With `Use Labels` disabled: Only secrets with empty labels are imported on initial sync - - With `Use Labels` enabled: Only secrets matching your specified label are imported on initial sync - - - - - - - Using the Azure App Configuration integration on a self-hosted instance of Infisical requires configuring an application in Azure - and registering your instance with it. - - **Prerequisites:** - - - Set up Azure and have an existing App Configuration instance. - - - - Navigate to Azure Active Directory > App registrations to create a new application. - - - Azure Active Directory is now Microsoft Entra ID. - - ![integrations Azure app config](../../images/integrations/azure-app-configuration/config-aad.png) - ![integrations Azure app config](../../images/integrations/azure-app-configuration/config-new-app.png) - - Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/integrations/azure-app-configuration/oauth2/callback`. - - The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. - - - ![integrations Azure app config](../../images/integrations/azure-app-configuration/app-registration-redirect.png) - - After registration, set the API permissions of the app to include the following Azure App Configuration permissions: KeyValue.Delete, KeyValue.Read, and KeyValue.Write. - ![integrations Azure app config](../../images/integrations/azure-app-configuration/app-api-permissions.png) - - - - Obtain the **Application (Client) ID** in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. - - ![integrations Azure app config](../../images/integrations/azure-app-configuration/config-credentials-1.png) - ![integrations Azure app config](../../images/integrations/azure-app-configuration/config-credentials-2.png) - ![integrations Azure app config](../../images/integrations/azure-app-configuration/config-credentials-3.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - - `CLIENT_ID_AZURE`: The **Application (Client) ID** of your Azure application. - - `CLIENT_SECRET_AZURE`: The **Client Secret** of your Azure application. - - Once added, restart your Infisical instance and use the Azure App Configuration integration. - - - - - + + The Azure App Configuration Native Integration will be deprecated in 2026. Please migrate to our new [Azure App Configuration Sync](../secret-syncs/azure-app-configuration). + \ No newline at end of file diff --git a/docs/integrations/cloud/azure-key-vault.mdx b/docs/integrations/cloud/azure-key-vault.mdx index ae04fda88..b0bd80c63 100644 --- a/docs/integrations/cloud/azure-key-vault.mdx +++ b/docs/integrations/cloud/azure-key-vault.mdx @@ -3,79 +3,6 @@ title: "Azure Key Vault" description: "How to sync secrets from Infisical to Azure Key Vault" --- - - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - Set up Azure and have an existing key vault - - - - Navigate to your project's integrations tab - - ![integrations](../../images/integrations.png) - - Press on the Azure Key Vault tile and grant Infisical access to Azure Key Vault. - You can optionally authenticate against a specific tenant by providing the Azure tenant or directory ID. - - ![integrations](/images/integrations/azure-key-vault/integrations-azure-key-vault-tenant-select.png) - - - - Obtain the Vault URI of your key vault in the Overview tab. - - ![integrations](../../images/integrations/azure-key-vault/integrations-azure-key-vault-vault-uri.png) - - Select which Infisical environment secrets you want to sync to your key vault. Then, input your Vault URI from the previous step. Finally, press create integration to start syncing secrets to Azure Key Vault. - - ![integrations](../../images/integrations/azure-key-vault/integrations-azure-key-vault-create.png) - - ![integrations](../../images/integrations/azure-key-vault/integrations-azure-key-vault.png) - - - The Azure Key Vault integration requires the following secrets permissions to be set on the user / service principal - for Infisical to sync secrets to Azure Key Vault: `secrets/list`, `secrets/get`, `secrets/set`, `secrets/recover`. - - Any role with these permissions would work such as the **Key Vault Secrets Officer** role. - - - - - - - Using the Azure KV integration on a self-hosted instance of Infisical requires configuring an application in Azure - and registering your instance with it. - - - - Navigate to Azure Active Directory > App registrations to create a new application. - - - Azure Active Directory is now Microsoft Entra ID. - - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-aad.png) - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-new-app.png) - - Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/integrations/azure-key-vault/oauth2/callback`. - - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-new-app-form.png) - - - Obtain the **Application (Client) ID** in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. - - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-credentials-1.png) - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-credentials-2.png) - ![integrations Azure KV config](../../images/integrations/azure-key-vault/integrations-azure-key-vault-config-credentials-3.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - - `CLIENT_ID_AZURE`: The **Application (Client) ID** of your Azure application. - - `CLIENT_SECRET_AZURE`: The **Client Secret** of your Azure application. - - Once added, restart your Infisical instance and use the Azure KV integration. - - - - - + + The Azure Key Vault Native Integration will be deprecated in 2026. Please migrate to our new [Azure Key Vault Sync](../secret-syncs/azure-key-vault). + \ No newline at end of file diff --git a/docs/integrations/cloud/databricks.mdx b/docs/integrations/cloud/databricks.mdx index 1971190de..e5ad22939 100644 --- a/docs/integrations/cloud/databricks.mdx +++ b/docs/integrations/cloud/databricks.mdx @@ -3,35 +3,6 @@ title: "Databricks" description: "Learn how to sync secrets from Infisical to Databricks." --- -Prerequisites: - -- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - - When integrating with Databricks, Infisical is intended to be the source of truth for the secrets in the configured Databricks scope. - - Any secrets not present in Infisical will be removed from the specified scope. To prevent removal of secrets not managed by Infisical, Infisical recommends creating a designated secret scope for your integration. - - - - - Obtain a Personal Access Token in **User Settings** > **Developer** > **Access Tokens**. - - ![integrations databricks token](../../images/integrations/databricks/pat-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Databricks tile and enter your Databricks instance URL in the following format: `https://xxx.cloud.databricks.com`. Then, input your Databricks Access Token to grant Infisical the necessary permissions in your Databricks account. - - ![integrations databricks authorization](../../images/integrations/databricks/integrations-databricks-auth.png) - - - - Select which Infisical environment and secret path you want to sync to which Databricks scope. Then, press create integration to start syncing secrets to Databricks. - - ![create integration Databricks](../../images/integrations/databricks/integrations-databricks-create.png) - ![integrations Databricks](../../images/integrations/databricks/integrations-databricks.png) - - \ No newline at end of file + The Databricks Native Integration will be deprecated in 2026. Please migrate to our new [Databricks Sync](../secret-syncs/databricks). + \ No newline at end of file diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index e57a976f0..22462feef 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -3,138 +3,6 @@ title: "GCP Secret Manager" description: "How to sync secrets from Infisical to GCP Secret Manager" --- - - - - - Prerequisites: - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the GCP Secret Manager tile and select **Continue with OAuth** - - ![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png) - - Grant Infisical access to GCP. - - ![integrations GCP authorization](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth.png) - - - - In the **Connection** tab, select which Infisical environment secrets you want to sync to which GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. - - ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png) - - Note that the GCP Secret Manager integration supports a few options in the **Options** tab: - - - Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. - - Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. - - Label in GCP Secret Manager: If selected, every secret will be labeled in GCP Secret Manager (e.g. as `managed-by:infisical`); labels can be customized. - - Setting a secret prefix, suffix, or enabling the labeling option ensures that existing secrets in GCP Secret Manager are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GCP Secret Manager without the specified prefix, suffix, or attached label. - - ![integrations GCP secret manager options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png) - - ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager.png) - - - Using Infisical to sync secrets to GCP Secret Manager requires that you enable - the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). - - Additionally, ensure that your GCP account has sufficient permission to manage secret and service resources (you can assign Secret Manager Admin and Service Usage Admin roles for testing purposes) - - - - - - Prerequisites: - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - Have a GCP project and have/create a [service account](https://cloud.google.com/iam/docs/service-account-overview) in it - - - - Navigate to **IAM & Admin** page in GCP and add the **Secret Manager Admin** and **Service Usage Admin** roles to the service account. - - ![integrations GCP secret manager IAM](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png) - - - For enhanced security, you may want to assign more granular permissions to the service account. At minimum, - the service account should be able to read/write secrets from/to GCP Secret Manager (e.g. **Secret Manager Admin** role) - and list which GCP services are enabled/disabled (e.g. **Service Usage Admin** role). - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the GCP Secret Manager tile and paste in your **GCP Service Account JSON** (you can create and download the JSON for your - service account in IAM & Admin > Service Accounts > Service Account > Keys). - - ![integrations GCP authorization IAM key](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png) - - ![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png) - - - - In the **Connection** tab, select which Infisical environment secrets you want to sync to the GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. - - ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png) - - Note that the GCP Secret Manager integration supports a few options in the **Options** tab: - - - Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. - - Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. - - Label in GCP Secret Manager: If selected, every secret will be labeled in GCP Secret Manager (e.g. as `managed-by:infisical`); labels can be customized. - - Setting a secret prefix, suffix, or enabling the labeling option ensures that existing secrets in GCP Secret Manager are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GCP Secret Manager without the specified prefix, suffix, or attached label. - - ![integrations GCP secret manager options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png) - - ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager.png) - - - Using Infisical to sync secrets to GCP Secret Manager requires that you enable - the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). - - - - - - - - - Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP - and registering your instance with it. - - - - Navigate to your project API & Services > Credentials to create a new OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) - - Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gcp-secret-manager/oauth2/callback`. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) - - - Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your GCP OAuth2 application: - - - `CLIENT_ID_GCP_SECRET_MANAGER`: The **Client ID** of your GCP OAuth2 application. - - `CLIENT_SECRET_GCP_SECRET_MANAGER`: The **Client Secret** of your GCP OAuth2 application. - - Once added, restart your Infisical instance and use the GCP Secret Manager integration. - - - - - + + The GCP Secret Manager Native Integration will be deprecated in 2026. Please migrate to our new [GCP Secret Manager Sync](../secret-syncs/gcp-secret-manager). + \ No newline at end of file diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx index d68e8a14f..63398ef4a 100644 --- a/docs/integrations/cloud/terraform-cloud.mdx +++ b/docs/integrations/cloud/terraform-cloud.mdx @@ -3,35 +3,6 @@ title: "Terraform Cloud" description: "How to sync secrets from Infisical to Terraform Cloud" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Terraform Cloud API Token in User Settings > Tokens - - ![integrations terraform cloud dashboard](../../images/integrations/terraform/integrations-terraformcloud-dashboard.png) - ![integrations terraform cloud tokens](../../images/integrations/terraform/integrations-terraformcloud-tokens.png) - - Obtain your Terraform Cloud Workspace Id in Projects & Workspaces > Workspace > ID - - ![integrations terraform cloud projects & workspaces](../../images/integrations/terraform/integrations-terraformcloud-workspaces.png) - ![integrations terraform cloud workspace id](../../images/integrations/terraform/integrations-terraformcloud-workspaceid.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Terraform Cloud tile and input your Terraform Cloud API Token and Workspace Id to grant Infisical access to your Terraform Cloud account. - - ![integrations terraform cloud authorization](../../images/integrations/terraform/integrations-terraformcloud-auth.png) - - - - Select which Infisical environment secrets and Terraform Cloud variable type you want to sync to which Terraform Cloud workspace/project and press create integration to start syncing secrets to Terraform Cloud. - - ![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud-create.png) - ![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud.png) - - + + The Terraform Cloud Native Integration will be deprecated in 2026. Please migrate to our new [Terraform Cloud Sync](../secret-syncs/terraform-cloud). + \ No newline at end of file diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx index 1cb1c06c3..7456776bd 100644 --- a/docs/integrations/cloud/vercel.mdx +++ b/docs/integrations/cloud/vercel.mdx @@ -3,77 +3,6 @@ title: "Vercel" description: "How to sync secrets from Infisical to Vercel" --- - - - Prerequisites: - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Vercel tile and grant Infisical access to your Vercel account. - - ![integrations vercel authorization](../../images/integrations/vercel/integrations-vercel-auth.png) - - - Select which Infisical environment secrets you want to sync to which Vercel app and environment. Lastly, press create integration to start syncing secrets to Vercel. - - ![integrations vercel](../../images/integrations/vercel/integrations-vercel-create.png) - ![integrations vercel](../../images/integrations/vercel/integrations-vercel.png) - - - Infisical syncs every envar to Vercel with type `encrypted` unless an existing - envar with the same name in Vercel exists with a different type. Note that - Infisical will not be able to update Vercel envars with type `sensitive` since - they can only be decrypted and modified by Vercel's deployment systems. - - - - The following environment variable names are reserved by Vercel and cannot be - synced: `AWS_SECRET_KEY`, `AWS_EXECUTION_ENV`, `AWS_LAMBDA_LOG_GROUP_NAME`, - `AWS_LAMBDA_LOG_STREAM_NAME`, `AWS_LAMBDA_FUNCTION_NAME`, - `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`, `AWS_LAMBDA_FUNCTION_VERSION`, - `NOW_REGION`, `TZ`, `LAMBDA_TASK_ROOT`, `LAMBDA_RUNTIME_DIR`, - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, - `AWS_REGION`, and `AWS_DEFAULT_REGION`. - - - - - - Using the Vercel integration on a self-hosted instance of Infisical requires configuring an integration in Vercel. - and registering your instance with it. - - - - Navigate to Integrations > Integration Console to create a new integration. - - ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-integrations-console.png) - ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app.png) - - Create the application. As part of the form, set a **URL Slug** to a unique slug like `infisical-your-domain` and keep it handy. Also, set **Redirect URL** to `https://your-domain.com/integrations/vercel/oauth2/callback`. Lastly, - be sure to set the API Scopes according to the second screenshot below. - - ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app-form-1.png) - ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app-form-2.png) - - - Obtain the **Client (Integration) ID** and **Client (Integration) Secret** as well as the **URL Slug** from earlier for your Vercel integration. - - ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-credentials.png) - - Back in your Infisical instance, add three new environment variables for the credentials of your Vercel integration. - - - `CLIENT_ID_VERCEL`: The **Client (Integration) ID** of your Vercel integration. - - `CLIENT_SECRET_VERCEL`: The **Client (Integration) Secret** of your Vercel integration. - - `CLIENT_SLUG_VERCEL`: The **URL Slug** of your Vercel integration. - - Once added, restart your Infisical instance and use the Vercel integration. - - - - - + + The Vercel Native Integration will be deprecated in 2026. Please migrate to our new [Vercel Sync](../secret-syncs/vercel). + \ No newline at end of file diff --git a/docs/integrations/external/backstage.mdx b/docs/integrations/external/backstage.mdx new file mode 100644 index 000000000..106beee44 --- /dev/null +++ b/docs/integrations/external/backstage.mdx @@ -0,0 +1,123 @@ +--- +title: Backstage Infisical Plugin +description: A powerful plugin that integrates Infisical secrets management into your Backstage developer portal. +--- + +Integrate secrets management into your developer portal with the Backstage Infisical plugin suite. This plugin provides a seamless interface to manage your [Infisical](https://infisical.com) secrets directly within Backstage, including full support for environments and folder structure. + +## Features + +- **Secrets Management**: View, create, update, and delete secrets from Infisical +- **Folder Navigation**: Explore the full folder structure of your Infisical projects +- **Multi-Environment Support**: Easily switch between and manage different environments +- **Entity Linking**: Map Backstage entities to specific Infisical projects via annotations + +--- +## Installation + +### Frontend Plugin + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @infisical/backstage-plugin-infisical +``` + +### Backend Plugin + +```bash +# From your Backstage root directory +yarn --cwd packages/backend add @infisical/backstage-backend-plugin-infisical +``` + +## Configuration + +### Backend + +Update your `app-config.yaml`: + +```yaml +infisical: + baseUrl: https://app.infisical.com + + authentication: + # Option 1: API Token Authentication + auth_token: + token: ${INFISICAL_API_TOKEN} + + # Option 2: Client Credentials Authentication + universalAuth: + clientId: ${INFISICAL_CLIENT_ID} + clientSecret: ${INFISICAL_CLIENT_SECRET} +``` + + + If you have not created a machine identity yet, you can do so in [Identities](/documentation/platform/identities/machine-identities) + + +Register the plugin in `packages/backend/src/index.ts`: + +```ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +backend.add(import('@infisical/backstage-backend-plugin-infisical')); + +backend.start(); +``` + +### Frontend + +Update `packages/app/src/App.tsx` to include the plugin: + +```tsx +import { infisicalPlugin } from '@infisical/backstage-plugin-infisical'; + +const app = createApp({ + plugins: [ + infisicalPlugin, + // ...other plugins + ], +}); +``` + +Modify `packages/app/src/components/catalog/EntityPage.tsx`: + +```tsx +import { EntityInfisicalContent } from '@infisical/backstage-plugin-infisical'; + +const serviceEntityPage = ( + + {/* ...other tabs */} + + + + +); +``` + +### Entity Annotation + +Add the Infisical project ID to your entity yaml settings: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example-service + annotations: + infisical/projectId: +``` + +> Replace `` with the actual project ID from Infisical. + +## Usage + +Once installed and configured, you can: + +1. **View and manage secrets** in Infisical from within Backstage +2. **Create, update, and delete** secrets using the Infisical tab in entity pages +3. **Navigate environments and folders** +4. **Search and filter** secrets by key, value, or comments + +![Backstage Plugin Table](/images/integrations/external/backstage/backstage-plugin-infisical.png) \ No newline at end of file diff --git a/docs/integrations/secret-syncs/terraform-cloud.mdx b/docs/integrations/secret-syncs/terraform-cloud.mdx new file mode 100644 index 000000000..80a087d2b --- /dev/null +++ b/docs/integrations/secret-syncs/terraform-cloud.mdx @@ -0,0 +1,161 @@ +--- +title: "Terraform Cloud Sync" +description: "Learn how to configure a Terraform Cloud Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Terraform Cloud Connection](/integrations/app-connections/terraform-cloud) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Terraform Cloud** option. + ![Select Terraform Cloud](/images/secret-syncs/terraform-cloud/terraform-cloud-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/terraform-cloud/terraform-cloud-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png) + + - **Terraform Cloud Connection**: The Terraform Cloud Connection to authenticate with. + - **Organization**: The Terraform Cloud organization to deploy secrets to. + - **Category**: The Terraform Cloud variable category to use on secrets syncs. Choose from: + - **Environment**: Sync secrets as environment variables. + - **Terraform**: Sync secrets as Terraform variables. + - **Scope**: The Terraform Cloud secret scope to sync secrets to. + - **Variable Set**: Sync secrets to a specific variable set. + - **Workspace**: Sync secrets to a specific workspace. +

+ The remaining fields are determined by the selected **Scope**: + + + - **Variable Set**: The variable set to deploy secrets to. + + + - **Workspace**: The workspace to deploy secrets to. + + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/terraform-cloud/terraform-cloud-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + + Terraform Cloud does not support importing secrets. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Terraform Cloud Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/terraform-cloud/terraform-cloud-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Terraform Cloud Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/terraform-cloud/terraform-cloud-review.png) + + 8. If enabled, your Terraform Cloud Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/terraform-cloud/terraform-cloud-created.png) + + + + To create an **Terraform Cloud Sync**, make an API request to the [Create Terraform Cloud Sync](/api-reference/endpoints/secret-syncs/terraform-cloud/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/terraform-cloud \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-terraform-cloud-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "variable-set", + "variableSetId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "variableSetName": "my-variable-set", + "org": "my-organization-id", + "category": "env" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-terraform-cloud-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "terraform-cloud", + "name": "my-terraform-cloud-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "terraform-cloud", + "destinationConfig": { + "scope": "workspace", + "workspaceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "workspaceName": "my-workspace", + "org": "my-organization-id", + "category": "terraform" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index beb1dc563..c224ee23c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -424,9 +424,10 @@ "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/humanitec", - "integrations/app-connections/vercel", "integrations/app-connections/mssql", - "integrations/app-connections/postgres" + "integrations/app-connections/postgres", + "integrations/app-connections/terraform-cloud", + "integrations/app-connections/vercel" ] } ] @@ -447,6 +448,7 @@ "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel" ] } @@ -552,6 +554,12 @@ "group": "Build Tool Integrations", "pages": ["integrations/build-tools/gradle"] }, + { + "group": "Others", + "pages": [ + "integrations/external/backstage" + ] + }, { "group": "", "pages": ["sdks/overview"] @@ -1003,18 +1011,6 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, - { - "group": "Vercel", - "pages": [ - "api-reference/endpoints/app-connections/vercel/list", - "api-reference/endpoints/app-connections/vercel/available", - "api-reference/endpoints/app-connections/vercel/get-by-id", - "api-reference/endpoints/app-connections/vercel/get-by-name", - "api-reference/endpoints/app-connections/vercel/create", - "api-reference/endpoints/app-connections/vercel/update", - "api-reference/endpoints/app-connections/vercel/delete" - ] - }, { "group": "Microsoft SQL Server", "pages": [ @@ -1038,6 +1034,30 @@ "api-reference/endpoints/app-connections/postgres/update", "api-reference/endpoints/app-connections/postgres/delete" ] + }, + { + "group": "Terraform Cloud", + "pages": [ + "api-reference/endpoints/app-connections/terraform-cloud/list", + "api-reference/endpoints/app-connections/terraform-cloud/available", + "api-reference/endpoints/app-connections/terraform-cloud/get-by-id", + "api-reference/endpoints/app-connections/terraform-cloud/get-by-name", + "api-reference/endpoints/app-connections/terraform-cloud/create", + "api-reference/endpoints/app-connections/terraform-cloud/update", + "api-reference/endpoints/app-connections/terraform-cloud/delete" + ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/app-connections/vercel/list", + "api-reference/endpoints/app-connections/vercel/available", + "api-reference/endpoints/app-connections/vercel/get-by-id", + "api-reference/endpoints/app-connections/vercel/get-by-name", + "api-reference/endpoints/app-connections/vercel/create", + "api-reference/endpoints/app-connections/vercel/update", + "api-reference/endpoints/app-connections/vercel/delete" + ] } ] }, @@ -1168,6 +1188,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Terraform Cloud", + "pages": [ + "api-reference/endpoints/secret-syncs/terraform-cloud/list", + "api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id", + "api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name", + "api-reference/endpoints/secret-syncs/terraform-cloud/create", + "api-reference/endpoints/secret-syncs/terraform-cloud/update", + "api-reference/endpoints/secret-syncs/terraform-cloud/delete", + "api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets", + "api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets" + ] + }, { "group": "Vercel", "pages": [ @@ -1319,9 +1352,23 @@ "api-reference/endpoints/kms/keys/get-by-name", "api-reference/endpoints/kms/keys/create", "api-reference/endpoints/kms/keys/update", - "api-reference/endpoints/kms/keys/delete", - "api-reference/endpoints/kms/keys/encrypt", - "api-reference/endpoints/kms/keys/decrypt" + "api-reference/endpoints/kms/keys/delete" + ] + }, + { + "group": "Encryption", + "pages": [ + "api-reference/endpoints/kms/encryption/encrypt", + "api-reference/endpoints/kms/encryption/decrypt" + ] + }, + { + "group": "Signing", + "pages": [ + "api-reference/endpoints/kms/signing/sign", + "api-reference/endpoints/kms/signing/verify", + "api-reference/endpoints/kms/signing/public-key", + "api-reference/endpoints/kms/signing/signing-algorithms" ] } ] diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index 47bc9a9ea..b5792611d 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -284,7 +284,7 @@ if err != nil { } ``` -## Working With Secrets +## Secrets ### List Secrets @@ -373,6 +373,9 @@ secret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + The version of the secret to retrieve. + @@ -588,7 +591,7 @@ Create multiple secrets in Infisical. -## Working With Folders +## Folders ### @@ -745,3 +748,353 @@ deletedFolder, err := client.Folders().Delete(infisical.DeleteFolderOptions{ + +## KMS + +### Create Key + +`client.Kms().Keys().Create(options)` + +Create a new key in Infisical. + +```go + newKey, err := client.Kms().Keys().Create(infisical.KmsCreateKeyOptions{ + KeyUsage: "|", + Description: "", + Name: "", + EncryptionAlgorithm: "|||", + ProjectId: "", + }) +``` + +#### Parameters + + + + + The usage of the key. Valid options are `sign-verify` or `encrypt-decrypt`. + The usage dictates what the key can be used for. + + + The description of the key. + + + The name of the key. + + + The encryption algorithm of the key. + + Valid options for Signing/Verifying keys are: + - `rsa-4096` + - `ecc-nist-p256` + + Valid options for Encryption/Decryption keys are: + - `aes-256-gcm` + - `aes-128-gcm` + + + The ID of the project where the key will be created. + + + + +#### Return (object) + + + + The ID of the key that was created. + + + The name of the key that was created. + + + The description of the key that was created. + + + Whether or not the key is disabled. + + + The ID of the organization that the key belongs to. + + + The ID of the project that the key belongs to. + + + The intended usage of the key that was created. + + + The encryption algorithm of the key that was created. + + + The version of the key that was created. + + + + +### Delete Key + +`client.Kms().Keys().Delete(options)` + +Delete a key in Infisical. + +```go +deletedKey, err = client.Kms().Keys().Delete(infisical.KmsDeleteKeyOptions{ + KeyId: "", + }) +``` + +#### Parameters + + + + + The ID of the key to delete. + + + + +#### Return (object) + + + + The ID of the key that was deleted + + + The name of the key that was deleted. + + + The description of the key that was deleted. + + + Whether or not the key is disabled. + + + The ID of the organization that the key belonged to. + + + The ID of the project that the key belonged to. + + + The intended usage of the key that was deleted. + + + The encryption algorithm of the key that was deleted. + + + The version of the key that was deleted. + + + + +### Signing Data + +`client.Kms().Signing().Sign(options)` +Sign data in Infisical. + +```go +res, err := client.Kms().Signing().SignData(infisical.KmsSignDataOptions{ + KeyId: "", + Data: "", // Must be a base64 encoded string. + SigningAlgorithm: "", // The signing algorithm that will be used to sign the data. +}) +``` + +#### Parameters + + + + + The ID of the key to sign the data with. + + + The data to sign. Must be a base64 encoded string. + + + Whether the data is already digested or not. + + + The signing algorithm to use. You must use a signing algorithm that matches the key usage. + + + If you are unsure about which signing algorithms are available for your key, you can use the `client.Kms().Signing().ListSigningAlgorithms()` method. It will return an array of signing algorithms that are available for your key. + + + Valid options for `RSA 4096` keys are: + - `RSASSA_PSS_SHA_512` + - `RSASSA_PSS_SHA_384` + - `RSASSA_PSS_SHA_256` + - `RSASSA_PKCS1_V1_5_SHA_512` + - `RSASSA_PKCS1_V1_5_SHA_384` + - `RSASSA_PKCS1_V1_5_SHA_256` + + Valid options for `ECC NIST P256` keys are: + - `ECDSA_SHA_512` + - `ECDSA_SHA_384` + - `ECDSA_SHA_256` + + + + +#### Return ([]byte) + + The signature of the data that was signed. + + +### Verifying Data + +`client.Kms().Signing().Verify(options)` +Verify data in Infisical. + +```go +res, err := client.Kms().Signing().Verify(infisical.KmsVerifyDataOptions{ + KeyId: "", + Data: "", // Must be a base64 encoded string. + SigningAlgorithm: "", // The signing algorithm that was used to sign the data. +}) +``` + +#### Parameters + + + + + The ID of the key to verify the data with. + + + The data to verify. Must be a base64 encoded string. + + + Whether the data is already digested or not. + + + The signing algorithm that was used to sign the data. + + + + +#### Return (object) + + + + Whether or not the data is valid. + + + The ID of the key that was used to verify the data. + + + The signing algorithm that was used to verify the data. + + + + +### List Signing Algorithms + +`client.Kms().Signing().ListSigningAlgorithms(options)` +List signing algorithms in Infisical. + +```go +res, err := client.Kms().Signing().ListSigningAlgorithms(infisical.KmsListSigningAlgorithmsOptions{ + KeyId: "", +}) +``` + +#### Parameters + + + + + The ID of the key to list signing algorithms for. + + + + +#### Return ([]string) + + The signing algorithms that are available for the key. + + +### Get Public Key + + This method is only available for keys with key usage `sign-verify`. If you attempt to use this method on a key that is intended for encryption/decryption, it will return an error. + + +`client.Kms().Signing().GetPublicKey(options)` +Get the public key in Infisical. + +```go +publicKey, err := client.Kms().Signing().GetPublicKey(infisical.KmsGetPublicKeyOptions{ + KeyId: "", +}) +``` + +#### Parameters + + + + + The ID of the key to get the public key for. + + + + +#### Return (string) + + The public key for the key. + + +### Encrypt Data + +`client.Kms().Encryption().Encrypt(options)` +Encrypt data with a key in Infisical KMS. + +```go +res, err := client.Kms().EncryptData(infisical.KmsEncryptDataOptions{ + KeyId: "", + Plaintext: "", +}) +``` + +#### Parameters + + + + + The ID of the key to encrypt the data with. + + + + +#### Return (string) + + The encrypted data. + + +### Decrypt Data + +`client.Kms().DecryptData(options)` +Decrypt data with a key in Infisical KMS. + +```go +res, err := client.Kms().DecryptData(infisical.KmsDecryptDataOptions{ + KeyId: "", + Ciphertext: "", +}) +``` + +#### Parameters + + + + + The ID of the key to decrypt the data with. + + + The encrypted data to decrypt. + + + + +#### Return (string) + + The decrypted data. + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8eda21edd..103c6400e 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -34,6 +34,10 @@ Used to configure platform-specific security and operational settings this to `false`. + + Determines whether App Connections and Dynamic Secrets are permitted to connect with internal/private IP addresses. + + ## CORS Cross-Origin Resource Sharing (CORS) is a security feature that allows web applications running on one domain to access resources from another domain. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index abf46cadb..e7f57e85c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -129,7 +129,7 @@ "tailwindcss": "^3.4.16", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.11", + "vite": "^5.4.18", "vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", @@ -13907,9 +13907,9 @@ } }, "node_modules/vite": { - "version": "5.4.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", - "integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", + "version": "5.4.18", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz", + "integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e750783b2..6225b78f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -133,7 +133,7 @@ "tailwindcss": "^3.4.16", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.11", + "vite": "^5.4.18", "vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx index 3e232765c..a7c211bff 100644 --- a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Modal, ModalContent } from "@app/components/v2"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; @@ -10,6 +10,7 @@ import { SecretSyncSelect } from "./SecretSyncSelect"; type Props = { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + selectSync?: SecretSync | null; }; type ContentProps = { @@ -32,8 +33,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => return ; }; -export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => { - const [selectedSync, setSelectedSync] = useState(null); +export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { + const [selectedSync, setSelectedSync] = useState(selectSync); + + useEffect(() => { + setSelectedSync(selectSync); + }, [selectSync]); return ( { @@ -36,6 +37,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Humanitec: return ; + case SecretSync.TerraformCloud: + return ; case SecretSync.Camunda: return ; case SecretSync.Vercel: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx new file mode 100644 index 000000000..70394cc76 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx @@ -0,0 +1,244 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TERRAFORM_CLOUD_SYNC_SCOPES, + TerraformCloudSyncCategory, + TerraformCloudSyncScope, + TTerraformCloudConnectionOrganization, + TTerraformCloudConnectionVariableSet, + TTerraformCloudConnectionWorkspace, + useTerraformCloudConnectionListOrganizations +} from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TerraformCloudSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TerraformCloud } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentOrg = watch("destinationConfig.org"); + const currentScope = watch("destinationConfig.scope"); + + const { data: organizations = [], isPending: isOrganizationsPending } = + useTerraformCloudConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const selectedOrg = organizations?.find((org) => org.id === currentOrg); + const variableSets = selectedOrg?.variableSets || []; + const workspaces = selectedOrg?.workspaces || []; + + return ( + <> + { + setValue("destinationConfig.org", ""); + setValue("destinationConfig.variableSetId", ""); + setValue("destinationConfig.workspaceId", ""); + setValue("destinationConfig.variableSetName", ""); + setValue("destinationConfig.workspaceName", ""); + }} + /> + ( + + org.id === value) ?? null) : null} + onChange={(option) => { + onChange( + (option as SingleValue)?.id ?? null + ); + setValue("destinationConfig.variableSetId", ""); + setValue("destinationConfig.workspaceId", ""); + setValue("destinationConfig.variableSetName", ""); + setValue("destinationConfig.workspaceName", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + +

    +
  • +

    + + Environment variables configure Terraform's behavior (e.g., + credentials). + +

    +
  • +
  • +

    + + Terraform variables are used as input values in your configuration. + +

    +
  • +
+ + } + > + + + )} + /> + ( + +

+ Specify how Infisical should manage secrets from Terraform Cloud. The following + options are available: +

+
    + {Object.values(TERRAFORM_CLOUD_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+ + } + > + +
+ )} + /> + {currentScope === TerraformCloudSyncScope.VariableSet && ( + ( + + variableSet.id === value) ?? null} + onChange={(option) => { + const selectedOption = + option as SingleValue; + onChange(selectedOption?.id ?? null); + + if (selectedOption) { + setValue("destinationConfig.variableSetName", selectedOption.name); + } else { + setValue("destinationConfig.variableSetName", ""); + } + }} + options={variableSets} + placeholder="Select a variable set..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + {currentScope === TerraformCloudSyncScope.Workspace && ( + ( + + workspace.id === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue; + onChange(selectedOption?.id ?? null); + + if (selectedOption) { + setValue("destinationConfig.workspaceName", selectedOption.name); + } else { + setValue("destinationConfig.workspaceName", ""); + } + }} + options={workspaces} + placeholder="Select a workspace..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 6c09c65cc..5e8ff01d1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -39,6 +39,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.TerraformCloud: case SecretSync.Camunda: case SecretSync.Vercel: AdditionalSyncOptionsFieldsComponent = null; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 573854906..4e9e89427 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; export const SecretSyncReviewFields = () => { @@ -74,6 +75,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Humanitec: DestinationFieldsComponent = ; break; + case SecretSync.TerraformCloud: + DestinationFieldsComponent = ; + break; case SecretSync.Camunda: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx new file mode 100644 index 000000000..614e3e180 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx @@ -0,0 +1,28 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgId = watch("destinationConfig.org"); + const variableSetName = watch("destinationConfig.variableSetName"); + const workspaceName = watch("destinationConfig.workspaceName"); + const scope = watch("destinationConfig.scope"); + const category = watch("destinationConfig.category"); + + return ( + <> + {orgId} + {scope === TerraformCloudSyncScope.VariableSet && ( + {variableSetName} + )} + {scope === TerraformCloudSyncScope.Workspace && ( + {workspaceName} + )} + {category} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index e8f68de91..4eb1094ae 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -10,6 +10,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ @@ -21,6 +22,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, HumanitecSyncDestinationSchema, + TerraformCloudSyncDestinationSchema, CamundaSyncDestinationSchema, VercelSyncDestinationSchema ]); diff --git a/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts new file mode 100644 index 000000000..d818fd29a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { + TerraformCloudSyncCategory, + TerraformCloudSyncScope +} from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(TerraformCloudSyncScope.VariableSet), + org: z.string().trim().min(1, "Organization required"), + variableSetId: z.string().trim().min(1, "Variable Set required"), + variableSetName: z.string().trim().min(1, "Variable set name required"), + category: z.nativeEnum(TerraformCloudSyncCategory) + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace), + org: z.string().trim().min(1, "Organization required"), + workspaceId: z.string().trim().min(1, "Workspace required"), + workspaceName: z.string().trim().min(1, "Workspace name required"), + category: z.nativeEnum(TerraformCloudSyncCategory) + }) + ]) + }) +); diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 299b23bb5..a2b69eaba 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -16,7 +16,9 @@ type Props = { subTitle?: string; onDeleteApproved: () => Promise; buttonText?: string; + formContent?: ReactNode; children?: ReactNode; + deletionMessage?: ReactNode; }; export const DeleteActionModal = ({ @@ -28,6 +30,8 @@ export const DeleteActionModal = ({ title, subTitle = "This action is irreversible.", buttonText = "Delete", + formContent, + deletionMessage, children }: Props): JSX.Element => { const [inputData, setInputData] = useState(""); @@ -79,6 +83,7 @@ export const DeleteActionModal = ({ } onClose={onClose} > + {formContent}
{ evt.preventDefault(); @@ -88,7 +93,11 @@ export const DeleteActionModal = ({ - Type {deleteKey} to perform this action + {deletionMessage || ( + <> + Type {deleteKey} to perform this action + + )} } className="mb-0" diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 7c92988a1..a327913d9 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionKmipActions { @@ -98,7 +100,8 @@ export enum PermissionConditionOperators { $REGEX = "$regex", $EQ = "$eq", $NEQ = "$ne", - $GLOB = "$glob" + $GLOB = "$glob", + $ELEMENTMATCH = "$elemMatch" } export type IdentityManagementSubjectFields = { @@ -111,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato [PermissionConditionOperators.$ALL]: "contains all", [PermissionConditionOperators.$NEQ]: "not equal to", [PermissionConditionOperators.$GLOB]: "matches glob pattern", - [PermissionConditionOperators.$REGEX]: "matches regex pattern" + [PermissionConditionOperators.$REGEX]: "matches regex pattern", + [PermissionConditionOperators.$ELEMENTMATCH]: "element matches" }; export type TPermissionConditionOperators = { @@ -121,12 +125,24 @@ export type TPermissionConditionOperators = { [PermissionConditionOperators.$NEQ]: string; [PermissionConditionOperators.$REGEX]: string; [PermissionConditionOperators.$GLOB]: string; + [PermissionConditionOperators.$ELEMENTMATCH]: Record< + string, + Partial + >; }; export type TPermissionCondition = Record< string, | string - | { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string } + | { + $in: string[]; + $all: string[]; + $regex: string; + $eq: string; + $ne: string; + $glob: string; + $elemMatch: Partial; + } >; export enum ProjectPermissionSub { @@ -180,6 +196,7 @@ export type SecretFolderSubjectFields = { export type DynamicSecretSubjectFields = { environment: string; secretPath: string; + metadata?: (string | { key: string; value: string })[]; }; export type SecretImportSubjectFields = { diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 2eb18ca5b..666784b54 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -15,6 +15,7 @@ import { MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, + TerraformCloudConnectionMethod, VercelConnectionMethod } from "@app/hooks/api/appConnections/types"; @@ -35,6 +36,7 @@ export const APP_CONNECTION_MAP: Record< }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, + [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, [AppConnection.Vercel]: { name: "Vercel", image: "Vercel.png" }, [AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, @@ -61,6 +63,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case CamundaConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faKey }; case HumanitecConnectionMethod.ApiToken: + case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/kms.ts b/frontend/src/helpers/kms.ts new file mode 100644 index 000000000..64b85dc2c --- /dev/null +++ b/frontend/src/helpers/kms.ts @@ -0,0 +1,27 @@ +import { AsymmetricKeyAlgorithm, KmsKeyUsage, SymmetricKeyAlgorithm } from "@app/hooks/api/cmeks"; + +export const kmsKeyUsageOptions: Record< + KmsKeyUsage, + { + label: string; + tooltip: string; + } +> = { + [KmsKeyUsage.ENCRYPT_DECRYPT]: { + label: "Encrypt/Decrypt", + tooltip: "Use the key only to encrypt and decrypt data." + }, + [KmsKeyUsage.SIGN_VERIFY]: { + label: "Sign/Verify", + tooltip: + "Key pairs for digital signing. Uses the private key for signing and the public key for verification." + } +}; + +export const keyUsageDefaultOption: Record< + KmsKeyUsage, + SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm +> = { + [KmsKeyUsage.ENCRYPT_DECRYPT]: SymmetricKeyAlgorithm.AES_GCM_256, + [KmsKeyUsage.SIGN_VERIFY]: AsymmetricKeyAlgorithm.RSA_4096 +}; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 12521c4f8..fd7159c02 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -24,6 +24,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index d896bc5f1..823583d59 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx new file mode 100644 index 000000000..a5da22a0a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTerraformCloudOrganization } from "./types"; + +const terraformCloudConnectionKeys = { + all: [...appConnectionKeys.all, "terraform-cloud"] as const, + listOrganizations: (connectionId: string) => + [...terraformCloudConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useTerraformCloudConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTerraformCloudOrganization[], + unknown, + TTerraformCloudOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: terraformCloudConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/terraform-cloud/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts new file mode 100644 index 000000000..7402976f5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts @@ -0,0 +1,56 @@ +export type TTerraformCloudOrganization = { + name: string; + id: string; + variableSets: TTerraformCloudVariableSet[]; + workspaces: TTerraformCloudWorkspace[]; +}; + +export type TTerraformCloudVariableSet = { + id: string; + name: string; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudConnectionOrganization = { + id: string; + name: string; + variableSets: TTerraformCloudConnectionVariableSet[]; + workspaces: TTerraformCloudConnectionWorkspace[]; +}; + +export type TTerraformCloudConnectionVariableSet = { + id: string; + name: string; + description: string; + global: boolean; +}; + +export type TTerraformCloudConnectionWorkspace = { + id: string; + name: string; +}; + +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} + +export enum TerraformCloudSyncCategory { + Environment = "env", + Terraform = "terraform" +} + +export const TERRAFORM_CLOUD_SYNC_SCOPES = { + [TerraformCloudSyncScope.VariableSet]: { + name: "Variable Set", + description: "Sync secrets to a specific variable set in Terraform Cloud." + }, + [TerraformCloudSyncScope.Workspace]: { + name: "Workspace", + description: "Sync secrets to a specific workspace in Terraform Cloud." + } +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 7e3206c87..48cee7b81 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -39,6 +39,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TerraformCloud; +}; + export type TVercelConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Vercel; }; @@ -67,6 +71,7 @@ export type TAppConnectionOption = | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption + | TTerraformCloudConnectionOption | TVercelConnectionOption | TPostgresConnectionOption | TMsSqlConnectionOption @@ -81,6 +86,7 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index c9c2cce87..c227ae381 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -11,6 +11,7 @@ import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; export * from "./auth0-connection"; @@ -24,6 +25,7 @@ export * from "./github-connection"; export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; +export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export type TAppConnection = @@ -34,6 +36,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TTerraformCloudConnection | TVercelConnection | TPostgresConnection | TMsSqlConnection @@ -73,6 +76,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.TerraformCloud]: TTerraformCloudConnection; [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; diff --git a/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts new file mode 100644 index 000000000..ddc49d776 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TerraformCloudConnectionMethod { + ApiToken = "api-token" +} + +export type TTerraformCloudConnection = TRootAppConnection & { + app: AppConnection.TerraformCloud; +} & { + method: TerraformCloudConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 464a46aa8..159e840ec 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -106,6 +106,10 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_CMEK]: "Get KMS key", [EventType.CMEK_ENCRYPT]: "Encrypt with KMS key", [EventType.CMEK_DECRYPT]: "Decrypt with KMS key", + [EventType.CMEK_SIGN]: "Sign with KMS key", + [EventType.CMEK_VERIFY]: "Verify with KMS key", + [EventType.CMEK_LIST_SIGNING_ALGORITHMS]: "List signing algorithms for KMS key", + [EventType.CMEK_GET_PUBLIC_KEY]: "Get public key for KMS key", [EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "Update SSO group to organization role mapping", [EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ab287226b..15adb0272 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -110,6 +110,10 @@ export enum EventType { GET_CMEK = "get-cmek", CMEK_ENCRYPT = "cmek-encrypt", CMEK_DECRYPT = "cmek-decrypt", + CMEK_SIGN = "cmek-sign", + CMEK_VERIFY = "cmek-verify", + CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", + CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", GET_PROJECT_TEMPLATES = "get-project-templates", diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index 41e70c193..b806e2b44 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -8,6 +8,10 @@ import { TCmekDecryptResponse, TCmekEncrypt, TCmekEncryptResponse, + TCmekSign, + TCmekSignResponse, + TCmekVerify, + TCmekVerifyResponse, TCreateCmek, TDeleteCmek, TUpdateCmek @@ -74,6 +78,44 @@ export const useCmekEncrypt = () => { }); }; +export const useCmekSign = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signingAlgorithm, + isBase64Encoded + }: TCmekSign & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/sign`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signingAlgorithm + }); + + return res.data; + } + }); +}; + +export const useCmekVerify = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signature, + signingAlgorithm, + isBase64Encoded + }: TCmekVerify & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/verify`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signature, + signingAlgorithm + }); + + return res.data; + } + }); +}; + export const useCmekDecrypt = () => { return useMutation({ mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => { diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index c557c1fc2..2f6b8788b 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -1,10 +1,18 @@ +import { z } from "zod"; + import { OrderByDirection } from "@app/hooks/api/generic/types"; +export enum KmsKeyUsage { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TCmek = { id: string; + keyUsage: KmsKeyUsage; name: string; description?: string; - encryptionAlgorithm: EncryptionAlgorithm; + encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; projectId: string; isDisabled: boolean; isReserved: boolean; @@ -17,7 +25,8 @@ export type TCmek = { type ProjectRef = { projectId: string }; type KeyRef = { keyId: string }; -export type TCreateCmek = Pick & ProjectRef; +export type TCreateCmek = Pick & + ProjectRef; export type TUpdateCmek = KeyRef & Partial> & ProjectRef; @@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef; export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean }; export type TCmekDecrypt = KeyRef & { ciphertext: string }; +export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm }; +export type TCmekVerify = KeyRef & { + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TProjectCmeksList = { keys: TCmek[]; totalCount: number; @@ -44,6 +60,18 @@ export type TCmekEncryptResponse = { ciphertext: string; }; +export type TCmekSignResponse = { + signature: string; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + +export type TCmekVerifyResponse = { + signatureValid: boolean; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TCmekDecryptResponse = { plaintext: string; }; @@ -52,7 +80,35 @@ export enum CmekOrderBy { Name = "name" } -export enum EncryptionAlgorithm { +export enum AsymmetricKeyAlgorithm { + RSA_4096 = "RSA_4096", + ECC_NIST_P256 = "ECC_NIST_P256" +} + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyAlgorithm { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyAlgorithm), + ...Object.values(AsymmetricKeyAlgorithm) +] as [string, ...string[]]).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + + // RSA PKCS#1 v1.5 algorithms + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + + // ECDSA algorithms + ECDSA_SHA_256 = "ECDSA_SHA_256", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_512 = "ECDSA_SHA_512" +} diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 6a3e9ba7c..e80874f8e 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -24,6 +24,7 @@ export type DashboardProjectSecretsOverviewResponse = { totalUniqueDynamicSecretsInPage: number; totalUniqueFoldersInPage: number; totalUniqueSecretImportsInPage: number; + importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]; totalUniqueSecretRotationsInPage: number; }; @@ -41,6 +42,16 @@ export type DashboardProjectSecretsDetailsResponse = { totalSecretCount?: number; totalSecretRotationCount?: number; totalCount: number; + importedBy?: ProjectSecretsImportedBy[]; +}; + +export type ProjectSecretsImportedBy = { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string }[]; + isImported: boolean; + }[]; }; export type DashboardProjectSecretsByKeys = { diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 7d8c6920a..1aedf264f 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -13,6 +13,7 @@ export type TDynamicSecret = { status?: DynamicSecretStatus; statusDetails?: string; maxTTL: string; + metadata?: { key: string; value: string }[]; }; export enum DynamicSecretProviders { @@ -261,6 +262,7 @@ export type TDynamicSecretProvider = digits?: number; }; }; + export type TCreateDynamicSecretDTO = { projectSlug: string; provider: TDynamicSecretProvider; @@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = { path: string; environmentSlug: string; name: string; + metadata?: { key: string; value: string }[]; }; export type TUpdateDynamicSecretDTO = { @@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = { environmentSlug: string; data: { newName?: string; + metadata?: { key: string; value: string }[]; defaultTTL?: string; maxTTL?: string | null; inputs?: unknown; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index b7adb0f53..3d437333e 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -1,6 +1,7 @@ export type TCloudIntegration = { name: string; slug: string; + syncSlug?: string; image: string; isAvailable: boolean; type: string; diff --git a/frontend/src/hooks/api/secretRotation/types.ts b/frontend/src/hooks/api/secretRotation/types.ts index 07a4c70df..19c4cdcdf 100644 --- a/frontend/src/hooks/api/secretRotation/types.ts +++ b/frontend/src/hooks/api/secretRotation/types.ts @@ -45,6 +45,7 @@ export type TSecretRotationProviderTemplate = { image?: string; description?: string; template: THttpProviderTemplate | TDbProviderTemplate; + isDeprecated?: boolean; }; export type THttpProviderTemplate = { diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 9fa5f6c99..449ddf7e0 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -7,6 +7,7 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel" } diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 207ab0118..c6e5f2762 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; export type TSecretSyncOption = { @@ -27,6 +28,7 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync + | TTerraformCloudSync | TCamundaSync | TVercelSync; diff --git a/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts new file mode 100644 index 000000000..a8ab23aeb --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts @@ -0,0 +1,34 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +import { TerraformCloudSyncCategory } from "../../appConnections/terraform-cloud"; + +export type TTerraformCloudSync = TRootSecretSync & { + destination: SecretSync.TerraformCloud; + destinationConfig: + | { + scope: TerraformCloudSyncScope.VariableSet; + org: string; + category: TerraformCloudSyncCategory; + variableSetId: string; + variableSetName: string; + } + | { + scope: TerraformCloudSyncScope.Workspace; + org: string; + category: TerraformCloudSyncCategory; + workspaceId: string; + workspaceName: string; + }; + connection: { + app: AppConnection.TerraformCloud; + name: string; + id: string; + }; +}; + +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} diff --git a/frontend/src/lib/fn/base64.ts b/frontend/src/lib/fn/base64.ts new file mode 100644 index 000000000..bcef1a60b --- /dev/null +++ b/frontend/src/lib/fn/base64.ts @@ -0,0 +1,14 @@ +const base64WithPadding = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; + +export const isBase64 = (str: string): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + if (str === "") return true; + + const regex = base64WithPadding; + + return regex.test(str); +}; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx index 7598d8e80..c43b6c8d0 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx @@ -15,13 +15,23 @@ import { TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + AllowedEncryptionKeyAlgorithms, + AsymmetricKeyAlgorithm, + KmsKeyUsage, + SymmetricKeyAlgorithm, + TCmek, + useCreateCmek, + useUpdateCmek +} from "@app/hooks/api/cmeks"; import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), - encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) + encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms), + keyUsage: z.nativeEnum(KmsKeyUsage) }); export type FormData = z.infer; @@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { control, handleSubmit, register, + setValue, + watch, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: cmek?.name, description: cmek?.description, - encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256 + encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT } }); - const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => { + const handleCreateCmek = async ({ + encryptionAlgorithm, + name, + description, + keyUsage + }: FormData) => { const mutation = isUpdate ? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description }) : createCmek.mutateAsync({ projectId, - encryptionAlgorithm, name, - description + description, + keyUsage, + encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm }); try { @@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { } }; + const selectedKeyUsage = watch("keyUsage"); + return ( { > - {!isUpdate && ( - ( - - - - )} - /> - )} +
+ {!isUpdate && ( + <> + ( + + {Object.entries(KmsKeyUsage).map(([key, value]) => ( +
+

{kmsKeyUsageOptions[value].label}

+

{kmsKeyUsageOptions[value].tooltip}

+
+ ))} +
+ } + label="Key Usage" + errorText={error?.message} + isError={Boolean(error)} + > + +
+ )} + /> + ( + + + + )} + /> + + )} + ; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + cmek: TCmek; +}; + +type FormProps = Pick; + +const SignForm = ({ cmek }: FormProps) => { + const cmekSign = useCmekSign(); + + const { + handleSubmit, + register, + control, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA") + ? SigningAlgorithm.RSASSA_PSS_SHA_512 + : SigningAlgorithm.ECDSA_SHA_256, + isBase64Encoded: false + } + }); + + const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset({ + initialState: "Copy to Clipboard" + }); + + const handleSignData = async (formData: FormData) => { + try { + await cmekSign.mutateAsync({ ...formData, keyId: cmek.id }); + createNotification({ + text: "Successfully signed data", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to sign data", + type: "error" + }); + } + }; + + const signature = cmekSign.data?.signature; + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(signature ?? ""); + + setCopySignature("Copied to Clipboard"); + }; + + const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) => + cmek?.encryptionAlgorithm?.startsWith("RSA") + ? a.toLowerCase().startsWith("rsa") + : a.toLowerCase().startsWith("ecdsa") + ); + + return ( + + {signature ? ( + +