diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index 99e3999e1..3f49bcfea 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -11,6 +11,7 @@ export const mockQueue = (): TQueueServiceFactory => { job[name] = jobData; }, queuePg: async () => {}, + schedulePg: async () => {}, initialize: async () => {}, shutdown: async () => undefined, stopRepeatableJob: async () => true, diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 5d10830ad..d3aed3543 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -33,6 +33,7 @@ import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; +import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; @@ -237,6 +238,7 @@ declare module "fastify" { kmip: TKmipServiceFactory; kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; + secretRotationV2: TSecretRotationV2ServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 346b08e4c..82582bfed 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -17,6 +17,9 @@ import { TApiKeys, TApiKeysInsert, TApiKeysUpdate, + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate, TAuditLogs, TAuditLogsInsert, TAuditLogStreams, @@ -65,6 +68,9 @@ import { TDynamicSecrets, TDynamicSecretsInsert, TDynamicSecretsUpdate, + TExternalGroupOrgRoleMappings, + TExternalGroupOrgRoleMappingsInsert, + TExternalGroupOrgRoleMappingsUpdate, TExternalKms, TExternalKmsInsert, TExternalKmsUpdate, @@ -299,6 +305,12 @@ import { TSecretRotations, TSecretRotationsInsert, TSecretRotationsUpdate, + TSecretRotationsV2, + TSecretRotationsV2Insert, + TSecretRotationsV2Update, + TSecretRotationV2SecretMappings, + TSecretRotationV2SecretMappingsInsert, + TSecretRotationV2SecretMappingsUpdate, TSecrets, TSecretScanningGitRisks, TSecretScanningGitRisksInsert, @@ -320,15 +332,27 @@ import { TSecretSnapshotsInsert, TSecretSnapshotsUpdate, TSecretsUpdate, + TSecretsV2, + TSecretsV2Insert, + TSecretsV2Update, + TSecretSyncs, + TSecretSyncsInsert, + TSecretSyncsUpdate, TSecretTagJunction, TSecretTagJunctionInsert, TSecretTagJunctionUpdate, TSecretTags, TSecretTagsInsert, TSecretTagsUpdate, + TSecretV2TagJunction, + TSecretV2TagJunctionInsert, + TSecretV2TagJunctionUpdate, TSecretVersions, TSecretVersionsInsert, TSecretVersionsUpdate, + TSecretVersionsV2, + TSecretVersionsV2Insert, + TSecretVersionsV2Update, TSecretVersionTagJunction, TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionUpdate, @@ -387,24 +411,6 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; -import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; -import { - TExternalGroupOrgRoleMappings, - TExternalGroupOrgRoleMappingsInsert, - TExternalGroupOrgRoleMappingsUpdate -} from "@app/db/schemas/external-group-org-role-mappings"; -import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs"; -import { - TSecretV2TagJunction, - TSecretV2TagJunctionInsert, - TSecretV2TagJunctionUpdate -} from "@app/db/schemas/secret-v2-tag-junction"; -import { - TSecretVersionsV2, - TSecretVersionsV2Insert, - TSecretVersionsV2Update -} from "@app/db/schemas/secret-versions-v2"; -import { TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas/secrets-v2"; declare module "knex" { namespace Knex { @@ -950,5 +956,15 @@ declare module "knex/types/tables" { TOrgGatewayConfigInsert, TOrgGatewayConfigUpdate >; + [TableName.SecretRotationV2]: KnexOriginal.CompositeTableType< + TSecretRotationsV2, + TSecretRotationsV2Insert, + TSecretRotationsV2Update + >; + [TableName.SecretRotationV2SecretMapping]: KnexOriginal.CompositeTableType< + TSecretRotationV2SecretMappings, + TSecretRotationV2SecretMappingsInsert, + TSecretRotationV2SecretMappingsUpdate + >; } } diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts index 9823f4d8e..5a16666af 100644 --- a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts +++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { diff --git a/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts new file mode 100644 index 000000000..79d455605 --- /dev/null +++ b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials"))) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.boolean("isPlatformManagedCredentials").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials")) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.dropColumn("isPlatformManagedCredentials"); + }); + } +} diff --git a/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts new file mode 100644 index 000000000..0fd9b9881 --- /dev/null +++ b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretRotationV2))) { + await knex.schema.createTable(TableName.SecretRotationV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("type").notNullable(); + t.jsonb("parameters").notNullable(); + t.jsonb("secretsMapping").notNullable(); + t.binary("encryptedGeneratedCredentials").notNullable(); + t.boolean("isAutoRotationEnabled").notNullable().defaultTo(true); + t.integer("activeIndex").notNullable().defaultTo(0); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + t.integer("rotationInterval").notNullable(); + t.jsonb("rotateAtUtc").notNullable(); // { hours: number; minutes: number } + t.string("rotationStatus").notNullable(); + t.datetime("lastRotationAttemptedAt").notNullable(); + t.datetime("lastRotatedAt").notNullable(); + t.binary("encryptedLastRotationMessage"); // we encrypt this because it may contain sensitive info (SQL errors showing credentials) + t.string("lastRotationJobId"); + t.datetime("nextRotationAt"); + }); + + await createOnUpdateTrigger(knex, TableName.SecretRotationV2); + + await knex.schema.alterTable(TableName.SecretRotationV2, (t) => { + t.unique(["folderId", "name"]); + }); + } + + if (!(await knex.schema.hasTable(TableName.SecretRotationV2SecretMapping))) { + await knex.schema.createTable(TableName.SecretRotationV2SecretMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("secretId").notNullable(); + // scott: this is deferred to block secret deletion but not prevent folder/environment/project deletion + // ie, if rotation is being deleted as well we permit it, otherwise throw + t.foreign("secretId").references("id").inTable(TableName.SecretV2).deferrable("deferred"); + t.uuid("rotationId").notNullable(); + t.foreign("rotationId").references("id").inTable(TableName.SecretRotationV2).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretRotationV2SecretMapping); + await knex.schema.dropTableIfExists(TableName.SecretRotationV2); + await dropOnUpdateTrigger(knex, TableName.SecretRotationV2); +} diff --git a/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts b/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts new file mode 100644 index 000000000..f97bb9b6f --- /dev/null +++ b/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +const INDEX_NAME = "idx_unique_secret_v2_key"; + +export async function up(knex: Knex): Promise { + const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key"); + const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId"); + const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type"); + + if (hasKeyCol && hasFolderIdCol && hasTypeCol) { + await knex.raw(` + CREATE UNIQUE INDEX ${INDEX_NAME} + ON ${TableName.SecretV2} ("key", "folderId") + WHERE type = 'shared' + `); + } +} + +export async function down(knex: Knex): Promise { + const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key"); + const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId"); + const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type"); + + if (hasKeyCol && hasFolderIdCol && hasTypeCol) { + await knex.raw(`DROP INDEX IF EXISTS ${INDEX_NAME}`); + } +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index 8c9dff236..ee4282b73 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -19,7 +19,8 @@ export const AppConnectionsSchema = z.object({ version: z.number().default(1), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isPlatformManagedCredentials: z.boolean().default(false).nullable().optional() }); export type TAppConnections = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 92fc47c23..5b78cf86f 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; +export * from "./app-connections"; export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; @@ -19,6 +20,7 @@ export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; +export * from "./external-group-org-role-mappings"; export * from "./external-kms"; export * from "./gateways"; export * from "./git-app-install-sessions"; @@ -97,13 +99,16 @@ export * from "./secret-references"; export * from "./secret-references-v2"; export * from "./secret-rotation-output-v2"; export * from "./secret-rotation-outputs"; +export * from "./secret-rotation-v2-secret-mappings"; export * from "./secret-rotations"; +export * from "./secret-rotations-v2"; export * from "./secret-scanning-git-risks"; export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; export * from "./secret-snapshot-secrets-v2"; export * from "./secret-snapshots"; +export * from "./secret-syncs"; export * from "./secret-tag-junction"; export * from "./secret-tags"; export * from "./secret-v2-tag-junction"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e626443c3..f2c61e0fa 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -140,7 +140,9 @@ export enum TableName { KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", - KmipClientCertificates = "kmip_client_certificates" + KmipClientCertificates = "kmip_client_certificates", + SecretRotationV2 = "secret_rotations_v2", + SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts new file mode 100644 index 000000000..5baf6942c --- /dev/null +++ b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretRotationV2SecretMappingsSchema = z.object({ + id: z.string().uuid(), + secretId: z.string().uuid(), + rotationId: z.string().uuid() +}); + +export type TSecretRotationV2SecretMappings = z.infer; +export type TSecretRotationV2SecretMappingsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretRotationV2SecretMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-rotations-v2.ts b/backend/src/db/schemas/secret-rotations-v2.ts new file mode 100644 index 000000000..cecc58e07 --- /dev/null +++ b/backend/src/db/schemas/secret-rotations-v2.ts @@ -0,0 +1,38 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretRotationsV2Schema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + type: z.string(), + parameters: z.unknown(), + secretsMapping: z.unknown(), + encryptedGeneratedCredentials: zodBuffer, + isAutoRotationEnabled: z.boolean().default(true), + activeIndex: z.number().default(0), + folderId: z.string().uuid(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + rotationInterval: z.number(), + rotateAtUtc: z.unknown(), + rotationStatus: z.string(), + lastRotationAttemptedAt: z.date(), + lastRotatedAt: z.date(), + encryptedLastRotationMessage: zodBuffer.nullable().optional(), + lastRotationJobId: z.string().nullable().optional(), + nextRotationAt: z.date().nullable().optional() +}); + +export type TSecretRotationsV2 = z.infer; +export type TSecretRotationsV2Insert = Omit, TImmutableDBKeys>; +export type TSecretRotationsV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/secret-rotation-router.ts b/backend/src/ee/routes/v1/secret-rotation-router.ts index 936459fa1..1efc2c8aa 100644 --- a/backend/src/ee/routes/v1/secret-rotation-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-router.ts @@ -1,6 +1,7 @@ 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"; @@ -40,16 +41,10 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = } }, onRequest: verifyAuth([AuthMode.JWT]), - 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 + handler: async () => { + throw new BadRequestError({ + message: `This version of Secret Rotations has been deprecated. Please see docs for new version.` }); - return { secretRotation }; } }); diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index bede5a1cf..70e5005a4 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -1,3 +1,8 @@ +import { + registerSecretRotationV2Router, + SECRET_ROTATION_REGISTER_ROUTER_MAP +} from "@app/ee/routes/v2/secret-rotation-v2-routers"; + import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -13,4 +18,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { await server.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity-project-additional-privilege" }); + + await server.register( + async (secretRotationV2Router) => { + // register generic secret rotation endpoints + await secretRotationV2Router.register(registerSecretRotationV2Router); + + // register service specific secret rotation endpoints (secret-rotations/postgres-credentials, etc.) + for await (const [type, router] of Object.entries(SECRET_ROTATION_REGISTER_ROUTER_MAP)) { + await secretRotationV2Router.register(router, { prefix: `/${type}` }); + } + }, + { prefix: "/secret-rotations" } + ); }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts new file mode 100644 index 000000000..d641ec689 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -0,0 +1,14 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; +import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; + +export * from "./secret-rotation-v2-router"; + +export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< + SecretRotation, + (server: FastifyZodProvider) => Promise +> = { + [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, + [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter +}; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts new file mode 100644 index 000000000..4fea8869b --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateMsSqlCredentialsRotationSchema, + MsSqlCredentialsRotationSchema, + UpdateMsSqlCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerMsSqlCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.MsSqlCredentials, + server, + responseSchema: MsSqlCredentialsRotationSchema, + createSchema: CreateMsSqlCredentialsRotationSchema, + updateSchema: UpdateMsSqlCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts new file mode 100644 index 000000000..ab5ef5768 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreatePostgresCredentialsRotationSchema, + PostgresCredentialsRotationSchema, + UpdatePostgresCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerPostgresCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.PostgresCredentials, + server, + responseSchema: PostgresCredentialsRotationSchema, + createSchema: CreatePostgresCredentialsRotationSchema, + updateSchema: UpdatePostgresCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts new file mode 100644 index 000000000..cd3c8d4cb --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts @@ -0,0 +1,429 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { + TRotateAtUtc, + TSecretRotationV2, + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2Input +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { SecretRotations } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretRotationEndpoints = < + T extends TSecretRotationV2, + I extends TSecretRotationV2Input, + C extends TSecretRotationV2GeneratedCredentials +>({ + server, + type, + createSchema, + updateSchema, + responseSchema, + generatedCredentialsSchema +}: { + type: SecretRotation; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + environment: string; + secretPath: string; + projectId: string; + connectionId: string; + parameters: I["parameters"]; + secretsMapping: I["secretsMapping"]; + description?: string | null; + isAutoRotationEnabled?: boolean; + rotationInterval: number; + rotateAtUtc?: TRotateAtUtc; + }>; + updateSchema: z.ZodType<{ + connectionId?: string; + name?: string; + environment?: string; + secretPath?: string; + parameters?: I["parameters"]; + secretsMapping?: I["secretsMapping"]; + description?: string | null; + isAutoRotationEnabled?: boolean; + rotationInterval?: number; + rotateAtUtc?: TRotateAtUtc; + }>; + responseSchema: z.ZodTypeAny; + generatedCredentialsSchema: z.ZodTypeAny; +}) => { + const rotationType = SECRET_ROTATION_NAME_MAP[type]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${rotationType} Rotations for the specified project.`, + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST(type).projectId) + }), + response: { + 200: z.object({ secretRotations: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const secretRotations = (await server.services.secretRotationV2.listSecretRotationsByProjectId( + { projectId, type }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + type, + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id) + } + } + }); + + return { secretRotations }; + } + }); + + server.route({ + method: "GET", + url: "/:rotationId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${rotationType} Rotation by ID.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.GET_BY_ID(type).rotationId) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.findSecretRotationById( + { rotationId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.GET_SECRET_ROTATION, + metadata: { + rotationId, + type, + secretPath: secretRotation.folder.path, + environment: secretRotation.environment.slug + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "GET", + url: `/rotation-name/:rotationName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${rotationType} Rotation by name and project ID.`, + params: z.object({ + rotationName: z + .string() + .trim() + .min(1, "Rotation name required") + .describe(SecretRotations.GET_BY_NAME(type).rotationName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretRotations.GET_BY_NAME(type).projectId), + secretPath: z + .string() + .trim() + .min(1, "Secret path required") + .describe(SecretRotations.GET_BY_NAME(type).secretPath), + environment: z + .string() + .trim() + .min(1, "Environment required") + .describe(SecretRotations.GET_BY_NAME(type).environment) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationName } = req.params; + const { projectId, secretPath, environment } = req.query; + + const secretRotation = (await server.services.secretRotationV2.findSecretRotationByName( + { rotationName, projectId, type, secretPath, environment }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATION, + metadata: { + rotationId: secretRotation.id, + type, + secretPath, + environment + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create ${ + startsWithVowel(rotationType) ? "an" : "a" + } ${rotationType} Rotation for the specified project.`, + body: createSchema, + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretRotation = (await server.services.secretRotationV2.createSecretRotation( + { ...req.body, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.CREATE_SECRET_ROTATION, + metadata: { + rotationId: secretRotation.id, + type, + ...req.body + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "PATCH", + url: "/:rotationId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.UPDATE(type).rotationId) + }), + body: updateSchema, + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.updateSecretRotation( + { ...req.body, rotationId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.UPDATE_SECRET_ROTATION, + metadata: { + rotationId, + type, + ...req.body + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "DELETE", + url: `/:rotationId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.DELETE(type).rotationId) + }), + querystring: z.object({ + deleteSecrets: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .describe(SecretRotations.DELETE(type).deleteSecrets), + revokeGeneratedCredentials: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .describe(SecretRotations.DELETE(type).revokeGeneratedCredentials) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + const { deleteSecrets, revokeGeneratedCredentials } = req.query; + + const secretRotation = (await server.services.secretRotationV2.deleteSecretRotation( + { type, rotationId, deleteSecrets, revokeGeneratedCredentials }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.DELETE_SECRET_ROTATION, + metadata: { + type, + rotationId, + deleteSecrets, + revokeGeneratedCredentials + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "GET", + url: "/:rotationId/generated-credentials", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the generated credentials for the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.GET_GENERATED_CREDENTIALS_BY_ID(type).rotationId) + }), + response: { + 200: z.object({ + generatedCredentials: generatedCredentialsSchema, + activeIndex: z.number(), + rotationId: z.string().uuid(), + type: z.literal(type) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const { + generatedCredentials, + secretRotation: { activeIndex, projectId, folder, environment } + } = await server.services.secretRotationV2.findSecretRotationGeneratedCredentialsById( + { + rotationId, + type + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS, + metadata: { + type, + rotationId, + secretPath: folder.path, + environment: environment.slug + } + } + }); + + return { generatedCredentials: generatedCredentials as C, activeIndex, rotationId, type }; + } + }); + + server.route({ + method: "POST", + url: "/:rotationId/rotate-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Rotate the generated credentials for the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.ROTATE(type).rotationId) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.rotateSecretRotation( + { + rotationId, + type, + auditLogInfo: req.auditLogInfo + }, + req.permission + )) as T; + + return { secretRotation }; + } + }); +}; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts new file mode 100644 index 000000000..abdfc14f6 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; +import { SecretRotations } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ + PostgresCredentialsRotationListItemSchema, + MsSqlCredentialsRotationListItemSchema +]); + +export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available Secret Rotation Options.", + response: { + 200: z.object({ + secretRotationOptions: SecretRotationV2OptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const secretRotationOptions = server.services.secretRotationV2.listSecretRotationOptions(); + return { secretRotationOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the Secret Rotations for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST().projectId) + }), + response: { + 200: z.object({ secretRotations: SecretRotationV2Schema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const secretRotations = await server.services.secretRotationV2.listSecretRotationsByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + rotationIds: secretRotations.map((sync) => sync.id), + count: secretRotations.length + } + } + }); + + return { secretRotations }; + } + }); +}; 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 1214cc5a0..ab950f3bf 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,6 +2,13 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + TCreateSecretRotationV2DTO, + TDeleteSecretRotationV2DTO, + TSecretRotationV2Raw, + TUpdateSecretRotationV2DTO +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; @@ -54,6 +61,8 @@ export type TCreateAuditLogDTO = { projectId?: string; } & BaseAuthData; +export type AuditLogInfo = Pick; + interface BaseAuthData { ipAddress?: string; userAgent?: string; @@ -283,7 +292,15 @@ export enum EventType { KMIP_OPERATION_ACTIVATE = "kmip-operation-activate", KMIP_OPERATION_REVOKE = "kmip-operation-revoke", KMIP_OPERATION_LOCATE = "kmip-operation-locate", - KMIP_OPERATION_REGISTER = "kmip-operation-register" + KMIP_OPERATION_REGISTER = "kmip-operation-register", + + GET_SECRET_ROTATIONS = "get-secret-rotations", + GET_SECRET_ROTATION = "get-secret-rotation", + GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials", + CREATE_SECRET_ROTATION = "create-secret-rotation", + UPDATE_SECRET_ROTATION = "update-secret-rotation", + DELETE_SECRET_ROTATION = "delete-secret-rotation", + SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets" } interface UserActorMetadata { @@ -2290,6 +2307,63 @@ interface RegisterKmipServerEvent { }; } +interface GetSecretRotationsEvent { + type: EventType.GET_SECRET_ROTATIONS; + metadata: { + type?: SecretRotation; + count: number; + rotationIds: string[]; + secretPath?: string; + environment?: string; + }; +} + +interface GetSecretRotationEvent { + type: EventType.GET_SECRET_ROTATION; + metadata: { + type: SecretRotation; + rotationId: string; + secretPath: string; + environment: string; + }; +} + +interface GetSecretRotationCredentialsEvent { + type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS; + metadata: { + type: SecretRotation; + rotationId: string; + secretPath: string; + environment: string; + }; +} + +interface CreateSecretRotationEvent { + type: EventType.CREATE_SECRET_ROTATION; + metadata: Omit & { rotationId: string }; +} + +interface UpdateSecretRotationEvent { + type: EventType.UPDATE_SECRET_ROTATION; + metadata: TUpdateSecretRotationV2DTO; +} + +interface DeleteSecretRotationEvent { + type: EventType.DELETE_SECRET_ROTATION; + metadata: TDeleteSecretRotationV2DTO; +} + +interface RotateSecretRotationEvent { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS; + metadata: Pick & { + status: SecretRotationStatus; + rotationId: string; + jobId?: string | undefined; + occurredAt: Date; + message?: string | null | undefined; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -2500,4 +2574,11 @@ export type Event = | KmipOperationLocateEvent | KmipOperationRegisterEvent | CreateSecretRequestEvent - | SecretApprovalRequestReview; + | SecretApprovalRequestReview + | GetSecretRotationsEvent + | GetSecretRotationEvent + | GetSecretRotationCredentialsEvent + | CreateSecretRotationEvent + | UpdateSecretRotationEvent + | DeleteSecretRotationEvent + | RotateSecretRotationEvent; 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 02b83c0f9..5c5b9f30c 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -8,7 +8,8 @@ import { getDbConnectionHost } from "@app/lib/knex"; export const verifyHostInputValidity = async (host: string, isGateway = false) => { const appCfg = getConfig(); - // if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev + + if (appCfg.isDevelopmentMode) return [host]; const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 21d378802..3f4af174b 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -39,7 +39,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true, + secretRotation: false, caCrl: false, instanceUserManagement: false, externalKms: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 34e350cf6..c2bf42e2e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -56,7 +56,7 @@ export type TFeatureSet = { trial_end: null; has_used_trial: true; secretApproval: false; - secretRotation: true; + secretRotation: false; caCrl: false; instanceUserManagement: false; externalKms: false; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 2637db994..4d3fff12a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -77,6 +77,15 @@ export enum ProjectPermissionSecretSyncActions { RemoveSecrets = "remove-secrets" } +export enum ProjectPermissionSecretRotationActions { + Read = "read", + ReadGeneratedCredentials = "read-generated-credentials", + Create = "create", + Edit = "edit", + Delete = "delete", + RotateSecrets = "rotate-secrets" +} + export enum ProjectPermissionKmipActions { CreateClients = "create-clients", UpdateClients = "update-clients", @@ -142,6 +151,11 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type SecretRotationsSubjectFields = { + environment: string; + secretPath: string; +}; + export type IdentityManagementSubjectFields = { identityId: string; }; @@ -184,7 +198,13 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] - | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ + ProjectPermissionSecretRotationActions, + ( + | ProjectPermissionSub.SecretRotation + | (ForcedSubject & SecretRotationsSubjectFields) + ) + ] | [ ProjectPermissionIdentityActions, ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) @@ -300,12 +320,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRollback).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read, ProjectPermissionActions.Create]).describe( @@ -487,6 +501,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); @@ -541,6 +561,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretRotationActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretConditionV1Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); @@ -554,7 +584,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretApproval, - ProjectPermissionSub.SecretRotation, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, @@ -678,6 +707,18 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Kmip ); + can( + [ + ProjectPermissionSecretRotationActions.Create, + ProjectPermissionSecretRotationActions.Edit, + ProjectPermissionSecretRotationActions.Delete, + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, + ProjectPermissionSecretRotationActions.RotateSecrets + ], + ProjectPermissionSub.SecretRotation + ); + return rules; }; @@ -727,7 +768,7 @@ const buildMemberPermissionRules = () => { ); can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation); + can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -873,7 +914,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts new file mode 100644 index 000000000..3ee1cf450 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./mssql-credentials-rotation-constants"; +export * from "./mssql-credentials-rotation-schemas"; +export * from "./mssql-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts new file mode 100644 index 000000000..670c98997 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts @@ -0,0 +1,16 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MSSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Microsoft SQL Server Credentials", + type: SecretRotation.MsSqlCredentials, + connection: AppConnection.MsSql, + template: { + createUserStatement: `CREATE LOGIN [my_mssql_user] WITH PASSWORD = 'my_temporary_password'; CREATE USER [my_mssql_user] FOR LOGIN [my_mssql_user]; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [my_mssql_user];`, + secretsMapping: { + username: "MSSQL_DB_USERNAME", + password: "MSSQL_DB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..3f02d8144 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MsSqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MsSqlCredentials).extend({ + type: z.literal(SecretRotation.MsSqlCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreateMsSqlCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.MsSqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdateMsSqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.MsSqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const MsSqlCredentialsRotationListItemSchema = z.object({ + name: z.literal("Microsoft SQL Server Credentials"), + connection: z.literal(AppConnection.MsSql), + type: z.literal(SecretRotation.MsSqlCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts new file mode 100644 index 000000000..ed707c4e4 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TMsSqlConnection } from "@app/services/app-connection/mssql"; + +import { + CreateMsSqlCredentialsRotationSchema, + MsSqlCredentialsRotationListItemSchema, + MsSqlCredentialsRotationSchema +} from "./mssql-credentials-rotation-schemas"; + +export type TMsSqlCredentialsRotation = z.infer; + +export type TMsSqlCredentialsRotationInput = z.infer; + +export type TMsSqlCredentialsRotationListItem = z.infer; + +export type TMsSqlCredentialsRotationWithConnection = TMsSqlCredentialsRotation & { + connection: TMsSqlConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts new file mode 100644 index 000000000..aba568d1d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./postgres-credentials-rotation-constants"; +export * from "./postgres-credentials-rotation-schemas"; +export * from "./postgres-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts new file mode 100644 index 000000000..68a31e8c9 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts @@ -0,0 +1,16 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "PostgreSQL Credentials", + type: SecretRotation.PostgresCredentials, + connection: AppConnection.Postgres, + template: { + createUserStatement: `CREATE USER "my_pg_user" WITH ENCRYPTED PASSWORD 'temporary_password'; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "my_pg_user";`, + secretsMapping: { + username: "POSTGRES_DB_USERNAME", + password: "POSTGRES_DB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts new file mode 100644 index 000000000..0527a6116 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const PostgresCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.PostgresCredentials).extend({ + type: z.literal(SecretRotation.PostgresCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreatePostgresCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.PostgresCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdatePostgresCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.PostgresCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const PostgresCredentialsRotationListItemSchema = z.object({ + name: z.literal("PostgreSQL Credentials"), + connection: z.literal(AppConnection.Postgres), + type: z.literal(SecretRotation.PostgresCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts new file mode 100644 index 000000000..28e9fb29f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TPostgresConnection } from "@app/services/app-connection/postgres"; + +import { + CreatePostgresCredentialsRotationSchema, + PostgresCredentialsRotationListItemSchema, + PostgresCredentialsRotationSchema +} from "./postgres-credentials-rotation-schemas"; + +export type TPostgresCredentialsRotation = z.infer; + +export type TPostgresCredentialsRotationInput = z.infer; + +export type TPostgresCredentialsRotationListItem = z.infer; + +export type TPostgresCredentialsRotationWithConnection = TPostgresCredentialsRotation & { + connection: TPostgresConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts new file mode 100644 index 000000000..45809aae5 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -0,0 +1,465 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TSecretRotationsV2 } from "@app/db/schemas/secret-rotations-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindOpt +} from "@app/lib/knex"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +export type TSecretRotationV2DALFactory = ReturnType; + +type TSecretRotationFindFilter = Parameters>[0]; +type TSecretRotationFindOptions = TFindOpt; + +const baseSecretRotationV2Query = ({ + filter = {}, + options, + db, + tx +}: { + db: TDbClient; + filter?: { projectId?: string } & TSecretRotationFindFilter; + options?: TSecretRotationFindOptions; + tx?: Knex; +}) => { + const { projectId, ...filters } = filter; + + const query = (tx || db.replicaNode())(TableName.SecretRotationV2) + .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join(TableName.AppConnection, `${TableName.SecretRotationV2}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.SecretRotationV2)) + .select( + // environment + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("projectId").withSchema(TableName.Environment), + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filters))); + } + + if (projectId) { + void query.where(`${TableName.Environment}.projectId`, projectId); + } + + if (options) { + const { offset, limit, sort, count, countDistinct } = options; + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + void query.select("*"); + } + 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 }))); + } + } + + return query; +}; + +const expandSecretRotation = >[number]>( + secretRotation: T, + folder: Awaited>[number] +) => { + const { + envId, + envName, + envSlug, + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + connectionIsPlatformManagedCredentials, + ...el + } = secretRotation; + + return { + ...el, + connectionId, + environment: { id: envId, name: envName, slug: envSlug }, + connection: { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + }, + folder: { + id: folder!.id, + path: folder!.path + } + }; +}; + +export const secretRotationV2DALFactory = ( + db: TDbClient, + folderDAL: Pick +) => { + const secretRotationV2Orm = ormify(db, TableName.SecretRotationV2); + const secretRotationV2SecretMappingOrm = ormify(db, TableName.SecretRotationV2SecretMapping); + + const find = async ( + filter: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string }, + options?: TSecretRotationFindOptions, + tx?: Knex + ) => { + try { + const secretRotations = await baseSecretRotationV2Query({ filter, db, tx, options }); + + if (!secretRotations.length) return []; + + const foldersWithPath = await folderDAL.findSecretPathByFolderIds( + filter.projectId, + secretRotations.map((rotation) => rotation.folderId), + tx + ); + + const folderRecord: Record = {}; + + foldersWithPath.forEach((folder) => { + if (folder) folderRecord[folder.id] = folder; + }); + + return secretRotations.map((rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId])); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Rotation V2" }); + } + }; + + const findWithMappedSecretsCount = async ( + { + search, + projectId, + ...filter + }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string }, + tx?: Knex + ) => { + const query = (tx || db.replicaNode())(TableName.SecretRotationV2) + .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.rotationId`, + `${TableName.SecretRotationV2}.id` + ) + .join(TableName.SecretV2, `${TableName.SecretRotationV2SecretMapping}.secretId`, `${TableName.SecretV2}.id`) + .where(`${TableName.Environment}.projectId`, projectId) + .where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filter))) + .countDistinct(`${TableName.SecretRotationV2}.name`); + + if (search) { + void query + .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) + .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + } + + const result = await query; + + // @ts-expect-error knex infers wrong type... + return Number(result[0]?.count ?? 0); + }; + + const findWithMappedSecrets = async ( + { search, ...filter }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string }, + options?: TSecretRotationFindOptions, + tx?: Knex + ) => { + try { + const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + .join( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.rotationId`, + `${TableName.SecretRotationV2}.id` + ) + .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId`) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .select( + db.ref("id").withSchema(TableName.SecretV2).as("secretId"), + db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), + db.ref("type").withSchema(TableName.SecretV2).as("secretType"), + db.ref("encryptedValue").withSchema(TableName.SecretV2).as("secretEncryptedValue"), + db.ref("encryptedComment").withSchema(TableName.SecretV2).as("secretEncryptedComment"), + db.ref("reminderNote").withSchema(TableName.SecretV2).as("secretReminderNote"), + db.ref("reminderRepeatDays").withSchema(TableName.SecretV2).as("secretReminderRepeatDays"), + db.ref("skipMultilineEncoding").withSchema(TableName.SecretV2).as("secretSkipMultilineEncoding"), + db.ref("metadata").withSchema(TableName.SecretV2).as("secretMetadata"), + db.ref("userId").withSchema(TableName.SecretV2).as("secretUserId"), + db.ref("folderId").withSchema(TableName.SecretV2).as("secretFolderId"), + db.ref("createdAt").withSchema(TableName.SecretV2).as("secretCreatedAt"), + db.ref("updatedAt").withSchema(TableName.SecretV2).as("secretUpdatedAt"), + db.ref("id").withSchema(TableName.SecretTag).as("tagId"), + db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ); + + if (search) { + void extendedQuery.where((query) => { + void query + .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) + .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + }); + } + + const secretRotations = await extendedQuery; + + if (!secretRotations.length) return []; + + const foldersWithPath = await folderDAL.findSecretPathByFolderIds( + filter.projectId, + secretRotations.map((rotation) => rotation.folderId), + tx + ); + + const folderRecord: Record = {}; + + foldersWithPath.forEach((folder) => { + if (folder) folderRecord[folder.id] = folder; + }); + + return sqlNestRelationships({ + data: secretRotations, + key: "id", + parentMapper: (rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]), + childrenMapper: [ + { + key: "secretId", + label: "secrets" as const, + mapper: ({ + secretId, + secretKey, + secretVersion, + secretType, + secretEncryptedValue, + secretEncryptedComment, + secretReminderNote, + secretReminderRepeatDays, + secretSkipMultilineEncoding, + secretMetadata, + secretUserId, + secretFolderId, + secretCreatedAt, + secretUpdatedAt, + id + }) => ({ + id: secretId, + key: secretKey, + version: secretVersion, + type: secretType, + encryptedValue: secretEncryptedValue, + encryptedComment: secretEncryptedComment, + reminderNote: secretReminderNote, + reminderRepeatDays: secretReminderRepeatDays, + skipMultilineEncoding: secretSkipMultilineEncoding, + metadata: secretMetadata, + userId: secretUserId, + folderId: secretFolderId, + createdAt: secretCreatedAt, + updatedAt: secretUpdatedAt, + rotationId: id, + isRotatedSecret: true + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + } + ] + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Mapped Secrets - Secret Rotation V2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const secretRotation = await baseSecretRotationV2Query({ + filter: { id }, + db, + tx + }).first(); + + if (secretRotation) { + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + return expandSecretRotation(secretRotation, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID - Secret Rotation V2" }); + } + }; + + const create = async (data: Parameters<(typeof secretRotationV2Orm)["create"]>[0], tx?: Knex) => { + const rotation = await secretRotationV2Orm.create(data, tx); + + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotation.id }, + db, + tx + }).first())!; + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const updateById = async ( + rotationId: string, + data: Parameters<(typeof secretRotationV2Orm)["updateById"]>[1], + tx?: Knex + ) => { + const rotation = await secretRotationV2Orm.updateById(rotationId, data, tx); + + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotation.id }, + db, + tx + }).first())!; + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const deleteById = async (rotationId: string, tx?: Knex) => { + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotationId }, + db, + tx + }).first())!; + + await secretRotationV2Orm.deleteById(rotationId, tx); + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const findOne = async (filter: Parameters<(typeof secretRotationV2Orm)["findOne"]>[0], tx?: Knex) => { + try { + const secretRotation = await baseSecretRotationV2Query({ filter, db, tx }).first(); + + if (secretRotation) { + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Rotation V2" }); + } + }; + + const findSecretRotationsToQueue = async (rotateBy: Date, tx?: Knex) => { + const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2) + .where(`${TableName.SecretRotationV2}.isAutoRotationEnabled`, true) + .whereNotNull(`${TableName.SecretRotationV2}.nextRotationAt`) + .andWhereRaw(`"nextRotationAt" <= ?`, [rotateBy]) + .select(selectAllTableCols(TableName.SecretRotationV2)); + + return secretRotations; + }; + + return { + ...secretRotationV2Orm, + find, + create, + findById, + updateById, + deleteById, + findOne, + insertSecretMappings: secretRotationV2SecretMappingOrm.insertMany, + findWithMappedSecrets, + findWithMappedSecretsCount, + findSecretRotationsToQueue + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts new file mode 100644 index 000000000..178a12516 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -0,0 +1,9 @@ +export enum SecretRotation { + PostgresCredentials = "postgres-credentials", + MsSqlCredentials = "mssql-credentials" +} + +export enum SecretRotationStatus { + Success = "success", + Failed = "failed" +} diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts new file mode 100644 index 000000000..20709e05a --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -0,0 +1,222 @@ +import { AxiosError } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; +import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; +import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; +import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; +import { + TSecretRotationV2, + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2ListItem, + TSecretRotationV2Raw +} from "./secret-rotation-v2-types"; + +const SECRET_ROTATION_LIST_OPTIONS: Record = { + [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION, + [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION +}; + +export const listSecretRotationOptions = () => { + return Object.values(SECRET_ROTATION_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +const getNextUTCMidnight = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { + const now = new Date(); + + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + 1, // Add 1 day to get tomorrow + hours, + minutes, + 0, + 0 + ) + ); +}; + +const getNextUTCMinute = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes() + 1, // Add 1 minute to get the next minute + minutes, // use minutes as seconds in dev + 0 + ) + ); +}; + +export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rotateAtUtc"]) => { + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + return getNextUTCMinute(rotateAtUtc); + } + + return getNextUTCMidnight(rotateAtUtc); +}; + +export const encryptSecretRotationCredentials = async ({ + projectId, + generatedCredentials, + kmsService +}: { + projectId: string; + generatedCredentials: TSecretRotationV2GeneratedCredentials; + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(generatedCredentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptSecretRotationCredentials = async ({ + projectId, + encryptedGeneratedCredentials, + kmsService +}: { + projectId: string; + encryptedGeneratedCredentials: Buffer; + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedGeneratedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TSecretRotationV2GeneratedCredentials; +}; + +export const getSecretRotationRotateSecretJobOptions = ({ + id, + nextRotationAt +}: Pick) => { + const appCfg = getConfig(); + + return { + jobId: `secret-rotation-v2-rotate-${id}`, + retryLimit: appCfg.isRotationDevelopmentMode ? 3 : 5, + retryBackoff: true, + startAfter: nextRotationAt ?? undefined + }; +}; + +export const calculateNextRotationAt = ({ + rotateAtUtc, + isAutoRotationEnabled, + rotationInterval, + rotationStatus, + isManualRotation, + ...params +}: Pick< + TSecretRotationV2, + "isAutoRotationEnabled" | "lastRotatedAt" | "rotateAtUtc" | "rotationInterval" | "rotationStatus" +> & { isManualRotation: boolean }) => { + if (!isAutoRotationEnabled) return null; + + if (rotationStatus === SecretRotationStatus.Failed) { + return getNextUtcRotationInterval(rotateAtUtc); + } + + const lastRotatedAt = new Date(params.lastRotatedAt); + + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + // treat interval as minute + const nextRotation = new Date(lastRotatedAt.getTime() + rotationInterval * 60 * 1000); + + // in development mode we use rotateAtUtc.minutes as seconds + nextRotation.setUTCSeconds(rotateAtUtc.minutes); + nextRotation.setUTCMilliseconds(0); + + // If creation/manual rotation seconds are after the configured seconds we pad an additional minute + // to ensure a full interval has elapsed before rotation + if (isManualRotation && lastRotatedAt.getUTCSeconds() >= rotateAtUtc.minutes) { + nextRotation.setUTCMinutes(nextRotation.getUTCMinutes() + 1); + } + + return nextRotation; + } + + // production mode - rotationInterval = days + + const nextRotation = new Date(lastRotatedAt); + + nextRotation.setUTCHours(rotateAtUtc.hours); + nextRotation.setUTCMinutes(rotateAtUtc.minutes); + nextRotation.setUTCSeconds(0); + nextRotation.setUTCMilliseconds(0); + + // If creation/manual rotation was after the daily rotation time, + // we need pad an additional day to ensure full rotation interval + if ( + isManualRotation && + (lastRotatedAt.getUTCHours() > rotateAtUtc.hours || + (lastRotatedAt.getUTCHours() === rotateAtUtc.hours && lastRotatedAt.getUTCMinutes() >= rotateAtUtc.minutes)) + ) { + nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval + 1); + } else { + nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval); + } + + return nextRotation; +}; + +export const expandSecretRotation = async ( + { encryptedLastRotationMessage, ...secretRotation }: TSecretRotationV2Raw, + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"] +) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: secretRotation.projectId + }); + + const lastRotationMessage = encryptedLastRotationMessage + ? decryptor({ + cipherTextBlob: encryptedLastRotationMessage + }).toString() + : null; + + return { + ...secretRotation, + lastRotationMessage + } as TSecretRotationV2; +}; + +const MAX_MESSAGE_LENGTH = 1024; + +export const parseRotationErrorMessage = (err: unknown): string => { + let errorMessage = `Infisical encountered an issue while generating credentials with the configured inputs: `; + + if (err instanceof AxiosError) { + errorMessage += err?.response?.data + ? JSON.stringify(err?.response?.data) + : err?.message ?? "An unknown error occurred."; + } else { + errorMessage += (err as Error)?.message || "An unknown error occurred."; + } + + return errorMessage.length <= MAX_MESSAGE_LENGTH + ? errorMessage + : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts new file mode 100644 index 000000000..c0d59332b --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -0,0 +1,12 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const SECRET_ROTATION_NAME_MAP: Record = { + [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", + [SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials" +}; + +export const SECRET_ROTATION_CONNECTION_MAP: Record = { + [SecretRotation.PostgresCredentials]: AppConnection.Postgres, + [SecretRotation.MsSqlCredentials]: AppConnection.MsSql +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts new file mode 100644 index 000000000..51946bfa7 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts @@ -0,0 +1,193 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + getNextUtcRotationInterval, + getSecretRotationRotateSecretJobOptions +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; +import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; +import { + TSecretRotationRotateSecretsJobPayload, + TSecretRotationSendNotificationJobPayload +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +type TSecretRotationV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + secretRotationV2DAL: Pick; + secretRotationV2Service: Pick; + smtpService: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; +}; + +export const secretRotationV2QueueServiceFactory = async ({ + queueService, + secretRotationV2DAL, + secretRotationV2Service, + projectMembershipDAL, + projectDAL, + smtpService +}: TSecretRotationV2QueueServiceFactoryDep) => { + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + logger.warn("Secret Rotation V2 is in development mode."); + } + + await queueService.startPg( + QueueJobs.SecretRotationV2QueueRotations, + async () => { + try { + const rotateBy = getNextUtcRotationInterval(); + + const currentTime = new Date(); + + const secretRotations = await secretRotationV2DAL.findSecretRotationsToQueue(rotateBy); + + logger.info( + `secretRotationV2Queue: Queue Rotations [currentTime=${currentTime.toISOString()}] [rotateBy=${rotateBy.toISOString()}] [count=${ + secretRotations.length + }]` + ); + + for await (const rotation of secretRotations) { + logger.info( + `secretRotationV2Queue: Queue Rotation [rotationId=${rotation.id}] [lastRotatedAt=${new Date( + rotation.lastRotatedAt + ).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]` + ); + await queueService.queuePg( + QueueJobs.SecretRotationV2RotateSecrets, + { + rotationId: rotation.id, + queuedAt: currentTime + }, + getSecretRotationRotateSecretJobOptions(rotation) + ); + } + } catch (error) { + logger.error(error, "secretRotationV2Queue: Queue Rotations Error:"); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 0.5 + } + ); + + await queueService.startPg( + QueueJobs.SecretRotationV2RotateSecrets, + async ([job]) => { + const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload; + const { retryCount, retryLimit } = job; + + const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + try { + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`); + + if (!secretRotation.isAutoRotationEnabled) { + logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`); + } + + if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) { + // rotated since being queued, skip rotation + logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`); + return; + } + + await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, { + jobId: job.id, + shouldSendNotification: true, + isFinalAttempt: retryCount === retryLimit, + isManualRotation + }); + + logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`); + } catch (error) { + logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + + await queueService.startPg( + QueueJobs.SecretRotationV2SendNotification, + async ([job]) => { + const { secretRotation } = job.data as TSecretRotationSendNotificationJobPayload; + try { + const { + name: rotationName, + type, + projectId, + lastRotationAttemptedAt, + folder, + environment, + id: rotationId + } = secretRotation; + + logger.info(`secretRotationV2Queue: Sending Status Notification [rotationId=${rotationId}]`); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation]; + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.SecretRotationFailed, + subjectLine: `Secret Rotation Failed`, + substitutions: { + rotationName, + rotationType, + content: `Your ${rotationType} Rotation failed to rotate during it's scheduled rotation. The last rotation attempt occurred at ${new Date( + lastRotationAttemptedAt + ).toISOString()}. Please check the rotation status in Infisical for more details.`, + secretPath: folder.path, + environment: environment.name, + projectName: project.name, + rotationUrl: encodeURI(`${appCfg.SITE_URL}/secret-manager/${projectId}/secrets/${environment.slug}`) + } + }); + } catch (error) { + logger.error( + error, + `secretRotationV2Queue: Failed to Send Status Notification [rotationId=${secretRotation.id}]` + ); + throw error; + } + }, + { + batchSize: 1, + workerCount: 5, + pollingIntervalSeconds: 30 + } + ); + + await queueService.schedulePg( + QueueJobs.SecretRotationV2QueueRotations, + appCfg.isRotationDevelopmentMode ? "* * * * *" : "0 0 * * *", + undefined, + { tz: "UTC" } + ); +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts new file mode 100644 index 000000000..b1be4ea22 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { SecretRotationsV2Schema } from "@app/db/schemas/secret-rotations-v2"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SecretRotations } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { slugSchema } from "@app/server/lib/schemas"; + +const RotateAtUtcSchema = z.object({ + hours: z.number().min(0).max(23), + minutes: z.number().min(0).max(59) +}); + +export const BaseSecretRotationSchema = (type: SecretRotation) => + SecretRotationsV2Schema.omit({ + encryptedGeneratedCredentials: true, + encryptedLastRotationMessage: true, + rotateAtUtc: true, + // unique to provider + type: true, + parameters: true, + secretMappings: true + }).extend({ + connection: z.object({ + app: z.literal(SECRET_ROTATION_CONNECTION_MAP[type]), + name: z.string(), + id: z.string().uuid() + }), + environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }), + projectId: z.string(), + folder: z.object({ id: z.string(), path: z.string() }), + rotateAtUtc: RotateAtUtcSchema, + lastRotationMessage: z.string().nullish() + }); + +export const BaseCreateSecretRotationSchema = (type: SecretRotation) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretRotations.CREATE(type).name), + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.CREATE(type).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretRotations.CREATE(type).description), + connectionId: z.string().uuid().describe(SecretRotations.CREATE(type).connectionId), + environment: slugSchema({ field: "environment", max: 64 }).describe(SecretRotations.CREATE(type).environment), + secretPath: z + .string() + .trim() + .min(1, "Secret path required") + .transform(removeTrailingSlash) + .describe(SecretRotations.CREATE(type).secretPath), + isAutoRotationEnabled: z + .boolean() + .optional() + .default(true) + .describe(SecretRotations.CREATE(type).isAutoRotationEnabled), + rotationInterval: z.coerce.number().min(1).describe(SecretRotations.CREATE(type).rotationInterval), + rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.CREATE(type).rotateAtUtc) + }); + +export const BaseUpdateSecretRotationSchema = (type: SecretRotation) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretRotations.UPDATE(type).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretRotations.UPDATE(type).description), + isAutoRotationEnabled: z.boolean().optional().describe(SecretRotations.UPDATE(type).isAutoRotationEnabled), + rotationInterval: z.coerce.number().min(1).optional().describe(SecretRotations.UPDATE(type).rotationInterval), + rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.UPDATE(type).rotateAtUtc) + }); 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 new file mode 100644 index 000000000..aecf8242a --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -0,0 +1,1230 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import isEqual from "lodash.isequal"; + +import { ActionProjectType, SecretType, TableName } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSecretRotationActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + calculateNextRotationAt, + decryptSecretRotationCredentials, + encryptSecretRotationCredentials, + expandSecretRotation, + getNextUtcRotationInterval, + getSecretRotationRotateSecretJobOptions, + listSecretRotationOptions, + parseRotationErrorMessage +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; +import { + SECRET_ROTATION_CONNECTION_MAP, + SECRET_ROTATION_NAME_MAP +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { + TCreateSecretRotationV2DTO, + TDeleteSecretRotationV2DTO, + TFindSecretRotationV2ByIdDTO, + TFindSecretRotationV2ByNameDTO, + TGetDashboardSecretRotationsV2, + TGetDashboardSecretRotationV2Count, + TListSecretRotationsV2ByProjectId, + TQuickSearchSecretRotationsV2, + TRotateSecretRotationV2, + TRotationFactory, + TSecretRotationRotateGeneratedCredentials, + TSecretRotationV2, + TSecretRotationV2Raw, + TSecretRotationV2WithConnection, + TUpdateSecretRotationV2DTO +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { sqlCredentialsRotationFactory } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; +import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { + fnSecretBulkDelete, + fnSecretBulkInsert, + fnSecretBulkUpdate, + reshapeBridgeSecret +} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; +import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; + +import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; + +export type TSecretRotationV2ServiceFactoryDep = { + secretRotationV2DAL: TSecretRotationV2DALFactory; + appConnectionService: Pick; + permissionService: Pick; + projectBotService: Pick; + kmsService: Pick; + licenseService: Pick; + auditLogService: Pick; + keyStore: Pick; + folderDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" + >; + secretVersionV2BridgeDAL: Pick; + secretVersionTagV2BridgeDAL: Pick; + resourceMetadataDAL: Pick; + secretTagDAL: Pick; + secretQueueService: Pick; + snapshotService: Pick; + queueService: Pick; +}; + +export type TSecretRotationV2ServiceFactory = ReturnType; + +const MAX_GENERATED_CREDENTIALS_LENGTH = 2; + +const SECRET_ROTATION_FACTORY_MAP: Record = { + [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory, + [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory +}; + +export const secretRotationV2ServiceFactory = ({ + secretRotationV2DAL, + folderDAL, + secretV2BridgeDAL, + secretVersionV2BridgeDAL, + secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL, + permissionService, + appConnectionService, + projectBotService, + licenseService, + kmsService, + auditLogService, + secretQueueService, + snapshotService, + keyStore, + queueService +}: TSecretRotationV2ServiceFactoryDep) => { + const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { + const appCfg = getConfig(); + if (!appCfg.isSmtpConfigured) return; // comment out if testing email sending + + await queueService.queuePg( + QueueJobs.SecretRotationV2SendNotification, + { secretRotation }, + { + jobId: `secret-rotation-v2-notification-${secretRotation.id}`, + retryLimit: 5, + retryBackoff: true + } + ); + }; + + const listSecretRotationsByProjectId = async ( + { projectId, type }: TListSecretRotationsV2ByProjectId, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotations due to plan restriction. Upgrade plan to access secret rotations." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); + + const secretRotations = await secretRotationV2DAL.find({ + ...(type && { type }), + projectId + }); + + return Promise.all( + secretRotations + .filter((rotation) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment: rotation.environment.slug, + secretPath: rotation.folder.path + }) + ) + ) + .map((rotation) => expandSecretRotation(rotation, kmsService)) + ); + }; + + const findSecretRotationById = async ({ type, rotationId }: TFindSecretRotationV2ByIdDTO, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + return expandSecretRotation(secretRotation, kmsService); + }; + + const findSecretRotationGeneratedCredentialsById = async ( + { type, rotationId }: TFindSecretRotationV2ByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to access secret rotation credentials due to plan restriction. Upgrade plan to access secret rotations credentials." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection, encryptedGeneratedCredentials } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const generatedCredentials = await decryptSecretRotationCredentials({ + projectId, + encryptedGeneratedCredentials, + kmsService + }); + + return { + generatedCredentials, + secretRotation: secretRotation as TSecretRotationV2 + }; + }; + + const findSecretRotationByName = async ( + { type, rotationName, secretPath, environment, projectId }: TFindSecretRotationV2ByNameDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + // we prevent conflicting names within a folder + const secretRotation = await secretRotationV2DAL.findOne({ + name: rotationName, + folderId: folder.id + }); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with name "${rotationName}"` + }); + + const { connection, id: rotationId } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment, + secretPath + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + return expandSecretRotation(secretRotation, kmsService); + }; + + const createSecretRotation = async ( + { + projectId, + secretPath, + environment, + rotateAtUtc = { hours: 0, minutes: 0 }, + ...payload + }: TCreateSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to create secret rotation due to plan restriction. Upgrade plan to create secret rotations." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ message: "Project version does not support Secret Rotation V2" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Create, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + const typeApp = SECRET_ROTATION_CONNECTION_MAP[payload.type]; + + // validates permission to connect and app is valid for rotation type + const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor); + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]({ + parameters: payload.parameters, + secretsMapping: payload.secretsMapping, + connection + } as TSecretRotationV2WithConnection); + + try { + const currentTime = new Date(); + + // callback structure to support transactional rollback when possible + const secretRotation = await rotationFactory.issueCredentials(async (newCredentials) => { + const encryptedGeneratedCredentials = await encryptSecretRotationCredentials({ + generatedCredentials: [newCredentials], + projectId, + kmsService + }); + + return secretRotationV2DAL.transaction(async (tx) => { + const createdRotation = await secretRotationV2DAL.create( + { + folderId: folder.id, + ...payload, + encryptedGeneratedCredentials, + rotateAtUtc, + rotationStatus: SecretRotationStatus.Success, + lastRotationAttemptedAt: currentTime, + lastRotatedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + lastRotatedAt: currentTime, + isAutoRotationEnabled: Boolean(payload.isAutoRotationEnabled), + rotateAtUtc, + rotationInterval: payload.rotationInterval, + rotationStatus: SecretRotationStatus.Success, + isManualRotation: true + }) + }, + tx + ); + + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const mappedSecrets = await fnSecretBulkInsert({ + folderId: folder.id, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + key, + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + await secretRotationV2DAL.insertSecretMappings( + mappedSecrets.map((secret) => ({ + secretId: secret.id, + rotationId: createdRotation.id + })), + tx + ); + + return createdRotation; + }); + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath, + projectId, + environmentSlug: environment, + excludeReplication: true + }); + + return await expandSecretRotation(secretRotation, kmsService); + } catch (err) { + if (err instanceof DatabaseError) { + const error = err.error as { code: string; message: string; table: string }; + + if (error.code === DatabaseErrorCode.UniqueViolation) { + switch (error.table) { + case TableName.SecretRotationV2: + throw new BadRequestError({ + message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"` + }); + case TableName.SecretV2: + throw new BadRequestError({ + message: `One or more of the following secrets already exists at the secret path "${secretPath}": ${Object.values( + payload.secretsMapping + ).join(", ")}` + }); + default: + throw err; + } + } + + throw err; + } + + throw new BadRequestError({ + message: parseRotationErrorMessage(err) + }); + } + }; + + const updateSecretRotation = async ( + { type, rotationId, ...payload }: TUpdateSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` + }); + + const { folder, environment, projectId, folderId, connection, secretsMapping } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Edit, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const nextRotationAt = calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + ...payload, + isManualRotation: false + }); + + try { + const updatedSecretRotation = await secretRotationV2DAL.transaction(async (tx) => { + if (payload.secretsMapping && !isEqual(payload.secretsMapping, secretsMapping)) { + // update mapped secrets names + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: Object.entries(secretsMapping as TSecretRotationV2["secretsMapping"]).map( + ([mappingKey, secretKey]) => ({ + filter: { + key: secretKey, + folderId, + type: SecretType.Shared + }, + data: { + key: payload.secretsMapping![mappingKey as keyof TSecretRotationV2["secretsMapping"]] + } + }) + ), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + return secretRotationV2DAL.updateById( + rotationId, + { + ...payload, + nextRotationAt + }, + tx + ); + }); + + // queue for rotation if adjusted time falls before next cron + if (nextRotationAt && nextRotationAt.getTime() < getNextUtcRotationInterval().getTime()) { + await queueService.queuePg( + QueueJobs.SecretRotationV2RotateSecrets, + { rotationId, queuedAt: new Date(), isManualRotation: true }, + getSecretRotationRotateSecretJobOptions(updatedSecretRotation) + ); + } + + return await expandSecretRotation(updatedSecretRotation, kmsService); + } catch (err) { + if (err instanceof DatabaseError) { + const error = err.error as { code: string; message: string; table: string }; + + if (error.code === DatabaseErrorCode.UniqueViolation) { + switch (error.table) { + case TableName.SecretRotationV2: + if (payload.name) + throw new BadRequestError({ + message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${folder.path}"` + }); + break; + case TableName.SecretV2: + if (payload.secretsMapping) + throw new BadRequestError({ + message: `One or more of the following secrets already exists at the secret path "${ + folder.path + }": ${Object.values(payload.secretsMapping).join(", ")}` + }); + break; + default: + throw err; + } + } + } + + throw err; + } + }; + + const deleteSecretRotation = async ( + { type, rotationId, deleteSecrets, revokeGeneratedCredentials }: TDeleteSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to delete secret rotation due to plan restriction. Upgrade plan to delete secret rotation." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { folder, environment, projectId, encryptedGeneratedCredentials, connection, folderId, secretsMapping } = + secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Delete, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const deleteTransaction = secretRotationV2DAL.transaction(async (tx) => { + if (deleteSecrets) { + await fnSecretBulkDelete({ + secretDAL: secretV2BridgeDAL, + secretQueueService, + inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({ + secretKey, + type: SecretType.Shared + })), + projectId, + folderId, + actorId: actor.id, // not actually used since rotated secrets are shared + tx + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + return secretRotationV2DAL.deleteById(rotationId, tx); + }); + + if (revokeGeneratedCredentials) { + const appConnection = await decryptAppConnection(connection, kmsService); + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type]({ + ...secretRotation, + connection: appConnection + } as TSecretRotationV2WithConnection); + + const generatedCredentials = await decryptSecretRotationCredentials({ + encryptedGeneratedCredentials, + projectId, + kmsService + }); + + await rotationFactory.revokeCredentials(generatedCredentials, async () => deleteTransaction); + } else { + await deleteTransaction; + } + + return expandSecretRotation(secretRotation, kmsService); + }; + + const rotateGeneratedCredentials = async ( + secretRotation: TSecretRotationV2Raw, + { + auditLogInfo, + jobId, + shouldSendNotification, + isFinalAttempt = true, + isManualRotation = false + }: TSecretRotationRotateGeneratedCredentials = {} + ) => { + const { + connection, + folder, + environment, + encryptedGeneratedCredentials, + activeIndex, + projectId, + type, + folderId, + id: rotationId, + parameters, + secretsMapping + } = secretRotation; + + let lock: Awaited> | undefined; + + try { + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.SecretRotationLock(rotationId)], 60 * 1000); + } catch (e) { + throw new InternalServerError({ + message: "Failed to acquire rotation lock." + }); + } + + const appConnection = await decryptAppConnection(connection, kmsService); + + const generatedCredentials = await decryptSecretRotationCredentials({ + projectId, + encryptedGeneratedCredentials, + kmsService + }); + + const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; + + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]({ + ...secretRotation, + connection: appConnection + } as TSecretRotationV2WithConnection); + + const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => { + const updatedCredentials = [...generatedCredentials]; + updatedCredentials[inactiveIndex] = newCredentials; + + const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ + projectId, + generatedCredentials: updatedCredentials, + kmsService + }); + + return secretRotationV2DAL.transaction(async (tx) => { + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + // update mapped secrets with new credential values + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + filter: { + key, + folderId, + type: SecretType.Shared + }, + data: { + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + } + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + const currentTime = new Date(); + + return secretRotationV2DAL.updateById( + secretRotation.id, + { + encryptedGeneratedCredentials: encryptedUpdatedCredentials, + activeIndex: inactiveIndex, + lastRotatedAt: currentTime, + lastRotationAttemptedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + rotationStatus: SecretRotationStatus.Success, + lastRotatedAt: currentTime, + isManualRotation + }), + rotationStatus: SecretRotationStatus.Success, + lastRotationJobId: jobId, + encryptedLastRotationMessage: null + }, + tx + ); + }); + }); + + await auditLogService.createAuditLog({ + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + projectId, + event: { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS, + metadata: { + type, + rotationId, + connectionId: connection.id, + folderId, + parameters, + secretsMapping, + status: SecretRotationStatus.Success, + occurredAt: new Date(), + message: null, + jobId + } + } + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + + return updatedRotation; + } catch (error) { + const errorMessage = parseRotationErrorMessage(error); + + if (isFinalAttempt) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedMessage } = encryptor({ + plainText: Buffer.from(errorMessage) + }); + + const updatedRotation = await secretRotationV2DAL.updateById(secretRotation.id, { + rotationStatus: SecretRotationStatus.Failed, + lastRotationJobId: jobId, + lastRotationAttemptedAt: new Date(), + encryptedLastRotationMessage: encryptedMessage, + nextRotationAt: getNextUtcRotationInterval(secretRotation.rotateAtUtc as TSecretRotationV2["rotateAtUtc"]) + }); + + if (shouldSendNotification) { + await $queueSendSecretRotationStatusNotification(updatedRotation); + } + } + + await auditLogService.createAuditLog({ + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + projectId, + event: { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS, + metadata: { + type, + rotationId, + connectionId: connection.id, + folderId, + parameters, + secretsMapping, + occurredAt: new Date(), + status: SecretRotationStatus.Failed, + message: isFinalAttempt ? "See Rotation status for details" : "Rotation will be re-attempted shortly...", + jobId + } + } + }); + + throw new BadRequestError({ message: errorMessage }); + } finally { + await lock?.release(); + } + }; + + const rotateSecretRotation = async ( + { rotationId, type, auditLogInfo }: TRotateSecretRotationV2, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to rotate secret rotation secrets due to plan restriction. Upgrade plan to rotate secret rotation secrets." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.RotateSecrets, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const isRotationOccurring = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretRotationLock(secretRotation.id))); + + if (isRotationOccurring) + throw new BadRequestError({ message: `A rotation is already in progress. Please try again shortly.` }); + + try { + const updatedRotation = await rotateGeneratedCredentials(secretRotation, { + auditLogInfo, + isManualRotation: true + }); + + return await expandSecretRotation(updatedRotation, kmsService); + } catch (err) { + throw new InternalServerError({ + message: (err as Error).message ?? "Failed to rotate secrets: check Rotation status for details." + }); + } + }; + + const getDashboardSecretRotationCount = async ( + { projectId, environments, secretPath, search }: TGetDashboardSecretRotationV2Count, + actor: OrgServiceActor + ) => { + // we don't check plan for dashboard like dynamic secret, actions will be prevented + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const permissiveEnvironments = environments.filter((environment) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ) + ); + + if (!permissiveEnvironments.length) return 0; + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); + + if (!folders.length) { + throw new NotFoundError({ + message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( + ", " + )}' not found` + }); + } + + const count = await secretRotationV2DAL.findWithMappedSecretsCount({ + $in: { folderId: folders.map((folder) => folder.id) }, + search, + projectId + }); + + return count; + }; + + const getDashboardSecretRotations = async ( + { + projectId, + environments, + secretPath, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: TGetDashboardSecretRotationsV2, + actor: OrgServiceActor + ) => { + // we don't check plan for dashboard like dynamic secret, actions will be prevented + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const permissiveEnvironments = environments.filter((environment) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ) + ); + + if (!permissiveEnvironments.length) return []; + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); + + if (!folders.length) { + throw new NotFoundError({ + message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( + ", " + )}' not found` + }); + } + + const folderIds = folders.map((folder) => folder.id); + + const secretRotations = await secretRotationV2DAL.findWithMappedSecrets( + { + $in: { folderId: folderIds }, + search, + projectId + }, + { + limit, + offset, + sort: orderBy ? [[orderBy, orderDirection]] : undefined + } + ); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const secretRotationsWithSecrets = await Promise.all( + secretRotations.map(async ({ secrets, ...rotation }) => { + const decryptedSecrets = secrets.map((secret) => { + const canDescribeSecret = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + { + environment: rotation.environment.slug, + secretPath: rotation.folder.path, + secretName: secret.key, + // TODO: scott/akhil our mapper seems to not propagate children's children types + // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) + } + ); + + if (!canDescribeSecret) { + return null; // return null so we know to display empty row in dashboard + } + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: rotation.environment.slug, + secretPath: rotation.folder.path, + secretName: secret.key, + // TODO: scott/akhil our mapper seems to not propagate children's children types + // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) + } + ); + + return reshapeBridgeSecret( + projectId, + rotation.environment.slug, + rotation.folder.path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + }, + secretValueHidden && secret.type === SecretType.Shared + ); + }); + + const expandedRotation = await expandSecretRotation(rotation, kmsService); + + return { + ...expandedRotation, + secrets: decryptedSecrets + }; + }) + ); + + return secretRotationsWithSecrets as (TSecretRotationV2 & { + secrets: Awaited>[]; + })[]; + }; + + const getQuickSearchSecretRotations = async ( + { folderMappings, filters: { search, ...options }, projectId }: TQuickSearchSecretRotationsV2, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const permissiveFolderMappings = folderMappings.filter(({ path, environment }) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath: path }) + ) + ); + + if (!permissiveFolderMappings.length) return []; + + const secretRotations = await secretRotationV2DAL.find( + { + projectId, + $search: { + name: `%${search}%` + }, + $in: { + folderId: permissiveFolderMappings.map(({ folderId }) => folderId) + } + }, + options + ); + + return secretRotations as TSecretRotationV2[]; + }; + + return { + listSecretRotationOptions, + listSecretRotationsByProjectId, + createSecretRotation, + updateSecretRotation, + findSecretRotationById, + findSecretRotationByName, + deleteSecretRotation, + findSecretRotationGeneratedCredentialsById, + rotateSecretRotation, + rotateGeneratedCredentials, + getDashboardSecretRotationCount, + getDashboardSecretRotations, + getQuickSearchSecretRotations + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts new file mode 100644 index 000000000..7102f7de3 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -0,0 +1,155 @@ +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; + +import { + TMsSqlCredentialsRotation, + TMsSqlCredentialsRotationInput, + TMsSqlCredentialsRotationListItem, + TMsSqlCredentialsRotationWithConnection +} from "./mssql-credentials"; +import { + TPostgresCredentialsRotation, + TPostgresCredentialsRotationInput, + TPostgresCredentialsRotationListItem, + TPostgresCredentialsRotationWithConnection +} from "./postgres-credentials"; +import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; +import { SecretRotation } from "./secret-rotation-v2-enums"; + +export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation; + +export type TSecretRotationV2WithConnection = + | TPostgresCredentialsRotationWithConnection + | TMsSqlCredentialsRotationWithConnection; + +export type TSecretRotationV2GeneratedCredentials = TSqlCredentialsRotationGeneratedCredentials; + +export type TSecretRotationV2Input = TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput; + +export type TSecretRotationV2ListItem = TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem; + +export type TSecretRotationV2Raw = NonNullable>>; + +export type TListSecretRotationsV2ByProjectId = { + projectId: string; + type?: SecretRotation; +}; + +export type TFindSecretRotationV2ByIdDTO = { + rotationId: string; + type: SecretRotation; +}; + +export type TRotateSecretRotationV2 = TFindSecretRotationV2ByIdDTO & { auditLogInfo: AuditLogInfo }; + +export type TRotateAtUtc = { hours: number; minutes: number }; + +export type TFindSecretRotationV2ByNameDTO = { + rotationName: string; + secretPath: string; + environment: string; + projectId: string; + type: SecretRotation; +}; + +export type TCreateSecretRotationV2DTO = Pick< + TSecretRotationV2, + "parameters" | "secretsMapping" | "description" | "rotationInterval" | "name" | "connectionId" | "projectId" +> & { + type: SecretRotation; + secretPath: string; + environment: string; + isAutoRotationEnabled?: boolean; + rotateAtUtc?: TRotateAtUtc; +}; + +export type TUpdateSecretRotationV2DTO = Partial< + Omit +> & { + rotationId: string; + type: SecretRotation; +}; + +export type TDeleteSecretRotationV2DTO = { + type: SecretRotation; + rotationId: string; + deleteSecrets: boolean; + revokeGeneratedCredentials: boolean; +}; + +export type TGetDashboardSecretRotationV2Count = { + search?: string; + projectId: string; + secretPath: string; + environments: string[]; +}; + +export type TGetDashboardSecretRotationsV2 = { + search?: string; + projectId: string; + secretPath: string; + environments: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + limit: number; + offset: number; +}; + +export type TQuickSearchSecretRotationsV2Filters = { + offset?: number; + limit?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; +}; + +export type TQuickSearchSecretRotationsV2 = { + projectId: string; + folderMappings: { folderId: string; path: string; environment: string }[]; + filters: TQuickSearchSecretRotationsV2Filters; +}; + +export type TSecretRotationRotateGeneratedCredentials = { + auditLogInfo?: AuditLogInfo; + jobId?: string; + shouldSendNotification?: boolean; + isFinalAttempt?: boolean; + isManualRotation?: boolean; +}; + +export type TSecretRotationRotateSecretsJobPayload = { rotationId: string; queuedAt: Date; isManualRotation: boolean }; + +export type TSecretRotationSendNotificationJobPayload = { + secretRotation: TSecretRotationV2Raw; +}; + +// scott: the reason for the callback structure of the rotation factory is to facilitate, when possible, +// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the +// third party credential changes (when supported), preventing credentials getting out of sync + +export type TRotationFactoryIssueCredentials = ( + callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise +) => Promise; + +export type TRotationFactoryRevokeCredentials = ( + generatedCredentials: TSecretRotationV2GeneratedCredentials, + callback: () => Promise +) => Promise; + +export type TRotationFactoryRotateCredentials = ( + credentialsToRevoke: TSecretRotationV2GeneratedCredentials[number] | undefined, + callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise +) => Promise; + +export type TRotationFactoryGetSecretsPayload = ( + generatedCredentials: TSecretRotationV2GeneratedCredentials[number] +) => { key: string; value: string }[]; + +export type TRotationFactory = (secretRotation: TSecretRotationV2WithConnection) => { + issueCredentials: TRotationFactoryIssueCredentials; + revokeCredentials: TRotationFactoryRevokeCredentials; + rotateCredentials: TRotationFactoryRotateCredentials; + getSecretsPayload: TRotationFactoryGetSecretsPayload; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts new file mode 100644 index 000000000..0c4bbd014 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; + +export const SecretRotationV2Schema = z.discriminatedUnion("type", [ + PostgresCredentialsRotationSchema, + MsSqlCredentialsRotationSchema +]); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts new file mode 100644 index 000000000..1ab210d66 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts @@ -0,0 +1,2 @@ +export * from "./sql-credentials-rotation-fns"; +export * from "./sql-credentials-rotation-schemas"; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts new file mode 100644 index 000000000..17983eb43 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -0,0 +1,232 @@ +import { randomInt } from "crypto"; + +import { + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql"; + +import { + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationWithConnection +} from "./sql-credentials-rotation-types"; + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = () => { + try { + const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGeneratedCredentials) => { + const error = e as Error; + + if (!error?.message) return "Unknown error"; + + let redactedMessage = error.message; + + credentials.forEach(({ password }) => { + redactedMessage = redactedMessage.replaceAll(password, "*******************"); + }); + + return redactedMessage; +}; + +export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRotationWithConnection) => { + const { + connection, + parameters: { username1, username2 }, + activeIndex, + secretsMapping + } = secretRotation; + + const validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { + const client = await getSqlConnectionClient({ + ...connection, + credentials: { + ...connection.credentials, + ...credentials + } + }); + + try { + await client.raw("SELECT 1"); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + await client.destroy(); + } + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async (callback) => { + const client = await getSqlConnectionClient(connection); + + // For SQL, since we get existing users, we change both their passwords + // on issue to invalidate their existing passwords + const credentialsSet = [ + { username: username1, password: generatePassword() }, + { username: username2, password: generatePassword() } + ]; + + try { + await client.transaction(async (tx) => { + for await (const credentials of credentialsSet) { + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); + } catch (error) { + throw new Error(redactPasswords(error, credentialsSet)); + } finally { + await client.destroy(); + } + + for await (const credentials of credentialsSet) { + await validateCredentials(credentials); + } + + return callback(credentialsSet[0]); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async (credentialsToRevoke, callback) => { + const client = await getSqlConnectionClient(connection); + + const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() })); + + try { + await client.transaction(async (tx) => { + for await (const credentials of revokedCredentials) { + // invalidate previous passwords + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); + } catch (error) { + throw new Error(redactPasswords(error, revokedCredentials)); + } finally { + await client.destroy(); + } + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async (_, callback) => { + const client = await getSqlConnectionClient(connection); + + // generate new password for the next active user + const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() }; + + try { + await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + await client.destroy(); + } + + await validateCredentials(credentials); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = (generatedCredentials) => { + const { username, password } = secretsMapping; + + const secrets = [ + { + key: username, + value: generatedCredentials.username + }, + { + key: password, + value: generatedCredentials.password + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload, + validateCredentials + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..7ec47741f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; + +export const SqlCredentialsRotationGeneratedCredentialsSchema = z + .object({ + username: z.string(), + password: z.string() + }) + .array() + .min(1) + .max(2); + +export const SqlCredentialsRotationParametersSchema = z.object({ + username1: z + .string() + .trim() + .min(1, "Username1 Required") + .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username1), + username2: z + .string() + .trim() + .min(1, "Username2 Required") + .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username2) +}); + +export const SqlCredentialsRotationSecretsMappingSchema = z.object({ + username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.username), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.password) +}); + +export const SqlCredentialsRotationTemplateSchema = z.object({ + createUserStatement: z.string(), + secretsMapping: z.object({ + username: z.string(), + password: z.string() + }) +}); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts new file mode 100644 index 000000000..6eada6019 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; + +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas"; + +export type TSqlCredentialsRotationWithConnection = + | TPostgresCredentialsRotationWithConnection + | TMsSqlCredentialsRotationWithConnection; + +export type TSqlCredentialsRotationGeneratedCredentials = z.infer< + typeof SqlCredentialsRotationGeneratedCredentialsSchema +>; 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 8d458111a..df7b86a0b 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -16,8 +16,8 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { - ProjectPermissionActions, ProjectPermissionSecretActions, + ProjectPermissionSecretRotationActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretRotationDALFactory } from "./secret-rotation-dal"; @@ -69,7 +69,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); return { custom: [], @@ -99,7 +102,7 @@ export const secretRotationServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation ); @@ -208,7 +211,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (shouldUseSecretV2Bridge) { const docs = await secretRotationDAL.findSecretV2({ projectId }); @@ -254,7 +260,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Edit, + ProjectPermissionSub.SecretRotation + ); await secretRotationQueue.removeFromQueue(doc.id, doc.interval); await secretRotationQueue.addToQueue(doc.id, doc.interval); return doc; @@ -273,7 +282,7 @@ export const secretRotationServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretRotationActions.Delete, ProjectPermissionSub.SecretRotation ); const deletedDoc = await secretRotationDAL.transaction(async (tx) => { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8ff07db26..ea26a2cf7 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -33,6 +33,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ec855168e..6802b6dcb 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,8 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + SECRET_ROTATION_CONNECTION_MAP, + SECRET_ROTATION_NAME_MAP +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -817,7 +822,8 @@ export const DASHBOARD = { includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", - includeImports: "Whether to include project secret imports in the response." + includeImports: "Whether to include project secret imports in the response.", + includeSecretRotations: "Whether to include project secret rotations in the response." }, SECRET_DETAILS_LIST: { projectId: "The ID of the project to list secrets/folders from.", @@ -832,7 +838,8 @@ export const DASHBOARD = { includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", includeImports: "Whether to include project secret imports in the response.", - includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", + includeSecretRotations: "Whether to include secret rotations in the response." } } as const; @@ -1653,7 +1660,8 @@ export const AppConnections = { name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, description: `An optional description for the ${appName} Connection.`, credentials: `The credentials used to connect with ${appName}.`, - method: `The method used to authenticate with ${appName}.` + method: `The method used to authenticate with ${appName}.`, + isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.` }; }, UPDATE: (app: AppConnection) => { @@ -1663,12 +1671,23 @@ export const AppConnections = { name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, description: `The updated description of the ${appName} Connection.`, credentials: `The credentials used to connect with ${appName}.`, - method: `The method used to authenticate with ${appName}.` + method: `The method used to authenticate with ${appName}.`, + isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.` }; }, DELETE: (app: AppConnection) => ({ connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.` - }) + }), + CREDENTIALS: { + SQL_CONNECTION: { + host: "The hostname of the database server.", + port: "The port number of the database.", + database: "The name of the database to connect to.", + username: "The username to connect to the database with.", + password: "The password to connect to the database with.", + sslCertificate: "The SSL certificate to use for connection." + } + } }; export const SecretSyncs = { @@ -1785,3 +1804,70 @@ export const SecretSyncs = { } } }; + +export const SecretRotations = { + LIST: (type?: SecretRotation) => ({ + projectId: `The ID of the project to list ${type ? SECRET_ROTATION_NAME_MAP[type] : "Secret"} Rotations from.` + }), + GET_BY_ID: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.` + }), + GET_GENERATED_CREDENTIALS_BY_ID: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve the generated credentials for.` + }), + GET_BY_NAME: (type: SecretRotation) => ({ + rotationName: `The name of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`, + projectId: `The ID of the project the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`, + secretPath: `The secret path the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located at.`, + environment: `The environment the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.` + }), + CREATE: (type: SecretRotation) => { + const destinationName = SECRET_ROTATION_NAME_MAP[type]; + return { + name: `The name of the ${destinationName} Rotation to create. Must be slug-friendly.`, + description: `An optional description for the ${destinationName} Rotation.`, + projectId: "The ID of the project to create the rotation in.", + environment: `The slug of the project environment to create the rotation in.`, + secretPath: `The secret path of the project to create the rotation in.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_ROTATION_CONNECTION_MAP[type]] + } Connection to use for rotation.`, + isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`, + rotationInterval: `The interval, in days, to automatically rotate secrets.`, + rotateAtUtc: `The hours and minutes rotation should occur at in UTC. Defaults to Midnight (00:00) UTC.` + }; + }, + UPDATE: (type: SecretRotation) => { + const typeName = SECRET_ROTATION_NAME_MAP[type]; + return { + rotationId: `The ID of the ${typeName} Rotation to be updated.`, + name: `The updated name of the ${typeName} Rotation. Must be slug-friendly.`, + description: `The updated description of the ${typeName} Rotation.`, + isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`, + rotationInterval: `The updated interval, in days, to automatically rotate secrets.`, + rotateAtUtc: `The updated hours and minutes rotation should occur at in UTC.` + }; + }, + DELETE: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to be deleted.`, + deleteSecrets: `Whether the mapped secrets belonging to this rotation should be deleted.`, + revokeGeneratedCredentials: `Whether the generated credentials associated with this rotation should be revoked.` + }), + ROTATE: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to rotate generated credentials for.` + }), + PARAMETERS: { + SQL_CREDENTIALS: { + username1: + "The username of the first login to rotate passwords for. This user must already exists in your database.", + username2: + "The username of the second login to rotate passwords for. This user must already exists in your database." + } + }, + SECRETS_MAPPING: { + SQL_CREDENTIALS: { + username: "The name of the secret that the active username will be mapped to.", + password: "The name of the secret that the generated password will be mapped to." + } + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4ca9c1b12..0906e5269 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -58,6 +58,7 @@ const envSchema = z ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), HTTPS_ENABLED: zodStrBool, + ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -262,6 +263,7 @@ const envSchema = z isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", + isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isSecretScanningConfigured: diff --git a/backend/src/lib/knex/prependTableNameToFindFilter.ts b/backend/src/lib/knex/prependTableNameToFindFilter.ts index ee48dce5a..3fb1dabb3 100644 --- a/backend/src/lib/knex/prependTableNameToFindFilter.ts +++ b/backend/src/lib/knex/prependTableNameToFindFilter.ts @@ -7,7 +7,7 @@ export const prependTableNameToFindFilter = (tableName: TableName, filterObj: ob Object.fromEntries( Object.entries(filterObj).map(([key, value]) => key.startsWith("$") - ? [key, prependTableNameToFindFilter(tableName, value as object)] + ? [key, value ? prependTableNameToFindFilter(tableName, value as object) : value] : [`${tableName}.${key}`, value] ) ); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 94006b5a5..ae1a3e821 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -4,6 +4,10 @@ import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { + TSecretRotationRotateSecretsJobPayload, + TSecretRotationSendNotificationJobPayload +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { TScanFullRepoEventPayload, TScanPushEventPayload @@ -44,7 +48,8 @@ export enum QueueName { ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", - AppConnectionSecretSync = "app-connection-secret-sync" + AppConnectionSecretSync = "app-connection-secret-sync", + SecretRotationV2 = "secret-rotation-v2" } export enum QueueJobs { @@ -73,7 +78,10 @@ export enum QueueJobs { SecretSyncSyncSecrets = "secret-sync-sync-secrets", SecretSyncImportSecrets = "secret-sync-import-secrets", SecretSyncRemoveSecrets = "secret-sync-remove-secrets", - SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications" + SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", + SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", + SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", + SecretRotationV2SendNotification = "secret-rotation-v2-send-notification" } export type TQueueJobTypes = { @@ -213,6 +221,19 @@ export type TQueueJobTypes = { name: QueueJobs.SecretSyncSendActionFailedNotifications; payload: TQueueSendSecretSyncActionFailedNotificationsDTO; }; + [QueueName.SecretRotationV2]: + | { + name: QueueJobs.SecretRotationV2QueueRotations; + payload: undefined; + } + | { + name: QueueJobs.SecretRotationV2RotateSecrets; + payload: TSecretRotationRotateSecretsJobPayload; + } + | { + name: QueueJobs.SecretRotationV2SendNotification; + payload: TSecretRotationSendNotificationJobPayload; + }; }; export type TQueueServiceFactory = ReturnType; @@ -229,6 +250,7 @@ export const queueServiceFactory = ( const pgBoss = new PgBoss({ connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, + cronMonitorIntervalSeconds: 5, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried deleteAfterSeconds: 30, ssl: dbRootCert @@ -247,15 +269,12 @@ export const queueServiceFactory = ( >; const initialize = async () => { - const appCfg = getConfig(); - if (appCfg.SHOULD_INIT_PG_QUEUE) { - logger.info("Initializing pg-queue..."); - await pgBoss.start(); + logger.info("Initializing pg-queue..."); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); - } + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); }; const start = ( @@ -283,7 +302,7 @@ export const queueServiceFactory = ( const startPg = async ( jobName: QueueJobs, - jobsFn: (jobs: PgBoss.Job[]) => Promise, + jobsFn: (jobs: PgBoss.JobWithMetadata[]) => Promise, options: WorkOptions & { workerCount: number; } @@ -297,7 +316,7 @@ export const queueServiceFactory = ( await Promise.all( Array.from({ length: options.workerCount }).map(() => - pgBoss.work(jobName, options, jobsFn) + pgBoss.work(jobName, { ...options, includeMetadata: true }, jobsFn) ) ); }; @@ -342,6 +361,15 @@ export const queueServiceFactory = ( }); }; + const schedulePg = async ( + job: TQueueJobTypes[T]["name"], + cron: string, + data: TQueueJobTypes[T]["payload"], + opts?: PgBoss.ScheduleOptions & { jobId?: string } + ) => { + await pgBoss.schedule(job, cron, data, opts); + }; + const stopRepeatableJob = async ( name: T, job: TQueueJobTypes[T]["name"], @@ -403,6 +431,7 @@ export const queueServiceFactory = ( stopJobById, getRepeatableJobs, startPg, - queuePg + queuePg, + schedulePg }; }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 43a30760e..d09a2c40b 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -39,3 +39,10 @@ export const GenericResourceNameSchema = z ])(val), "Name can only contain alphanumeric characters, dashes, underscores, and spaces" ); + +export const BaseSecretNameSchema = z.string().trim().min(1); + +export const SecretNameSchema = BaseSecretNameSchema.refine( + (el) => !el.includes(" "), + "Secret name cannot contain spaces." +).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dac88791f..6553dad96 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -76,6 +76,9 @@ import { secretReplicationServiceFactory } from "@app/ee/services/secret-replica import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; +import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; +import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue"; +import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal"; import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal"; import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; @@ -406,6 +409,8 @@ export const registerRoutes = async ( const gatewayDAL = gatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db); + const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -1497,6 +1502,35 @@ export const registerRoutes = async ( permissionService }); + const secretRotationV2Service = secretRotationV2ServiceFactory({ + secretRotationV2DAL, + permissionService, + appConnectionService, + folderDAL, + projectBotService, + licenseService, + kmsService, + auditLogService, + secretV2BridgeDAL, + secretTagDAL, + secretVersionTagV2BridgeDAL, + secretVersionV2BridgeDAL, + keyStore, + resourceMetadataDAL, + snapshotService, + secretQueueService, + queueService + }); + + await secretRotationV2QueueServiceFactory({ + secretRotationV2Service, + secretRotationV2DAL, + queueService, + projectDAL, + projectMembershipDAL, + smtpService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1598,7 +1632,8 @@ export const registerRoutes = async ( secretSync: secretSyncService, kmip: kmipService, kmipOperation: kmipOperationService, - gateway: gatewayService + gateway: gatewayService, + secretRotationV2: secretRotationV2Service }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 67bd26552..8bf6f7390 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -134,7 +134,9 @@ export const secretRawSchema = z.object({ membershipId: z.string().nullable().optional() }) .optional() - .nullable() + .nullable(), + isRotatedSecret: z.boolean().optional(), + rotationId: z.string().uuid().nullish() }); export const ProjectPermissionSchema = z.object({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index e23d52004..dfb451a4c 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -24,8 +24,14 @@ export const registerAppConnectionEndpoints = ; + updateSchema: z.ZodType<{ + name?: string; + credentials?: I["credentials"]; + description?: string | null; + isPlatformManagedCredentials?: boolean; }>; - updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; sanitizedResponseSchema: z.ZodTypeAny; }) => { const appName = APP_CONNECTION_NAME_MAP[app]; @@ -208,10 +214,10 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials, description } = req.body; + const { name, method, credentials, description, isPlatformManagedCredentials } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials, description }, + { name, method, app, credentials, description, isPlatformManagedCredentials }, req.permission )) as T; @@ -224,7 +230,8 @@ export const registerAppConnectionEndpoints = { - const { name, credentials, description } = req.body; + const { name, credentials, description, isPlatformManagedCredentials } = req.body; const { connectionId } = req.params; const appConnection = (await server.services.appConnection.updateAppConnection( - { name, credentials, connectionId, description }, + { name, credentials, connectionId, description, isPlatformManagedCredentials }, req.permission )) as T; @@ -268,7 +275,8 @@ export const registerAppConnectionEndpoints = { 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 c2b688a43..906ffaee9 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -7,6 +7,8 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; +import { registerPostgresConnectionRouter } from "./postgres-connection-router"; export * from "./app-connection-router"; @@ -18,5 +20,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.MsSql, + server, + sanitizedResponseSchema: SanitizedMsSqlConnectionSchema, + createSchema: CreateMsSqlConnectionSchema, + updateSchema: UpdateMsSqlConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts new file mode 100644 index 000000000..8662f2e52 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreatePostgresConnectionSchema, + SanitizedPostgresConnectionSchema, + UpdatePostgresConnectionSchema +} from "@app/services/app-connection/postgres"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerPostgresConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Postgres, + server, + sanitizedResponseSchema: SanitizedPostgresConnectionSchema, + createSchema: CreatePostgresConnectionSchema, + updateSchema: UpdatePostgresConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 901723647..813a8e3ab 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -8,6 +8,7 @@ import { ProjectPermissionSecretActions, ProjectPermissionSub } 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"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -101,12 +102,30 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports), + includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecretRotations), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) }), response: { 200: z.object({ folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), + secretRotations: z + .intersection( + SecretRotationV2Schema, + z.object({ + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean(), + secretPath: z.string().optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tags: SanitizedTagSchema.array().optional() + }) + .nullable() + .array() + }) + ) + .array() + .optional(), secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -127,6 +146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), totalImportCount: z.number().optional(), + totalSecretRotationCount: z.number().optional(), totalCount: z.number() }) } @@ -144,7 +164,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeFolders, includeSecrets, includeImports, - includeDynamicSecrets + includeDynamicSecrets, + includeSecretRotations } = req.query; const environments = req.query.environments.split(","); @@ -166,11 +187,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let dynamicSecrets: | Awaited> | undefined; + let secretRotations: + | Awaited> + | undefined; let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; let totalImportCount: number | undefined; + let totalSecretRotationCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({ @@ -322,6 +347,56 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeSecretRotations) { + totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount( + { + projectId, + search, + environments, + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { + secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments, + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id), + secretPath, + environment: environments.join(",") + } + } + }); + + // get the count of unique secret rotation names to properly adjust remaining limit + const uniqueSecretRotationCount = new Set(secretRotations.map((rotation) => rotation.name)).size; + + remainingLimit -= uniqueSecretRotationCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount); + } + } + if (includeSecrets) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ @@ -353,38 +428,44 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, isInternal: true }); + } + } - for await (const environment of environments) { - const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length; + if (secrets?.length || secretRotations?.length) { + for await (const environment of environments) { + const secretCountFromEnv = + (secrets?.filter((secret) => secret.environment === environment).length ?? 0) + + (secretRotations + ?.filter((rotation) => rotation.environment.slug === environment) + .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); - if (secretCountFromEnv) { - await server.services.auditLog.createAuditLog({ - projectId, - ...req.auditLogInfo, - event: { - type: EventType.GET_SECRETS, - metadata: { - environment, - secretPath, - numberOfSecrets: secretCountFromEnv - } + if (secretCountFromEnv) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCountFromEnv + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCountFromEnv, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo } }); - - if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: secretCountFromEnv, - workspaceId: projectId, - environment, - secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); - } } } } @@ -395,12 +476,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { dynamicSecrets, secrets, imports, + secretRotations, totalFolderCount, totalDynamicSecretCount, totalImportCount, totalSecretCount, + totalSecretRotationCount, totalCount: - (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) + (totalFolderCount ?? 0) + + (totalDynamicSecretCount ?? 0) + + (totalSecretCount ?? 0) + + (totalImportCount ?? 0) + + (totalSecretRotationCount ?? 0) }; } }); @@ -445,7 +532,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), - includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports) + includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports), + includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecretRotations) }), response: { 200: z.object({ @@ -457,6 +545,23 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), folders: SecretFoldersSchema.array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), + secretRotations: z + .intersection( + SecretRotationV2Schema, + z.object({ + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean(), + secretPath: z.string().optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tags: SanitizedTagSchema.array().optional() + }) + .nullable() + .array() + }) + ) + .array() + .optional(), secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -470,6 +575,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), + totalSecretRotationCount: z.number().optional(), totalCount: z.number() }) } @@ -488,7 +594,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeFolders, includeSecrets, includeDynamicSecrets, - includeImports + includeImports, + includeSecretRotations } = req.query; if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); @@ -507,11 +614,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let folders: Awaited> | undefined; let secrets: Awaited>["secrets"] | undefined; let dynamicSecrets: Awaited> | undefined; + let secretRotations: + | Awaited> + | undefined; let totalImportCount: number | undefined; let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; + let totalSecretRotationCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportCount({ @@ -594,6 +705,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeSecretRotations) { + totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount( + { + projectId, + search, + environments: [environment], + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { + secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id), + secretPath, + environment + } + } + }); + + remainingLimit -= secretRotations.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount); + } + } + try { if (includeDynamicSecrets) { totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({ @@ -629,7 +787,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); } } + } catch (error) { + if (!(error instanceof ForbiddenError)) { + throw error; + } + } + try { if (includeSecrets) { totalSecretCount = await server.services.secret.getSecretsCount({ actorId: req.permission.id, @@ -663,34 +827,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { tagSlugs: tags }) ).secrets; - - await server.services.auditLog.createAuditLog({ - projectId, - ...req.auditLogInfo, - event: { - type: EventType.GET_SECRETS, - metadata: { - environment, - secretPath, - numberOfSecrets: secrets.length - } - } - }); - - if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: secrets.length, - workspaceId: projectId, - environment, - secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); - } } } } catch (error) { @@ -699,17 +835,57 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (secrets?.length || secretRotations?.length) { + const secretCount = + (secrets?.length ?? 0) + + (secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCount + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCount, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + return { imports, folders, dynamicSecrets, secrets, + secretRotations, totalImportCount, totalFolderCount, totalDynamicSecretCount, totalSecretCount, + totalSecretRotationCount, totalCount: - (totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) + + (totalFolderCount ?? 0) + + (totalDynamicSecretCount ?? 0) + + (totalSecretCount ?? 0) + + (totalSecretRotationCount ?? 0) }; } }); @@ -747,7 +923,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { tags: SanitizedTagSchema.array().optional() }) .array() - .optional() + .optional(), + secretRotations: SecretRotationV2Schema.array().optional() }) } }, @@ -811,6 +988,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { req.permission ); + const secretRotations = searchHasTags + ? [] + : await server.services.secretRotationV2.getQuickSearchSecretRotations( + { + projectId, + folderMappings, + filters: sharedFilters + }, + req.permission + ); + for await (const environment of environments) { const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length; @@ -843,6 +1031,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); } } + + const secretRotationsFromEnv = secretRotations.filter((rotation) => rotation.environment.slug === environment); + + if (secretRotationsFromEnv.length) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotationsFromEnv.length, + rotationIds: secretRotationsFromEnv.map((rotation) => rotation.id), + secretPath, + environment + } + } + }); + } } const sliceQuickSearch = (array: T[]) => array.slice(0, 25); @@ -856,6 +1062,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath)) : dynamicSecrets ), + secretRotations: sliceQuickSearch( + searchPath ? secretRotations.filter((rotation) => rotation.folder.path.endsWith(searchPath)) : secretRotations + ), folders: searchHasTags ? [] : sliceQuickSearch( diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index f854baade..0f53b777a 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -7,6 +7,7 @@ import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { secretsLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { BaseSecretNameSchema, SecretNameSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -39,13 +40,6 @@ const SecretReferenceNodeTree: z.ZodType = SecretReference children: z.lazy(() => SecretReferenceNodeTree.array()) }); -const BaseSecretNameSchema = z.string().trim().min(1); - -const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(" "), - "Secret name cannot contain spaces." -).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); - export const registerSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -630,6 +624,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() .describe(RAW_SECRETS.UPDATE.secretValue), secretPath: z .string() @@ -2049,6 +2044,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() .describe(RAW_SECRETS.UPDATE.secretValue), secretPath: z .string() diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 9da622541..f5f921c4e 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,7 +5,9 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Humanitec = "humanitec" + Humanitec = "humanitec", + Postgres = "postgres", + MsSql = "mssql" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index fe5130ff1..b2d45e71f 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,30 +1,22 @@ import { TAppConnections } from "@app/db/schemas/app-connections"; import { generateHash } from "@app/lib/crypto/encryption"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; -import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; +import { BadRequestError } from "@app/lib/errors"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { - AwsConnectionMethod, - getAwsConnectionListItem, - validateAwsConnectionCredentials -} from "@app/services/app-connection/aws"; -import { - DatabricksConnectionMethod, - getDatabricksConnectionListItem, - validateDatabricksConnectionCredentials -} from "@app/services/app-connection/databricks"; -import { - GcpConnectionMethod, - getGcpConnectionListItem, - validateGcpConnectionCredentials -} from "@app/services/app-connection/gcp"; -import { - getGitHubConnectionListItem, - GitHubConnectionMethod, - validateGitHubConnectionCredentials -} from "@app/services/app-connection/github"; + transferSqlConnectionCredentialsToPlatform, + validateSqlConnectionCredentials +} from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { AppConnection } from "./app-connection-enums"; +import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; +import { + TAppConnection, + TAppConnectionConfig, + TAppConnectionCredentialsValidator, + TAppConnectionTransitionCredentialsToPlatform +} from "./app-connection-types"; +import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws"; import { AzureAppConfigurationConnectionMethod, getAzureAppConfigurationConnectionListItem, @@ -35,11 +27,20 @@ import { getAzureKeyVaultConnectionListItem, validateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { + DatabricksConnectionMethod, + getDatabricksConnectionListItem, + validateDatabricksConnectionCredentials +} from "./databricks"; +import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; +import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; import { getHumanitecConnectionListItem, HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; +import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; export const listAppConnectionOptions = () => { return [ @@ -49,7 +50,9 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), - getHumanitecConnectionListItem() + getHumanitecConnectionListItem(), + getPostgresConnectionListItem(), + getMsSqlConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -95,30 +98,22 @@ export const decryptAppConnectionCredentials = async ({ return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; }; +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureAppConfiguration]: + validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator +}; + export const validateAppConnectionCredentials = async ( appConnection: TAppConnectionConfig -): Promise => { - const { app } = appConnection; - switch (app) { - case AppConnection.AWS: - return validateAwsConnectionCredentials(appConnection); - case AppConnection.Databricks: - return validateDatabricksConnectionCredentials(appConnection); - case AppConnection.GitHub: - return validateGitHubConnectionCredentials(appConnection); - case AppConnection.GCP: - return validateGcpConnectionCredentials(appConnection); - case AppConnection.AzureKeyVault: - return validateAzureKeyVaultConnectionCredentials(appConnection); - case AppConnection.AzureAppConfiguration: - return validateAzureAppConfigurationConnectionCredentials(appConnection); - case AppConnection.Humanitec: - return validateHumanitecConnectionCredentials(appConnection); - default: - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Unhandled App Connection ${app}`); - } -}; +): Promise => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { switch (method) { @@ -136,8 +131,11 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Service Account Impersonation"; case DatabricksConnectionMethod.ServicePrincipal: return "Service Principal"; - case HumanitecConnectionMethod.API_TOKEN: + case HumanitecConnectionMethod.ApiToken: return "API Token"; + case PostgresConnectionMethod.UsernameAndPassword: + case MsSqlConnectionMethod.UsernameAndPassword: + return "Username & Password"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -158,3 +156,24 @@ export const decryptAppConnection = async ( credentialsHash: generateHash(appConnection.encryptedCredentials) } as TAppConnection; }; + +const platformManagedCredentialsNotSupported: TAppConnectionTransitionCredentialsToPlatform = ({ app }) => { + throw new BadRequestError({ + message: `${APP_CONNECTION_NAME_MAP[app]} Connections do not support platform managed credentials.` + }); +}; + +export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< + AppConnection, + TAppConnectionTransitionCredentialsToPlatform +> = { + [AppConnection.AWS]: platformManagedCredentialsNotSupported, + [AppConnection.Databricks]: platformManagedCredentialsNotSupported, + [AppConnection.GitHub]: platformManagedCredentialsNotSupported, + [AppConnection.GCP]: platformManagedCredentialsNotSupported, + [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, + [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, + [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, + [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 8a6c65426..eb28070d5 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -7,5 +7,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", - [AppConnection.Humanitec]: "Humanitec" + [AppConnection.Humanitec]: "Humanitec", + [AppConnection.Postgres]: "PostgreSQL", + [AppConnection.MsSql]: "Microsoft SQL Server" }; diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts index ef3c16cf8..0d3968637 100644 --- a/backend/src/services/app-connection/app-connection-schemas.ts +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; import { AppConnections } from "@app/lib/api-docs"; import { slugSchema } from "@app/server/lib/schemas"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { TAppConnectionBaseConfig } from "@app/services/app-connection/app-connection-types"; import { AppConnection } from "./app-connection-enums"; @@ -14,7 +16,10 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ credentialsHash: z.string().optional() }); -export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => +export const GenericCreateAppConnectionFieldsSchema = ( + app: AppConnection, + { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} +) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), description: z @@ -22,10 +27,16 @@ export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => .trim() .max(256, "Description cannot exceed 256 characters") .nullish() - .describe(AppConnections.CREATE(app).description) + .describe(AppConnections.CREATE(app).description), + isPlatformManagedCredentials: supportsPlatformManagedCredentials + ? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials) + : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); -export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => +export const GenericUpdateAppConnectionFieldsSchema = ( + app: AppConnection, + { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} +) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), description: z @@ -33,5 +44,8 @@ export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => .trim() .max(256, "Description cannot exceed 256 characters") .nullish() - .describe(AppConnections.UPDATE(app).description) + .describe(AppConnections.UPDATE(app).description), + isPlatformManagedCredentials: supportsPlatformManagedCredentials + ? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials) + : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e2e55bba0..200b2fe0c 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -6,25 +6,27 @@ import { generateHash } from "@app/lib/crypto/encryption"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection, encryptAppConnectionCredentials, getAppConnectionMethodName, listAppConnectionOptions, + TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM, validateAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; -import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; -import { - TAppConnection, - TAppConnectionConfig, - TCreateAppConnectionDTO, - TUpdateAppConnectionDTO, - TValidateAppConnectionCredentials -} from "@app/services/app-connection/app-connection-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; +import { AppConnection } from "./app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps"; +import { + TAppConnection, + TAppConnectionConfig, + TAppConnectionRaw, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO, + TValidateAppConnectionCredentials +} from "./app-connection-types"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; @@ -37,6 +39,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; +import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -53,7 +57,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record + appConnectionDAL.transaction(async (tx) => { + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }); + + return appConnectionDAL.create( + { + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }, + tx + ); + }); + + let connection: TAppConnectionRaw; + + if (params.isPlatformManagedCredentials) { + connection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app]( + { + app, + orgId: actor.orgId, + credentials: validatedCredentials, + method + } as TAppConnectionConfig, + (platformCredentials) => createTransaction(platformCredentials) + ); + } else { + connection = await createTransaction(validatedCredentials); + } return { ...connection, @@ -213,11 +241,18 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); - let encryptedCredentials: undefined | Buffer; + // prevent updating credentials or management status if platform managed + if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) { + throw new BadRequestError({ + message: "Cannot update credentials or management status for platform managed connections" + }); + } + + let updatedCredentials: undefined | TAppConnection["credentials"]; + + const { app, method } = appConnection as DiscriminativePick; if (credentials) { - const { app, method } = appConnection as DiscriminativePick; - if ( !VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({ method, @@ -230,29 +265,58 @@ export const appConnectionServiceFactory = ({ } Connection with method ${getAppConnectionMethodName(method)}` }); - const validatedCredentials = await validateAppConnectionCredentials({ + updatedCredentials = await validateAppConnectionCredentials({ app, orgId: actor.orgId, credentials, method } as TAppConnectionConfig); - if (!validatedCredentials) + if (!updatedCredentials) throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); - - encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService - }); } try { - const updatedConnection = await appConnectionDAL.updateById(connectionId, { - orgId: actor.orgId, - encryptedCredentials, - ...params - }); + const updateTransaction = (connectionCredentials: TAppConnection["credentials"] | undefined) => + appConnectionDAL.transaction(async (tx) => { + const encryptedCredentials = connectionCredentials + ? await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }) + : undefined; + + return appConnectionDAL.updateById( + connectionId, + { + orgId: actor.orgId, + encryptedCredentials, + ...params + }, + tx + ); + }); + + let updatedConnection: TAppConnectionRaw; + + if (params.isPlatformManagedCredentials) { + if (!updatedCredentials) + // prevent enabling platform managed credentials without re-confirming credentials + throw new BadRequestError({ message: "Credentials required to transition to platform managed credentials" }); + + updatedConnection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app]( + { + app, + orgId: actor.orgId, + credentials: updatedCredentials, + method + } as TAppConnectionConfig, + (platformCredentials) => updateTransaction(platformCredentials) + ); + } else { + updatedConnection = await updateTransaction(updatedCredentials); + } return await decryptAppConnection(updatedConnection, kmsService); } catch (err) { diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 7051ecb11..1690f13da 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,24 +1,9 @@ -import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; -import { - TAwsConnection, - TAwsConnectionConfig, - TAwsConnectionInput, - TValidateAwsConnectionCredentials -} from "@app/services/app-connection/aws"; -import { - TDatabricksConnection, - TDatabricksConnectionConfig, - TDatabricksConnectionInput, - TValidateDatabricksConnectionCredentials -} from "@app/services/app-connection/databricks"; -import { - TGitHubConnection, - TGitHubConnectionConfig, - TGitHubConnectionInput, - TValidateGitHubConnectionCredentials -} from "@app/services/app-connection/github"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { AWSRegion } from "./app-connection-enums"; +import { TAwsConnection, TAwsConnectionConfig, TAwsConnectionInput, TValidateAwsConnectionCredentials } from "./aws"; import { TAzureAppConfigurationConnection, TAzureAppConfigurationConnectionConfig, @@ -31,13 +16,27 @@ import { TAzureKeyVaultConnectionInput, TValidateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { + TDatabricksConnection, + TDatabricksConnectionConfig, + TDatabricksConnectionInput, + TValidateDatabricksConnectionCredentials +} from "./databricks"; import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentials +} from "./github"; import { THumanitecConnection, THumanitecConnectionConfig, THumanitecConnectionInput, TValidateHumanitecConnectionCredentials } from "./humanitec"; +import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentials } from "./mssql"; +import { TPostgresConnection, TPostgresConnectionInput, TValidatePostgresConnectionCredentials } from "./postgres"; export type TAppConnection = { id: string } & ( | TAwsConnection @@ -47,8 +46,14 @@ export type TAppConnection = { id: string } & ( | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TPostgresConnection + | TMsSqlConnection ); +export type TAppConnectionRaw = NonNullable>>; + +export type TSqlConnection = TPostgresConnection | TMsSqlConnection; + export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput | TGitHubConnectionInput @@ -57,11 +62,15 @@ export type TAppConnectionInput = { id: string } & ( | TAzureAppConfigurationConnectionInput | TDatabricksConnectionInput | THumanitecConnectionInput + | TPostgresConnectionInput + | TMsSqlConnectionInput ); +export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; + export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, - "credentials" | "method" | "name" | "app" | "description" + "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" >; export type TUpdateAppConnectionDTO = Partial> & { @@ -75,7 +84,8 @@ export type TAppConnectionConfig = | TAzureKeyVaultConnectionConfig | TAzureAppConfigurationConnectionConfig | TDatabricksConnectionConfig - | THumanitecConnectionConfig; + | THumanitecConnectionConfig + | TSqlConnectionConfig; export type TValidateAppConnectionCredentials = | TValidateAwsConnectionCredentials @@ -84,10 +94,25 @@ export type TValidateAppConnectionCredentials = | TValidateAzureKeyVaultConnectionCredentials | TValidateAzureAppConfigurationConnectionCredentials | TValidateDatabricksConnectionCredentials - | TValidateHumanitecConnectionCredentials; + | TValidateHumanitecConnectionCredentials + | TValidatePostgresConnectionCredentials + | TValidateMsSqlConnectionCredentials; export type TListAwsConnectionKmsKeys = { connectionId: string; region: AWSRegion; destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; }; + +export type TAppConnectionCredentialsValidator = ( + appConnection: TAppConnectionConfig +) => Promise; + +export type TAppConnectionTransitionCredentialsToPlatform = ( + appConnection: TAppConnectionConfig, + callback: (credentials: TAppConnection["credentials"]) => Promise +) => Promise; + +export type TAppConnectionBaseConfig = { + supportsPlatformManagedCredentials?: boolean; +}; diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts index 00a44745a..767cb82fb 100644 --- a/backend/src/services/app-connection/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -92,7 +92,7 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec resp = await sts.getCallerIdentity().promise(); } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts index c06c6f0ed..8cb19ba26 100644 --- a/backend/src/services/app-connection/aws/aws-connection-schemas.ts +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -48,11 +48,11 @@ export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ - method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method), + method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method), credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials) }), z.object({ - method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method), + method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method), credentials: AwsConnectionAccessTokenCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AWS).credentials ) diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 9ccfc72b6..937a8a84f 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -57,7 +57,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 12b1b3f3b..8e8a6b2a7 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -129,7 +129,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } } diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts index a12fe290c..fc8da062d 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts @@ -86,7 +86,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } }; diff --git a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts index af1a75127..d876b9749 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts @@ -49,7 +49,7 @@ export const ValidateDatabricksConnectionCredentialsSchema = z.discriminatedUnio z.object({ method: z .literal(DatabricksConnectionMethod.ServicePrincipal) - .describe(AppConnections?.CREATE(AppConnection.Databricks).method), + .describe(AppConnections.CREATE(AppConnection.Databricks).method), credentials: DatabricksConnectionServicePrincipalInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.Databricks).credentials ) diff --git a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts index 8f54735e4..8bde74062 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts @@ -4,10 +4,10 @@ import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { AppConnection } from "../app-connection-enums"; -import { getAppConnectionMethodName } from "../app-connection-fns"; import { GcpConnectionMethod } from "./gcp-connection-enums"; import { GCPApp, diff --git a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts index 3c313f205..3637f06dc 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts @@ -37,7 +37,7 @@ export const ValidateGcpConnectionCredentialsSchema = z.discriminatedUnion("meth z.object({ method: z .literal(GcpConnectionMethod.ServiceAccountImpersonation) - .describe(AppConnections?.CREATE(AppConnection.GCP).method), + .describe(AppConnections.CREATE(AppConnection.GCP).method), credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.describe( AppConnections.CREATE(AppConnection.GCP).credentials ) diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 391ba5f96..6ec675c0f 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -200,7 +200,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }); } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts index a3f31ed66..8011999b2 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts @@ -1,3 +1,3 @@ export enum HumanitecConnectionMethod { - API_TOKEN = "api-token" + ApiToken = "api-token" } diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts index 0eeb9bfbf..b8d257026 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts @@ -18,7 +18,7 @@ export const getHumanitecConnectionListItem = () => { return { name: "Humanitec" as const, app: AppConnection.Humanitec as const, - methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.API_TOKEN] + methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.ApiToken] }; }; @@ -40,7 +40,7 @@ export const validateHumanitecConnectionCredentials = async (config: THumanitecC }); } throw new BadRequestError({ - message: "Unable to validate connection - verify credentials" + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts index 145f78b85..4e6cb0078 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts @@ -17,13 +17,13 @@ export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({ const BaseHumanitecConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Humanitec) }); export const HumanitecConnectionSchema = BaseHumanitecConnectionSchema.extend({ - method: z.literal(HumanitecConnectionMethod.API_TOKEN), + method: z.literal(HumanitecConnectionMethod.ApiToken), credentials: HumanitecConnectionAccessTokenCredentialsSchema }); export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", [ BaseHumanitecConnectionSchema.extend({ - method: z.literal(HumanitecConnectionMethod.API_TOKEN), + method: z.literal(HumanitecConnectionMethod.ApiToken), credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({}) }) ]); @@ -31,8 +31,8 @@ export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z - .literal(HumanitecConnectionMethod.API_TOKEN) - .describe(AppConnections?.CREATE(AppConnection.Humanitec).method), + .literal(HumanitecConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Humanitec).method), credentials: HumanitecConnectionAccessTokenCredentialsSchema.describe( AppConnections.CREATE(AppConnection.Humanitec).credentials ) diff --git a/backend/src/services/app-connection/mssql/index.ts b/backend/src/services/app-connection/mssql/index.ts new file mode 100644 index 000000000..81044d0aa --- /dev/null +++ b/backend/src/services/app-connection/mssql/index.ts @@ -0,0 +1,4 @@ +export * from "./mssql-connection-enums"; +export * from "./mssql-connection-fns"; +export * from "./mssql-connection-schemas"; +export * from "./mssql-connection-types"; diff --git a/backend/src/services/app-connection/mssql/mssql-connection-enums.ts b/backend/src/services/app-connection/mssql/mssql-connection-enums.ts new file mode 100644 index 000000000..335b00441 --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-enums.ts @@ -0,0 +1,3 @@ +export enum MsSqlConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/mssql/mssql-connection-fns.ts b/backend/src/services/app-connection/mssql/mssql-connection-fns.ts new file mode 100644 index 000000000..3b6ecf98a --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { MsSqlConnectionMethod } from "./mssql-connection-enums"; + +export const getMsSqlConnectionListItem = () => { + return { + name: "Microsoft SQL Server" as const, + app: AppConnection.MsSql as const, + methods: Object.values(MsSqlConnectionMethod) as [MsSqlConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts new file mode 100644 index 000000000..1b659b658 --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -0,0 +1,65 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { MsSqlConnectionMethod } from "./mssql-connection-enums"; + +export const MsSqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BaseMsSqlConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.MsSql) +}); + +export const MsSqlConnectionSchema = BaseMsSqlConnectionSchema.extend({ + method: z.literal(MsSqlConnectionMethod.UsernameAndPassword), + credentials: MsSqlConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [ + BaseMsSqlConnectionSchema.extend({ + method: z.literal(MsSqlConnectionMethod.UsernameAndPassword), + credentials: MsSqlConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true + }) + }) +]); + +export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(MsSqlConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.MsSql).method), + credentials: MsSqlConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.MsSql).credentials + ) + }) +]); + +export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }) +); + +export const UpdateMsSqlConnectionSchema = z + .object({ + credentials: MsSqlConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.MsSql).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })); + +export const MsSqlConnectionListItemSchema = z.object({ + name: z.literal("Microsoft SQL Server"), + app: z.literal(AppConnection.MsSql), + methods: z.nativeEnum(MsSqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/mssql/mssql-connection-types.ts b/backend/src/services/app-connection/mssql/mssql-connection-types.ts new file mode 100644 index 000000000..ac8fa8f0f --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateMsSqlConnectionSchema, + MsSqlConnectionSchema, + ValidateMsSqlConnectionCredentialsSchema +} from "./mssql-connection-schemas"; + +export type TMsSqlConnection = z.infer; + +export type TMsSqlConnectionInput = z.infer & { + app: AppConnection.MsSql; +}; + +export type TValidateMsSqlConnectionCredentials = typeof ValidateMsSqlConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/postgres/index.ts b/backend/src/services/app-connection/postgres/index.ts new file mode 100644 index 000000000..23ddbba98 --- /dev/null +++ b/backend/src/services/app-connection/postgres/index.ts @@ -0,0 +1,4 @@ +export * from "./postgres-connection-enums"; +export * from "./postgres-connection-fns"; +export * from "./postgres-connection-schemas"; +export * from "./postgres-connection-types"; diff --git a/backend/src/services/app-connection/postgres/postgres-connection-enums.ts b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts new file mode 100644 index 000000000..a29807987 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts @@ -0,0 +1,3 @@ +export enum PostgresConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/postgres/postgres-connection-fns.ts b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts new file mode 100644 index 000000000..39053dbf6 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PostgresConnectionMethod } from "./postgres-connection-enums"; + +export const getPostgresConnectionListItem = () => { + return { + name: "PostgreSQL" as const, + app: AppConnection.Postgres as const, + methods: Object.values(PostgresConnectionMethod) as [PostgresConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts new file mode 100644 index 000000000..3867ea8bf --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -0,0 +1,63 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { PostgresConnectionMethod } from "./postgres-connection-enums"; + +export const PostgresConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BasePostgresConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Postgres) }); + +export const PostgresConnectionSchema = BasePostgresConnectionSchema.extend({ + method: z.literal(PostgresConnectionMethod.UsernameAndPassword), + credentials: PostgresConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", [ + BasePostgresConnectionSchema.extend({ + method: z.literal(PostgresConnectionMethod.UsernameAndPassword), + credentials: PostgresConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true + }) + }) +]); + +export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(PostgresConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.Postgres).method), + credentials: PostgresConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Postgres).credentials + ) + }) +]); + +export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }) +); + +export const UpdatePostgresConnectionSchema = z + .object({ + credentials: PostgresConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Postgres).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })); + +export const PostgresConnectionListItemSchema = z.object({ + name: z.literal("PostgreSQL"), + app: z.literal(AppConnection.Postgres), + methods: z.nativeEnum(PostgresConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/postgres/postgres-connection-types.ts b/backend/src/services/app-connection/postgres/postgres-connection-types.ts new file mode 100644 index 000000000..0c8bcff42 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreatePostgresConnectionSchema, + PostgresConnectionSchema, + ValidatePostgresConnectionCredentialsSchema +} from "./postgres-connection-schemas"; + +export type TPostgresConnection = z.infer; + +export type TPostgresConnectionInput = z.infer & { + app: AppConnection.Postgres; +}; + +export type TValidatePostgresConnectionCredentials = typeof ValidatePostgresConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/shared/sql/index.ts b/backend/src/services/app-connection/shared/sql/index.ts new file mode 100644 index 000000000..107929154 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/index.ts @@ -0,0 +1,2 @@ +export * from "./sql-connection-fns"; +export * from "./sql-connection-schemas"; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts new file mode 100644 index 000000000..b8a29fa77 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -0,0 +1,113 @@ +import knex, { Knex } from "knex"; + +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types"; +import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const SQL_CONNECTION_CLIENT_MAP = { + [AppConnection.Postgres]: "pg", + [AppConnection.MsSql]: "mssql" +}; + +export const getSqlConnectionClient = async ( + appConnection: Pick, + options?: Record +) => { + const { + app, + credentials: { host: baseHost, database, port, sslCertificate, password, username } + } = appConnection; + + const ssl = sslCertificate ? { rejectUnauthorized: false, ca: sslCertificate } : undefined; + + const [host] = await verifyHostInputValidity(baseHost); + + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database, + port, + host, + user: username, + password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl, + options + } + }); + + return client; +}; + +export const validateSqlConnectionCredentials = async (config: TSqlConnectionConfig) => { + const { credentials, app } = config; + + const client = await getSqlConnectionClient({ app, credentials }); + + try { + await client.raw(`Select 1`); + + return credentials; + } catch (error) { + throw new BadRequestError({ + message: + (error as Error)?.message?.replaceAll(credentials.password, "********************") ?? + "Unable to validate connection: verify credentials" + }); + } finally { + await client.destroy(); + } +}; + +export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< + TSqlCredentialsRotationWithConnection["connection"]["app"], + (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding] +> = { + [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]], + [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]] +}; + +export const transferSqlConnectionCredentialsToPlatform = async ( + config: TSqlConnectionConfig, + callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise +) => { + const { credentials, app } = config; + + const client = await getSqlConnectionClient({ app, credentials }); + + const newPassword = alphaNumericNanoId(32); + + try { + return await client.transaction(async (tx) => { + await tx.raw( + ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) + ); + return callback({ + ...credentials, + password: newPassword + }); + }); + } catch (error) { + // update/create service function will handle + if (error instanceof DatabaseError) { + throw error; + } + + throw new BadRequestError({ + message: + (error as Error)?.message?.replaceAll(newPassword, "********************") ?? + "Encountered an error transferring credentials to platform" + }); + } finally { + await client.destroy(); + } +}; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts new file mode 100644 index 000000000..9688d8d43 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; + +export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({ + host: z.string().trim().min(1, "Host required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.host), + port: z.coerce.number().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.port), + database: z.string().trim().min(1, "Database required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.database), + username: z.string().trim().min(1, "Username required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.username), + password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.password), + sslCertificate: z.string().trim().optional().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslCertificate) +}); diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts new file mode 100644 index 000000000..bbfe4086c --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts @@ -0,0 +1,6 @@ +import { DiscriminativePick } from "@app/lib/types"; +import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types"; + +export type TSqlConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index cc2cd1fcf..617393668 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -31,7 +31,11 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), - db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt") + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") ); if (filter) { @@ -60,6 +64,7 @@ const expandSecretSync = ( connectionCreatedAt, connectionUpdatedAt, connectionVersion, + connectionIsPlatformManagedCredentials, ...el } = secretSync; @@ -77,7 +82,8 @@ const expandSecretSync = ( description: connectionDescription, createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, - version: connectionVersion + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials }, folder: folder ? { diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 5c4a7e850..14a1a1cf0 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -119,14 +119,10 @@ export const secretSyncServiceFactory = ({ { destination, syncName, projectId }: TFindSecretSyncByNameDTO, actor: OrgServiceActor ) => { - const folders = await folderDAL.findByProjectId(projectId); - - // we prevent conflicting names within a project so this will only return one at most - const [secretSync] = await secretSyncDAL.find({ + // we prevent conflicting names within a project + const secretSync = await secretSyncDAL.findOne({ name: syncName, - $in: { - folderId: folders.map((folder) => folder.id) - } + projectId }); if (!secretSync) diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 2044c7c17..bd28e1ee7 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -1,6 +1,6 @@ import { Job } from "bullmq"; -import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { QueueJobs } from "@app/queue"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { @@ -129,8 +129,6 @@ export type TDeleteSecretSyncDTO = { removeSecrets: boolean; }; -type AuditLogInfo = Pick; - export enum SecretSyncStatus { Pending = "pending", Running = "running", 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 b4619abd3..8d9e5f0cc 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 @@ -35,15 +35,25 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) - .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); - + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); const data = sqlNestRelationships({ data: docs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + isRotatedSecret: Boolean(el.rotationId), + rotationId: el.rotationId + }), childrenMapper: [ { key: "tagId", @@ -79,6 +89,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretTag}.id` ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select( db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), @@ -87,7 +102,8 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) - .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -98,7 +114,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { const data = sqlNestRelationships({ data: docs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + rotationId: el.rotationId, + isRotatedSecret: Boolean(el.rotationId) + }), childrenMapper: [ { key: "tagId", @@ -332,6 +353,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } const query = (tx || db.replicaNode())(TableName.SecretV2) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .whereIn("folderId", folderIds) .where((bd) => { if (filters?.search) { @@ -414,6 +440,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretTag}.id` ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .where((qb) => { if (filters?.metadataFilter && filters.metadataFilter.length > 0) { filters.metadataFilter.forEach((meta) => { @@ -444,6 +475,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") ) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)) .where((bd) => { const slugs = filters?.tagSlugs?.filter(Boolean); if (slugs && slugs.length > 0) { @@ -472,7 +504,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { const data = sqlNestRelationships({ data: secs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + rotationId: el.rotationId, + isRotatedSecret: Boolean(el.rotationId) + }), childrenMapper: [ { key: "tagId", 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 b2fe923aa..71fff2949 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 @@ -666,6 +666,8 @@ export const reshapeBridgeSecret = ( name: string; }[]; secretMetadata?: ResourceMetadataDTO; + isRotatedSecret?: boolean; + rotationId?: string; }, secretValueHidden: boolean ) => ({ @@ -695,7 +697,8 @@ export const reshapeBridgeSecret = ( secretMetadata: secret.secretMetadata, createdAt: secret.createdAt, updatedAt: secret.updatedAt, - + isRotatedSecret: secret.isRotatedSecret, + rotationId: secret.rotationId, ...(secretValueHidden ? { secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, 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 dc975854f..dc209a794 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 @@ -25,6 +25,7 @@ import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-app import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { diff, groupBy } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; @@ -414,6 +415,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); + if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) + throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -624,66 +627,79 @@ export const secretV2BridgeServiceFactory = ({ }) ); - const deletedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ - projectId, - folderId, - actorId, - secretDAL, - secretQueueService, - inputSecrets: [ - { - type: inputSecret.type as SecretType, - secretKey: inputSecret.secretName - } - ], - tx - }) - ); + try { + const deletedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + projectId, + folderId, + actorId, + secretDAL, + secretQueueService, + inputSecrets: [ + { + type: inputSecret.type as SecretType, + secretKey: inputSecret.secretName + } + ], + tx + }) + ); - if (inputSecret.type === SecretType.Shared) { - await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ - secretPath, - actorId, - actor, - projectId, - orgId: actorOrgId, - environmentSlug: folder.environment.slug + if (inputSecret.type === SecretType.Shared) { + await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + secretPath, + actorId, + actor, + projectId, + orgId: actorOrgId, + environmentSlug: folder.environment.slug + }); + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId }); - } - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: secretToDelete.key, + secretTags: secretToDelete.tags?.map((el) => el.slug) + } + ); - const secretValueHidden = !hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { + return reshapeBridgeSecret( + projectId, environment, secretPath, - secretName: secretToDelete.key, - secretTags: secretToDelete.tags?.map((el) => el.slug) + { + ...deletedSecret[0], + value: deletedSecret[0].encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() + : "", + comment: deletedSecret[0].encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() + : "" + }, + secretValueHidden + ); + } catch (err) { + // deferred errors aren't return as DatabaseError + const error = err as { code: string; table: string }; + if ( + error?.code === DatabaseErrorCode.ForeignKeyViolation && + error?.table === TableName.SecretRotationV2SecretMapping + ) { + throw new BadRequestError({ message: "Cannot delete rotated secrets" }); } - ); - return reshapeBridgeSecret( - projectId, - environment, - secretPath, - { - ...deletedSecret[0], - value: deletedSecret[0].encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() - : "", - comment: deletedSecret[0].encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() - : "" - }, - secretValueHidden - ); + throw err; + } }; // get unique secrets count for multiple envs @@ -946,6 +962,7 @@ export const secretV2BridgeServiceFactory = ({ projectId }); + // scott: if any of this changes it also needs to be mirrored in secret rotation for getting dashboard secrets const decryptedSecrets = secrets .filter((el) => { const canDescribeSecret = hasSecretReadValueOrDescribePermission( @@ -1667,6 +1684,13 @@ export const secretV2BridgeServiceFactory = ({ secretTags: el.tags.map((i) => i.slug) }) ); + + if (el.isRotatedSecret) { + const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); + + if (input && (input.newSecretName || input.secretValue)) + throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + } }); // get all tags @@ -1969,61 +1993,76 @@ export const secretV2BridgeServiceFactory = ({ ); }); - const secretsDeleted = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ - secretDAL, - secretQueueService, - inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ - secretKey, - type: type || SecretType.Shared - })), - projectId, - folderId, - actorId, - tx - }) - ); - - // await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ - actor, - actorId, - secretPath, - projectId, - orgId: actorOrgId, - environmentSlug: folder.environment.slug - }); - - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - return secretsDeleted.map((el) => { - const secretToDeleteMatch = secretsToDelete.find( - (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type + try { + const secretsDeleted = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + secretDAL, + secretQueueService, + inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ + secretKey, + type: type || SecretType.Shared + })), + projectId, + folderId, + actorId, + tx + }) ); - const secretValueHidden = - !secretToDeleteMatch || - !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + orgId: actorOrgId, + environmentSlug: folder.environment.slug + }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + return secretsDeleted.map((el) => { + const secretToDeleteMatch = secretsToDelete.find( + (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type + ); + + const secretValueHidden = + !secretToDeleteMatch || + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName: el.key, + secretTags: secretToDeleteMatch.tags?.map((i) => i.slug) + }); + + return reshapeBridgeSecret( + projectId, environment, secretPath, - secretName: el.key, - secretTags: secretToDeleteMatch.tags?.map((i) => i.slug) - }); + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }, + secretValueHidden + ); + }); + } catch (err) { + // deferred errors aren't return as DatabaseError + const error = err as { code: string; table: string }; + if ( + error?.code === DatabaseErrorCode.ForeignKeyViolation && + error?.table === TableName.SecretRotationV2SecretMapping + ) { + throw new BadRequestError({ message: "Cannot delete rotated secrets" }); + } - return reshapeBridgeSecret( - projectId, - environment, - secretPath, - { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }, - secretValueHidden - ); - }); + throw err; + } }; const getSecretVersions = async ({ 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 7f415bff8..1c23db183 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 @@ -132,7 +132,7 @@ export type TUpdateManySecretDTO = Omit & { secrets: { secretKey: string; newSecretName?: string; - secretValue: string; + secretValue?: string; secretComment?: string; skipMultilineEncoding?: boolean; tagIds?: string[]; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 6a5c097ed..5ea290b6a 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -297,7 +297,7 @@ export type TUpdateManySecretRawDTO = Omit & { secrets: { secretKey: string; newSecretName?: string; - secretValue: string; + secretValue?: string; secretComment?: string; skipMultilineEncoding?: boolean; tagIds?: string[]; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 68e0ecd22..0e7483ef8 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -40,7 +40,8 @@ export enum SmtpTemplates { ExternalImportSuccessful = "externalImportSuccessful.handlebars", ExternalImportFailed = "externalImportFailed.handlebars", ExternalImportStarted = "externalImportStarted.handlebars", - SecretRequestCompleted = "secretRequestCompleted.handlebars" + SecretRequestCompleted = "secretRequestCompleted.handlebars", + SecretRotationFailed = "secretRotationFailed.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/secretRotationFailed.handlebars b/backend/src/services/smtp/templates/secretRotationFailed.handlebars new file mode 100644 index 000000000..728798ce8 --- /dev/null +++ b/backend/src/services/smtp/templates/secretRotationFailed.handlebars @@ -0,0 +1,31 @@ + + + + + + Your {{rotationType}} Rotation "{{rotationName}}" Failed to Rotate + + + +

Infisical

+ + + +
+
+

Name: {{rotationName}}

+

Type: {{rotationType}}

+

Project: {{projectName}}

+

Environment: {{environment}}

+

Secret Path: {{secretPath}}

+
+ + {{emailFooter}} + + + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/mssql/available.mdx b/docs/api-reference/endpoints/app-connections/mssql/available.mdx new file mode 100644 index 000000000..cb8949c4a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/mssql/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/create.mdx b/docs/api-reference/endpoints/app-connections/mssql/create.mdx new file mode 100644 index 000000000..d582d7219 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/mssql" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/mssql/delete.mdx b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx new file mode 100644 index 000000000..af45cb416 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/mssql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx new file mode 100644 index 000000000..9eb08c97d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/mssql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx new file mode 100644 index 000000000..c916d2219 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/mssql/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/list.mdx b/docs/api-reference/endpoints/app-connections/mssql/list.mdx new file mode 100644 index 000000000..490bb497b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/mssql" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/update.mdx b/docs/api-reference/endpoints/app-connections/mssql/update.mdx new file mode 100644 index 000000000..84522b9e4 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/mssql/{connectionId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/postgres/available.mdx b/docs/api-reference/endpoints/app-connections/postgres/available.mdx new file mode 100644 index 000000000..92e360d06 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/postgres/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/create.mdx b/docs/api-reference/endpoints/app-connections/postgres/create.mdx new file mode 100644 index 000000000..8657954e8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/postgres" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/postgres/delete.mdx b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx new file mode 100644 index 000000000..927bfec49 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/postgres/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx new file mode 100644 index 000000000..3ee3f5996 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/postgres/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx new file mode 100644 index 000000000..c9b29cb66 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/postgres/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/list.mdx b/docs/api-reference/endpoints/app-connections/postgres/list.mdx new file mode 100644 index 000000000..5d1be4664 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/postgres" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/update.mdx b/docs/api-reference/endpoints/app-connections/postgres/update.mdx new file mode 100644 index 000000000..19a206958 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/postgres/{connectionId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/list.mdx b/docs/api-reference/endpoints/secret-rotations/list.mdx new file mode 100644 index 000000000..8b3e931f0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx new file mode 100644 index 000000000..14249aced --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/mssql-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx new file mode 100644 index 000000000..117948674 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx new file mode 100644 index 000000000..e0fc208ee --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx new file mode 100644 index 000000000..442ab5bd7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..311715879 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx new file mode 100644 index 000000000..e79ee758b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/mssql-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..543acb9e3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/mssql-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx new file mode 100644 index 000000000..e5404d049 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/options.mdx b/docs/api-reference/endpoints/secret-rotations/options.mdx new file mode 100644 index 000000000..9e1a4e544 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v2/secret-rotations/options" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx new file mode 100644 index 000000000..76540dfdc --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/postgres-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx new file mode 100644 index 000000000..7919313b6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx new file mode 100644 index 000000000..7914eac7e --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx new file mode 100644 index 000000000..f215a1d7b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..34f308514 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx new file mode 100644 index 000000000..6c93a2790 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/postgres-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..687c15279 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/postgres-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx new file mode 100644 index 000000000..7e4ec0883 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- diff --git a/docs/mint.json b/docs/mint.json index 478db0276..e0d15ed8f 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -823,6 +823,39 @@ "api-reference/endpoints/secret-imports/delete" ] }, + { + "group": "Secret Rotations", + "pages": [ + "api-reference/endpoints/secret-rotations/list", + "api-reference/endpoints/secret-rotations/options", + { + "group": "Microsoft SQL Server Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mssql-credentials/create", + "api-reference/endpoints/secret-rotations/mssql-credentials/delete", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/list", + "api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mssql-credentials/update" + ] + }, + { + "group": "PostgreSQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/postgres-credentials/create", + "api-reference/endpoints/secret-rotations/postgres-credentials/delete", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/list", + "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/postgres-credentials/update" + ] + } + ] + }, { "group": "Identity Specific Privilege", "pages": [ @@ -922,6 +955,30 @@ "api-reference/endpoints/app-connections/humanitec/update", "api-reference/endpoints/app-connections/humanitec/delete" ] + }, + { + "group": "Microsoft SQL Server", + "pages": [ + "api-reference/endpoints/app-connections/mssql/list", + "api-reference/endpoints/app-connections/mssql/available", + "api-reference/endpoints/app-connections/mssql/get-by-id", + "api-reference/endpoints/app-connections/mssql/get-by-name", + "api-reference/endpoints/app-connections/mssql/create", + "api-reference/endpoints/app-connections/mssql/update", + "api-reference/endpoints/app-connections/mssql/delete" + ] + }, + { + "group": "PostgreSQL", + "pages": [ + "api-reference/endpoints/app-connections/postgres/list", + "api-reference/endpoints/app-connections/postgres/available", + "api-reference/endpoints/app-connections/postgres/get-by-id", + "api-reference/endpoints/app-connections/postgres/get-by-name", + "api-reference/endpoints/app-connections/postgres/create", + "api-reference/endpoints/app-connections/postgres/update", + "api-reference/endpoints/app-connections/postgres/delete" + ] } ] }, diff --git a/frontend/public/images/integrations/MsSql.png b/frontend/public/images/integrations/MsSql.png new file mode 100644 index 000000000..108ed60f9 Binary files /dev/null and b/frontend/public/images/integrations/MsSql.png differ diff --git a/frontend/public/images/integrations/MySql.png b/frontend/public/images/integrations/MySql.png new file mode 100644 index 000000000..d92befdbc Binary files /dev/null and b/frontend/public/images/integrations/MySql.png differ diff --git a/frontend/public/images/integrations/Postgres.png b/frontend/public/images/integrations/Postgres.png new file mode 100644 index 000000000..b7152860d Binary files /dev/null and b/frontend/public/images/integrations/Postgres.png differ diff --git a/frontend/public/images/integrations/SendGrid.png b/frontend/public/images/integrations/SendGrid.png new file mode 100644 index 000000000..3d2c9a92d Binary files /dev/null and b/frontend/public/images/integrations/SendGrid.png differ diff --git a/frontend/public/images/secretRotation/secret-rotations-v2-location.png b/frontend/public/images/secretRotation/secret-rotations-v2-location.png new file mode 100644 index 000000000..6c0e7d8f1 Binary files /dev/null and b/frontend/public/images/secretRotation/secret-rotations-v2-location.png differ diff --git a/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx new file mode 100644 index 000000000..8a824d101 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; + +import { SecretRotationV2Form } from "@app/components/secret-rotations-v2/forms"; +import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader"; +import { SecretRotationV2Select } from "@app/components/secret-rotations-v2/SecretRotationV2Select"; +import { Modal, ModalContent } from "@app/components/v2"; +import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +type SharedProps = { + secretPath: string; + environment?: string; + environments?: WorkspaceEnv[]; +}; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} & SharedProps; + +type ContentProps = { + onComplete: (secretRotation: TSecretRotationV2) => void; + selectedRotation: SecretRotation | null; + setSelectedRotation: (selectedRotation: SecretRotation | null) => void; +} & SharedProps; + +const Content = ({ setSelectedRotation, selectedRotation, ...props }: ContentProps) => { + if (selectedRotation) { + return ( + setSelectedRotation(null)} + type={selectedRotation} + {...props} + /> + ); + } + + return ; +}; + +export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: Props) => { + const [selectedRotation, setSelectedRotation] = useState(null); + + return ( + { + if (!open) setSelectedRotation(null); + onOpenChange(open); + }} + > + + ) : ( + "Add Secret Rotation" + ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className={selectedRotation ? "max-w-2xl" : "max-w-3xl"} + subTitle={ + selectedRotation ? undefined : "Select a provider to create a secret rotation for." + } + bodyClassName="overflow-visible" + > + { + setSelectedRotation(null); + onOpenChange(false); + }} + selectedRotation={selectedRotation} + setSelectedRotation={setSelectedRotation} + {...props} + /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx new file mode 100644 index 000000000..16f9a67d4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, Switch } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { useDeleteSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretRotationV2Modal = ({ + isOpen, + onOpenChange, + secretRotation, + onComplete +}: Props) => { + const deleteSecretRotation = useDeleteSecretRotationV2(); + const [revokeGeneratedCredentials, setRevokeGeneratedCredentials] = useState(false); + const [deleteSecrets, setDeleteSecrets] = useState(false); + + useEffect(() => { + if (!isOpen) { + setRevokeGeneratedCredentials(false); + setDeleteSecrets(false); + } + }, [isOpen]); + + if (!secretRotation) return null; + + const { id: rotationId, name, type, projectId, folder } = secretRotation; + + const handleDeleteSecretRotation = async () => { + const rotationType = SECRET_ROTATION_MAP[type].name; + + try { + await deleteSecretRotation.mutateAsync({ + rotationId, + type, + revokeGeneratedCredentials, + deleteSecrets, + projectId, + secretPath: folder.path + }); + + createNotification({ + text: `Successfully deleted ${rotationType} Rotation`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch { + createNotification({ + text: `Failed to delete ${rotationType} Rotation`, + type: "error" + }); + } + }; + + return ( + + + Revoke Credentials + +

+ Generated credentials will {revokeGeneratedCredentials ? "" : "not"} be revoked on deletion + {revokeGeneratedCredentials ? "" : " and remain active"}. +

+ + Delete Secrets + +

+ Rotation secrets will {deleteSecrets ? "" : "not"} be removed from your project on deletion. +

+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx new file mode 100644 index 000000000..e9efc27f2 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx @@ -0,0 +1,34 @@ +import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationV2Form } from "./forms"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + secretRotation?: TSecretRotationV2; +}; + +export const EditSecretRotationV2Modal = ({ secretRotation, onOpenChange, ...props }: Props) => { + if (!secretRotation) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + secretRotation={secretRotation} + type={secretRotation.type} + secretPath={secretRotation.folder.path} + environment={secretRotation.environment.slug} + /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx new file mode 100644 index 000000000..e2c931d49 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx @@ -0,0 +1,88 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { useRotateSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretRotation: TSecretRotationV2; + onComplete: () => void; +}; + +const Content = ({ secretRotation, onComplete }: ContentProps) => { + const rotateSecrets = useRotateSecretRotationV2(); + + const { id: rotationId, type, projectId, folder } = secretRotation; + const rotationType = SECRET_ROTATION_MAP[type].name; + + const handleRotateSecrets = async () => { + try { + await rotateSecrets.mutateAsync({ + rotationId, + type, + projectId, + secretPath: folder.path + }); + + createNotification({ + text: `Successfully rotated ${rotationType} secrets`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to rotate ${rotationType} secrets`, + type: "error" + }); + } + }; + + return ( +
+

+ Are you sure you want to rotate the secrets for this {rotationType} Rotation? +

+
+ + + + +
+
+ ); +}; + +export const RotateSecretRotationV2Modal = ({ isOpen, onOpenChange, secretRotation }: Props) => { + if (!secretRotation) return null; + + const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx new file mode 100644 index 000000000..319607d70 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + type: SecretRotation; + isConfigured: boolean; +}; + +export const SecretRotationV2ModalHeader = ({ type, isConfigured }: Props) => { + const destinationDetails = SECRET_ROTATION_MAP[type]; + + return ( +
+ {`${destinationDetails.name} +
+
+ {destinationDetails.name} Rotation + +
+ + Docs + +
+
+
+

+ {isConfigured + ? `Edit ${destinationDetails.name} Rotation` + : `Rotate ${destinationDetails.name}`} +

+
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx new file mode 100644 index 000000000..d934dcb65 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx @@ -0,0 +1,151 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { SecretRotation, useSecretRotationV2Options } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + onSelect: (type: SecretRotation) => void; +}; + +export const SecretRotationV2Select = ({ onSelect }: Props) => { + const { isPending, data: secretRotationOptions } = useSecretRotationV2Options(); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {secretRotationOptions?.map(({ type }) => { + const { image, name } = SECRET_ROTATION_MAP[type]; + + let size: number; + + switch (type) { + case SecretRotation.MsSqlCredentials: + size = 50; + break; + default: + size = 45; + } + + return ( + + ); + })} + {/* templates for kubecon, remove once implemented */} + {[ + { + name: "MySQL Credentials", + image: "MySql.png" + }, + { + name: "SendGrid API Key", + image: "SendGrid.png" + }, + { + name: "AWS IAM User Credentials", + image: "Amazon Web Services.png" + } + ].map(({ name, image }) => { + let size: number; + + switch (name) { + case "MySQL Credentials": + size = 80; + break; + case "SendGrid API Key": + size = 50; + break; + default: + size = 45; + } + + return ( + + ); + })} + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ +
+ Coming Soon +
+
+
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx new file mode 100644 index 000000000..ace829aae --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx @@ -0,0 +1,119 @@ +import { faBan, faRotate, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistanceToNow } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "@app/components/v2"; +import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + secretRotation: TSecretRotationV2; + className?: string; +}; + +export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props) => { + const { isAutoRotationEnabled, rotationStatus, nextRotationAt, lastRotationMessage } = + secretRotation; + + if (rotationStatus === SecretRotationStatus.Failed) { + let errorMessage = lastRotationMessage; + if (lastRotationMessage) { + try { + errorMessage = JSON.stringify(JSON.parse(lastRotationMessage), null, 2); + } catch { + errorMessage = lastRotationMessage; + } + } + + return ( + +
+
+ +
Failure Reason
+
+
{errorMessage}
+
+ {nextRotationAt && ( + + Next rotation attempt on {format(nextRotationAt, "MM/dd/yyyy")} at{" "} + {format(nextRotationAt, "h:mm aa")}. + + )} + + } + > +
+ + + Rotation Failed + +
+
+ ); + } + + if (!isAutoRotationEnabled) { + return ( + + + Auto-Rotation Disabled + + ); + } + + const daysToRotation = + (new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24); + + let variant: BadgeProps["variant"]; + let label: string; + let tooltipContent: string; + + if (daysToRotation >= 7) { + variant = "success"; + label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; + tooltipContent = `Rotates ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; + } else if (daysToRotation < 0) { + variant = "primary"; + label = "Rotating"; + tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; + } else if (daysToRotation < 1) { + variant = "primary"; + label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; + tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; + } else { + variant = "primary"; + label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; + tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; + } + + return ( + +
+ + + {label} + +
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx new file mode 100644 index 000000000..79a431bc8 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -0,0 +1,96 @@ +import { ReactNode } from "react"; +import { faRotate } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { Modal, ModalContent, Spinner } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { + SecretRotation, + TSecretRotationV2, + useViewSecretRotationV2GeneratedCredentials +} from "@app/hooks/api/secretRotationsV2"; + +import { ViewSqlRotationGeneratedCredentials } from "./shared"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretRotation: TSecretRotationV2; +}; + +const Content = ({ secretRotation }: ContentProps) => { + const { id: rotationId, type, nextRotationAt } = secretRotation; + + const { data: generatedCredentialsResponse, isPending } = + useViewSecretRotationV2GeneratedCredentials({ + rotationId, + type + }); + + if (isPending) { + return ( +
+ +

Loading generated credentials...

+
+ ); + } + + let Component: ReactNode; + switch (generatedCredentialsResponse!.type) { + case SecretRotation.PostgresCredentials: + case SecretRotation.MsSqlCredentials: + Component = ( + + ); + break; + default: + throw new Error("Unhandled View Generated Credential Rotation Type"); + } + + return ( +
+ {Component} + {nextRotationAt && ( +
+ + + Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "} + {format(nextRotationAt, "h:mm aa")} + +
+ )} +
+ ); +}; + +export const ViewSecretRotationV2GeneratedCredentialsModal = ({ + isOpen, + onOpenChange, + secretRotation +}: Props) => { + if (!secretRotation) return null; + + const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name; + + return ( + + { + event.preventDefault(); + }} + title="Generated Credentials" + subTitle={`View the current and retired ${rotationType}.`} + > + + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts new file mode 100644 index 000000000..31aa279b4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts @@ -0,0 +1 @@ +export * from "./ViewSecretRotationV2GeneratedCredentials"; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx new file mode 100644 index 000000000..783510336 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx @@ -0,0 +1,55 @@ +import { useReducer } from "react"; +import { faCheck, faCopy, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + children?: string; + label: string; + isSensitive?: boolean; +}; + +export const CredentialDisplay = ({ children, label, isSensitive }: Props) => { + const [showCredential, toggleShowCredential] = useReducer((prev) => !prev, !isSensitive); + + const [, isCopyingCredential, setCopyCredential] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + return ( + + {children ? ( +
+ {showCredential ? children : "****************************"} + + { + setCopyCredential(children); + navigator.clipboard.writeText(children); + }} + ariaLabel="Copy credential" + variant="plain" + size="xs" + > + + + + {isSensitive && ( + + + + + + )} +
+ ) : null} +
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..08068b1fc --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx @@ -0,0 +1,57 @@ +import { faCheck, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TViewSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + generatedCredentialsResponse: TViewSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewSqlRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + <> +
+
+ + + Current Credentials + +
+

+ The active credential set currently mapped to the rotation secrets. +

+
+ {activeCredentials?.username} + + {activeCredentials?.password} + +
+
+
+
+ + + Retired Credentials + +
+

+ The retired credential set that will be revoked during the next rotation cycle. +

+
+ {inactiveCredentials?.username} + + {inactiveCredentials?.password} + +
+
+ + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts new file mode 100644 index 000000000..b964f5bba --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts @@ -0,0 +1 @@ +export * from "./ViewSqlRotationGeneratedCredentials"; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx new file mode 100644 index 000000000..0fd14bb1f --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx @@ -0,0 +1,124 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { format, setHours, setMinutes } from "date-fns"; + +import { FilterableSelect, FormControl, Input, Switch } from "@app/components/v2"; +import { getRotateAtLocal } from "@app/helpers/secretRotationsV2"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +import { TSecretRotationV2Form } from "./schemas"; +import { SecretRotationV2ConnectionField } from "./SecretRotationV2ConnectionField"; + +type Props = { + isUpdate: boolean; + environments?: WorkspaceEnv[]; +}; + +export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => { + const { control, watch } = useFormContext(); + + console.log(watch("rotateAtUtc")); + + return ( + <> +

+ Configure the connection rotation strategy for this Secret Rotation. +

+ {!isUpdate && environments && ( + ( + + option?.name} + getOptionValue={(option) => option?.id} + /> + + )} + /> + )} + + + ( + + + + )} + control={control} + name="rotationInterval" + /> + { + return ( + + { + const time = e.target.value; + if (time) { + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); + const newSelectedDate = setHours(setMinutes(new Date(), minutes), hours); + onChange({ + hours: newSelectedDate.getUTCHours(), + minutes: newSelectedDate.getUTCMinutes() + }); + } + }} + className="bg-mineshaft-700 text-white [color-scheme:dark]" + /> + + ); + }} + control={control} + name="rotateAtUtc" + /> + { + return ( + + +

Auto-Rotation {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx new file mode 100644 index 000000000..7bfa62201 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx @@ -0,0 +1,85 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_ROTATION_CONNECTION_MAP } from "@app/helpers/secretRotationsV2"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretRotationV2Form } from "./schemas"; + +type Props = { + onChange?: VoidFunction; + isUpdate: boolean; +}; + +export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate }: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const rotationType = watch("type"); + const app = SECRET_ROTATION_CONNECTION_MAP[rotationType]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + const appName = APP_CONNECTION_MAP[app].name; + + return ( + <> + ( + + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + isDisabled={isUpdate} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + control={control} + name="connection" + /> + {!isUpdate && availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx new file mode 100644 index 000000000..573217f92 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TSecretRotationV2Form } from "./schemas"; + +export const SecretRotationV2DetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Secret Rotation. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +