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/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..dfe0e6888 --- /dev/null +++ b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts @@ -0,0 +1,58 @@ +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"); + t.boolean("isLastRotationManual").notNullable().defaultTo(true); // creation is considered a "manual" rotation + }); + + 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/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 f58cbe400..e2a0f153f 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/organizations.ts b/backend/src/db/schemas/organizations.ts index 7e5994938..a18e258c7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,10 +23,10 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), - privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional() + privilegeUpgradeInitiatedAt: z.date().nullable().optional() }); export type TOrganizations = z.infer; 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..95873b447 --- /dev/null +++ b/backend/src/db/schemas/secret-rotations-v2.ts @@ -0,0 +1,39 @@ +// 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(), + isLastRotationManual: z.boolean().default(true) +}); + +export type TSecretRotationsV2 = z.infer; +export type TSecretRotationsV2Insert = Omit, TImmutableDBKeys>; +export type TSecretRotationsV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 98cb9244d..7d2cdcc0c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -277,8 +277,10 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), secretPath: z.string(), commits: secretRawSchema - .omit({ _id: true, environment: true, workspace: true, type: true, version: true }) + .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) .extend({ + secretValue: z.string().optional(), + isRotatedSecret: z.boolean().optional(), op: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.nullish(), 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/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index 283b9b31e..494871ec1 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -33,7 +33,8 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { .extend({ secretValueHidden: z.boolean(), secretId: z.string(), - tags: SanitizedTagSchema.array() + tags: SanitizedTagSchema.array(), + isRotatedSecret: z.boolean().optional() }) .array(), folderVersion: z.object({ id: z.string(), name: z.string() }).array(), 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..05d3c7961 --- /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, secret path, environment 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 509d6754e..2ab46b6ad 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"; @@ -56,6 +63,8 @@ export type TCreateAuditLogDTO = { projectId?: string; } & BaseAuthData; +export type AuditLogInfo = Pick; + interface BaseAuthData { ipAddress?: string; userAgent?: string; @@ -287,6 +296,14 @@ export enum EventType { KMIP_OPERATION_LOCATE = "kmip-operation-locate", 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", + PROJECT_ACCESS_REQUEST = "project-access-request" } @@ -2313,6 +2330,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 @@ -2524,4 +2598,11 @@ export type Event = | KmipOperationRegisterEvent | ProjectAccessRequestEvent | 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..d4b634b5d 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -8,11 +8,13 @@ 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)), - getDbConnectionHost(appCfg.REDIS_URL) + getDbConnectionHost(appCfg.REDIS_URL), + getDbConnectionHost(appCfg.AUDIT_LOGS_DB_CONNECTION_URI) ); // get host db ip 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-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index f72977a9b..c1b18e43d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -257,6 +257,11 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema("secVerTag") ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select(selectAllTableCols(TableName.SecretApprovalRequestSecretV2)) .select({ secVerTagId: "secVerTag.id", @@ -285,7 +290,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") - ); + ) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); const formatedDoc = sqlNestRelationships({ data: doc, key: "id", @@ -304,14 +310,16 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secretId", label: "secret" as const, - mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId }) => + mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId, rotationId }) => secretId ? { id: secretId, version: orgSecVersion, key: orgSecKey, encryptedValue: orgSecValue, - encryptedComment: orgSecComment + encryptedComment: orgSecComment, + isRotatedSecret: Boolean(rotationId), + rotationId } : undefined }, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 879065ef5..e24cd923e 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -262,7 +262,13 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - secretValue: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + isRotatedSecret: el.secret.isRotatedSecret, + // eslint-disable-next-line no-nested-ternary + secretValue: el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -609,7 +615,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - typeof el.encryptedValue !== "undefined" + !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue 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..b256bd77f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts @@ -0,0 +1,29 @@ +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 at the server level +CREATE LOGIN [infisical_user] WITH PASSWORD = 'my-password'; + +-- Grant server-level connect permission +GRANT CONNECT SQL TO [infisical_user]; + +-- Switch to the database where you want to create the user +USE my_database; + +-- Create the database user mapped to the login +CREATE USER [infisical_user] FOR LOGIN [infisical_user]; + +-- Grant permissions to the user on the schema in this database +GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_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..395ed46d1 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts @@ -0,0 +1,23 @@ +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 role +CREATE USER infisical_user WITH ENCRYPTED PASSWORD 'temporary_password'; + +-- grant database connection permissions +GRANT CONNECT ON DATABASE my_database TO infisical_user; + +-- grant relevant table permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_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..c3e7a12fb --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -0,0 +1,467 @@ +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.where((qb) => { + void qb + .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..376b497f1 --- /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 getNextUTCDayInterval = ({ 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 getNextUTCMinuteInterval = ({ 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 getNextUTCMinuteInterval(rotateAtUtc); + } + + return getNextUTCDayInterval(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..f15cc4974 --- /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: appCfg.isRotationDevelopmentMode ? 0.5 : 30 + } + ); + + 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: 2, + 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: 2, + pollingIntervalSeconds: 1 + } + ); + + 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..d0b38808e --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -0,0 +1,1290 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import { Knex } from "knex"; +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, PgSqlLock, 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 $throwOnConflictingSecrets = async ({ + secretKeys, + folderId, + tx, + secretPath + }: { + secretKeys: string[]; + folderId: string; + tx: Knex; + secretPath: string; + }) => { + if (new Set(secretKeys).size !== secretKeys.length) { + throw new BadRequestError({ + message: `Secrets mapping keys must be unique. "${secretKeys.join(", ")}" contains duplicate keys.` + }); + } + + const conflictingSecrets = await secretV2BridgeDAL.find( + { + $in: { + [`${TableName.SecretV2}.key` as "key"]: secretKeys + }, + [`${TableName.SecretV2}.folderId` as "folderId"]: folderId, + [`${TableName.SecretV2}.type` as "type"]: SecretType.Shared + }, + { tx } + ); + + if (conflictingSecrets.length) { + throw new BadRequestError({ + message: `The following secrets already exist at the path "${secretPath}": ${conflictingSecrets + .map(({ key }) => key) + .join(", ")}` + }); + } + }; + + 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 }, + secretsMapping, + ...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. Please upgrade your project via the Infiscal Dashboard to gain access." + }); + + 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, + 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) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + + await $throwOnConflictingSecrets({ + secretPath, + secretKeys: Object.values(secretsMapping), + tx, + folderId: folder.id + }); + + const createdRotation = await secretRotationV2DAL.create( + { + folderId: folder.id, + secretsMapping, + ...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}"` + }); + default: + throw err; + } + } + + throw err; + } + + if (err instanceof BadRequestError) 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 } = secretRotation; + const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; + + 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: secretRotation.isLastRotationManual + }); + + let secretsMappingUpdated = false; + + try { + const updatedSecretRotation = await secretRotationV2DAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + + if (payload.secretsMapping && !isEqual(payload.secretsMapping, secretsMapping)) { + const currentMappingKeys = Object.values(secretsMapping); + await $throwOnConflictingSecrets({ + secretPath: folder.path, + secretKeys: Object.values(payload.secretsMapping).filter((key) => !currentMappingKeys.includes(key)), + tx, + folderId: folder.id + }); + + // update mapped secrets names + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: Object.entries(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 + }); + + secretsMappingUpdated = true; + } + + return secretRotationV2DAL.updateById( + rotationId, + { + ...payload, + nextRotationAt + }, + tx + ); + }); + + if (secretsMappingUpdated) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + // 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; + default: + throw err; + } + } + } + + if (err instanceof BadRequestError) 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 + }); + } + + 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; + } + + if (deleteSecrets) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + 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, + isLastRotationManual: isManualRotation, + 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/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 5fbb3f598..015a8d420 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -398,8 +398,32 @@ export const secretSnapshotServiceFactory = ({ if (shouldUseBridge) { const rollback = await snapshotDAL.transaction(async (tx) => { const rollbackSnaps = await snapshotDAL.findRecursivelySnapshotsV2Bridge(snapshot.id, tx); - // this will remove all secrets in current folder - const deletedTopLevelSecs = await secretV2BridgeDAL.delete({ folderId: snapshot.folderId }, tx); + const secretRotationIds = rollbackSnaps + .flatMap((snap) => snap.secretVersions) + .filter((el) => el.isRotatedSecret) + .map((el) => el.secretId); + + // this will remove all secrets in current folder except rotated secrets which we ignore + const deletedTopLevelSecs = await secretV2BridgeDAL.delete( + { + $complex: { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: snapshot.folderId + }, + { + operator: "notIn", + field: "id", + value: secretRotationIds + } + ] + } + }, + tx + ); const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); // this will remove all secrets and folders on child // due to sql foreign key and link list connection removing the folders removes everything below too @@ -424,28 +448,31 @@ export const secretSnapshotServiceFactory = ({ ); const secrets = await secretV2BridgeDAL.insertMany( rollbackSnaps.flatMap(({ secretVersions, folderId }) => - secretVersions.map( - ({ - latestSecretVersion, - version, - updatedAt, - createdAt, - secretId, - envId, - id, - tags, - // exclude the bottom fields from the secret - they are for versioning only. - userActorId, - identityActorId, - actorType, - ...el - }) => ({ - ...el, - id: secretId, - version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, - folderId - }) - ) + secretVersions + .filter((v) => !v.isRotatedSecret) + .map( + ({ + latestSecretVersion, + version, + updatedAt, + createdAt, + secretId, + envId, + id, + tags, + // exclude the bottom fields from the secret - they are for versioning only. + userActorId, + identityActorId, + actorType, + isRotatedSecret, + ...el + }) => ({ + ...el, + id: secretId, + version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, + folderId + }) + ) ), tx ); diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index d8240f27e..c547d85c2 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -181,6 +181,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SnapshotFolder}.folderVersionId`, `${TableName.SecretFolderVersion}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.secretId`, + `${TableName.SecretVersionV2}.secretId` + ) .select(selectAllTableCols(TableName.SecretVersionV2)) .select( db.ref("id").withSchema(TableName.Snapshot).as("snapshotId"), @@ -195,7 +200,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); return sqlNestRelationships({ data, @@ -221,7 +227,11 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "id", label: "secretVersions" as const, - mapper: (el) => SecretVersionsV2Schema.parse(el), + mapper: (el) => ({ + ...SecretVersionsV2Schema.parse(el), + isRotatedSecret: Boolean(el.rotationId), + rotationId: el.rotationId + }), childrenMapper: [ { key: "tagVersionId", @@ -476,6 +486,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretVersionV2}.secretId`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .leftJoin<{ latestSecretVersion: number }>( (tx || db)(TableName.SecretVersionV2) .groupBy("secretId") @@ -506,7 +521,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); const formated = sqlNestRelationships({ @@ -523,7 +539,8 @@ export const snapshotDALFactory = (db: TDbClient) => { label: "secretVersions" as const, mapper: (el) => ({ ...SecretVersionsV2Schema.parse(el), - latestSecretVersion: el.latestSecretVersion as number + latestSecretVersion: el.latestSecretVersion as number, + isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8ff07db26..8fef532f5 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -8,7 +8,8 @@ export const PgSqlLock = { SuperAdminInit: 2024, KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), - OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`) + OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) } as const; export type TKeyStoreFactory = ReturnType; @@ -33,6 +34,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 ca59c1fc8..ea5c07789 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"; @@ -819,7 +824,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.", @@ -834,7 +840,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; @@ -1659,7 +1666,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) => { @@ -1669,12 +1677,25 @@ 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.", + sslEnabled: "Whether or not to use SSL when connecting to the database.", + sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.", + sslCertificate: "The SSL certificate to use for connection." + } + } }; export const SecretSyncs = { @@ -1791,3 +1812,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/connection.ts b/backend/src/lib/knex/connection.ts index 993615a0b..68b40e18f 100644 --- a/backend/src/lib/knex/connection.ts +++ b/backend/src/lib/knex/connection.ts @@ -1,6 +1,8 @@ import { URL } from "url"; // Import the URL class -export const getDbConnectionHost = (urlString: string) => { +export const getDbConnectionHost = (urlString?: string) => { + if (!urlString) return null; + try { const url = new URL(urlString); // Split hostname and port (if provided) diff --git a/backend/src/lib/knex/dynamic.ts b/backend/src/lib/knex/dynamic.ts index b8bc8ab57..a57464fac 100644 --- a/backend/src/lib/knex/dynamic.ts +++ b/backend/src/lib/knex/dynamic.ts @@ -2,11 +2,17 @@ import { Knex } from "knex"; import { UnauthorizedError } from "../errors"; -type TKnexDynamicPrimitiveOperator = { - operator: "eq" | "ne" | "startsWith" | "endsWith"; - value: string; - field: Extract; -}; +type TKnexDynamicPrimitiveOperator = + | { + operator: "eq" | "ne" | "startsWith" | "endsWith"; + value: string; + field: Extract; + } + | { + operator: "notIn"; + value: string[]; + field: Extract; + }; type TKnexDynamicInOperator = { operator: "in"; @@ -48,6 +54,10 @@ export const buildDynamicKnexQuery = ( void queryBuilder.whereILike(filterAst.field, `%${filterAst.value}`); break; } + case "notIn": { + void queryBuilder.whereNotIn(filterAst.field, filterAst.value); + break; + } case "and": { filterAst.value.forEach((el) => { void queryBuilder.andWhere((subQueryBuilder) => { 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 b314b9e07..21988e12d 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, @@ -1499,6 +1504,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 @@ -1600,7 +1634,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 fc33bcafe..bcb0a949a 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..978f3bfd7 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, + TValidateAppConnectionCredentialsSchema +} 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; @@ -46,14 +50,16 @@ export type TAppConnectionServiceFactoryDep = { export type TAppConnectionServiceFactory = ReturnType; -const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, [AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema, [AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema, [AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema, - [AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema + [AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema, + [AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema, + [AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema }; export const appConnectionServiceFactory = ({ @@ -163,20 +169,38 @@ export const appConnectionServiceFactory = ({ orgId: actor.orgId } as TAppConnectionConfig); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService - }); - try { - const connection = await appConnectionDAL.create({ - orgId: actor.orgId, - encryptedCredentials, - method, - app, - ...params - }); + const createConnection = async (connectionCredentials: TAppConnection["credentials"]) => { + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }); + + return appConnectionDAL.create({ + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }); + }; + + let connection: TAppConnectionRaw; + + if (params.isPlatformManagedCredentials) { + connection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app]( + { + app, + orgId: actor.orgId, + credentials: validatedCredentials, + method + } as TAppConnectionConfig, + (platformCredentials) => createConnection(platformCredentials) + ); + } else { + connection = await createConnection(validatedCredentials); + } return { ...connection, @@ -213,11 +237,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 +261,53 @@ 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 updateConnection = async (connectionCredentials: TAppConnection["credentials"] | undefined) => { + const encryptedCredentials = connectionCredentials + ? await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }) + : undefined; + + return appConnectionDAL.updateById(connectionId, { + orgId: actor.orgId, + encryptedCredentials, + ...params + }); + }; + + 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) => updateConnection(platformCredentials) + ); + } else { + updatedConnection = await updateConnection(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..be276d606 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,43 +1,56 @@ -import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +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 "@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 { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; - + TValidateAwsConnectionCredentialsSchema +} from "./aws"; import { TAzureAppConfigurationConnection, TAzureAppConfigurationConnectionConfig, TAzureAppConfigurationConnectionInput, - TValidateAzureAppConfigurationConnectionCredentials + TValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; import { TAzureKeyVaultConnection, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionInput, - TValidateAzureKeyVaultConnectionCredentials + TValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; -import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; +import { + TDatabricksConnection, + TDatabricksConnectionConfig, + TDatabricksConnectionInput, + TValidateDatabricksConnectionCredentialsSchema +} from "./databricks"; +import { + TGcpConnection, + TGcpConnectionConfig, + TGcpConnectionInput, + TValidateGcpConnectionCredentialsSchema +} from "./gcp"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentialsSchema +} from "./github"; import { THumanitecConnection, THumanitecConnectionConfig, THumanitecConnectionInput, - TValidateHumanitecConnectionCredentials + TValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; +import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { + TPostgresConnection, + TPostgresConnectionInput, + TValidatePostgresConnectionCredentialsSchema +} from "./postgres"; export type TAppConnection = { id: string } & ( | TAwsConnection @@ -47,8 +60,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 +76,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,19 +98,35 @@ export type TAppConnectionConfig = | TAzureKeyVaultConnectionConfig | TAzureAppConfigurationConnectionConfig | TDatabricksConnectionConfig - | THumanitecConnectionConfig; + | THumanitecConnectionConfig + | TSqlConnectionConfig; -export type TValidateAppConnectionCredentials = - | TValidateAwsConnectionCredentials - | TValidateGitHubConnectionCredentials - | TValidateGcpConnectionCredentials - | TValidateAzureKeyVaultConnectionCredentials - | TValidateAzureAppConfigurationConnectionCredentials - | TValidateDatabricksConnectionCredentials - | TValidateHumanitecConnectionCredentials; +export type TValidateAppConnectionCredentialsSchema = + | TValidateAwsConnectionCredentialsSchema + | TValidateGitHubConnectionCredentialsSchema + | TValidateGcpConnectionCredentialsSchema + | TValidateAzureKeyVaultConnectionCredentialsSchema + | TValidateAzureAppConfigurationConnectionCredentialsSchema + | TValidateDatabricksConnectionCredentialsSchema + | TValidateHumanitecConnectionCredentialsSchema + | TValidatePostgresConnectionCredentialsSchema + | TValidateMsSqlConnectionCredentialsSchema; 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/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts index a0b74c3d0..a311d604b 100644 --- a/backend/src/services/app-connection/aws/aws-connection-types.ts +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -15,7 +15,7 @@ export type TAwsConnectionInput = z.infer & { app: AppConnection.AWS; }; -export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; +export type TValidateAwsConnectionCredentialsSchema = typeof ValidateAwsConnectionCredentialsSchema; export type TAwsConnectionConfig = DiscriminativePick & { orgId: string; 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-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index db59a1558..8111b4c50 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -16,7 +16,7 @@ export type TAzureAppConfigurationConnectionInput = z.infer & { app: AppConnection.GCP; }; -export type TValidateGcpConnectionCredentials = typeof ValidateGcpConnectionCredentialsSchema; +export type TValidateGcpConnectionCredentialsSchema = typeof ValidateGcpConnectionCredentialsSchema; export type TGcpConnectionConfig = DiscriminativePick & { orgId: string; 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/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 714c87174..600506277 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -15,6 +15,6 @@ export type TGitHubConnectionInput = z.infer; 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/humanitec/humanitec-connection-types.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts index 94613bfba..b9d084a86 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts @@ -15,7 +15,7 @@ export type THumanitecConnectionInput = z.infer { + 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..38ef0eef6 --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -0,0 +1,67 @@ +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, + sslEnabled: true, + sslRejectUnauthorized: 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..dda4dfe9d --- /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 TValidateMsSqlConnectionCredentialsSchema = 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..510f7b7d0 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-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 { 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, + sslEnabled: true, + sslRejectUnauthorized: 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..845b2b825 --- /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 TValidatePostgresConnectionCredentialsSchema = 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..ed1d99941 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -0,0 +1,139 @@ +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" +}; + +const getConnectionConfig = ({ + app, + credentials: { host, sslCertificate, sslEnabled, sslRejectUnauthorized } +}: Pick) => { + switch (app) { + case AppConnection.Postgres: { + return { + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host + } + : false + }; + } + case AppConnection.MsSql: { + return { + options: sslEnabled + ? { + trustServerCertificate: !sslRejectUnauthorized, + encrypt: true, + cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {} + } + : { encrypt: false } + }; + } + default: + throw new Error(`Unhandled SQL Connection Config: ${app as AppConnection}`); + } +}; + +export const getSqlConnectionClient = async (appConnection: Pick) => { + const { + app, + credentials: { host: baseHost, database, port, password, username } + } = appConnection; + + 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, + ...getConnectionConfig(appConnection) + } + }); + + 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..500ed596a --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts @@ -0,0 +1,19 @@ +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), + sslEnabled: z.boolean().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslEnabled), + sslRejectUnauthorized: z.boolean().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .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..9bed01637 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", @@ -511,6 +548,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { try { const secrets = await (tx || db.replicaNode())(TableName.SecretV2) .where({ folderId }) + .where((bd) => { query.forEach((el) => { if (el.type === SecretType.Personal && !el.userId) { @@ -522,10 +560,20 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId: el.type === SecretType.Personal ? el.userId : null }); }); - }); - return secrets; + }) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); + return secrets.map((secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + })); } catch (error) { - throw new DatabaseError({ error, name: "find by blind indexes" }); + throw new DatabaseError({ error, name: "find by secret keys" }); } }; 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 1544e963c..4ab021510 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 6fdfb10f1..88b410b55 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"; @@ -416,6 +417,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; } @@ -626,66 +629,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 @@ -948,6 +964,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( @@ -1669,6 +1686,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 @@ -1971,61 +1995,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 ({ @@ -2197,6 +2236,10 @@ export const secretV2BridgeServiceFactory = ({ const destinationActions = [ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit] as const; sourceSecrets.forEach((secret) => { + if (secret.isRotatedSecret) { + throw new BadRequestError({ message: `Cannot move rotated secret: ${secret.key}` }); + } + for (const sourceAction of sourceActions) { if ( sourceAction === ProjectPermissionSecretActions.DescribeSecret || 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 5c5b26ae4..7b4ea1ee1 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 bea59a439..be036cab8 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -298,7 +298,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 81416eae8..452283235 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -41,6 +41,7 @@ export enum SmtpTemplates { ExternalImportFailed = "externalImportFailed.handlebars", ExternalImportStarted = "externalImportStarted.handlebars", SecretRequestCompleted = "secretRequestCompleted.handlebars", + SecretRotationFailed = "secretRotationFailed.handlebars", ProjectAccessRequest = "projectAccess.handlebars", OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars" } 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..996fe8e10 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/mssql" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Connections](/integrations/app-connections/mssql) to learn how to obtain the + required credentials. + \ 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..75f91dc3b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/mssql/{connectionId}" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Connections](/integrations/app-connections/mssql) to learn how to obtain the + required credentials. + \ 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..3dca12325 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/postgres" +--- + + + Check out the configuration docs for [PostgreSQL + Connections](/integrations/app-connections/postgres) to learn how to obtain the + required credentials. + \ 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..32a4217c1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/postgres/{connectionId}" +--- + + + Check out the configuration docs for [PostgreSQL + Connections](/integrations/app-connections/postgres) to learn how to obtain the + required credentials. + \ 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..8b6c8bc88 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/mssql-credentials" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Credentials Rotations](/documentation/platform/secret-rotation/mssql) to learn how to obtain the + required parameters. + 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..4dd5f3267 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Credentials Rotations](/documentation/platform/secret-rotation/mssql) to learn how to obtain the + required parameters. + 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..fabd51f94 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/postgres-credentials" +--- + + + Check out the configuration docs for [PostgreSQL + Credentials Rotations](/documentation/platform/secret-rotation/postgres) to learn how to obtain the + required parameters. + \ No newline at end of file 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..7aebcb72c --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- + + + Check out the configuration docs for [PostgreSQL + Credentials Rotations](/documentation/platform/secret-rotation/postgres) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/documentation/platform/secret-rotation/mssql.mdx b/docs/documentation/platform/secret-rotation/mssql.mdx index c34bb9034..8789e4152 100644 --- a/docs/documentation/platform/secret-rotation/mssql.mdx +++ b/docs/documentation/platform/secret-rotation/mssql.mdx @@ -1,139 +1,163 @@ --- -title: "Microsoft SQL Server" -description: "Learn how to automatically rotate Microsoft SQL Server user passwords." +title: "Microsoft SQL Server Credentials" +description: "Learn how to automatically rotate Microsoft SQL Server credentials." --- -The Infisical SQL Server secret rotation allows you to automatically rotate your database users' passwords at a predefined interval. - ## Prerequisites -1. Create two SQL Server logins and database users with the required permissions. We'll refer to them as `user-a` and `user-b`. -2. Create another SQL Server login with permissions to alter logins for `user-a` and `user-b`. We'll refer to this as the `admin` login. +1. Create a [Microsoft SQL Server Connection](/integrations/app-connections/mssql) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. -Here's how to set up the prerequisites: +An example creation statement might look like: + ```SQL + -- create server-level logins + CREATE LOGIN [infisical_user_1] WITH PASSWORD = 'my-password'; + CREATE LOGIN [infisical_user_2] WITH PASSWORD = 'my-password'; + GRANT CONNECT SQL TO [infisical_user_1]; + GRANT CONNECT SQL TO [infisical_user_2]; -```sql --- Create the logins (at server level) -CREATE LOGIN [user-a] WITH PASSWORD = 'ComplexPassword1'; -CREATE LOGIN [user-b] WITH PASSWORD = 'ComplexPassword2'; + -- create database-level users with login from above + USE my_database; + CREATE USER [infisical_user_1] FOR LOGIN [infisical_user_1]; + CREATE USER [infisical_user_2] FOR LOGIN [infisical_user_2]; --- Create database users for the logins (in your specific database) -USE [YourDatabase]; -CREATE USER [user-a] FOR LOGIN [user-a]; -CREATE USER [user-b] FOR LOGIN [user-b]; + -- grant relevant permissions + GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_user_1]; + GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_user_2]; + ``` --- Grant necessary permissions to the users -GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [user-a]; -GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [user-b]; + + To learn more about Microsoft SQL Server's permission system, please visit their [documentation](https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-transact-sql?view=sql-server-ver16). + --- Create admin login with permission to alter other logins -CREATE LOGIN [admin] WITH PASSWORD = 'AdminComplexPassword'; -CREATE USER [admin] FOR LOGIN [admin]; --- Grant permission to alter any login -GRANT ALTER ANY LOGIN TO [admin]; -``` +## Create a Microsoft SQL Server Credentials Rotation in Infisical -To learn more about SQL Server's permission system, please visit this [documentation](https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions). + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) -## How it works + 2. Select the **Microsoft SQL Server Credentials** option. + ![Select Microsoft SQL Server Credentials](/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png) -1. Infisical connects to your database using the provided `admin` login credentials. -2. A random value is generated and the password for `user-a` is updated with the new value. -3. The new password is then tested by logging into the database. -4. If test is successful, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). -5. The process is then repeated for `user-b` on the next rotation. -6. The cycle repeats until secret rotation is deleted/stopped. + 3. Select the **Microsoft SQL Server Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png) -## Rotation Configuration + - **Microsoft SQL Server Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. - - - Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar - - - - - SQL Server admin username - + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png) - - SQL Server admin password - + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. - - SQL Server host url (e.g., your-server.database.windows.net) - + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png) - - Database port number (default: 1433) - + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. - - Database name (default: master) - + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png) - - The first login name to rotate - `user-a` - + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. - - The second login name to rotate - `user-b` - + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png) - - Optional database certificate to connect with database - + 8. Your **Microsoft SQL Server Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png) + + + To create a Microsoft SQL Server Credentials Rotation, make an API request to the [Create Microsoft SQL Server + Credentials Rotation](/api-reference/endpoints/secret-rotations/mssql-credentials/create) API endpoint. - - - When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + ### Sample request - - The environment where the rotated credentials should be mapped to. - + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/mssql-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mssql-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "MSSQL_DB_USERNAME", + "password": "MSSQL_DB_PASSWORD" + } + }' + ``` - - The secret path where the rotated credentials should be mapped to. - + ### Sample response - - What interval should the credentials be rotated in days. - - - - Select an existing secret key where the rotated database username value should be saved to. - - - - Select an existing select key where the rotated database password value should be saved to. - - - - - -## FAQ - - - - When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. - - This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. - - To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. - - - - The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. - - You don't need to grant all permissions for your admin account but rather just the permission to alter logins (ALTER ANY LOGIN). - - - - When using Azure SQL Database, you'll need to: - - 1. Use the full server name as your host (e.g., your-server.database.windows.net) - 2. Ensure your admin account is either the Azure SQL Server admin or an Azure AD account with appropriate permissions - 3. Configure your Azure SQL Server firewall rules to allow connections from Infisical's IP addresses - - + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mssql-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "MSSQL_DB_USERNAME", + "password": "MSSQL_DB_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "mssql", + "name": "my-mssql-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "mssql-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-rotation/overview.mdx b/docs/documentation/platform/secret-rotation/overview.mdx index 57ad17e09..ee1fd9e42 100644 --- a/docs/documentation/platform/secret-rotation/overview.mdx +++ b/docs/documentation/platform/secret-rotation/overview.mdx @@ -6,44 +6,92 @@ description: "Learn how to set up automated secret rotation in Infisical." ## Introduction -Secret rotation is a process that involves updating secret credentials periodically to minimize the risk of their compromise. -Rotating secrets helps prevent unauthorized access to systems and sensitive data by ensuring that old credentials are replaced with new ones regularly. +Secret rotation is a security best practice that involves systematically updating credentials and access tokens at regular intervals to minimize the risk of compromise. By proactively replacing existing secrets with new ones, organizations reduce the potential impact of credential theft or leakage. -Rotated secrets may include, but are not limited to: +Examples of rotated secrets include: -1. API keys for external services; -2. Database credentials for various platforms. +- API keys and authentication tokens for cloud services and third-party integrations +- Database credentials across production, staging, and development environments -## Rotation Process +## How Rotation Works -The practice of rotating secrets is a systematic and interval-based operation, carried out in four fundamental phases. +Secret Rotation systematically replaces secrets at regular intervals while ensuring zero downtime for your applications. This overlapping lifecycle approach maintains continuous availability while enhancing your security posture. -### 1. Creation +### Visual Timeline -The system initiates the rotation process by either making an API call to an external service or generating a new secret value internally. -Upon successful creation, the system will temporarily have three versions of the secret: +```mermaid +gantt + title Credential Lifecycle (Interval = 30 days) + dateFormat YYYY-MM-DD + axisFormat %b %d -- **Current active secret**: The one currently in use. -- **Future active secret (pending)**: The newly created secret, awaiting validation. -- **Previous active secret**: The old secret, soon to be retired. + section Credentials 1 + Active :active, a1, 2023-01-01, 30d + Inactive :done, i1, after a1, 30d + Revoked :crit, r1, after i1, 30d -### 2. Testing + section Credentials 2 + Active :active, a2, 2023-01-31, 30d + Inactive :done, i2, after a2, 30d + Revoked :crit, r2, after i2, 30d -The newly generated secret is subjected to a verification process to ensure its validity and functionality. -This involves conducting checks or tests that simulate actual operations the secret would perform. -Only the current active and the future active (pending) secrets are considered operational at this stage, while the previous active secret remains in standby mode. + section Credentials 3 + Active :active, a3, 2023-03-02, 30d + Inactive :done, i3, after a3, 30d + Revoked :crit, r3, after i3, 30d +``` -### 3. Deletion +### Credential States -Post-verification, the system deactivates and deletes the previous active secret, leaving only the current and future active (pending) secrets in the system. +Each set of credentials transitions through three distinct states: -### 4. Activation +- **Active**: The primary credentials that will be used for new connections +- **Inactive**: These credentials are still valid but are no longer issued for new connections +- **Revoked**: Permanently invalidated and deleted from the system -Finally, the system promotes the future active (pending) secret to be the new current active secret. It then triggers necessary side effects, such as invoking webhooks and generating events, to notify other services of the change. +### Rotation Cycle Example (30-Day Interval) + +Using a __30-Day__ rotation interval as an example, here's how the process unfolds: + +1. __Day 0__ + - `Credential set 1` is issued and set to **Active** + - Applications begin using this set for authentication + +2. __Day 30__ + - `Credential set 2` is issued and set to **Active** + - `Credential set 1` transitions to **Inactive** but remains valid + - New connections utilize set 2 while existing connections with set 1 continue to work + + + This overlapping validity period ensures that at any point during the active period of a credential set, you are guaranteed that retrieved credentials will be valid for the specified rotation period. + + +3. __Day 60__ + - `Credential set 3` is issued and set to **Active** + - `Credential set 2` transitions to **Inactive** but remains valid + - `Credential set 1` is **Revoked** and securely deleted + - By now, all applications should have transitioned to using set 2 or 3 + +4. __Day 90__ + - `Credential set 4` is issued and set to **Active** + - `Credential set 3` transitions to **Inactive** but remains valid + - `Credential set 2` is **Revoked** and securely deleted + - The cycle continues... + +### Benefits of This Approach + +- **Zero Downtime**: Applications always have valid credentials +- **Grace Period**: The inactive period gives applications time to update to new credentials +- **Reduced Risk**: Credentials are regularly cycled, limiting the impact of potential compromise +- **Predictable Schedule**: Makes credential management more systematic and easier to automate + +### Implementation Considerations + +- Choose a rotation interval appropriate for your security requirements and operational needs +- Ensure your applications can handle credential updates gracefully +- Monitor for applications still using credentials nearing revocation ## Infisical Secret Rotation Strategies -1. [SendGrid Integration](./sendgrid) -2. [PostgreSQL/CockroachDB Implementation](./postgres) -3. [MySQL/MariaDB Configuration](./mysql) -4. [AWS IAM User](./aws-iam) +- [PostgreSQL Credentials](./postgres) +- [Microsoft SQL Server Credentials](./mssql) diff --git a/docs/documentation/platform/secret-rotation/postgres.mdx b/docs/documentation/platform/secret-rotation/postgres.mdx index 0a6339e4e..e0606e6ab 100644 --- a/docs/documentation/platform/secret-rotation/postgres.mdx +++ b/docs/documentation/platform/secret-rotation/postgres.mdx @@ -1,104 +1,160 @@ --- -title: "PostgreSQL/CockroachDB" -description: "Learn how to automatically rotate PostgreSQL/CockroachDB user passwords." +title: "PostgreSQL Credentials" +description: "Learn how to automatically rotate PostgreSQL credentials." --- -The Infisical Postgres secret rotation allows you to automatically rotate your Postgres database user's password at a predefined interval. +## Prerequisites + +1. Create a [PostgreSQL Connection](/integrations/app-connections/postgres) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. + + An example creation statement might look like: + ```SQL + -- create user roles + CREATE USER infisical_user_1 WITH ENCRYPTED PASSWORD 'temporary_password'; + CREATE USER infisical_user_2 WITH ENCRYPTED PASSWORD 'temporary_password'; + + -- grant database connection permissions + GRANT CONNECT ON DATABASE my_database TO infisical_user_1; + GRANT CONNECT ON DATABASE my_database TO infisical_user_2; + + -- grant relevant table permissions + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_user_1; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_user_2; + ``` + + + To learn more about PostgreSQL's permission system, please visit their [documentation](https://www.postgresql.org/docs/current/sql-grant.html). + -## Prerequisite +## Create a PostgreSQL Credentials Rotation in Infisical -1. Create two users with the required permission in your PostgreSQL instance. We'll refer to them as `user-a` and `user-b`. -2. Create another PostgreSQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user. + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) -To learn more about Postgres permission system, please visit this [documentation](https://www.postgresql.org/docs/9.1/sql-grant.html). + 2. Select the **PostgreSQL Credentials** option. + ![Select PostgreSQL Credentials](/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png) + 3. Select the **PostgreSQL Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png) -## How it works + - **PostgreSQL Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. -1. Infisical connects to your database using the provided `admin` user account. -2. A random value is generated and the password for `user-a` is updated with the new value. -3. The new password is then tested by logging into the database -4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). -5. The process is then repeated for `user-b` on the next rotation. -6. The cycle repeats until secret rotation is deleted/stopped. + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png) -## Rotation Configuration + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. - - - Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar - - + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png) - - - Rotator admin username - + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. - - Rotator admin password - + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png) - - Database host url - + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. - - Database port number - + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png) - - The first username of two to rotate - `user-a` - + 8. Your **PostgreSQL Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png) + + + To create a PostgreSQL Credentials Rotation, make an API request to the [Create PostgreSQL + Credentials Rotation](/api-reference/endpoints/secret-rotations/postgres-credentials/create) API endpoint. - - The second username of two to rotate - `user-b` - - - - Optional database certificate to connect with database - - - + ### Sample request - When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/postgres-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-pg-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "POSTGRES_DB_USERNAME", + "password": "POSTGRES_DB_PASSWORD" + } + }' + ``` - - The environment where the rotated credentials should be mapped to. - + ### Sample response - - The secret path where the rotated credentials should be mapped to. - - - - What interval should the credentials be rotated in days. - - - - Select an existing secret key where the rotated database username value should be saved to. - - - - Select an existing select key where the rotated database password value should be saved to. - - - - -## FAQ - - - - When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. - - This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. - - To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. - - - The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. - - You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords. - - + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "POSTGRES_DB_USERNAME", + "password": "POSTGRES_DB_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "postgres", + "name": "my-pg-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "postgres-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/images/app-connections/mssql/create-username-and-password-method.png b/docs/images/app-connections/mssql/create-username-and-password-method.png new file mode 100644 index 000000000..c30423857 Binary files /dev/null and b/docs/images/app-connections/mssql/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/mssql/select-mssql-connection.png b/docs/images/app-connections/mssql/select-mssql-connection.png new file mode 100644 index 000000000..cc5f14ce4 Binary files /dev/null and b/docs/images/app-connections/mssql/select-mssql-connection.png differ diff --git a/docs/images/app-connections/mssql/username-and-password-connection.png b/docs/images/app-connections/mssql/username-and-password-connection.png new file mode 100644 index 000000000..b188f1f45 Binary files /dev/null and b/docs/images/app-connections/mssql/username-and-password-connection.png differ diff --git a/docs/images/app-connections/postgres/create-username-and-password-method.png b/docs/images/app-connections/postgres/create-username-and-password-method.png new file mode 100644 index 000000000..deecd87b6 Binary files /dev/null and b/docs/images/app-connections/postgres/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/postgres/select-postgres-connection.png b/docs/images/app-connections/postgres/select-postgres-connection.png new file mode 100644 index 000000000..e6e7053b0 Binary files /dev/null and b/docs/images/app-connections/postgres/select-postgres-connection.png differ diff --git a/docs/images/app-connections/postgres/username-and-password-connection.png b/docs/images/app-connections/postgres/username-and-password-connection.png new file mode 100644 index 000000000..c31cd4758 Binary files /dev/null and b/docs/images/app-connections/postgres/username-and-password-connection.png differ diff --git a/docs/images/secret-rotations-v2/generic/add-secret-rotation.png b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png new file mode 100644 index 000000000..86b84001b Binary files /dev/null and b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png new file mode 100644 index 000000000..fe2cc7f58 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png new file mode 100644 index 000000000..3b0c1c3d0 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png new file mode 100644 index 000000000..22cfb6478 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png new file mode 100644 index 000000000..a19ea2aae Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png new file mode 100644 index 000000000..2778bad8e Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png new file mode 100644 index 000000000..4171dad75 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png b/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png new file mode 100644 index 000000000..7a212edaa Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png new file mode 100644 index 000000000..c2d35686f Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png new file mode 100644 index 000000000..35806b0ea Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png new file mode 100644 index 000000000..efc733df8 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png new file mode 100644 index 000000000..35904a466 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png new file mode 100644 index 000000000..800d9d823 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png new file mode 100644 index 000000000..575e58abc Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png b/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png new file mode 100644 index 000000000..64b3055e9 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png differ diff --git a/docs/integrations/app-connections/mssql.mdx b/docs/integrations/app-connections/mssql.mdx new file mode 100644 index 000000000..45082103b --- /dev/null +++ b/docs/integrations/app-connections/mssql.mdx @@ -0,0 +1,134 @@ +--- +title: "Microsoft SQL Server Connection" +description: "Learn how to configure a Microsoft SQL Server Connection for Infisical." +--- + +Infisical supports connecting to Microsoft SQL Server using database principals. + +## Configure a Microsoft SQL Server Principal for Infisical + + + + Infisical recommends creating a designated server login and database user in your Microsoft SQL Server database for your connection. + ```SQL + -- Create login at the server level + CREATE LOGIN [infisical_app] WITH PASSWORD = 'my-password'; + + -- Grant server-level connect permission + GRANT CONNECT SQL TO [infisical_app]; + + -- If you intend to use Platform Managed Credentials (see below) + GRANT ALTER ANY LOGIN TO [infisical_app]; + + -- Switch to the specific database where you want to create the user + USE my_database; + + -- Create the database user mapped to the login + CREATE USER [infisical_app] FOR LOGIN [infisical_app]; + ``` + + + Depending on how you intend to use your Microsoft SQL Server connection, you'll need to grant one or more of the following permissions. + + + To learn more about Microsoft SQL Server's permission system, please visit their [documentation](https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-transact-sql?view=sql-server-ver16). + + + + + For Secret Rotations, your Infisical user will require the ability to alter other logins' passwords: + ```SQL + GRANT ALTER ANY LOGIN TO infisical_login; + ``` + + + + + You'll need the following information to create your Microsoft SQL Server connection: + - `host` - The hostname or IP address of your Microsoft SQL Server server + - `port` - The port number your Microsoft SQL Server server is listening on (default: 1433) + - `database` - The name of the specific database you want to connect to + - `username` - The username of the login created in the steps above + - `password` - The password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **Microsoft SQL Server Connection** option. + ![Select Microsoft SQL Server Connection](/images/app-connections/mssql/select-mssql-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to Microsoft SQL Server**. + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + ![Create Microsoft SQL Server Connection](/images/app-connections/mssql/create-username-and-password-method.png) + + 4. Your **Microsoft SQL Server Connection** is now available for use. + ![Assume Role Microsoft SQL Server Connection](/images/app-connections/mssql/username-and-password-connection.png) + + + To create a Microsoft SQL Server Connection, make an API request to the [Create Microsoft SQL Server + Connection](/api-reference/endpoints/app-connections/mssql/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/mssql \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mssql-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1433, + "database": "default", + "username": "infisical_login", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "mssql", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1433, + "database": "default", + "username": "infisical_login", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 64f3616de..92e6ab9d2 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -74,4 +74,9 @@ in the UI or by passing the associated `connectionId` when generating resources Infisical is continuously expanding its third-party application support. If your desired application isn't listed, you can still use previous methods of connecting to it such as our Native Integrations. - \ No newline at end of file + + +## Platform Managed Credentials + +Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option, +Infisical will modify the credentials to prevent external use of the configured access entity. \ No newline at end of file diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx new file mode 100644 index 000000000..523fc35a8 --- /dev/null +++ b/docs/integrations/app-connections/postgres.mdx @@ -0,0 +1,124 @@ +--- +title: "PostgreSQL Connection" +description: "Learn how to configure a PostgreSQL Connection for Infisical." +--- + +Infisical supports connecting to PostgreSQL using a database role. + +## Configure a PostgreSQL Role for Infisical + + + + Infisical recommends creating a designated role in your PostgreSQL database for your connection. + ```SQL + -- create user role + CREATE ROLE infisical_role WITH LOGIN PASSWORD 'my-password'; + + -- grant login access to the specified database + GRANT CONNECT ON DATABASE my_database TO infisical_role; + ``` + + + Depending on how you intend to use your PostgreSQL connection, you'll need to grant one or more of the following permissions. + + To learn more about PostgreSQL's permission system, please visit their [documentation](https://www.postgresql.org/docs/current/sql-grant.html). + + + + For Secret Rotations, your Infisical user will require the ability to alter other users' passwords: + ```SQL + -- enable permissions to alter login credentials + ALTER ROLE infisical_role WITH CREATEROLE; + ``` + + + + + You'll need the following information to create your PostgreSQL connection: + - `host` - The hostname or IP address of your PostgreSQL server + - `port` - The port number your PostgreSQL server is listening on (default: 5432) + - `database` - The name of the specific database you want to connect to + - `username` - The role name of the login created in the steps above + - `password` - The role password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **PostgreSQL Connection** option. + ![Select PostgreSQL Connection](/images/app-connections/postgres/select-postgres-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to PostgreSQL**. + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + ![Create PostgreSQL Connection](/images/app-connections/postgres/create-username-and-password-method.png) + + 4. Your **PostgreSQL Connection** is now available for use. + ![Assume Role PostgreSQL Connection](/images/app-connections/postgres/username-and-password-connection.png) + + + To create a PostgreSQL Connection, make an API request to the [Create PostgreSQL + Connection](/api-reference/endpoints/app-connections/postgres/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/postgres \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-pg-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 5432, + "database": "default", + "username": "infisical_role", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "postgres", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 5432, + "database": "default", + "username": "infisical_role", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 553b9d305..4e0c592cb 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -187,12 +187,15 @@ Supports conditions and permission inversion #### Subject: `secret-rotation` -| Action | Description | -| -------- | ------------------------------------- | -| `read` | View secret rotation policies | -| `create` | Set up automatic secret rotation | -| `edit` | Modify rotation schedules or policies | -| `delete` | Remove rotation policies | +Supports conditions and permission inversion +| Action | Description | +| ------------------------------ | ---------------------------------------------- | +| `read` | View secret rotation configurations | +| `read-generated-credentials` | View the generated credentials of a rotation | +| `create` | Set up secret rotation configurations | +| `edit` | Modify secret rotation configurations | +| `rotate-secrets` | Rotate the generated credentials of a rotation | +| `delete` | Remove secret rotation configurations | #### Subject: `secret-syncs` diff --git a/docs/mint.json b/docs/mint.json index ad8e0403e..f94a05e18 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -178,11 +178,8 @@ "group": "Secret Rotation", "pages": [ "documentation/platform/secret-rotation/overview", - "documentation/platform/secret-rotation/sendgrid", "documentation/platform/secret-rotation/postgres", - "documentation/platform/secret-rotation/mysql", - "documentation/platform/secret-rotation/mssql", - "documentation/platform/secret-rotation/aws-iam" + "documentation/platform/secret-rotation/mssql" ] }, { @@ -423,7 +420,9 @@ "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", - "integrations/app-connections/humanitec" + "integrations/app-connections/humanitec", + "integrations/app-connections/mssql", + "integrations/app-connections/postgres" ] } ] @@ -827,6 +826,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": [ @@ -926,6 +958,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..c464279ed --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx @@ -0,0 +1,98 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +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 ( + + ); + })} + +

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..a7e6cbbce --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx @@ -0,0 +1,109 @@ +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 } 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); + + return ( + + + Rotates on {format(nextRotationAt, "MM/dd/yyyy")} at {format(nextRotationAt, "h:mm aa")} + {" "} + (Local Time) + + } + > +
+ = 7 ? "success" : "primary"} + className={twMerge( + "flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize", + className + )} + > + + {daysToRotation < 0 + ? "Rotating" + : `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`} + +
+
+ ); +}; 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..7a3c05d3d --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -0,0 +1,105 @@ +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...

+
+ ); + } + + if (!generatedCredentialsResponse) { + return ( +
+

No generated credentials found for this rotation.

+
+ ); + } + + 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")}{" "} + (Local Time) + +
+ )} +
+ ); +}; + +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..d2ef136f3 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx @@ -0,0 +1,122 @@ +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 } = useFormContext(); + + 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..665ab05a6 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx @@ -0,0 +1,102 @@ +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 ( + <> + ( + + Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret rotation. +

+ ) + } + > + { + 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" + /> + ( + +