diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts
index 99e3999e1..3f49bcfea 100644
--- a/backend/e2e-test/mocks/queue.ts
+++ b/backend/e2e-test/mocks/queue.ts
@@ -11,6 +11,7 @@ export const mockQueue = (): TQueueServiceFactory => {
job[name] = jobData;
},
queuePg: async () => {},
+ schedulePg: async () => {},
initialize: async () => {},
shutdown: async () => undefined,
stopRepeatableJob: async () => true,
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 5d10830ad..d3aed3543 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -33,6 +33,7 @@ import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
+import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
@@ -237,6 +238,7 @@ declare module "fastify" {
kmip: TKmipServiceFactory;
kmipOperation: TKmipOperationServiceFactory;
gateway: TGatewayServiceFactory;
+ secretRotationV2: TSecretRotationV2ServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 346b08e4c..82582bfed 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -17,6 +17,9 @@ import {
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
+ TAppConnections,
+ TAppConnectionsInsert,
+ TAppConnectionsUpdate,
TAuditLogs,
TAuditLogsInsert,
TAuditLogStreams,
@@ -65,6 +68,9 @@ import {
TDynamicSecrets,
TDynamicSecretsInsert,
TDynamicSecretsUpdate,
+ TExternalGroupOrgRoleMappings,
+ TExternalGroupOrgRoleMappingsInsert,
+ TExternalGroupOrgRoleMappingsUpdate,
TExternalKms,
TExternalKmsInsert,
TExternalKmsUpdate,
@@ -299,6 +305,12 @@ import {
TSecretRotations,
TSecretRotationsInsert,
TSecretRotationsUpdate,
+ TSecretRotationsV2,
+ TSecretRotationsV2Insert,
+ TSecretRotationsV2Update,
+ TSecretRotationV2SecretMappings,
+ TSecretRotationV2SecretMappingsInsert,
+ TSecretRotationV2SecretMappingsUpdate,
TSecrets,
TSecretScanningGitRisks,
TSecretScanningGitRisksInsert,
@@ -320,15 +332,27 @@ import {
TSecretSnapshotsInsert,
TSecretSnapshotsUpdate,
TSecretsUpdate,
+ TSecretsV2,
+ TSecretsV2Insert,
+ TSecretsV2Update,
+ TSecretSyncs,
+ TSecretSyncsInsert,
+ TSecretSyncsUpdate,
TSecretTagJunction,
TSecretTagJunctionInsert,
TSecretTagJunctionUpdate,
TSecretTags,
TSecretTagsInsert,
TSecretTagsUpdate,
+ TSecretV2TagJunction,
+ TSecretV2TagJunctionInsert,
+ TSecretV2TagJunctionUpdate,
TSecretVersions,
TSecretVersionsInsert,
TSecretVersionsUpdate,
+ TSecretVersionsV2,
+ TSecretVersionsV2Insert,
+ TSecretVersionsV2Update,
TSecretVersionTagJunction,
TSecretVersionTagJunctionInsert,
TSecretVersionTagJunctionUpdate,
@@ -387,24 +411,6 @@ import {
TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate
} from "@app/db/schemas";
-import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections";
-import {
- TExternalGroupOrgRoleMappings,
- TExternalGroupOrgRoleMappingsInsert,
- TExternalGroupOrgRoleMappingsUpdate
-} from "@app/db/schemas/external-group-org-role-mappings";
-import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs";
-import {
- TSecretV2TagJunction,
- TSecretV2TagJunctionInsert,
- TSecretV2TagJunctionUpdate
-} from "@app/db/schemas/secret-v2-tag-junction";
-import {
- TSecretVersionsV2,
- TSecretVersionsV2Insert,
- TSecretVersionsV2Update
-} from "@app/db/schemas/secret-versions-v2";
-import { TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas/secrets-v2";
declare module "knex" {
namespace Knex {
@@ -950,5 +956,15 @@ declare module "knex/types/tables" {
TOrgGatewayConfigInsert,
TOrgGatewayConfigUpdate
>;
+ [TableName.SecretRotationV2]: KnexOriginal.CompositeTableType<
+ TSecretRotationsV2,
+ TSecretRotationsV2Insert,
+ TSecretRotationsV2Update
+ >;
+ [TableName.SecretRotationV2SecretMapping]: KnexOriginal.CompositeTableType<
+ TSecretRotationV2SecretMappings,
+ TSecretRotationV2SecretMappingsInsert,
+ TSecretRotationV2SecretMappingsUpdate
+ >;
}
}
diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts
index 9823f4d8e..5a16666af 100644
--- a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts
+++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts
@@ -1,4 +1,5 @@
import { Knex } from "knex";
+
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise {
diff --git a/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts
new file mode 100644
index 000000000..79d455605
--- /dev/null
+++ b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts
@@ -0,0 +1,19 @@
+import { Knex } from "knex";
+
+import { TableName } from "@app/db/schemas";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials"))) {
+ await knex.schema.alterTable(TableName.AppConnection, (t) => {
+ t.boolean("isPlatformManagedCredentials").defaultTo(false);
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ if (await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials")) {
+ await knex.schema.alterTable(TableName.AppConnection, (t) => {
+ t.dropColumn("isPlatformManagedCredentials");
+ });
+ }
+}
diff --git a/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts
new file mode 100644
index 000000000..0fd9b9881
--- /dev/null
+++ b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts
@@ -0,0 +1,57 @@
+import { Knex } from "knex";
+
+import { TableName } from "@app/db/schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.SecretRotationV2))) {
+ await knex.schema.createTable(TableName.SecretRotationV2, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.string("name", 32).notNullable();
+ t.string("description");
+ t.string("type").notNullable();
+ t.jsonb("parameters").notNullable();
+ t.jsonb("secretsMapping").notNullable();
+ t.binary("encryptedGeneratedCredentials").notNullable();
+ t.boolean("isAutoRotationEnabled").notNullable().defaultTo(true);
+ t.integer("activeIndex").notNullable().defaultTo(0);
+ t.uuid("folderId").notNullable();
+ t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
+ t.uuid("connectionId").notNullable();
+ t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
+ t.timestamps(true, true, true);
+ t.integer("rotationInterval").notNullable();
+ t.jsonb("rotateAtUtc").notNullable(); // { hours: number; minutes: number }
+ t.string("rotationStatus").notNullable();
+ t.datetime("lastRotationAttemptedAt").notNullable();
+ t.datetime("lastRotatedAt").notNullable();
+ t.binary("encryptedLastRotationMessage"); // we encrypt this because it may contain sensitive info (SQL errors showing credentials)
+ t.string("lastRotationJobId");
+ t.datetime("nextRotationAt");
+ });
+
+ await createOnUpdateTrigger(knex, TableName.SecretRotationV2);
+
+ await knex.schema.alterTable(TableName.SecretRotationV2, (t) => {
+ t.unique(["folderId", "name"]);
+ });
+ }
+
+ if (!(await knex.schema.hasTable(TableName.SecretRotationV2SecretMapping))) {
+ await knex.schema.createTable(TableName.SecretRotationV2SecretMapping, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.uuid("secretId").notNullable();
+ // scott: this is deferred to block secret deletion but not prevent folder/environment/project deletion
+ // ie, if rotation is being deleted as well we permit it, otherwise throw
+ t.foreign("secretId").references("id").inTable(TableName.SecretV2).deferrable("deferred");
+ t.uuid("rotationId").notNullable();
+ t.foreign("rotationId").references("id").inTable(TableName.SecretRotationV2).onDelete("CASCADE");
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.dropTableIfExists(TableName.SecretRotationV2SecretMapping);
+ await knex.schema.dropTableIfExists(TableName.SecretRotationV2);
+ await dropOnUpdateTrigger(knex, TableName.SecretRotationV2);
+}
diff --git a/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts b/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts
new file mode 100644
index 000000000..f97bb9b6f
--- /dev/null
+++ b/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts
@@ -0,0 +1,29 @@
+import { Knex } from "knex";
+
+import { TableName } from "@app/db/schemas";
+
+const INDEX_NAME = "idx_unique_secret_v2_key";
+
+export async function up(knex: Knex): Promise {
+ const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key");
+ const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId");
+ const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type");
+
+ if (hasKeyCol && hasFolderIdCol && hasTypeCol) {
+ await knex.raw(`
+ CREATE UNIQUE INDEX ${INDEX_NAME}
+ ON ${TableName.SecretV2} ("key", "folderId")
+ WHERE type = 'shared'
+ `);
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key");
+ const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId");
+ const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type");
+
+ if (hasKeyCol && hasFolderIdCol && hasTypeCol) {
+ await knex.raw(`DROP INDEX IF EXISTS ${INDEX_NAME}`);
+ }
+}
diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts
index 8c9dff236..ee4282b73 100644
--- a/backend/src/db/schemas/app-connections.ts
+++ b/backend/src/db/schemas/app-connections.ts
@@ -19,7 +19,8 @@ export const AppConnectionsSchema = z.object({
version: z.number().default(1),
orgId: z.string().uuid(),
createdAt: z.date(),
- updatedAt: z.date()
+ updatedAt: z.date(),
+ isPlatformManagedCredentials: z.boolean().default(false).nullable().optional()
});
export type TAppConnections = z.infer;
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index 92fc47c23..5b78cf86f 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
export * from "./api-keys";
+export * from "./app-connections";
export * from "./audit-log-streams";
export * from "./audit-logs";
export * from "./auth-token-sessions";
@@ -19,6 +20,7 @@ export * from "./certificate-templates";
export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";
+export * from "./external-group-org-role-mappings";
export * from "./external-kms";
export * from "./gateways";
export * from "./git-app-install-sessions";
@@ -97,13 +99,16 @@ export * from "./secret-references";
export * from "./secret-references-v2";
export * from "./secret-rotation-output-v2";
export * from "./secret-rotation-outputs";
+export * from "./secret-rotation-v2-secret-mappings";
export * from "./secret-rotations";
+export * from "./secret-rotations-v2";
export * from "./secret-scanning-git-risks";
export * from "./secret-sharing";
export * from "./secret-snapshot-folders";
export * from "./secret-snapshot-secrets";
export * from "./secret-snapshot-secrets-v2";
export * from "./secret-snapshots";
+export * from "./secret-syncs";
export * from "./secret-tag-junction";
export * from "./secret-tags";
export * from "./secret-v2-tag-junction";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index e626443c3..f2c61e0fa 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -140,7 +140,9 @@ export enum TableName {
KmipClient = "kmip_clients",
KmipOrgConfig = "kmip_org_configs",
KmipOrgServerCertificates = "kmip_org_server_certificates",
- KmipClientCertificates = "kmip_client_certificates"
+ KmipClientCertificates = "kmip_client_certificates",
+ SecretRotationV2 = "secret_rotations_v2",
+ SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
diff --git a/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts
new file mode 100644
index 000000000..5baf6942c
--- /dev/null
+++ b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts
@@ -0,0 +1,23 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SecretRotationV2SecretMappingsSchema = z.object({
+ id: z.string().uuid(),
+ secretId: z.string().uuid(),
+ rotationId: z.string().uuid()
+});
+
+export type TSecretRotationV2SecretMappings = z.infer;
+export type TSecretRotationV2SecretMappingsInsert = Omit<
+ z.input,
+ TImmutableDBKeys
+>;
+export type TSecretRotationV2SecretMappingsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/secret-rotations-v2.ts b/backend/src/db/schemas/secret-rotations-v2.ts
new file mode 100644
index 000000000..cecc58e07
--- /dev/null
+++ b/backend/src/db/schemas/secret-rotations-v2.ts
@@ -0,0 +1,38 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { zodBuffer } from "@app/lib/zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SecretRotationsV2Schema = z.object({
+ id: z.string().uuid(),
+ name: z.string(),
+ description: z.string().nullable().optional(),
+ type: z.string(),
+ parameters: z.unknown(),
+ secretsMapping: z.unknown(),
+ encryptedGeneratedCredentials: zodBuffer,
+ isAutoRotationEnabled: z.boolean().default(true),
+ activeIndex: z.number().default(0),
+ folderId: z.string().uuid(),
+ connectionId: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ rotationInterval: z.number(),
+ rotateAtUtc: z.unknown(),
+ rotationStatus: z.string(),
+ lastRotationAttemptedAt: z.date(),
+ lastRotatedAt: z.date(),
+ encryptedLastRotationMessage: zodBuffer.nullable().optional(),
+ lastRotationJobId: z.string().nullable().optional(),
+ nextRotationAt: z.date().nullable().optional()
+});
+
+export type TSecretRotationsV2 = z.infer;
+export type TSecretRotationsV2Insert = Omit, TImmutableDBKeys>;
+export type TSecretRotationsV2Update = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/ee/routes/v1/secret-rotation-router.ts b/backend/src/ee/routes/v1/secret-rotation-router.ts
index 936459fa1..1efc2c8aa 100644
--- a/backend/src/ee/routes/v1/secret-rotation-router.ts
+++ b/backend/src/ee/routes/v1/secret-rotation-router.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { SecretRotationOutputsSchema, SecretRotationsSchema } from "@app/db/schemas";
+import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -40,16 +41,10 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) =
}
},
onRequest: verifyAuth([AuthMode.JWT]),
- handler: async (req) => {
- const secretRotation = await server.services.secretRotation.createRotation({
- actor: req.permission.type,
- actorAuthMethod: req.permission.authMethod,
- actorId: req.permission.id,
- actorOrgId: req.permission.orgId,
- ...req.body,
- projectId: req.body.workspaceId
+ handler: async () => {
+ throw new BadRequestError({
+ message: `This version of Secret Rotations has been deprecated. Please see docs for new version.`
});
- return { secretRotation };
}
});
diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts
index bede5a1cf..70e5005a4 100644
--- a/backend/src/ee/routes/v2/index.ts
+++ b/backend/src/ee/routes/v2/index.ts
@@ -1,3 +1,8 @@
+import {
+ registerSecretRotationV2Router,
+ SECRET_ROTATION_REGISTER_ROUTER_MAP
+} from "@app/ee/routes/v2/secret-rotation-v2-routers";
+
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerProjectRoleRouter } from "./project-role-router";
@@ -13,4 +18,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerIdentityProjectAdditionalPrivilegeRouter, {
prefix: "/identity-project-additional-privilege"
});
+
+ await server.register(
+ async (secretRotationV2Router) => {
+ // register generic secret rotation endpoints
+ await secretRotationV2Router.register(registerSecretRotationV2Router);
+
+ // register service specific secret rotation endpoints (secret-rotations/postgres-credentials, etc.)
+ for await (const [type, router] of Object.entries(SECRET_ROTATION_REGISTER_ROUTER_MAP)) {
+ await secretRotationV2Router.register(router, { prefix: `/${type}` });
+ }
+ },
+ { prefix: "/secret-rotations" }
+ );
};
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts
new file mode 100644
index 000000000..d641ec689
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts
@@ -0,0 +1,14 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+
+import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router";
+import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router";
+
+export * from "./secret-rotation-v2-router";
+
+export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record<
+ SecretRotation,
+ (server: FastifyZodProvider) => Promise
+> = {
+ [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter,
+ [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter
+};
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts
new file mode 100644
index 000000000..4fea8869b
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts
@@ -0,0 +1,19 @@
+import {
+ CreateMsSqlCredentialsRotationSchema,
+ MsSqlCredentialsRotationSchema,
+ UpdateMsSqlCredentialsRotationSchema
+} from "@app/ee/services/secret-rotation-v2/mssql-credentials";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
+
+import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
+
+export const registerMsSqlCredentialsRotationRouter = async (server: FastifyZodProvider) =>
+ registerSecretRotationEndpoints({
+ type: SecretRotation.MsSqlCredentials,
+ server,
+ responseSchema: MsSqlCredentialsRotationSchema,
+ createSchema: CreateMsSqlCredentialsRotationSchema,
+ updateSchema: UpdateMsSqlCredentialsRotationSchema,
+ generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema
+ });
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts
new file mode 100644
index 000000000..ab5ef5768
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts
@@ -0,0 +1,19 @@
+import {
+ CreatePostgresCredentialsRotationSchema,
+ PostgresCredentialsRotationSchema,
+ UpdatePostgresCredentialsRotationSchema
+} from "@app/ee/services/secret-rotation-v2/postgres-credentials";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
+
+import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
+
+export const registerPostgresCredentialsRotationRouter = async (server: FastifyZodProvider) =>
+ registerSecretRotationEndpoints({
+ type: SecretRotation.PostgresCredentials,
+ server,
+ responseSchema: PostgresCredentialsRotationSchema,
+ createSchema: CreatePostgresCredentialsRotationSchema,
+ updateSchema: UpdatePostgresCredentialsRotationSchema,
+ generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema
+ });
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts
new file mode 100644
index 000000000..cd3c8d4cb
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts
@@ -0,0 +1,429 @@
+import { z } from "zod";
+
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
+import {
+ TRotateAtUtc,
+ TSecretRotationV2,
+ TSecretRotationV2GeneratedCredentials,
+ TSecretRotationV2Input
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { SecretRotations } from "@app/lib/api-docs";
+import { startsWithVowel } from "@app/lib/fn";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+export const registerSecretRotationEndpoints = <
+ T extends TSecretRotationV2,
+ I extends TSecretRotationV2Input,
+ C extends TSecretRotationV2GeneratedCredentials
+>({
+ server,
+ type,
+ createSchema,
+ updateSchema,
+ responseSchema,
+ generatedCredentialsSchema
+}: {
+ type: SecretRotation;
+ server: FastifyZodProvider;
+ createSchema: z.ZodType<{
+ name: string;
+ environment: string;
+ secretPath: string;
+ projectId: string;
+ connectionId: string;
+ parameters: I["parameters"];
+ secretsMapping: I["secretsMapping"];
+ description?: string | null;
+ isAutoRotationEnabled?: boolean;
+ rotationInterval: number;
+ rotateAtUtc?: TRotateAtUtc;
+ }>;
+ updateSchema: z.ZodType<{
+ connectionId?: string;
+ name?: string;
+ environment?: string;
+ secretPath?: string;
+ parameters?: I["parameters"];
+ secretsMapping?: I["secretsMapping"];
+ description?: string | null;
+ isAutoRotationEnabled?: boolean;
+ rotationInterval?: number;
+ rotateAtUtc?: TRotateAtUtc;
+ }>;
+ responseSchema: z.ZodTypeAny;
+ generatedCredentialsSchema: z.ZodTypeAny;
+}) => {
+ const rotationType = SECRET_ROTATION_NAME_MAP[type];
+
+ server.route({
+ method: "GET",
+ url: `/`,
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: `List the ${rotationType} Rotations for the specified project.`,
+ querystring: z.object({
+ projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST(type).projectId)
+ }),
+ response: {
+ 200: z.object({ secretRotations: responseSchema.array() })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const {
+ query: { projectId }
+ } = req;
+
+ const secretRotations = (await server.services.secretRotationV2.listSecretRotationsByProjectId(
+ { projectId, type },
+ req.permission
+ )) as T[];
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId,
+ event: {
+ type: EventType.GET_SECRET_ROTATIONS,
+ metadata: {
+ type,
+ count: secretRotations.length,
+ rotationIds: secretRotations.map((rotation) => rotation.id)
+ }
+ }
+ });
+
+ return { secretRotations };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:rotationId",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: `Get the specified ${rotationType} Rotation by ID.`,
+ params: z.object({
+ rotationId: z.string().uuid().describe(SecretRotations.GET_BY_ID(type).rotationId)
+ }),
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationId } = req.params;
+
+ const secretRotation = (await server.services.secretRotationV2.findSecretRotationById(
+ { rotationId, type },
+ req.permission
+ )) as T;
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: secretRotation.projectId,
+ event: {
+ type: EventType.GET_SECRET_ROTATION,
+ metadata: {
+ rotationId,
+ type,
+ secretPath: secretRotation.folder.path,
+ environment: secretRotation.environment.slug
+ }
+ }
+ });
+
+ return { secretRotation };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: `/rotation-name/:rotationName`,
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: `Get the specified ${rotationType} Rotation by name and project ID.`,
+ params: z.object({
+ rotationName: z
+ .string()
+ .trim()
+ .min(1, "Rotation name required")
+ .describe(SecretRotations.GET_BY_NAME(type).rotationName)
+ }),
+ querystring: z.object({
+ projectId: z
+ .string()
+ .trim()
+ .min(1, "Project ID required")
+ .describe(SecretRotations.GET_BY_NAME(type).projectId),
+ secretPath: z
+ .string()
+ .trim()
+ .min(1, "Secret path required")
+ .describe(SecretRotations.GET_BY_NAME(type).secretPath),
+ environment: z
+ .string()
+ .trim()
+ .min(1, "Environment required")
+ .describe(SecretRotations.GET_BY_NAME(type).environment)
+ }),
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationName } = req.params;
+ const { projectId, secretPath, environment } = req.query;
+
+ const secretRotation = (await server.services.secretRotationV2.findSecretRotationByName(
+ { rotationName, projectId, type, secretPath, environment },
+ req.permission
+ )) as T;
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId,
+ event: {
+ type: EventType.GET_SECRET_ROTATION,
+ metadata: {
+ rotationId: secretRotation.id,
+ type,
+ secretPath,
+ environment
+ }
+ }
+ });
+
+ return { secretRotation };
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ description: `Create ${
+ startsWithVowel(rotationType) ? "an" : "a"
+ } ${rotationType} Rotation for the specified project.`,
+ body: createSchema,
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const secretRotation = (await server.services.secretRotationV2.createSecretRotation(
+ { ...req.body, type },
+ req.permission
+ )) as T;
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: secretRotation.projectId,
+ event: {
+ type: EventType.CREATE_SECRET_ROTATION,
+ metadata: {
+ rotationId: secretRotation.id,
+ type,
+ ...req.body
+ }
+ }
+ });
+
+ return { secretRotation };
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/:rotationId",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ description: `Update the specified ${rotationType} Rotation.`,
+ params: z.object({
+ rotationId: z.string().uuid().describe(SecretRotations.UPDATE(type).rotationId)
+ }),
+ body: updateSchema,
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationId } = req.params;
+
+ const secretRotation = (await server.services.secretRotationV2.updateSecretRotation(
+ { ...req.body, rotationId, type },
+ req.permission
+ )) as T;
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: secretRotation.projectId,
+ event: {
+ type: EventType.UPDATE_SECRET_ROTATION,
+ metadata: {
+ rotationId,
+ type,
+ ...req.body
+ }
+ }
+ });
+
+ return { secretRotation };
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: `/:rotationId`,
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ description: `Delete the specified ${rotationType} Rotation.`,
+ params: z.object({
+ rotationId: z.string().uuid().describe(SecretRotations.DELETE(type).rotationId)
+ }),
+ querystring: z.object({
+ deleteSecrets: z
+ .enum(["true", "false"])
+ .transform((value) => value === "true")
+ .describe(SecretRotations.DELETE(type).deleteSecrets),
+ revokeGeneratedCredentials: z
+ .enum(["true", "false"])
+ .transform((value) => value === "true")
+ .describe(SecretRotations.DELETE(type).revokeGeneratedCredentials)
+ }),
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationId } = req.params;
+ const { deleteSecrets, revokeGeneratedCredentials } = req.query;
+
+ const secretRotation = (await server.services.secretRotationV2.deleteSecretRotation(
+ { type, rotationId, deleteSecrets, revokeGeneratedCredentials },
+ req.permission
+ )) as T;
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: secretRotation.projectId,
+ event: {
+ type: EventType.DELETE_SECRET_ROTATION,
+ metadata: {
+ type,
+ rotationId,
+ deleteSecrets,
+ revokeGeneratedCredentials
+ }
+ }
+ });
+
+ return { secretRotation };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:rotationId/generated-credentials",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: `Get the generated credentials for the specified ${rotationType} Rotation.`,
+ params: z.object({
+ rotationId: z.string().uuid().describe(SecretRotations.GET_GENERATED_CREDENTIALS_BY_ID(type).rotationId)
+ }),
+ response: {
+ 200: z.object({
+ generatedCredentials: generatedCredentialsSchema,
+ activeIndex: z.number(),
+ rotationId: z.string().uuid(),
+ type: z.literal(type)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationId } = req.params;
+
+ const {
+ generatedCredentials,
+ secretRotation: { activeIndex, projectId, folder, environment }
+ } = await server.services.secretRotationV2.findSecretRotationGeneratedCredentialsById(
+ {
+ rotationId,
+ type
+ },
+ req.permission
+ );
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId,
+ event: {
+ type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS,
+ metadata: {
+ type,
+ rotationId,
+ secretPath: folder.path,
+ environment: environment.slug
+ }
+ }
+ });
+
+ return { generatedCredentials: generatedCredentials as C, activeIndex, rotationId, type };
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/:rotationId/rotate-secrets",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ description: `Rotate the generated credentials for the specified ${rotationType} Rotation.`,
+ params: z.object({
+ rotationId: z.string().uuid().describe(SecretRotations.ROTATE(type).rotationId)
+ }),
+ response: {
+ 200: z.object({ secretRotation: responseSchema })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { rotationId } = req.params;
+
+ const secretRotation = (await server.services.secretRotationV2.rotateSecretRotation(
+ {
+ rotationId,
+ type,
+ auditLogInfo: req.auditLogInfo
+ },
+ req.permission
+ )) as T;
+
+ return { secretRotation };
+ }
+ });
+};
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts
new file mode 100644
index 000000000..abdfc14f6
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts
@@ -0,0 +1,81 @@
+import { z } from "zod";
+
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
+import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
+import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
+import { SecretRotations } from "@app/lib/api-docs";
+import { readLimit } from "@app/server/config/rateLimiter";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [
+ PostgresCredentialsRotationListItemSchema,
+ MsSqlCredentialsRotationListItemSchema
+]);
+
+export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "GET",
+ url: "/options",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: "List the available Secret Rotation Options.",
+ response: {
+ 200: z.object({
+ secretRotationOptions: SecretRotationV2OptionsSchema.array()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: () => {
+ const secretRotationOptions = server.services.secretRotationV2.listSecretRotationOptions();
+ return { secretRotationOptions };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ description: "List all the Secret Rotations for the specified project.",
+ querystring: z.object({
+ projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST().projectId)
+ }),
+ response: {
+ 200: z.object({ secretRotations: SecretRotationV2Schema.array() })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const {
+ query: { projectId },
+ permission
+ } = req;
+
+ const secretRotations = await server.services.secretRotationV2.listSecretRotationsByProjectId(
+ { projectId },
+ permission
+ );
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId,
+ event: {
+ type: EventType.GET_SECRET_ROTATIONS,
+ metadata: {
+ rotationIds: secretRotations.map((sync) => sync.id),
+ count: secretRotations.length
+ }
+ }
+ });
+
+ return { secretRotations };
+ }
+ });
+};
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index 1214cc5a0..ab950f3bf 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -2,6 +2,13 @@ import {
TCreateProjectTemplateDTO,
TUpdateProjectTemplateDTO
} from "@app/ee/services/project-template/project-template-types";
+import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ TCreateSecretRotationV2DTO,
+ TDeleteSecretRotationV2DTO,
+ TSecretRotationV2Raw,
+ TUpdateSecretRotationV2DTO
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
@@ -54,6 +61,8 @@ export type TCreateAuditLogDTO = {
projectId?: string;
} & BaseAuthData;
+export type AuditLogInfo = Pick;
+
interface BaseAuthData {
ipAddress?: string;
userAgent?: string;
@@ -283,7 +292,15 @@ export enum EventType {
KMIP_OPERATION_ACTIVATE = "kmip-operation-activate",
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
- KMIP_OPERATION_REGISTER = "kmip-operation-register"
+ KMIP_OPERATION_REGISTER = "kmip-operation-register",
+
+ GET_SECRET_ROTATIONS = "get-secret-rotations",
+ GET_SECRET_ROTATION = "get-secret-rotation",
+ GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials",
+ CREATE_SECRET_ROTATION = "create-secret-rotation",
+ UPDATE_SECRET_ROTATION = "update-secret-rotation",
+ DELETE_SECRET_ROTATION = "delete-secret-rotation",
+ SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets"
}
interface UserActorMetadata {
@@ -2290,6 +2307,63 @@ interface RegisterKmipServerEvent {
};
}
+interface GetSecretRotationsEvent {
+ type: EventType.GET_SECRET_ROTATIONS;
+ metadata: {
+ type?: SecretRotation;
+ count: number;
+ rotationIds: string[];
+ secretPath?: string;
+ environment?: string;
+ };
+}
+
+interface GetSecretRotationEvent {
+ type: EventType.GET_SECRET_ROTATION;
+ metadata: {
+ type: SecretRotation;
+ rotationId: string;
+ secretPath: string;
+ environment: string;
+ };
+}
+
+interface GetSecretRotationCredentialsEvent {
+ type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS;
+ metadata: {
+ type: SecretRotation;
+ rotationId: string;
+ secretPath: string;
+ environment: string;
+ };
+}
+
+interface CreateSecretRotationEvent {
+ type: EventType.CREATE_SECRET_ROTATION;
+ metadata: Omit & { rotationId: string };
+}
+
+interface UpdateSecretRotationEvent {
+ type: EventType.UPDATE_SECRET_ROTATION;
+ metadata: TUpdateSecretRotationV2DTO;
+}
+
+interface DeleteSecretRotationEvent {
+ type: EventType.DELETE_SECRET_ROTATION;
+ metadata: TDeleteSecretRotationV2DTO;
+}
+
+interface RotateSecretRotationEvent {
+ type: EventType.SECRET_ROTATION_ROTATE_SECRETS;
+ metadata: Pick & {
+ status: SecretRotationStatus;
+ rotationId: string;
+ jobId?: string | undefined;
+ occurredAt: Date;
+ message?: string | null | undefined;
+ };
+}
+
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -2500,4 +2574,11 @@ export type Event =
| KmipOperationLocateEvent
| KmipOperationRegisterEvent
| CreateSecretRequestEvent
- | SecretApprovalRequestReview;
+ | SecretApprovalRequestReview
+ | GetSecretRotationsEvent
+ | GetSecretRotationEvent
+ | GetSecretRotationCredentialsEvent
+ | CreateSecretRotationEvent
+ | UpdateSecretRotationEvent
+ | DeleteSecretRotationEvent
+ | RotateSecretRotationEvent;
diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts
index 02b83c0f9..5c5b9f30c 100644
--- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts
+++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts
@@ -8,7 +8,8 @@ import { getDbConnectionHost } from "@app/lib/knex";
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
const appCfg = getConfig();
- // if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev
+
+ if (appCfg.isDevelopmentMode) return [host];
const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat(
(appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)),
diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts
index 21d378802..3f4af174b 100644
--- a/backend/src/ee/services/license/license-fns.ts
+++ b/backend/src/ee/services/license/license-fns.ts
@@ -39,7 +39,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
trial_end: null,
has_used_trial: true,
secretApproval: false,
- secretRotation: true,
+ secretRotation: false,
caCrl: false,
instanceUserManagement: false,
externalKms: false,
diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts
index 34e350cf6..c2bf42e2e 100644
--- a/backend/src/ee/services/license/license-types.ts
+++ b/backend/src/ee/services/license/license-types.ts
@@ -56,7 +56,7 @@ export type TFeatureSet = {
trial_end: null;
has_used_trial: true;
secretApproval: false;
- secretRotation: true;
+ secretRotation: false;
caCrl: false;
instanceUserManagement: false;
externalKms: false;
diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts
index 2637db994..4d3fff12a 100644
--- a/backend/src/ee/services/permission/project-permission.ts
+++ b/backend/src/ee/services/permission/project-permission.ts
@@ -77,6 +77,15 @@ export enum ProjectPermissionSecretSyncActions {
RemoveSecrets = "remove-secrets"
}
+export enum ProjectPermissionSecretRotationActions {
+ Read = "read",
+ ReadGeneratedCredentials = "read-generated-credentials",
+ Create = "create",
+ Edit = "edit",
+ Delete = "delete",
+ RotateSecrets = "rotate-secrets"
+}
+
export enum ProjectPermissionKmipActions {
CreateClients = "create-clients",
UpdateClients = "update-clients",
@@ -142,6 +151,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
+export type SecretRotationsSubjectFields = {
+ environment: string;
+ secretPath: string;
+};
+
export type IdentityManagementSubjectFields = {
identityId: string;
};
@@ -184,7 +198,13 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
- | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
+ | [
+ ProjectPermissionSecretRotationActions,
+ (
+ | ProjectPermissionSub.SecretRotation
+ | (ForcedSubject & SecretRotationsSubjectFields)
+ )
+ ]
| [
ProjectPermissionIdentityActions,
ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields)
@@ -300,12 +320,6 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
- z.object({
- subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
- action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
- "Describe what action an entity can take."
- )
- }),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRollback).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read, ProjectPermissionActions.Create]).describe(
@@ -487,6 +501,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
"Describe what action an entity can take."
)
}),
+ z.object({
+ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
+ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
+ "Describe what action an entity can take."
+ )
+ }),
...GeneralPermissionSchema
]);
@@ -541,6 +561,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
+ z.object({
+ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
+ inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
+ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretRotationActions).describe(
+ "Describe what action an entity can take."
+ ),
+ conditions: SecretConditionV1Schema.describe(
+ "When specified, only matching conditions will be allowed to access given resource."
+ ).optional()
+ }),
...GeneralPermissionSchema
]);
@@ -554,7 +584,6 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports,
ProjectPermissionSub.SecretApproval,
- ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Role,
ProjectPermissionSub.Integrations,
ProjectPermissionSub.Webhooks,
@@ -678,6 +707,18 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.Kmip
);
+ can(
+ [
+ ProjectPermissionSecretRotationActions.Create,
+ ProjectPermissionSecretRotationActions.Edit,
+ ProjectPermissionSecretRotationActions.Delete,
+ ProjectPermissionSecretRotationActions.Read,
+ ProjectPermissionSecretRotationActions.ReadGeneratedCredentials,
+ ProjectPermissionSecretRotationActions.RotateSecrets
+ ],
+ ProjectPermissionSub.SecretRotation
+ );
+
return rules;
};
@@ -727,7 +768,7 @@ const buildMemberPermissionRules = () => {
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval);
- can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation);
+ can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation);
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback);
@@ -873,7 +914,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
- can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
+ can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation);
can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts
new file mode 100644
index 000000000..3ee1cf450
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts
@@ -0,0 +1,3 @@
+export * from "./mssql-credentials-rotation-constants";
+export * from "./mssql-credentials-rotation-schemas";
+export * from "./mssql-credentials-rotation-types";
diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts
new file mode 100644
index 000000000..670c98997
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts
@@ -0,0 +1,16 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const MSSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
+ name: "Microsoft SQL Server Credentials",
+ type: SecretRotation.MsSqlCredentials,
+ connection: AppConnection.MsSql,
+ template: {
+ createUserStatement: `CREATE LOGIN [my_mssql_user] WITH PASSWORD = 'my_temporary_password'; CREATE USER [my_mssql_user] FOR LOGIN [my_mssql_user]; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [my_mssql_user];`,
+ secretsMapping: {
+ username: "MSSQL_DB_USERNAME",
+ password: "MSSQL_DB_PASSWORD"
+ }
+ }
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts
new file mode 100644
index 000000000..3f02d8144
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts
@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ BaseCreateSecretRotationSchema,
+ BaseSecretRotationSchema,
+ BaseUpdateSecretRotationSchema
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
+import {
+ SqlCredentialsRotationParametersSchema,
+ SqlCredentialsRotationSecretsMappingSchema,
+ SqlCredentialsRotationTemplateSchema
+} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const MsSqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MsSqlCredentials).extend({
+ type: z.literal(SecretRotation.MsSqlCredentials),
+ parameters: SqlCredentialsRotationParametersSchema,
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema
+});
+
+export const CreateMsSqlCredentialsRotationSchema = BaseCreateSecretRotationSchema(
+ SecretRotation.MsSqlCredentials
+).extend({
+ parameters: SqlCredentialsRotationParametersSchema,
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema
+});
+
+export const UpdateMsSqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
+ SecretRotation.MsSqlCredentials
+).extend({
+ parameters: SqlCredentialsRotationParametersSchema.optional(),
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional()
+});
+
+export const MsSqlCredentialsRotationListItemSchema = z.object({
+ name: z.literal("Microsoft SQL Server Credentials"),
+ connection: z.literal(AppConnection.MsSql),
+ type: z.literal(SecretRotation.MsSqlCredentials),
+ template: SqlCredentialsRotationTemplateSchema
+});
diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts
new file mode 100644
index 000000000..ed707c4e4
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+import { TMsSqlConnection } from "@app/services/app-connection/mssql";
+
+import {
+ CreateMsSqlCredentialsRotationSchema,
+ MsSqlCredentialsRotationListItemSchema,
+ MsSqlCredentialsRotationSchema
+} from "./mssql-credentials-rotation-schemas";
+
+export type TMsSqlCredentialsRotation = z.infer;
+
+export type TMsSqlCredentialsRotationInput = z.infer;
+
+export type TMsSqlCredentialsRotationListItem = z.infer;
+
+export type TMsSqlCredentialsRotationWithConnection = TMsSqlCredentialsRotation & {
+ connection: TMsSqlConnection;
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts
new file mode 100644
index 000000000..aba568d1d
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts
@@ -0,0 +1,3 @@
+export * from "./postgres-credentials-rotation-constants";
+export * from "./postgres-credentials-rotation-schemas";
+export * from "./postgres-credentials-rotation-types";
diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts
new file mode 100644
index 000000000..68a31e8c9
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts
@@ -0,0 +1,16 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
+ name: "PostgreSQL Credentials",
+ type: SecretRotation.PostgresCredentials,
+ connection: AppConnection.Postgres,
+ template: {
+ createUserStatement: `CREATE USER "my_pg_user" WITH ENCRYPTED PASSWORD 'temporary_password'; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "my_pg_user";`,
+ secretsMapping: {
+ username: "POSTGRES_DB_USERNAME",
+ password: "POSTGRES_DB_PASSWORD"
+ }
+ }
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts
new file mode 100644
index 000000000..0527a6116
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts
@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ BaseCreateSecretRotationSchema,
+ BaseSecretRotationSchema,
+ BaseUpdateSecretRotationSchema
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
+import {
+ SqlCredentialsRotationParametersSchema,
+ SqlCredentialsRotationSecretsMappingSchema,
+ SqlCredentialsRotationTemplateSchema
+} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const PostgresCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.PostgresCredentials).extend({
+ type: z.literal(SecretRotation.PostgresCredentials),
+ parameters: SqlCredentialsRotationParametersSchema,
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema
+});
+
+export const CreatePostgresCredentialsRotationSchema = BaseCreateSecretRotationSchema(
+ SecretRotation.PostgresCredentials
+).extend({
+ parameters: SqlCredentialsRotationParametersSchema,
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema
+});
+
+export const UpdatePostgresCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
+ SecretRotation.PostgresCredentials
+).extend({
+ parameters: SqlCredentialsRotationParametersSchema.optional(),
+ secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional()
+});
+
+export const PostgresCredentialsRotationListItemSchema = z.object({
+ name: z.literal("PostgreSQL Credentials"),
+ connection: z.literal(AppConnection.Postgres),
+ type: z.literal(SecretRotation.PostgresCredentials),
+ template: SqlCredentialsRotationTemplateSchema
+});
diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts
new file mode 100644
index 000000000..28e9fb29f
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+import { TPostgresConnection } from "@app/services/app-connection/postgres";
+
+import {
+ CreatePostgresCredentialsRotationSchema,
+ PostgresCredentialsRotationListItemSchema,
+ PostgresCredentialsRotationSchema
+} from "./postgres-credentials-rotation-schemas";
+
+export type TPostgresCredentialsRotation = z.infer;
+
+export type TPostgresCredentialsRotationInput = z.infer;
+
+export type TPostgresCredentialsRotationListItem = z.infer;
+
+export type TPostgresCredentialsRotationWithConnection = TPostgresCredentialsRotation & {
+ connection: TPostgresConnection;
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts
new file mode 100644
index 000000000..45809aae5
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts
@@ -0,0 +1,465 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { TSecretRotationsV2 } from "@app/db/schemas/secret-rotations-v2";
+import { DatabaseError } from "@app/lib/errors";
+import {
+ buildFindFilter,
+ ormify,
+ prependTableNameToFindFilter,
+ selectAllTableCols,
+ sqlNestRelationships,
+ TFindOpt
+} from "@app/lib/knex";
+import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
+
+export type TSecretRotationV2DALFactory = ReturnType;
+
+type TSecretRotationFindFilter = Parameters>[0];
+type TSecretRotationFindOptions = TFindOpt;
+
+const baseSecretRotationV2Query = ({
+ filter = {},
+ options,
+ db,
+ tx
+}: {
+ db: TDbClient;
+ filter?: { projectId?: string } & TSecretRotationFindFilter;
+ options?: TSecretRotationFindOptions;
+ tx?: Knex;
+}) => {
+ const { projectId, ...filters } = filter;
+
+ const query = (tx || db.replicaNode())(TableName.SecretRotationV2)
+ .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`)
+ .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
+ .join(TableName.AppConnection, `${TableName.SecretRotationV2}.connectionId`, `${TableName.AppConnection}.id`)
+ .select(selectAllTableCols(TableName.SecretRotationV2))
+ .select(
+ // environment
+ db.ref("name").withSchema(TableName.Environment).as("envName"),
+ db.ref("id").withSchema(TableName.Environment).as("envId"),
+ db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
+ db.ref("projectId").withSchema(TableName.Environment),
+ // entire connection
+ db.ref("name").withSchema(TableName.AppConnection).as("connectionName"),
+ db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"),
+ db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"),
+ db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"),
+ db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"),
+ db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
+ db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
+ db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
+ db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
+ db
+ .ref("isPlatformManagedCredentials")
+ .withSchema(TableName.AppConnection)
+ .as("connectionIsPlatformManagedCredentials")
+ );
+
+ if (filter) {
+ /* eslint-disable @typescript-eslint/no-misused-promises */
+ void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filters)));
+ }
+
+ if (projectId) {
+ void query.where(`${TableName.Environment}.projectId`, projectId);
+ }
+
+ if (options) {
+ const { offset, limit, sort, count, countDistinct } = options;
+ if (countDistinct) {
+ void query.countDistinct(countDistinct);
+ } else if (count) {
+ void query.select(db.raw("COUNT(*) OVER() AS count"));
+ void query.select("*");
+ }
+ if (limit) void query.limit(limit);
+ if (offset) void query.offset(offset);
+ if (sort) {
+ void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
+ }
+ }
+
+ return query;
+};
+
+const expandSecretRotation = >[number]>(
+ secretRotation: T,
+ folder: Awaited>[number]
+) => {
+ const {
+ envId,
+ envName,
+ envSlug,
+ connectionApp,
+ connectionName,
+ connectionId,
+ connectionOrgId,
+ connectionEncryptedCredentials,
+ connectionMethod,
+ connectionDescription,
+ connectionCreatedAt,
+ connectionUpdatedAt,
+ connectionVersion,
+ connectionIsPlatformManagedCredentials,
+ ...el
+ } = secretRotation;
+
+ return {
+ ...el,
+ connectionId,
+ environment: { id: envId, name: envName, slug: envSlug },
+ connection: {
+ app: connectionApp,
+ id: connectionId,
+ name: connectionName,
+ orgId: connectionOrgId,
+ encryptedCredentials: connectionEncryptedCredentials,
+ method: connectionMethod,
+ description: connectionDescription,
+ createdAt: connectionCreatedAt,
+ updatedAt: connectionUpdatedAt,
+ version: connectionVersion,
+ isPlatformManagedCredentials: connectionIsPlatformManagedCredentials
+ },
+ folder: {
+ id: folder!.id,
+ path: folder!.path
+ }
+ };
+};
+
+export const secretRotationV2DALFactory = (
+ db: TDbClient,
+ folderDAL: Pick
+) => {
+ const secretRotationV2Orm = ormify(db, TableName.SecretRotationV2);
+ const secretRotationV2SecretMappingOrm = ormify(db, TableName.SecretRotationV2SecretMapping);
+
+ const find = async (
+ filter: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string },
+ options?: TSecretRotationFindOptions,
+ tx?: Knex
+ ) => {
+ try {
+ const secretRotations = await baseSecretRotationV2Query({ filter, db, tx, options });
+
+ if (!secretRotations.length) return [];
+
+ const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
+ filter.projectId,
+ secretRotations.map((rotation) => rotation.folderId),
+ tx
+ );
+
+ const folderRecord: Record = {};
+
+ foldersWithPath.forEach((folder) => {
+ if (folder) folderRecord[folder.id] = folder;
+ });
+
+ return secretRotations.map((rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]));
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find - Secret Rotation V2" });
+ }
+ };
+
+ const findWithMappedSecretsCount = async (
+ {
+ search,
+ projectId,
+ ...filter
+ }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string },
+ tx?: Knex
+ ) => {
+ const query = (tx || db.replicaNode())(TableName.SecretRotationV2)
+ .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`)
+ .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
+ .join(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretRotationV2SecretMapping}.rotationId`,
+ `${TableName.SecretRotationV2}.id`
+ )
+ .join(TableName.SecretV2, `${TableName.SecretRotationV2SecretMapping}.secretId`, `${TableName.SecretV2}.id`)
+ .where(`${TableName.Environment}.projectId`, projectId)
+ .where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filter)))
+ .countDistinct(`${TableName.SecretRotationV2}.name`);
+
+ if (search) {
+ void query
+ .whereILike(`${TableName.SecretV2}.key`, `%${search}%`)
+ .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`);
+ }
+
+ const result = await query;
+
+ // @ts-expect-error knex infers wrong type...
+ return Number(result[0]?.count ?? 0);
+ };
+
+ const findWithMappedSecrets = async (
+ { search, ...filter }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string },
+ options?: TSecretRotationFindOptions,
+ tx?: Knex
+ ) => {
+ try {
+ const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options })
+ .join(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretRotationV2SecretMapping}.rotationId`,
+ `${TableName.SecretRotationV2}.id`
+ )
+ .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId`)
+ .leftJoin(
+ TableName.SecretV2JnTag,
+ `${TableName.SecretV2}.id`,
+ `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
+ )
+ .leftJoin(
+ TableName.SecretTag,
+ `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
+ `${TableName.SecretTag}.id`
+ )
+ .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
+ .select(
+ db.ref("id").withSchema(TableName.SecretV2).as("secretId"),
+ db.ref("key").withSchema(TableName.SecretV2).as("secretKey"),
+ db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"),
+ db.ref("type").withSchema(TableName.SecretV2).as("secretType"),
+ db.ref("encryptedValue").withSchema(TableName.SecretV2).as("secretEncryptedValue"),
+ db.ref("encryptedComment").withSchema(TableName.SecretV2).as("secretEncryptedComment"),
+ db.ref("reminderNote").withSchema(TableName.SecretV2).as("secretReminderNote"),
+ db.ref("reminderRepeatDays").withSchema(TableName.SecretV2).as("secretReminderRepeatDays"),
+ db.ref("skipMultilineEncoding").withSchema(TableName.SecretV2).as("secretSkipMultilineEncoding"),
+ db.ref("metadata").withSchema(TableName.SecretV2).as("secretMetadata"),
+ db.ref("userId").withSchema(TableName.SecretV2).as("secretUserId"),
+ db.ref("folderId").withSchema(TableName.SecretV2).as("secretFolderId"),
+ db.ref("createdAt").withSchema(TableName.SecretV2).as("secretCreatedAt"),
+ db.ref("updatedAt").withSchema(TableName.SecretV2).as("secretUpdatedAt"),
+ db.ref("id").withSchema(TableName.SecretTag).as("tagId"),
+ db.ref("color").withSchema(TableName.SecretTag).as("tagColor"),
+ db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"),
+ db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
+ db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
+ db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
+ );
+
+ if (search) {
+ void extendedQuery.where((query) => {
+ void query
+ .whereILike(`${TableName.SecretV2}.key`, `%${search}%`)
+ .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`);
+ });
+ }
+
+ const secretRotations = await extendedQuery;
+
+ if (!secretRotations.length) return [];
+
+ const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
+ filter.projectId,
+ secretRotations.map((rotation) => rotation.folderId),
+ tx
+ );
+
+ const folderRecord: Record = {};
+
+ foldersWithPath.forEach((folder) => {
+ if (folder) folderRecord[folder.id] = folder;
+ });
+
+ return sqlNestRelationships({
+ data: secretRotations,
+ key: "id",
+ parentMapper: (rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]),
+ childrenMapper: [
+ {
+ key: "secretId",
+ label: "secrets" as const,
+ mapper: ({
+ secretId,
+ secretKey,
+ secretVersion,
+ secretType,
+ secretEncryptedValue,
+ secretEncryptedComment,
+ secretReminderNote,
+ secretReminderRepeatDays,
+ secretSkipMultilineEncoding,
+ secretMetadata,
+ secretUserId,
+ secretFolderId,
+ secretCreatedAt,
+ secretUpdatedAt,
+ id
+ }) => ({
+ id: secretId,
+ key: secretKey,
+ version: secretVersion,
+ type: secretType,
+ encryptedValue: secretEncryptedValue,
+ encryptedComment: secretEncryptedComment,
+ reminderNote: secretReminderNote,
+ reminderRepeatDays: secretReminderRepeatDays,
+ skipMultilineEncoding: secretSkipMultilineEncoding,
+ metadata: secretMetadata,
+ userId: secretUserId,
+ folderId: secretFolderId,
+ createdAt: secretCreatedAt,
+ updatedAt: secretUpdatedAt,
+ rotationId: id,
+ isRotatedSecret: true
+ }),
+ childrenMapper: [
+ {
+ key: "tagId",
+ label: "tags" as const,
+ mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
+ id,
+ color,
+ slug,
+ name: slug
+ })
+ },
+ {
+ key: "metadataId",
+ label: "secretMetadata" as const,
+ mapper: ({ metadataKey, metadataValue, metadataId }) => ({
+ id: metadataId,
+ key: metadataKey,
+ value: metadataValue
+ })
+ }
+ ]
+ }
+ ]
+ });
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find with Mapped Secrets - Secret Rotation V2" });
+ }
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const secretRotation = await baseSecretRotationV2Query({
+ filter: { id },
+ db,
+ tx
+ }).first();
+
+ if (secretRotation) {
+ const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
+ secretRotation.projectId,
+ [secretRotation.folderId],
+ tx
+ );
+ return expandSecretRotation(secretRotation, folderWithPath);
+ }
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find by ID - Secret Rotation V2" });
+ }
+ };
+
+ const create = async (data: Parameters<(typeof secretRotationV2Orm)["create"]>[0], tx?: Knex) => {
+ const rotation = await secretRotationV2Orm.create(data, tx);
+
+ const secretRotation = (await baseSecretRotationV2Query({
+ filter: { id: rotation.id },
+ db,
+ tx
+ }).first())!;
+
+ const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
+ secretRotation.projectId,
+ [secretRotation.folderId],
+ tx
+ );
+
+ return expandSecretRotation(secretRotation, folderWithPath);
+ };
+
+ const updateById = async (
+ rotationId: string,
+ data: Parameters<(typeof secretRotationV2Orm)["updateById"]>[1],
+ tx?: Knex
+ ) => {
+ const rotation = await secretRotationV2Orm.updateById(rotationId, data, tx);
+
+ const secretRotation = (await baseSecretRotationV2Query({
+ filter: { id: rotation.id },
+ db,
+ tx
+ }).first())!;
+
+ const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
+ secretRotation.projectId,
+ [secretRotation.folderId],
+ tx
+ );
+
+ return expandSecretRotation(secretRotation, folderWithPath);
+ };
+
+ const deleteById = async (rotationId: string, tx?: Knex) => {
+ const secretRotation = (await baseSecretRotationV2Query({
+ filter: { id: rotationId },
+ db,
+ tx
+ }).first())!;
+
+ await secretRotationV2Orm.deleteById(rotationId, tx);
+
+ const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
+ secretRotation.projectId,
+ [secretRotation.folderId],
+ tx
+ );
+
+ return expandSecretRotation(secretRotation, folderWithPath);
+ };
+
+ const findOne = async (filter: Parameters<(typeof secretRotationV2Orm)["findOne"]>[0], tx?: Knex) => {
+ try {
+ const secretRotation = await baseSecretRotationV2Query({ filter, db, tx }).first();
+
+ if (secretRotation) {
+ const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
+ secretRotation.projectId,
+ [secretRotation.folderId],
+ tx
+ );
+
+ return expandSecretRotation(secretRotation, folderWithPath);
+ }
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find One - Secret Rotation V2" });
+ }
+ };
+
+ const findSecretRotationsToQueue = async (rotateBy: Date, tx?: Knex) => {
+ const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2)
+ .where(`${TableName.SecretRotationV2}.isAutoRotationEnabled`, true)
+ .whereNotNull(`${TableName.SecretRotationV2}.nextRotationAt`)
+ .andWhereRaw(`"nextRotationAt" <= ?`, [rotateBy])
+ .select(selectAllTableCols(TableName.SecretRotationV2));
+
+ return secretRotations;
+ };
+
+ return {
+ ...secretRotationV2Orm,
+ find,
+ create,
+ findById,
+ updateById,
+ deleteById,
+ findOne,
+ insertSecretMappings: secretRotationV2SecretMappingOrm.insertMany,
+ findWithMappedSecrets,
+ findWithMappedSecretsCount,
+ findSecretRotationsToQueue
+ };
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts
new file mode 100644
index 000000000..178a12516
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts
@@ -0,0 +1,9 @@
+export enum SecretRotation {
+ PostgresCredentials = "postgres-credentials",
+ MsSqlCredentials = "mssql-credentials"
+}
+
+export enum SecretRotationStatus {
+ Success = "success",
+ Failed = "failed"
+}
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts
new file mode 100644
index 000000000..20709e05a
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts
@@ -0,0 +1,222 @@
+import { AxiosError } from "axios";
+
+import { getConfig } from "@app/lib/config/env";
+import { KmsDataKey } from "@app/services/kms/kms-types";
+
+import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
+import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
+import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
+import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
+import {
+ TSecretRotationV2,
+ TSecretRotationV2GeneratedCredentials,
+ TSecretRotationV2ListItem,
+ TSecretRotationV2Raw
+} from "./secret-rotation-v2-types";
+
+const SECRET_ROTATION_LIST_OPTIONS: Record = {
+ [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION,
+ [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION
+};
+
+export const listSecretRotationOptions = () => {
+ return Object.values(SECRET_ROTATION_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
+};
+
+const getNextUTCMidnight = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => {
+ const now = new Date();
+
+ return new Date(
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate() + 1, // Add 1 day to get tomorrow
+ hours,
+ minutes,
+ 0,
+ 0
+ )
+ );
+};
+
+const getNextUTCMinute = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => {
+ const now = new Date();
+ return new Date(
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ now.getUTCHours(),
+ now.getUTCMinutes() + 1, // Add 1 minute to get the next minute
+ minutes, // use minutes as seconds in dev
+ 0
+ )
+ );
+};
+
+export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rotateAtUtc"]) => {
+ const appCfg = getConfig();
+
+ if (appCfg.isRotationDevelopmentMode) {
+ return getNextUTCMinute(rotateAtUtc);
+ }
+
+ return getNextUTCMidnight(rotateAtUtc);
+};
+
+export const encryptSecretRotationCredentials = async ({
+ projectId,
+ generatedCredentials,
+ kmsService
+}: {
+ projectId: string;
+ generatedCredentials: TSecretRotationV2GeneratedCredentials;
+ kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"];
+}) => {
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
+ plainText: Buffer.from(JSON.stringify(generatedCredentials))
+ });
+
+ return encryptedCredentialsBlob;
+};
+
+export const decryptSecretRotationCredentials = async ({
+ projectId,
+ encryptedGeneratedCredentials,
+ kmsService
+}: {
+ projectId: string;
+ encryptedGeneratedCredentials: Buffer;
+ kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"];
+}) => {
+ const { decryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ const decryptedPlainTextBlob = decryptor({
+ cipherTextBlob: encryptedGeneratedCredentials
+ });
+
+ return JSON.parse(decryptedPlainTextBlob.toString()) as TSecretRotationV2GeneratedCredentials;
+};
+
+export const getSecretRotationRotateSecretJobOptions = ({
+ id,
+ nextRotationAt
+}: Pick) => {
+ const appCfg = getConfig();
+
+ return {
+ jobId: `secret-rotation-v2-rotate-${id}`,
+ retryLimit: appCfg.isRotationDevelopmentMode ? 3 : 5,
+ retryBackoff: true,
+ startAfter: nextRotationAt ?? undefined
+ };
+};
+
+export const calculateNextRotationAt = ({
+ rotateAtUtc,
+ isAutoRotationEnabled,
+ rotationInterval,
+ rotationStatus,
+ isManualRotation,
+ ...params
+}: Pick<
+ TSecretRotationV2,
+ "isAutoRotationEnabled" | "lastRotatedAt" | "rotateAtUtc" | "rotationInterval" | "rotationStatus"
+> & { isManualRotation: boolean }) => {
+ if (!isAutoRotationEnabled) return null;
+
+ if (rotationStatus === SecretRotationStatus.Failed) {
+ return getNextUtcRotationInterval(rotateAtUtc);
+ }
+
+ const lastRotatedAt = new Date(params.lastRotatedAt);
+
+ const appCfg = getConfig();
+
+ if (appCfg.isRotationDevelopmentMode) {
+ // treat interval as minute
+ const nextRotation = new Date(lastRotatedAt.getTime() + rotationInterval * 60 * 1000);
+
+ // in development mode we use rotateAtUtc.minutes as seconds
+ nextRotation.setUTCSeconds(rotateAtUtc.minutes);
+ nextRotation.setUTCMilliseconds(0);
+
+ // If creation/manual rotation seconds are after the configured seconds we pad an additional minute
+ // to ensure a full interval has elapsed before rotation
+ if (isManualRotation && lastRotatedAt.getUTCSeconds() >= rotateAtUtc.minutes) {
+ nextRotation.setUTCMinutes(nextRotation.getUTCMinutes() + 1);
+ }
+
+ return nextRotation;
+ }
+
+ // production mode - rotationInterval = days
+
+ const nextRotation = new Date(lastRotatedAt);
+
+ nextRotation.setUTCHours(rotateAtUtc.hours);
+ nextRotation.setUTCMinutes(rotateAtUtc.minutes);
+ nextRotation.setUTCSeconds(0);
+ nextRotation.setUTCMilliseconds(0);
+
+ // If creation/manual rotation was after the daily rotation time,
+ // we need pad an additional day to ensure full rotation interval
+ if (
+ isManualRotation &&
+ (lastRotatedAt.getUTCHours() > rotateAtUtc.hours ||
+ (lastRotatedAt.getUTCHours() === rotateAtUtc.hours && lastRotatedAt.getUTCMinutes() >= rotateAtUtc.minutes))
+ ) {
+ nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval + 1);
+ } else {
+ nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval);
+ }
+
+ return nextRotation;
+};
+
+export const expandSecretRotation = async (
+ { encryptedLastRotationMessage, ...secretRotation }: TSecretRotationV2Raw,
+ kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]
+) => {
+ const { decryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId: secretRotation.projectId
+ });
+
+ const lastRotationMessage = encryptedLastRotationMessage
+ ? decryptor({
+ cipherTextBlob: encryptedLastRotationMessage
+ }).toString()
+ : null;
+
+ return {
+ ...secretRotation,
+ lastRotationMessage
+ } as TSecretRotationV2;
+};
+
+const MAX_MESSAGE_LENGTH = 1024;
+
+export const parseRotationErrorMessage = (err: unknown): string => {
+ let errorMessage = `Infisical encountered an issue while generating credentials with the configured inputs: `;
+
+ if (err instanceof AxiosError) {
+ errorMessage += err?.response?.data
+ ? JSON.stringify(err?.response?.data)
+ : err?.message ?? "An unknown error occurred.";
+ } else {
+ errorMessage += (err as Error)?.message || "An unknown error occurred.";
+ }
+
+ return errorMessage.length <= MAX_MESSAGE_LENGTH
+ ? errorMessage
+ : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts
new file mode 100644
index 000000000..c0d59332b
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts
@@ -0,0 +1,12 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const SECRET_ROTATION_NAME_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials",
+ [SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials"
+};
+
+export const SECRET_ROTATION_CONNECTION_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: AppConnection.Postgres,
+ [SecretRotation.MsSqlCredentials]: AppConnection.MsSql
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts
new file mode 100644
index 000000000..51946bfa7
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts
@@ -0,0 +1,193 @@
+import { ProjectMembershipRole } from "@app/db/schemas";
+import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ getNextUtcRotationInterval,
+ getSecretRotationRotateSecretJobOptions
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
+import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
+import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
+import {
+ TSecretRotationRotateSecretsJobPayload,
+ TSecretRotationSendNotificationJobPayload
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { getConfig } from "@app/lib/config/env";
+import { logger } from "@app/lib/logger";
+import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
+import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
+
+type TSecretRotationV2QueueServiceFactoryDep = {
+ queueService: TQueueServiceFactory;
+ secretRotationV2DAL: Pick;
+ secretRotationV2Service: Pick;
+ smtpService: Pick;
+ projectMembershipDAL: Pick;
+ projectDAL: Pick;
+};
+
+export const secretRotationV2QueueServiceFactory = async ({
+ queueService,
+ secretRotationV2DAL,
+ secretRotationV2Service,
+ projectMembershipDAL,
+ projectDAL,
+ smtpService
+}: TSecretRotationV2QueueServiceFactoryDep) => {
+ const appCfg = getConfig();
+
+ if (appCfg.isRotationDevelopmentMode) {
+ logger.warn("Secret Rotation V2 is in development mode.");
+ }
+
+ await queueService.startPg(
+ QueueJobs.SecretRotationV2QueueRotations,
+ async () => {
+ try {
+ const rotateBy = getNextUtcRotationInterval();
+
+ const currentTime = new Date();
+
+ const secretRotations = await secretRotationV2DAL.findSecretRotationsToQueue(rotateBy);
+
+ logger.info(
+ `secretRotationV2Queue: Queue Rotations [currentTime=${currentTime.toISOString()}] [rotateBy=${rotateBy.toISOString()}] [count=${
+ secretRotations.length
+ }]`
+ );
+
+ for await (const rotation of secretRotations) {
+ logger.info(
+ `secretRotationV2Queue: Queue Rotation [rotationId=${rotation.id}] [lastRotatedAt=${new Date(
+ rotation.lastRotatedAt
+ ).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
+ );
+ await queueService.queuePg(
+ QueueJobs.SecretRotationV2RotateSecrets,
+ {
+ rotationId: rotation.id,
+ queuedAt: currentTime
+ },
+ getSecretRotationRotateSecretJobOptions(rotation)
+ );
+ }
+ } catch (error) {
+ logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
+ throw error;
+ }
+ },
+ {
+ batchSize: 1,
+ workerCount: 1,
+ pollingIntervalSeconds: 0.5
+ }
+ );
+
+ await queueService.startPg(
+ QueueJobs.SecretRotationV2RotateSecrets,
+ async ([job]) => {
+ const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload;
+ const { retryCount, retryLimit } = job;
+
+ const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
+
+ try {
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
+
+ if (!secretRotation.isAutoRotationEnabled) {
+ logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
+ }
+
+ if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
+ // rotated since being queued, skip rotation
+ logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
+ return;
+ }
+
+ await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
+ jobId: job.id,
+ shouldSendNotification: true,
+ isFinalAttempt: retryCount === retryLimit,
+ isManualRotation
+ });
+
+ logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
+ } catch (error) {
+ logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
+ throw error;
+ }
+ },
+ {
+ batchSize: 1,
+ workerCount: 30,
+ pollingIntervalSeconds: 0.5
+ }
+ );
+
+ await queueService.startPg(
+ QueueJobs.SecretRotationV2SendNotification,
+ async ([job]) => {
+ const { secretRotation } = job.data as TSecretRotationSendNotificationJobPayload;
+ try {
+ const {
+ name: rotationName,
+ type,
+ projectId,
+ lastRotationAttemptedAt,
+ folder,
+ environment,
+ id: rotationId
+ } = secretRotation;
+
+ logger.info(`secretRotationV2Queue: Sending Status Notification [rotationId=${rotationId}]`);
+
+ const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
+ const project = await projectDAL.findById(projectId);
+
+ const projectAdmins = projectMembers.filter((member) =>
+ member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
+ );
+
+ const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation];
+
+ await smtpService.sendMail({
+ recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
+ template: SmtpTemplates.SecretRotationFailed,
+ subjectLine: `Secret Rotation Failed`,
+ substitutions: {
+ rotationName,
+ rotationType,
+ content: `Your ${rotationType} Rotation failed to rotate during it's scheduled rotation. The last rotation attempt occurred at ${new Date(
+ lastRotationAttemptedAt
+ ).toISOString()}. Please check the rotation status in Infisical for more details.`,
+ secretPath: folder.path,
+ environment: environment.name,
+ projectName: project.name,
+ rotationUrl: encodeURI(`${appCfg.SITE_URL}/secret-manager/${projectId}/secrets/${environment.slug}`)
+ }
+ });
+ } catch (error) {
+ logger.error(
+ error,
+ `secretRotationV2Queue: Failed to Send Status Notification [rotationId=${secretRotation.id}]`
+ );
+ throw error;
+ }
+ },
+ {
+ batchSize: 1,
+ workerCount: 5,
+ pollingIntervalSeconds: 30
+ }
+ );
+
+ await queueService.schedulePg(
+ QueueJobs.SecretRotationV2QueueRotations,
+ appCfg.isRotationDevelopmentMode ? "* * * * *" : "0 0 * * *",
+ undefined,
+ { tz: "UTC" }
+ );
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts
new file mode 100644
index 000000000..b1be4ea22
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+import { SecretRotationsV2Schema } from "@app/db/schemas/secret-rotations-v2";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
+import { SecretRotations } from "@app/lib/api-docs";
+import { removeTrailingSlash } from "@app/lib/fn";
+import { slugSchema } from "@app/server/lib/schemas";
+
+const RotateAtUtcSchema = z.object({
+ hours: z.number().min(0).max(23),
+ minutes: z.number().min(0).max(59)
+});
+
+export const BaseSecretRotationSchema = (type: SecretRotation) =>
+ SecretRotationsV2Schema.omit({
+ encryptedGeneratedCredentials: true,
+ encryptedLastRotationMessage: true,
+ rotateAtUtc: true,
+ // unique to provider
+ type: true,
+ parameters: true,
+ secretMappings: true
+ }).extend({
+ connection: z.object({
+ app: z.literal(SECRET_ROTATION_CONNECTION_MAP[type]),
+ name: z.string(),
+ id: z.string().uuid()
+ }),
+ environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }),
+ projectId: z.string(),
+ folder: z.object({ id: z.string(), path: z.string() }),
+ rotateAtUtc: RotateAtUtcSchema,
+ lastRotationMessage: z.string().nullish()
+ });
+
+export const BaseCreateSecretRotationSchema = (type: SecretRotation) =>
+ z.object({
+ name: slugSchema({ field: "name" }).describe(SecretRotations.CREATE(type).name),
+ projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.CREATE(type).projectId),
+ description: z
+ .string()
+ .trim()
+ .max(256, "Description cannot exceed 256 characters")
+ .nullish()
+ .describe(SecretRotations.CREATE(type).description),
+ connectionId: z.string().uuid().describe(SecretRotations.CREATE(type).connectionId),
+ environment: slugSchema({ field: "environment", max: 64 }).describe(SecretRotations.CREATE(type).environment),
+ secretPath: z
+ .string()
+ .trim()
+ .min(1, "Secret path required")
+ .transform(removeTrailingSlash)
+ .describe(SecretRotations.CREATE(type).secretPath),
+ isAutoRotationEnabled: z
+ .boolean()
+ .optional()
+ .default(true)
+ .describe(SecretRotations.CREATE(type).isAutoRotationEnabled),
+ rotationInterval: z.coerce.number().min(1).describe(SecretRotations.CREATE(type).rotationInterval),
+ rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.CREATE(type).rotateAtUtc)
+ });
+
+export const BaseUpdateSecretRotationSchema = (type: SecretRotation) =>
+ z.object({
+ name: slugSchema({ field: "name" }).describe(SecretRotations.UPDATE(type).name).optional(),
+ description: z
+ .string()
+ .trim()
+ .max(256, "Description cannot exceed 256 characters")
+ .nullish()
+ .describe(SecretRotations.UPDATE(type).description),
+ isAutoRotationEnabled: z.boolean().optional().describe(SecretRotations.UPDATE(type).isAutoRotationEnabled),
+ rotationInterval: z.coerce.number().min(1).optional().describe(SecretRotations.UPDATE(type).rotationInterval),
+ rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.UPDATE(type).rotateAtUtc)
+ });
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts
new file mode 100644
index 000000000..aecf8242a
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts
@@ -0,0 +1,1230 @@
+import { ForbiddenError, subject } from "@casl/ability";
+import isEqual from "lodash.isequal";
+
+import { ActionProjectType, SecretType, TableName } from "@app/db/schemas";
+import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
+import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
+import {
+ ProjectPermissionSecretActions,
+ ProjectPermissionSecretRotationActions,
+ ProjectPermissionSub
+} from "@app/ee/services/permission/project-permission";
+import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ calculateNextRotationAt,
+ decryptSecretRotationCredentials,
+ encryptSecretRotationCredentials,
+ expandSecretRotation,
+ getNextUtcRotationInterval,
+ getSecretRotationRotateSecretJobOptions,
+ listSecretRotationOptions,
+ parseRotationErrorMessage
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
+import {
+ SECRET_ROTATION_CONNECTION_MAP,
+ SECRET_ROTATION_NAME_MAP
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
+import {
+ TCreateSecretRotationV2DTO,
+ TDeleteSecretRotationV2DTO,
+ TFindSecretRotationV2ByIdDTO,
+ TFindSecretRotationV2ByNameDTO,
+ TGetDashboardSecretRotationsV2,
+ TGetDashboardSecretRotationV2Count,
+ TListSecretRotationsV2ByProjectId,
+ TQuickSearchSecretRotationsV2,
+ TRotateSecretRotationV2,
+ TRotationFactory,
+ TSecretRotationRotateGeneratedCredentials,
+ TSecretRotationV2,
+ TSecretRotationV2Raw,
+ TSecretRotationV2WithConnection,
+ TUpdateSecretRotationV2DTO
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { sqlCredentialsRotationFactory } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
+import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
+import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
+import { getConfig } from "@app/lib/config/env";
+import { DatabaseErrorCode } from "@app/lib/error-codes";
+import { BadRequestError, DatabaseError, InternalServerError, NotFoundError } from "@app/lib/errors";
+import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
+import { QueueJobs, TQueueServiceFactory } from "@app/queue";
+import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns";
+import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
+import { ActorType } from "@app/services/auth/auth-type";
+import { TKmsServiceFactory } from "@app/services/kms/kms-service";
+import { KmsDataKey } from "@app/services/kms/kms-types";
+import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
+import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
+import { TSecretQueueFactory } from "@app/services/secret/secret-queue";
+import { SecretsOrderBy } from "@app/services/secret/secret-types";
+import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
+import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
+import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
+import {
+ fnSecretBulkDelete,
+ fnSecretBulkInsert,
+ fnSecretBulkUpdate,
+ reshapeBridgeSecret
+} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
+import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
+import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
+
+import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
+
+export type TSecretRotationV2ServiceFactoryDep = {
+ secretRotationV2DAL: TSecretRotationV2DALFactory;
+ appConnectionService: Pick;
+ permissionService: Pick;
+ projectBotService: Pick;
+ kmsService: Pick;
+ licenseService: Pick;
+ auditLogService: Pick;
+ keyStore: Pick;
+ folderDAL: Pick;
+ secretV2BridgeDAL: Pick<
+ TSecretV2BridgeDALFactory,
+ "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find"
+ >;
+ secretVersionV2BridgeDAL: Pick;
+ secretVersionTagV2BridgeDAL: Pick;
+ resourceMetadataDAL: Pick;
+ secretTagDAL: Pick;
+ secretQueueService: Pick;
+ snapshotService: Pick;
+ queueService: Pick;
+};
+
+export type TSecretRotationV2ServiceFactory = ReturnType;
+
+const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
+
+const SECRET_ROTATION_FACTORY_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory,
+ [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory
+};
+
+export const secretRotationV2ServiceFactory = ({
+ secretRotationV2DAL,
+ folderDAL,
+ secretV2BridgeDAL,
+ secretVersionV2BridgeDAL,
+ secretVersionTagV2BridgeDAL,
+ secretTagDAL,
+ resourceMetadataDAL,
+ permissionService,
+ appConnectionService,
+ projectBotService,
+ licenseService,
+ kmsService,
+ auditLogService,
+ secretQueueService,
+ snapshotService,
+ keyStore,
+ queueService
+}: TSecretRotationV2ServiceFactoryDep) => {
+ const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => {
+ const appCfg = getConfig();
+ if (!appCfg.isSmtpConfigured) return; // comment out if testing email sending
+
+ await queueService.queuePg(
+ QueueJobs.SecretRotationV2SendNotification,
+ { secretRotation },
+ {
+ jobId: `secret-rotation-v2-notification-${secretRotation.id}`,
+ retryLimit: 5,
+ retryBackoff: true
+ }
+ );
+ };
+
+ const listSecretRotationsByProjectId = async (
+ { projectId, type }: TListSecretRotationsV2ByProjectId,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to access secret rotations due to plan restriction. Upgrade plan to access secret rotations."
+ });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Read,
+ ProjectPermissionSub.SecretRotation
+ );
+
+ const secretRotations = await secretRotationV2DAL.find({
+ ...(type && { type }),
+ projectId
+ });
+
+ return Promise.all(
+ secretRotations
+ .filter((rotation) =>
+ permission.can(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: rotation.environment.slug,
+ secretPath: rotation.folder.path
+ })
+ )
+ )
+ .map((rotation) => expandSecretRotation(rotation, kmsService))
+ );
+ };
+
+ const findSecretRotationById = async ({ type, rotationId }: TFindSecretRotationV2ByIdDTO, actor: OrgServiceActor) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations."
+ });
+
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"`
+ });
+
+ const { projectId, environment, folder, connection } = secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: environment.slug,
+ secretPath: folder.path
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ return expandSecretRotation(secretRotation, kmsService);
+ };
+
+ const findSecretRotationGeneratedCredentialsById = async (
+ { type, rotationId }: TFindSecretRotationV2ByIdDTO,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message:
+ "Failed to access secret rotation credentials due to plan restriction. Upgrade plan to access secret rotations credentials."
+ });
+
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"`
+ });
+
+ const { projectId, environment, folder, connection, encryptedGeneratedCredentials } = secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.ReadGeneratedCredentials,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: environment.slug,
+ secretPath: folder.path
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ const generatedCredentials = await decryptSecretRotationCredentials({
+ projectId,
+ encryptedGeneratedCredentials,
+ kmsService
+ });
+
+ return {
+ generatedCredentials,
+ secretRotation: secretRotation as TSecretRotationV2
+ };
+ };
+
+ const findSecretRotationByName = async (
+ { type, rotationName, secretPath, environment, projectId }: TFindSecretRotationV2ByNameDTO,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations."
+ });
+
+ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
+
+ if (!folder)
+ throw new BadRequestError({
+ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
+ });
+
+ // we prevent conflicting names within a folder
+ const secretRotation = await secretRotationV2DAL.findOne({
+ name: rotationName,
+ folderId: folder.id
+ });
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with name "${rotationName}"`
+ });
+
+ const { connection, id: rotationId } = secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment,
+ secretPath
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ return expandSecretRotation(secretRotation, kmsService);
+ };
+
+ const createSecretRotation = async (
+ {
+ projectId,
+ secretPath,
+ environment,
+ rotateAtUtc = { hours: 0, minutes: 0 },
+ ...payload
+ }: TCreateSecretRotationV2DTO,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to create secret rotation due to plan restriction. Upgrade plan to create secret rotations."
+ });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
+
+ if (!shouldUseSecretV2Bridge)
+ throw new BadRequestError({ message: "Project version does not support Secret Rotation V2" });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Create,
+ subject(ProjectPermissionSub.SecretRotation, { environment, secretPath })
+ );
+
+ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
+
+ if (!folder)
+ throw new BadRequestError({
+ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
+ });
+
+ const typeApp = SECRET_ROTATION_CONNECTION_MAP[payload.type];
+
+ // validates permission to connect and app is valid for rotation type
+ const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor);
+
+ const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]({
+ parameters: payload.parameters,
+ secretsMapping: payload.secretsMapping,
+ connection
+ } as TSecretRotationV2WithConnection);
+
+ try {
+ const currentTime = new Date();
+
+ // callback structure to support transactional rollback when possible
+ const secretRotation = await rotationFactory.issueCredentials(async (newCredentials) => {
+ const encryptedGeneratedCredentials = await encryptSecretRotationCredentials({
+ generatedCredentials: [newCredentials],
+ projectId,
+ kmsService
+ });
+
+ return secretRotationV2DAL.transaction(async (tx) => {
+ const createdRotation = await secretRotationV2DAL.create(
+ {
+ folderId: folder.id,
+ ...payload,
+ encryptedGeneratedCredentials,
+ rotateAtUtc,
+ rotationStatus: SecretRotationStatus.Success,
+ lastRotationAttemptedAt: currentTime,
+ lastRotatedAt: currentTime,
+ nextRotationAt: calculateNextRotationAt({
+ lastRotatedAt: currentTime,
+ isAutoRotationEnabled: Boolean(payload.isAutoRotationEnabled),
+ rotateAtUtc,
+ rotationInterval: payload.rotationInterval,
+ rotationStatus: SecretRotationStatus.Success,
+ isManualRotation: true
+ })
+ },
+ tx
+ );
+
+ const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
+
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ const mappedSecrets = await fnSecretBulkInsert({
+ folderId: folder.id,
+ orgId: connection.orgId,
+ tx,
+ inputSecrets: secretsPayload.map(({ key, value }) => ({
+ key,
+ encryptedValue: encryptor({
+ plainText: Buffer.from(value)
+ }).cipherTextBlob,
+ references: []
+ })),
+ secretDAL: secretV2BridgeDAL,
+ secretVersionDAL: secretVersionV2BridgeDAL,
+ secretVersionTagDAL: secretVersionTagV2BridgeDAL,
+ secretTagDAL,
+ resourceMetadataDAL
+ });
+
+ await secretRotationV2DAL.insertSecretMappings(
+ mappedSecrets.map((secret) => ({
+ secretId: secret.id,
+ rotationId: createdRotation.id
+ })),
+ tx
+ );
+
+ return createdRotation;
+ });
+ });
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({
+ orgId: connection.orgId,
+ secretPath,
+ projectId,
+ environmentSlug: environment,
+ excludeReplication: true
+ });
+
+ return await expandSecretRotation(secretRotation, kmsService);
+ } catch (err) {
+ if (err instanceof DatabaseError) {
+ const error = err.error as { code: string; message: string; table: string };
+
+ if (error.code === DatabaseErrorCode.UniqueViolation) {
+ switch (error.table) {
+ case TableName.SecretRotationV2:
+ throw new BadRequestError({
+ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"`
+ });
+ case TableName.SecretV2:
+ throw new BadRequestError({
+ message: `One or more of the following secrets already exists at the secret path "${secretPath}": ${Object.values(
+ payload.secretsMapping
+ ).join(", ")}`
+ });
+ default:
+ throw err;
+ }
+ }
+
+ throw err;
+ }
+
+ throw new BadRequestError({
+ message: parseRotationErrorMessage(err)
+ });
+ }
+ };
+
+ const updateSecretRotation = async (
+ { type, rotationId, ...payload }: TUpdateSecretRotationV2DTO,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations."
+ });
+
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}`
+ });
+
+ const { folder, environment, projectId, folderId, connection, secretsMapping } = secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Edit,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: environment.slug,
+ secretPath: folder.path
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ const nextRotationAt = calculateNextRotationAt({
+ ...(secretRotation as TSecretRotationV2),
+ ...payload,
+ isManualRotation: false
+ });
+
+ try {
+ const updatedSecretRotation = await secretRotationV2DAL.transaction(async (tx) => {
+ if (payload.secretsMapping && !isEqual(payload.secretsMapping, secretsMapping)) {
+ // update mapped secrets names
+ await fnSecretBulkUpdate({
+ folderId,
+ orgId: connection.orgId,
+ tx,
+ inputSecrets: Object.entries(secretsMapping as TSecretRotationV2["secretsMapping"]).map(
+ ([mappingKey, secretKey]) => ({
+ filter: {
+ key: secretKey,
+ folderId,
+ type: SecretType.Shared
+ },
+ data: {
+ key: payload.secretsMapping![mappingKey as keyof TSecretRotationV2["secretsMapping"]]
+ }
+ })
+ ),
+ secretDAL: secretV2BridgeDAL,
+ secretVersionDAL: secretVersionV2BridgeDAL,
+ secretVersionTagDAL: secretVersionTagV2BridgeDAL,
+ secretTagDAL,
+ resourceMetadataDAL
+ });
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({
+ orgId: connection.orgId,
+ secretPath: folder.path,
+ projectId,
+ environmentSlug: environment.slug,
+ excludeReplication: true
+ });
+ }
+
+ return secretRotationV2DAL.updateById(
+ rotationId,
+ {
+ ...payload,
+ nextRotationAt
+ },
+ tx
+ );
+ });
+
+ // queue for rotation if adjusted time falls before next cron
+ if (nextRotationAt && nextRotationAt.getTime() < getNextUtcRotationInterval().getTime()) {
+ await queueService.queuePg(
+ QueueJobs.SecretRotationV2RotateSecrets,
+ { rotationId, queuedAt: new Date(), isManualRotation: true },
+ getSecretRotationRotateSecretJobOptions(updatedSecretRotation)
+ );
+ }
+
+ return await expandSecretRotation(updatedSecretRotation, kmsService);
+ } catch (err) {
+ if (err instanceof DatabaseError) {
+ const error = err.error as { code: string; message: string; table: string };
+
+ if (error.code === DatabaseErrorCode.UniqueViolation) {
+ switch (error.table) {
+ case TableName.SecretRotationV2:
+ if (payload.name)
+ throw new BadRequestError({
+ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${folder.path}"`
+ });
+ break;
+ case TableName.SecretV2:
+ if (payload.secretsMapping)
+ throw new BadRequestError({
+ message: `One or more of the following secrets already exists at the secret path "${
+ folder.path
+ }": ${Object.values(payload.secretsMapping).join(", ")}`
+ });
+ break;
+ default:
+ throw err;
+ }
+ }
+ }
+
+ throw err;
+ }
+ };
+
+ const deleteSecretRotation = async (
+ { type, rotationId, deleteSecrets, revokeGeneratedCredentials }: TDeleteSecretRotationV2DTO,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message: "Failed to delete secret rotation due to plan restriction. Upgrade plan to delete secret rotation."
+ });
+
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"`
+ });
+
+ const { folder, environment, projectId, encryptedGeneratedCredentials, connection, folderId, secretsMapping } =
+ secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Delete,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: environment.slug,
+ secretPath: folder.path
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ const deleteTransaction = secretRotationV2DAL.transaction(async (tx) => {
+ if (deleteSecrets) {
+ await fnSecretBulkDelete({
+ secretDAL: secretV2BridgeDAL,
+ secretQueueService,
+ inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({
+ secretKey,
+ type: SecretType.Shared
+ })),
+ projectId,
+ folderId,
+ actorId: actor.id, // not actually used since rotated secrets are shared
+ tx
+ });
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({
+ orgId: connection.orgId,
+ secretPath: folder.path,
+ projectId,
+ environmentSlug: environment.slug,
+ excludeReplication: true
+ });
+ }
+
+ return secretRotationV2DAL.deleteById(rotationId, tx);
+ });
+
+ if (revokeGeneratedCredentials) {
+ const appConnection = await decryptAppConnection(connection, kmsService);
+
+ const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type]({
+ ...secretRotation,
+ connection: appConnection
+ } as TSecretRotationV2WithConnection);
+
+ const generatedCredentials = await decryptSecretRotationCredentials({
+ encryptedGeneratedCredentials,
+ projectId,
+ kmsService
+ });
+
+ await rotationFactory.revokeCredentials(generatedCredentials, async () => deleteTransaction);
+ } else {
+ await deleteTransaction;
+ }
+
+ return expandSecretRotation(secretRotation, kmsService);
+ };
+
+ const rotateGeneratedCredentials = async (
+ secretRotation: TSecretRotationV2Raw,
+ {
+ auditLogInfo,
+ jobId,
+ shouldSendNotification,
+ isFinalAttempt = true,
+ isManualRotation = false
+ }: TSecretRotationRotateGeneratedCredentials = {}
+ ) => {
+ const {
+ connection,
+ folder,
+ environment,
+ encryptedGeneratedCredentials,
+ activeIndex,
+ projectId,
+ type,
+ folderId,
+ id: rotationId,
+ parameters,
+ secretsMapping
+ } = secretRotation;
+
+ let lock: Awaited> | undefined;
+
+ try {
+ try {
+ lock = await keyStore.acquireLock([KeyStorePrefixes.SecretRotationLock(rotationId)], 60 * 1000);
+ } catch (e) {
+ throw new InternalServerError({
+ message: "Failed to acquire rotation lock."
+ });
+ }
+
+ const appConnection = await decryptAppConnection(connection, kmsService);
+
+ const generatedCredentials = await decryptSecretRotationCredentials({
+ projectId,
+ encryptedGeneratedCredentials,
+ kmsService
+ });
+
+ const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH;
+
+ const inactiveCredentials = generatedCredentials[inactiveIndex];
+
+ const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]({
+ ...secretRotation,
+ connection: appConnection
+ } as TSecretRotationV2WithConnection);
+
+ const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => {
+ const updatedCredentials = [...generatedCredentials];
+ updatedCredentials[inactiveIndex] = newCredentials;
+
+ const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
+ projectId,
+ generatedCredentials: updatedCredentials,
+ kmsService
+ });
+
+ return secretRotationV2DAL.transaction(async (tx) => {
+ const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
+
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ // update mapped secrets with new credential values
+ await fnSecretBulkUpdate({
+ folderId,
+ orgId: connection.orgId,
+ tx,
+ inputSecrets: secretsPayload.map(({ key, value }) => ({
+ filter: {
+ key,
+ folderId,
+ type: SecretType.Shared
+ },
+ data: {
+ encryptedValue: encryptor({
+ plainText: Buffer.from(value)
+ }).cipherTextBlob,
+ references: []
+ }
+ })),
+ secretDAL: secretV2BridgeDAL,
+ secretVersionDAL: secretVersionV2BridgeDAL,
+ secretVersionTagDAL: secretVersionTagV2BridgeDAL,
+ secretTagDAL,
+ resourceMetadataDAL
+ });
+
+ const currentTime = new Date();
+
+ return secretRotationV2DAL.updateById(
+ secretRotation.id,
+ {
+ encryptedGeneratedCredentials: encryptedUpdatedCredentials,
+ activeIndex: inactiveIndex,
+ lastRotatedAt: currentTime,
+ lastRotationAttemptedAt: currentTime,
+ nextRotationAt: calculateNextRotationAt({
+ ...(secretRotation as TSecretRotationV2),
+ rotationStatus: SecretRotationStatus.Success,
+ lastRotatedAt: currentTime,
+ isManualRotation
+ }),
+ rotationStatus: SecretRotationStatus.Success,
+ lastRotationJobId: jobId,
+ encryptedLastRotationMessage: null
+ },
+ tx
+ );
+ });
+ });
+
+ await auditLogService.createAuditLog({
+ ...(auditLogInfo ?? {
+ actor: {
+ type: ActorType.PLATFORM,
+ metadata: {}
+ }
+ }),
+ projectId,
+ event: {
+ type: EventType.SECRET_ROTATION_ROTATE_SECRETS,
+ metadata: {
+ type,
+ rotationId,
+ connectionId: connection.id,
+ folderId,
+ parameters,
+ secretsMapping,
+ status: SecretRotationStatus.Success,
+ occurredAt: new Date(),
+ message: null,
+ jobId
+ }
+ }
+ });
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({
+ orgId: connection.orgId,
+ secretPath: folder.path,
+ projectId,
+ environmentSlug: environment.slug,
+ excludeReplication: true
+ });
+
+ return updatedRotation;
+ } catch (error) {
+ const errorMessage = parseRotationErrorMessage(error);
+
+ if (isFinalAttempt) {
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ const { cipherTextBlob: encryptedMessage } = encryptor({
+ plainText: Buffer.from(errorMessage)
+ });
+
+ const updatedRotation = await secretRotationV2DAL.updateById(secretRotation.id, {
+ rotationStatus: SecretRotationStatus.Failed,
+ lastRotationJobId: jobId,
+ lastRotationAttemptedAt: new Date(),
+ encryptedLastRotationMessage: encryptedMessage,
+ nextRotationAt: getNextUtcRotationInterval(secretRotation.rotateAtUtc as TSecretRotationV2["rotateAtUtc"])
+ });
+
+ if (shouldSendNotification) {
+ await $queueSendSecretRotationStatusNotification(updatedRotation);
+ }
+ }
+
+ await auditLogService.createAuditLog({
+ ...(auditLogInfo ?? {
+ actor: {
+ type: ActorType.PLATFORM,
+ metadata: {}
+ }
+ }),
+ projectId,
+ event: {
+ type: EventType.SECRET_ROTATION_ROTATE_SECRETS,
+ metadata: {
+ type,
+ rotationId,
+ connectionId: connection.id,
+ folderId,
+ parameters,
+ secretsMapping,
+ occurredAt: new Date(),
+ status: SecretRotationStatus.Failed,
+ message: isFinalAttempt ? "See Rotation status for details" : "Rotation will be re-attempted shortly...",
+ jobId
+ }
+ }
+ });
+
+ throw new BadRequestError({ message: errorMessage });
+ } finally {
+ await lock?.release();
+ }
+ };
+
+ const rotateSecretRotation = async (
+ { rotationId, type, auditLogInfo }: TRotateSecretRotationV2,
+ actor: OrgServiceActor
+ ) => {
+ const plan = await licenseService.getPlan(actor.orgId);
+
+ if (!plan.secretRotation)
+ throw new BadRequestError({
+ message:
+ "Failed to rotate secret rotation secrets due to plan restriction. Upgrade plan to rotate secret rotation secrets."
+ });
+
+ const secretRotation = await secretRotationV2DAL.findById(rotationId);
+
+ if (!secretRotation)
+ throw new NotFoundError({
+ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"`
+ });
+
+ const { projectId, environment, folder, connection } = secretRotation;
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.RotateSecrets,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: environment.slug,
+ secretPath: folder.path
+ })
+ );
+
+ if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type])
+ throw new BadRequestError({
+ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}`
+ });
+
+ const isRotationOccurring = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretRotationLock(secretRotation.id)));
+
+ if (isRotationOccurring)
+ throw new BadRequestError({ message: `A rotation is already in progress. Please try again shortly.` });
+
+ try {
+ const updatedRotation = await rotateGeneratedCredentials(secretRotation, {
+ auditLogInfo,
+ isManualRotation: true
+ });
+
+ return await expandSecretRotation(updatedRotation, kmsService);
+ } catch (err) {
+ throw new InternalServerError({
+ message: (err as Error).message ?? "Failed to rotate secrets: check Rotation status for details."
+ });
+ }
+ };
+
+ const getDashboardSecretRotationCount = async (
+ { projectId, environments, secretPath, search }: TGetDashboardSecretRotationV2Count,
+ actor: OrgServiceActor
+ ) => {
+ // we don't check plan for dashboard like dynamic secret, actions will be prevented
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ const permissiveEnvironments = environments.filter((environment) =>
+ permission.can(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, { environment, secretPath })
+ )
+ );
+
+ if (!permissiveEnvironments.length) return 0;
+
+ const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath);
+
+ if (!folders.length) {
+ throw new NotFoundError({
+ message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join(
+ ", "
+ )}' not found`
+ });
+ }
+
+ const count = await secretRotationV2DAL.findWithMappedSecretsCount({
+ $in: { folderId: folders.map((folder) => folder.id) },
+ search,
+ projectId
+ });
+
+ return count;
+ };
+
+ const getDashboardSecretRotations = async (
+ {
+ projectId,
+ environments,
+ secretPath,
+ search,
+ limit,
+ offset = 0,
+ orderBy = SecretsOrderBy.Name,
+ orderDirection = OrderByDirection.ASC
+ }: TGetDashboardSecretRotationsV2,
+ actor: OrgServiceActor
+ ) => {
+ // we don't check plan for dashboard like dynamic secret, actions will be prevented
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager,
+ projectId
+ });
+
+ const permissiveEnvironments = environments.filter((environment) =>
+ permission.can(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, { environment, secretPath })
+ )
+ );
+
+ if (!permissiveEnvironments.length) return [];
+
+ const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath);
+
+ if (!folders.length) {
+ throw new NotFoundError({
+ message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join(
+ ", "
+ )}' not found`
+ });
+ }
+
+ const folderIds = folders.map((folder) => folder.id);
+
+ const secretRotations = await secretRotationV2DAL.findWithMappedSecrets(
+ {
+ $in: { folderId: folderIds },
+ search,
+ projectId
+ },
+ {
+ limit,
+ offset,
+ sort: orderBy ? [[orderBy, orderDirection]] : undefined
+ }
+ );
+
+ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+
+ const secretRotationsWithSecrets = await Promise.all(
+ secretRotations.map(async ({ secrets, ...rotation }) => {
+ const decryptedSecrets = secrets.map((secret) => {
+ const canDescribeSecret = hasSecretReadValueOrDescribePermission(
+ permission,
+ ProjectPermissionSecretActions.DescribeSecret,
+ {
+ environment: rotation.environment.slug,
+ secretPath: rotation.folder.path,
+ secretName: secret.key,
+ // TODO: scott/akhil our mapper seems to not propagate children's children types
+ // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment
+ secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug)
+ }
+ );
+
+ if (!canDescribeSecret) {
+ return null; // return null so we know to display empty row in dashboard
+ }
+
+ const secretValueHidden = !hasSecretReadValueOrDescribePermission(
+ permission,
+ ProjectPermissionSecretActions.ReadValue,
+ {
+ environment: rotation.environment.slug,
+ secretPath: rotation.folder.path,
+ secretName: secret.key,
+ // TODO: scott/akhil our mapper seems to not propagate children's children types
+ // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment
+ secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug)
+ }
+ );
+
+ return reshapeBridgeSecret(
+ projectId,
+ rotation.environment.slug,
+ rotation.folder.path,
+ {
+ ...secret,
+ value: secret.encryptedValue
+ ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
+ : "",
+ comment: secret.encryptedComment
+ ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
+ : ""
+ },
+ secretValueHidden && secret.type === SecretType.Shared
+ );
+ });
+
+ const expandedRotation = await expandSecretRotation(rotation, kmsService);
+
+ return {
+ ...expandedRotation,
+ secrets: decryptedSecrets
+ };
+ })
+ );
+
+ return secretRotationsWithSecrets as (TSecretRotationV2 & {
+ secrets: Awaited>[];
+ })[];
+ };
+
+ const getQuickSearchSecretRotations = async (
+ { folderMappings, filters: { search, ...options }, projectId }: TQuickSearchSecretRotationsV2,
+ actor: OrgServiceActor
+ ) => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor: actor.type,
+ actorId: actor.id,
+ projectId,
+ actorAuthMethod: actor.authMethod,
+ actorOrgId: actor.orgId,
+ actionProjectType: ActionProjectType.SecretManager
+ });
+
+ const permissiveFolderMappings = folderMappings.filter(({ path, environment }) =>
+ permission.can(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, { environment, secretPath: path })
+ )
+ );
+
+ if (!permissiveFolderMappings.length) return [];
+
+ const secretRotations = await secretRotationV2DAL.find(
+ {
+ projectId,
+ $search: {
+ name: `%${search}%`
+ },
+ $in: {
+ folderId: permissiveFolderMappings.map(({ folderId }) => folderId)
+ }
+ },
+ options
+ );
+
+ return secretRotations as TSecretRotationV2[];
+ };
+
+ return {
+ listSecretRotationOptions,
+ listSecretRotationsByProjectId,
+ createSecretRotation,
+ updateSecretRotation,
+ findSecretRotationById,
+ findSecretRotationByName,
+ deleteSecretRotation,
+ findSecretRotationGeneratedCredentialsById,
+ rotateSecretRotation,
+ rotateGeneratedCredentials,
+ getDashboardSecretRotationCount,
+ getDashboardSecretRotations,
+ getQuickSearchSecretRotations
+ };
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts
new file mode 100644
index 000000000..7102f7de3
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts
@@ -0,0 +1,155 @@
+import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
+import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
+import { OrderByDirection } from "@app/lib/types";
+import { SecretsOrderBy } from "@app/services/secret/secret-types";
+
+import {
+ TMsSqlCredentialsRotation,
+ TMsSqlCredentialsRotationInput,
+ TMsSqlCredentialsRotationListItem,
+ TMsSqlCredentialsRotationWithConnection
+} from "./mssql-credentials";
+import {
+ TPostgresCredentialsRotation,
+ TPostgresCredentialsRotationInput,
+ TPostgresCredentialsRotationListItem,
+ TPostgresCredentialsRotationWithConnection
+} from "./postgres-credentials";
+import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
+import { SecretRotation } from "./secret-rotation-v2-enums";
+
+export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation;
+
+export type TSecretRotationV2WithConnection =
+ | TPostgresCredentialsRotationWithConnection
+ | TMsSqlCredentialsRotationWithConnection;
+
+export type TSecretRotationV2GeneratedCredentials = TSqlCredentialsRotationGeneratedCredentials;
+
+export type TSecretRotationV2Input = TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput;
+
+export type TSecretRotationV2ListItem = TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem;
+
+export type TSecretRotationV2Raw = NonNullable>>;
+
+export type TListSecretRotationsV2ByProjectId = {
+ projectId: string;
+ type?: SecretRotation;
+};
+
+export type TFindSecretRotationV2ByIdDTO = {
+ rotationId: string;
+ type: SecretRotation;
+};
+
+export type TRotateSecretRotationV2 = TFindSecretRotationV2ByIdDTO & { auditLogInfo: AuditLogInfo };
+
+export type TRotateAtUtc = { hours: number; minutes: number };
+
+export type TFindSecretRotationV2ByNameDTO = {
+ rotationName: string;
+ secretPath: string;
+ environment: string;
+ projectId: string;
+ type: SecretRotation;
+};
+
+export type TCreateSecretRotationV2DTO = Pick<
+ TSecretRotationV2,
+ "parameters" | "secretsMapping" | "description" | "rotationInterval" | "name" | "connectionId" | "projectId"
+> & {
+ type: SecretRotation;
+ secretPath: string;
+ environment: string;
+ isAutoRotationEnabled?: boolean;
+ rotateAtUtc?: TRotateAtUtc;
+};
+
+export type TUpdateSecretRotationV2DTO = Partial<
+ Omit
+> & {
+ rotationId: string;
+ type: SecretRotation;
+};
+
+export type TDeleteSecretRotationV2DTO = {
+ type: SecretRotation;
+ rotationId: string;
+ deleteSecrets: boolean;
+ revokeGeneratedCredentials: boolean;
+};
+
+export type TGetDashboardSecretRotationV2Count = {
+ search?: string;
+ projectId: string;
+ secretPath: string;
+ environments: string[];
+};
+
+export type TGetDashboardSecretRotationsV2 = {
+ search?: string;
+ projectId: string;
+ secretPath: string;
+ environments: string[];
+ orderBy?: SecretsOrderBy;
+ orderDirection?: OrderByDirection;
+ limit: number;
+ offset: number;
+};
+
+export type TQuickSearchSecretRotationsV2Filters = {
+ offset?: number;
+ limit?: number;
+ orderBy?: SecretsOrderBy;
+ orderDirection?: OrderByDirection;
+ search?: string;
+};
+
+export type TQuickSearchSecretRotationsV2 = {
+ projectId: string;
+ folderMappings: { folderId: string; path: string; environment: string }[];
+ filters: TQuickSearchSecretRotationsV2Filters;
+};
+
+export type TSecretRotationRotateGeneratedCredentials = {
+ auditLogInfo?: AuditLogInfo;
+ jobId?: string;
+ shouldSendNotification?: boolean;
+ isFinalAttempt?: boolean;
+ isManualRotation?: boolean;
+};
+
+export type TSecretRotationRotateSecretsJobPayload = { rotationId: string; queuedAt: Date; isManualRotation: boolean };
+
+export type TSecretRotationSendNotificationJobPayload = {
+ secretRotation: TSecretRotationV2Raw;
+};
+
+// scott: the reason for the callback structure of the rotation factory is to facilitate, when possible,
+// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the
+// third party credential changes (when supported), preventing credentials getting out of sync
+
+export type TRotationFactoryIssueCredentials = (
+ callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise
+) => Promise;
+
+export type TRotationFactoryRevokeCredentials = (
+ generatedCredentials: TSecretRotationV2GeneratedCredentials,
+ callback: () => Promise
+) => Promise;
+
+export type TRotationFactoryRotateCredentials = (
+ credentialsToRevoke: TSecretRotationV2GeneratedCredentials[number] | undefined,
+ callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise
+) => Promise;
+
+export type TRotationFactoryGetSecretsPayload = (
+ generatedCredentials: TSecretRotationV2GeneratedCredentials[number]
+) => { key: string; value: string }[];
+
+export type TRotationFactory = (secretRotation: TSecretRotationV2WithConnection) => {
+ issueCredentials: TRotationFactoryIssueCredentials;
+ revokeCredentials: TRotationFactoryRevokeCredentials;
+ rotateCredentials: TRotationFactoryRotateCredentials;
+ getSecretsPayload: TRotationFactoryGetSecretsPayload;
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts
new file mode 100644
index 000000000..0c4bbd014
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
+import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
+
+export const SecretRotationV2Schema = z.discriminatedUnion("type", [
+ PostgresCredentialsRotationSchema,
+ MsSqlCredentialsRotationSchema
+]);
diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts
new file mode 100644
index 000000000..1ab210d66
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts
@@ -0,0 +1,2 @@
+export * from "./sql-credentials-rotation-fns";
+export * from "./sql-credentials-rotation-schemas";
diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts
new file mode 100644
index 000000000..17983eb43
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts
@@ -0,0 +1,232 @@
+import { randomInt } from "crypto";
+
+import {
+ TRotationFactoryGetSecretsPayload,
+ TRotationFactoryIssueCredentials,
+ TRotationFactoryRevokeCredentials,
+ TRotationFactoryRotateCredentials
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql";
+
+import {
+ TSqlCredentialsRotationGeneratedCredentials,
+ TSqlCredentialsRotationWithConnection
+} from "./sql-credentials-rotation-types";
+
+const DEFAULT_PASSWORD_REQUIREMENTS = {
+ length: 48,
+ required: {
+ lowercase: 1,
+ uppercase: 1,
+ digits: 1,
+ symbols: 0
+ },
+ allowedSymbols: "-_.~!*"
+};
+
+const generatePassword = () => {
+ try {
+ const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS;
+
+ const chars = {
+ lowercase: "abcdefghijklmnopqrstuvwxyz",
+ uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ digits: "0123456789",
+ symbols: allowedSymbols || "-_.~!*"
+ };
+
+ const parts: string[] = [];
+
+ if (required.lowercase > 0) {
+ parts.push(
+ ...Array(required.lowercase)
+ .fill(0)
+ .map(() => chars.lowercase[randomInt(chars.lowercase.length)])
+ );
+ }
+
+ if (required.uppercase > 0) {
+ parts.push(
+ ...Array(required.uppercase)
+ .fill(0)
+ .map(() => chars.uppercase[randomInt(chars.uppercase.length)])
+ );
+ }
+
+ if (required.digits > 0) {
+ parts.push(
+ ...Array(required.digits)
+ .fill(0)
+ .map(() => chars.digits[randomInt(chars.digits.length)])
+ );
+ }
+
+ if (required.symbols > 0) {
+ parts.push(
+ ...Array(required.symbols)
+ .fill(0)
+ .map(() => chars.symbols[randomInt(chars.symbols.length)])
+ );
+ }
+
+ const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0);
+ const remainingLength = Math.max(length - requiredTotal, 0);
+
+ const allowedChars = Object.entries(chars)
+ .filter(([key]) => required[key as keyof typeof required] > 0)
+ .map(([, value]) => value)
+ .join("");
+
+ parts.push(
+ ...Array(remainingLength)
+ .fill(0)
+ .map(() => allowedChars[randomInt(allowedChars.length)])
+ );
+
+ // shuffle the array to mix up the characters
+ for (let i = parts.length - 1; i > 0; i -= 1) {
+ const j = randomInt(i + 1);
+ [parts[i], parts[j]] = [parts[j], parts[i]];
+ }
+
+ return parts.join("");
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ throw new Error(`Failed to generate password: ${message}`);
+ }
+};
+
+const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGeneratedCredentials) => {
+ const error = e as Error;
+
+ if (!error?.message) return "Unknown error";
+
+ let redactedMessage = error.message;
+
+ credentials.forEach(({ password }) => {
+ redactedMessage = redactedMessage.replaceAll(password, "*******************");
+ });
+
+ return redactedMessage;
+};
+
+export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRotationWithConnection) => {
+ const {
+ connection,
+ parameters: { username1, username2 },
+ activeIndex,
+ secretsMapping
+ } = secretRotation;
+
+ const validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => {
+ const client = await getSqlConnectionClient({
+ ...connection,
+ credentials: {
+ ...connection.credentials,
+ ...credentials
+ }
+ });
+
+ try {
+ await client.raw("SELECT 1");
+ } catch (error) {
+ throw new Error(redactPasswords(error, [credentials]));
+ } finally {
+ await client.destroy();
+ }
+ };
+
+ const issueCredentials: TRotationFactoryIssueCredentials = async (callback) => {
+ const client = await getSqlConnectionClient(connection);
+
+ // For SQL, since we get existing users, we change both their passwords
+ // on issue to invalidate their existing passwords
+ const credentialsSet = [
+ { username: username1, password: generatePassword() },
+ { username: username2, password: generatePassword() }
+ ];
+
+ try {
+ await client.transaction(async (tx) => {
+ for await (const credentials of credentialsSet) {
+ await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
+ }
+ });
+ } catch (error) {
+ throw new Error(redactPasswords(error, credentialsSet));
+ } finally {
+ await client.destroy();
+ }
+
+ for await (const credentials of credentialsSet) {
+ await validateCredentials(credentials);
+ }
+
+ return callback(credentialsSet[0]);
+ };
+
+ const revokeCredentials: TRotationFactoryRevokeCredentials = async (credentialsToRevoke, callback) => {
+ const client = await getSqlConnectionClient(connection);
+
+ const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() }));
+
+ try {
+ await client.transaction(async (tx) => {
+ for await (const credentials of revokedCredentials) {
+ // invalidate previous passwords
+ await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
+ }
+ });
+ } catch (error) {
+ throw new Error(redactPasswords(error, revokedCredentials));
+ } finally {
+ await client.destroy();
+ }
+
+ return callback();
+ };
+
+ const rotateCredentials: TRotationFactoryRotateCredentials = async (_, callback) => {
+ const client = await getSqlConnectionClient(connection);
+
+ // generate new password for the next active user
+ const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() };
+
+ try {
+ await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
+ } catch (error) {
+ throw new Error(redactPasswords(error, [credentials]));
+ } finally {
+ await client.destroy();
+ }
+
+ await validateCredentials(credentials);
+
+ return callback(credentials);
+ };
+
+ const getSecretsPayload: TRotationFactoryGetSecretsPayload = (generatedCredentials) => {
+ const { username, password } = secretsMapping;
+
+ const secrets = [
+ {
+ key: username,
+ value: generatedCredentials.username
+ },
+ {
+ key: password,
+ value: generatedCredentials.password
+ }
+ ];
+
+ return secrets;
+ };
+
+ return {
+ issueCredentials,
+ revokeCredentials,
+ rotateCredentials,
+ getSecretsPayload,
+ validateCredentials
+ };
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts
new file mode 100644
index 000000000..7ec47741f
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts
@@ -0,0 +1,39 @@
+import { z } from "zod";
+
+import { SecretRotations } from "@app/lib/api-docs";
+import { SecretNameSchema } from "@app/server/lib/schemas";
+
+export const SqlCredentialsRotationGeneratedCredentialsSchema = z
+ .object({
+ username: z.string(),
+ password: z.string()
+ })
+ .array()
+ .min(1)
+ .max(2);
+
+export const SqlCredentialsRotationParametersSchema = z.object({
+ username1: z
+ .string()
+ .trim()
+ .min(1, "Username1 Required")
+ .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username1),
+ username2: z
+ .string()
+ .trim()
+ .min(1, "Username2 Required")
+ .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username2)
+});
+
+export const SqlCredentialsRotationSecretsMappingSchema = z.object({
+ username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.username),
+ password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.password)
+});
+
+export const SqlCredentialsRotationTemplateSchema = z.object({
+ createUserStatement: z.string(),
+ secretsMapping: z.object({
+ username: z.string(),
+ password: z.string()
+ })
+});
diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts
new file mode 100644
index 000000000..6eada6019
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts
@@ -0,0 +1,14 @@
+import { z } from "zod";
+
+import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
+import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
+
+import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas";
+
+export type TSqlCredentialsRotationWithConnection =
+ | TPostgresCredentialsRotationWithConnection
+ | TMsSqlCredentialsRotationWithConnection;
+
+export type TSqlCredentialsRotationGeneratedCredentials = z.infer<
+ typeof SqlCredentialsRotationGeneratedCredentialsSchema
+>;
diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts
index 8d458111a..df7b86a0b 100644
--- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts
+++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts
@@ -16,8 +16,8 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret
import { TLicenseServiceFactory } from "../license/license-service";
import { TPermissionServiceFactory } from "../permission/permission-service";
import {
- ProjectPermissionActions,
ProjectPermissionSecretActions,
+ ProjectPermissionSecretRotationActions,
ProjectPermissionSub
} from "../permission/project-permission";
import { TSecretRotationDALFactory } from "./secret-rotation-dal";
@@ -69,7 +69,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
- ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Read,
+ ProjectPermissionSub.SecretRotation
+ );
return {
custom: [],
@@ -99,7 +102,7 @@ export const secretRotationServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
- ProjectPermissionActions.Create,
+ ProjectPermissionSecretRotationActions.Read,
ProjectPermissionSub.SecretRotation
);
@@ -208,7 +211,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
- ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Read,
+ ProjectPermissionSub.SecretRotation
+ );
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
const docs = await secretRotationDAL.findSecretV2({ projectId });
@@ -254,7 +260,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
- ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation);
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionSecretRotationActions.Edit,
+ ProjectPermissionSub.SecretRotation
+ );
await secretRotationQueue.removeFromQueue(doc.id, doc.interval);
await secretRotationQueue.addToQueue(doc.id, doc.interval);
return doc;
@@ -273,7 +282,7 @@ export const secretRotationServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
- ProjectPermissionActions.Delete,
+ ProjectPermissionSecretRotationActions.Delete,
ProjectPermissionSub.SecretRotation
);
const deletedDoc = await secretRotationDAL.transaction(async (tx) => {
diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts
index 8ff07db26..ea26a2cf7 100644
--- a/backend/src/keystore/keystore.ts
+++ b/backend/src/keystore/keystore.ts
@@ -33,6 +33,7 @@ export const KeyStorePrefixes = {
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
+ SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) =>
`identity-access-token-status:${identityAccessTokenId}`,
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index ec855168e..6802b6dcb 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -1,3 +1,8 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ SECRET_ROTATION_CONNECTION_MAP,
+ SECRET_ROTATION_NAME_MAP
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
@@ -817,7 +822,8 @@ export const DASHBOARD = {
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response.",
- includeImports: "Whether to include project secret imports in the response."
+ includeImports: "Whether to include project secret imports in the response.",
+ includeSecretRotations: "Whether to include project secret rotations in the response."
},
SECRET_DETAILS_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
@@ -832,7 +838,8 @@ export const DASHBOARD = {
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeImports: "Whether to include project secret imports in the response.",
- includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
+ includeDynamicSecrets: "Whether to include dynamic project secrets in the response.",
+ includeSecretRotations: "Whether to include secret rotations in the response."
}
} as const;
@@ -1653,7 +1660,8 @@ export const AppConnections = {
name: `The name of the ${appName} Connection to create. Must be slug-friendly.`,
description: `An optional description for the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`,
- method: `The method used to authenticate with ${appName}.`
+ method: `The method used to authenticate with ${appName}.`,
+ isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.`
};
},
UPDATE: (app: AppConnection) => {
@@ -1663,12 +1671,23 @@ export const AppConnections = {
name: `The updated name of the ${appName} Connection. Must be slug-friendly.`,
description: `The updated description of the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`,
- method: `The method used to authenticate with ${appName}.`
+ method: `The method used to authenticate with ${appName}.`,
+ isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.`
};
},
DELETE: (app: AppConnection) => ({
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.`
- })
+ }),
+ CREDENTIALS: {
+ SQL_CONNECTION: {
+ host: "The hostname of the database server.",
+ port: "The port number of the database.",
+ database: "The name of the database to connect to.",
+ username: "The username to connect to the database with.",
+ password: "The password to connect to the database with.",
+ sslCertificate: "The SSL certificate to use for connection."
+ }
+ }
};
export const SecretSyncs = {
@@ -1785,3 +1804,70 @@ export const SecretSyncs = {
}
}
};
+
+export const SecretRotations = {
+ LIST: (type?: SecretRotation) => ({
+ projectId: `The ID of the project to list ${type ? SECRET_ROTATION_NAME_MAP[type] : "Secret"} Rotations from.`
+ }),
+ GET_BY_ID: (type: SecretRotation) => ({
+ rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`
+ }),
+ GET_GENERATED_CREDENTIALS_BY_ID: (type: SecretRotation) => ({
+ rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve the generated credentials for.`
+ }),
+ GET_BY_NAME: (type: SecretRotation) => ({
+ rotationName: `The name of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`,
+ projectId: `The ID of the project the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`,
+ secretPath: `The secret path the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located at.`,
+ environment: `The environment the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`
+ }),
+ CREATE: (type: SecretRotation) => {
+ const destinationName = SECRET_ROTATION_NAME_MAP[type];
+ return {
+ name: `The name of the ${destinationName} Rotation to create. Must be slug-friendly.`,
+ description: `An optional description for the ${destinationName} Rotation.`,
+ projectId: "The ID of the project to create the rotation in.",
+ environment: `The slug of the project environment to create the rotation in.`,
+ secretPath: `The secret path of the project to create the rotation in.`,
+ connectionId: `The ID of the ${
+ APP_CONNECTION_NAME_MAP[SECRET_ROTATION_CONNECTION_MAP[type]]
+ } Connection to use for rotation.`,
+ isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`,
+ rotationInterval: `The interval, in days, to automatically rotate secrets.`,
+ rotateAtUtc: `The hours and minutes rotation should occur at in UTC. Defaults to Midnight (00:00) UTC.`
+ };
+ },
+ UPDATE: (type: SecretRotation) => {
+ const typeName = SECRET_ROTATION_NAME_MAP[type];
+ return {
+ rotationId: `The ID of the ${typeName} Rotation to be updated.`,
+ name: `The updated name of the ${typeName} Rotation. Must be slug-friendly.`,
+ description: `The updated description of the ${typeName} Rotation.`,
+ isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`,
+ rotationInterval: `The updated interval, in days, to automatically rotate secrets.`,
+ rotateAtUtc: `The updated hours and minutes rotation should occur at in UTC.`
+ };
+ },
+ DELETE: (type: SecretRotation) => ({
+ rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to be deleted.`,
+ deleteSecrets: `Whether the mapped secrets belonging to this rotation should be deleted.`,
+ revokeGeneratedCredentials: `Whether the generated credentials associated with this rotation should be revoked.`
+ }),
+ ROTATE: (type: SecretRotation) => ({
+ rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to rotate generated credentials for.`
+ }),
+ PARAMETERS: {
+ SQL_CREDENTIALS: {
+ username1:
+ "The username of the first login to rotate passwords for. This user must already exists in your database.",
+ username2:
+ "The username of the second login to rotate passwords for. This user must already exists in your database."
+ }
+ },
+ SECRETS_MAPPING: {
+ SQL_CREDENTIALS: {
+ username: "The name of the secret that the active username will be mapped to.",
+ password: "The name of the secret that the generated password will be mapped to."
+ }
+ }
+};
diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts
index 4ca9c1b12..0906e5269 100644
--- a/backend/src/lib/config/env.ts
+++ b/backend/src/lib/config/env.ts
@@ -58,6 +58,7 @@ const envSchema = z
ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()),
QUEUE_WORKERS_ENABLED: zodStrBool.default("true"),
HTTPS_ENABLED: zodStrBool,
+ ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
// smtp options
SMTP_HOST: zpStr(z.string().optional()),
SMTP_IGNORE_TLS: zodStrBool.default("false"),
@@ -262,6 +263,7 @@ const envSchema = z
isSmtpConfigured: Boolean(data.SMTP_HOST),
isRedisConfigured: Boolean(data.REDIS_URL),
isDevelopmentMode: data.NODE_ENV === "development",
+ isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE,
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
isSecretScanningConfigured:
diff --git a/backend/src/lib/knex/prependTableNameToFindFilter.ts b/backend/src/lib/knex/prependTableNameToFindFilter.ts
index ee48dce5a..3fb1dabb3 100644
--- a/backend/src/lib/knex/prependTableNameToFindFilter.ts
+++ b/backend/src/lib/knex/prependTableNameToFindFilter.ts
@@ -7,7 +7,7 @@ export const prependTableNameToFindFilter = (tableName: TableName, filterObj: ob
Object.fromEntries(
Object.entries(filterObj).map(([key, value]) =>
key.startsWith("$")
- ? [key, prependTableNameToFindFilter(tableName, value as object)]
+ ? [key, value ? prependTableNameToFindFilter(tableName, value as object) : value]
: [`${tableName}.${key}`, value]
)
);
diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts
index 94006b5a5..ae1a3e821 100644
--- a/backend/src/queue/queue-service.ts
+++ b/backend/src/queue/queue-service.ts
@@ -4,6 +4,10 @@ import PgBoss, { WorkOptions } from "pg-boss";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
+import {
+ TSecretRotationRotateSecretsJobPayload,
+ TSecretRotationSendNotificationJobPayload
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import {
TScanFullRepoEventPayload,
TScanPushEventPayload
@@ -44,7 +48,8 @@ export enum QueueName {
ProjectV3Migration = "project-v3-migration",
AccessTokenStatusUpdate = "access-token-status-update",
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
- AppConnectionSecretSync = "app-connection-secret-sync"
+ AppConnectionSecretSync = "app-connection-secret-sync",
+ SecretRotationV2 = "secret-rotation-v2"
}
export enum QueueJobs {
@@ -73,7 +78,10 @@ export enum QueueJobs {
SecretSyncSyncSecrets = "secret-sync-sync-secrets",
SecretSyncImportSecrets = "secret-sync-import-secrets",
SecretSyncRemoveSecrets = "secret-sync-remove-secrets",
- SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications"
+ SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications",
+ SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations",
+ SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets",
+ SecretRotationV2SendNotification = "secret-rotation-v2-send-notification"
}
export type TQueueJobTypes = {
@@ -213,6 +221,19 @@ export type TQueueJobTypes = {
name: QueueJobs.SecretSyncSendActionFailedNotifications;
payload: TQueueSendSecretSyncActionFailedNotificationsDTO;
};
+ [QueueName.SecretRotationV2]:
+ | {
+ name: QueueJobs.SecretRotationV2QueueRotations;
+ payload: undefined;
+ }
+ | {
+ name: QueueJobs.SecretRotationV2RotateSecrets;
+ payload: TSecretRotationRotateSecretsJobPayload;
+ }
+ | {
+ name: QueueJobs.SecretRotationV2SendNotification;
+ payload: TSecretRotationSendNotificationJobPayload;
+ };
};
export type TQueueServiceFactory = ReturnType;
@@ -229,6 +250,7 @@ export const queueServiceFactory = (
const pgBoss = new PgBoss({
connectionString: dbConnectionUrl,
archiveCompletedAfterSeconds: 60,
+ cronMonitorIntervalSeconds: 5,
archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried
deleteAfterSeconds: 30,
ssl: dbRootCert
@@ -247,15 +269,12 @@ export const queueServiceFactory = (
>;
const initialize = async () => {
- const appCfg = getConfig();
- if (appCfg.SHOULD_INIT_PG_QUEUE) {
- logger.info("Initializing pg-queue...");
- await pgBoss.start();
+ logger.info("Initializing pg-queue...");
+ await pgBoss.start();
- pgBoss.on("error", (error) => {
- logger.error(error, "pg-queue error");
- });
- }
+ pgBoss.on("error", (error) => {
+ logger.error(error, "pg-queue error");
+ });
};
const start = (
@@ -283,7 +302,7 @@ export const queueServiceFactory = (
const startPg = async (
jobName: QueueJobs,
- jobsFn: (jobs: PgBoss.Job[]) => Promise,
+ jobsFn: (jobs: PgBoss.JobWithMetadata[]) => Promise,
options: WorkOptions & {
workerCount: number;
}
@@ -297,7 +316,7 @@ export const queueServiceFactory = (
await Promise.all(
Array.from({ length: options.workerCount }).map(() =>
- pgBoss.work(jobName, options, jobsFn)
+ pgBoss.work(jobName, { ...options, includeMetadata: true }, jobsFn)
)
);
};
@@ -342,6 +361,15 @@ export const queueServiceFactory = (
});
};
+ const schedulePg = async (
+ job: TQueueJobTypes[T]["name"],
+ cron: string,
+ data: TQueueJobTypes[T]["payload"],
+ opts?: PgBoss.ScheduleOptions & { jobId?: string }
+ ) => {
+ await pgBoss.schedule(job, cron, data, opts);
+ };
+
const stopRepeatableJob = async (
name: T,
job: TQueueJobTypes[T]["name"],
@@ -403,6 +431,7 @@ export const queueServiceFactory = (
stopJobById,
getRepeatableJobs,
startPg,
- queuePg
+ queuePg,
+ schedulePg
};
};
diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts
index 43a30760e..d09a2c40b 100644
--- a/backend/src/server/lib/schemas.ts
+++ b/backend/src/server/lib/schemas.ts
@@ -39,3 +39,10 @@ export const GenericResourceNameSchema = z
])(val),
"Name can only contain alphanumeric characters, dashes, underscores, and spaces"
);
+
+export const BaseSecretNameSchema = z.string().trim().min(1);
+
+export const SecretNameSchema = BaseSecretNameSchema.refine(
+ (el) => !el.includes(" "),
+ "Secret name cannot contain spaces."
+).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index dac88791f..6553dad96 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -76,6 +76,9 @@ import { secretReplicationServiceFactory } from "@app/ee/services/secret-replica
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
+import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
+import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue";
+import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal";
import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal";
import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal";
@@ -406,6 +409,8 @@ export const registerRoutes = async (
const gatewayDAL = gatewayDALFactory(db);
const projectGatewayDAL = projectGatewayDALFactory(db);
+ const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL);
+
const permissionService = permissionServiceFactory({
permissionDAL,
orgRoleDAL,
@@ -1497,6 +1502,35 @@ export const registerRoutes = async (
permissionService
});
+ const secretRotationV2Service = secretRotationV2ServiceFactory({
+ secretRotationV2DAL,
+ permissionService,
+ appConnectionService,
+ folderDAL,
+ projectBotService,
+ licenseService,
+ kmsService,
+ auditLogService,
+ secretV2BridgeDAL,
+ secretTagDAL,
+ secretVersionTagV2BridgeDAL,
+ secretVersionV2BridgeDAL,
+ keyStore,
+ resourceMetadataDAL,
+ snapshotService,
+ secretQueueService,
+ queueService
+ });
+
+ await secretRotationV2QueueServiceFactory({
+ secretRotationV2Service,
+ secretRotationV2DAL,
+ queueService,
+ projectDAL,
+ projectMembershipDAL,
+ smtpService
+ });
+
await superAdminService.initServerCfg();
// setup the communication with license key server
@@ -1598,7 +1632,8 @@ export const registerRoutes = async (
secretSync: secretSyncService,
kmip: kmipService,
kmipOperation: kmipOperationService,
- gateway: gatewayService
+ gateway: gatewayService,
+ secretRotationV2: secretRotationV2Service
});
const cronJobs: CronJob[] = [];
diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts
index 67bd26552..8bf6f7390 100644
--- a/backend/src/server/routes/sanitizedSchemas.ts
+++ b/backend/src/server/routes/sanitizedSchemas.ts
@@ -134,7 +134,9 @@ export const secretRawSchema = z.object({
membershipId: z.string().nullable().optional()
})
.optional()
- .nullable()
+ .nullable(),
+ isRotatedSecret: z.boolean().optional(),
+ rotationId: z.string().uuid().nullish()
});
export const ProjectPermissionSchema = z.object({
diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts
index e23d52004..dfb451a4c 100644
--- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts
+++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts
@@ -24,8 +24,14 @@ export const registerAppConnectionEndpoints = ;
+ updateSchema: z.ZodType<{
+ name?: string;
+ credentials?: I["credentials"];
+ description?: string | null;
+ isPlatformManagedCredentials?: boolean;
}>;
- updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>;
sanitizedResponseSchema: z.ZodTypeAny;
}) => {
const appName = APP_CONNECTION_NAME_MAP[app];
@@ -208,10 +214,10 @@ export const registerAppConnectionEndpoints = {
- const { name, method, credentials, description } = req.body;
+ const { name, method, credentials, description, isPlatformManagedCredentials } = req.body;
const appConnection = (await server.services.appConnection.createAppConnection(
- { name, method, app, credentials, description },
+ { name, method, app, credentials, description, isPlatformManagedCredentials },
req.permission
)) as T;
@@ -224,7 +230,8 @@ export const registerAppConnectionEndpoints = {
- const { name, credentials, description } = req.body;
+ const { name, credentials, description, isPlatformManagedCredentials } = req.body;
const { connectionId } = req.params;
const appConnection = (await server.services.appConnection.updateAppConnection(
- { name, credentials, connectionId, description },
+ { name, credentials, connectionId, description, isPlatformManagedCredentials },
req.permission
)) as T;
@@ -268,7 +275,8 @@ export const registerAppConnectionEndpoints = {
diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts
index c2b688a43..906ffaee9 100644
--- a/backend/src/server/routes/v1/app-connection-routers/index.ts
+++ b/backend/src/server/routes/v1/app-connection-routers/index.ts
@@ -7,6 +7,8 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout
import { registerGcpConnectionRouter } from "./gcp-connection-router";
import { registerGitHubConnectionRouter } from "./github-connection-router";
import { registerHumanitecConnectionRouter } from "./humanitec-connection-router";
+import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
+import { registerPostgresConnectionRouter } from "./postgres-connection-router";
export * from "./app-connection-router";
@@ -18,5 +20,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record {
+ registerAppConnectionEndpoints({
+ app: AppConnection.MsSql,
+ server,
+ sanitizedResponseSchema: SanitizedMsSqlConnectionSchema,
+ createSchema: CreateMsSqlConnectionSchema,
+ updateSchema: UpdateMsSqlConnectionSchema
+ });
+};
diff --git a/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts
new file mode 100644
index 000000000..8662f2e52
--- /dev/null
+++ b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts
@@ -0,0 +1,18 @@
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+import {
+ CreatePostgresConnectionSchema,
+ SanitizedPostgresConnectionSchema,
+ UpdatePostgresConnectionSchema
+} from "@app/services/app-connection/postgres";
+
+import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
+
+export const registerPostgresConnectionRouter = async (server: FastifyZodProvider) => {
+ registerAppConnectionEndpoints({
+ app: AppConnection.Postgres,
+ server,
+ sanitizedResponseSchema: SanitizedPostgresConnectionSchema,
+ createSchema: CreatePostgresConnectionSchema,
+ updateSchema: UpdatePostgresConnectionSchema
+ });
+};
diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts
index 901723647..813a8e3ab 100644
--- a/backend/src/server/routes/v1/dashboard-router.ts
+++ b/backend/src/server/routes/v1/dashboard-router.ts
@@ -8,6 +8,7 @@ import {
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
+import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
@@ -101,12 +102,30 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports),
+ includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecretRotations),
includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
}),
response: {
200: z.object({
folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(),
+ secretRotations: z
+ .intersection(
+ SecretRotationV2Schema,
+ z.object({
+ secrets: secretRawSchema
+ .extend({
+ secretValueHidden: z.boolean(),
+ secretPath: z.string().optional(),
+ secretMetadata: ResourceMetadataSchema.optional(),
+ tags: SanitizedTagSchema.array().optional()
+ })
+ .nullable()
+ .array()
+ })
+ )
+ .array()
+ .optional(),
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
@@ -127,6 +146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalImportCount: z.number().optional(),
+ totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
}
@@ -144,7 +164,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeFolders,
includeSecrets,
includeImports,
- includeDynamicSecrets
+ includeDynamicSecrets,
+ includeSecretRotations
} = req.query;
const environments = req.query.environments.split(",");
@@ -166,11 +187,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let dynamicSecrets:
| Awaited>
| undefined;
+ let secretRotations:
+ | Awaited>
+ | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
let totalImportCount: number | undefined;
+ let totalSecretRotationCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({
@@ -322,6 +347,56 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
+ if (includeSecretRotations) {
+ totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount(
+ {
+ projectId,
+ search,
+ environments,
+ secretPath
+ },
+ req.permission
+ );
+
+ if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) {
+ secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations(
+ {
+ projectId,
+ search,
+ orderBy,
+ orderDirection,
+ environments,
+ secretPath,
+ limit: remainingLimit,
+ offset: adjustedOffset
+ },
+ req.permission
+ );
+
+ await server.services.auditLog.createAuditLog({
+ projectId,
+ ...req.auditLogInfo,
+ event: {
+ type: EventType.GET_SECRET_ROTATIONS,
+ metadata: {
+ count: secretRotations.length,
+ rotationIds: secretRotations.map((rotation) => rotation.id),
+ secretPath,
+ environment: environments.join(",")
+ }
+ }
+ });
+
+ // get the count of unique secret rotation names to properly adjust remaining limit
+ const uniqueSecretRotationCount = new Set(secretRotations.map((rotation) => rotation.name)).size;
+
+ remainingLimit -= uniqueSecretRotationCount;
+ adjustedOffset = 0;
+ } else {
+ adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount);
+ }
+ }
+
if (includeSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
@@ -353,38 +428,44 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
offset: adjustedOffset,
isInternal: true
});
+ }
+ }
- for await (const environment of environments) {
- const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length;
+ if (secrets?.length || secretRotations?.length) {
+ for await (const environment of environments) {
+ const secretCountFromEnv =
+ (secrets?.filter((secret) => secret.environment === environment).length ?? 0) +
+ (secretRotations
+ ?.filter((rotation) => rotation.environment.slug === environment)
+ .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0);
- if (secretCountFromEnv) {
- await server.services.auditLog.createAuditLog({
- projectId,
- ...req.auditLogInfo,
- event: {
- type: EventType.GET_SECRETS,
- metadata: {
- environment,
- secretPath,
- numberOfSecrets: secretCountFromEnv
- }
+ if (secretCountFromEnv) {
+ await server.services.auditLog.createAuditLog({
+ projectId,
+ ...req.auditLogInfo,
+ event: {
+ type: EventType.GET_SECRETS,
+ metadata: {
+ environment,
+ secretPath,
+ numberOfSecrets: secretCountFromEnv
+ }
+ }
+ });
+
+ if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
+ await server.services.telemetry.sendPostHogEvents({
+ event: PostHogEventTypes.SecretPulled,
+ distinctId: getTelemetryDistinctId(req),
+ properties: {
+ numberOfSecrets: secretCountFromEnv,
+ workspaceId: projectId,
+ environment,
+ secretPath,
+ channel: getUserAgentType(req.headers["user-agent"]),
+ ...req.auditLogInfo
}
});
-
- if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
- await server.services.telemetry.sendPostHogEvents({
- event: PostHogEventTypes.SecretPulled,
- distinctId: getTelemetryDistinctId(req),
- properties: {
- numberOfSecrets: secretCountFromEnv,
- workspaceId: projectId,
- environment,
- secretPath,
- channel: getUserAgentType(req.headers["user-agent"]),
- ...req.auditLogInfo
- }
- });
- }
}
}
}
@@ -395,12 +476,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
dynamicSecrets,
secrets,
imports,
+ secretRotations,
totalFolderCount,
totalDynamicSecretCount,
totalImportCount,
totalSecretCount,
+ totalSecretRotationCount,
totalCount:
- (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0)
+ (totalFolderCount ?? 0) +
+ (totalDynamicSecretCount ?? 0) +
+ (totalSecretCount ?? 0) +
+ (totalImportCount ?? 0) +
+ (totalSecretRotationCount ?? 0)
};
}
});
@@ -445,7 +532,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets),
includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders),
includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets),
- includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports)
+ includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports),
+ includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecretRotations)
}),
response: {
200: z.object({
@@ -457,6 +545,23 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
.optional(),
folders: SecretFoldersSchema.array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(),
+ secretRotations: z
+ .intersection(
+ SecretRotationV2Schema,
+ z.object({
+ secrets: secretRawSchema
+ .extend({
+ secretValueHidden: z.boolean(),
+ secretPath: z.string().optional(),
+ secretMetadata: ResourceMetadataSchema.optional(),
+ tags: SanitizedTagSchema.array().optional()
+ })
+ .nullable()
+ .array()
+ })
+ )
+ .array()
+ .optional(),
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
@@ -470,6 +575,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
+ totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
}
@@ -488,7 +594,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeFolders,
includeSecrets,
includeDynamicSecrets,
- includeImports
+ includeImports,
+ includeSecretRotations
} = req.query;
if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" });
@@ -507,11 +614,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let folders: Awaited> | undefined;
let secrets: Awaited>["secrets"] | undefined;
let dynamicSecrets: Awaited> | undefined;
+ let secretRotations:
+ | Awaited>
+ | undefined;
let totalImportCount: number | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
+ let totalSecretRotationCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportCount({
@@ -594,6 +705,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
+ if (includeSecretRotations) {
+ totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount(
+ {
+ projectId,
+ search,
+ environments: [environment],
+ secretPath
+ },
+ req.permission
+ );
+
+ if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) {
+ secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations(
+ {
+ projectId,
+ search,
+ orderBy,
+ orderDirection,
+ environments: [environment],
+ secretPath,
+ limit: remainingLimit,
+ offset: adjustedOffset
+ },
+ req.permission
+ );
+
+ await server.services.auditLog.createAuditLog({
+ projectId,
+ ...req.auditLogInfo,
+ event: {
+ type: EventType.GET_SECRET_ROTATIONS,
+ metadata: {
+ count: secretRotations.length,
+ rotationIds: secretRotations.map((rotation) => rotation.id),
+ secretPath,
+ environment
+ }
+ }
+ });
+
+ remainingLimit -= secretRotations.length;
+ adjustedOffset = 0;
+ } else {
+ adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount);
+ }
+ }
+
try {
if (includeDynamicSecrets) {
totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({
@@ -629,7 +787,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
+ } catch (error) {
+ if (!(error instanceof ForbiddenError)) {
+ throw error;
+ }
+ }
+ try {
if (includeSecrets) {
totalSecretCount = await server.services.secret.getSecretsCount({
actorId: req.permission.id,
@@ -663,34 +827,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
tagSlugs: tags
})
).secrets;
-
- await server.services.auditLog.createAuditLog({
- projectId,
- ...req.auditLogInfo,
- event: {
- type: EventType.GET_SECRETS,
- metadata: {
- environment,
- secretPath,
- numberOfSecrets: secrets.length
- }
- }
- });
-
- if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
- await server.services.telemetry.sendPostHogEvents({
- event: PostHogEventTypes.SecretPulled,
- distinctId: getTelemetryDistinctId(req),
- properties: {
- numberOfSecrets: secrets.length,
- workspaceId: projectId,
- environment,
- secretPath,
- channel: getUserAgentType(req.headers["user-agent"]),
- ...req.auditLogInfo
- }
- });
- }
}
}
} catch (error) {
@@ -699,17 +835,57 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
+ if (secrets?.length || secretRotations?.length) {
+ const secretCount =
+ (secrets?.length ?? 0) +
+ (secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0);
+
+ await server.services.auditLog.createAuditLog({
+ projectId,
+ ...req.auditLogInfo,
+ event: {
+ type: EventType.GET_SECRETS,
+ metadata: {
+ environment,
+ secretPath,
+ numberOfSecrets: secretCount
+ }
+ }
+ });
+
+ if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
+ await server.services.telemetry.sendPostHogEvents({
+ event: PostHogEventTypes.SecretPulled,
+ distinctId: getTelemetryDistinctId(req),
+ properties: {
+ numberOfSecrets: secretCount,
+ workspaceId: projectId,
+ environment,
+ secretPath,
+ channel: getUserAgentType(req.headers["user-agent"]),
+ ...req.auditLogInfo
+ }
+ });
+ }
+ }
+
return {
imports,
folders,
dynamicSecrets,
secrets,
+ secretRotations,
totalImportCount,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
+ totalSecretRotationCount,
totalCount:
- (totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
+ (totalImportCount ?? 0) +
+ (totalFolderCount ?? 0) +
+ (totalDynamicSecretCount ?? 0) +
+ (totalSecretCount ?? 0) +
+ (totalSecretRotationCount ?? 0)
};
}
});
@@ -747,7 +923,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
tags: SanitizedTagSchema.array().optional()
})
.array()
- .optional()
+ .optional(),
+ secretRotations: SecretRotationV2Schema.array().optional()
})
}
},
@@ -811,6 +988,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
req.permission
);
+ const secretRotations = searchHasTags
+ ? []
+ : await server.services.secretRotationV2.getQuickSearchSecretRotations(
+ {
+ projectId,
+ folderMappings,
+ filters: sharedFilters
+ },
+ req.permission
+ );
+
for await (const environment of environments) {
const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length;
@@ -843,6 +1031,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
});
}
}
+
+ const secretRotationsFromEnv = secretRotations.filter((rotation) => rotation.environment.slug === environment);
+
+ if (secretRotationsFromEnv.length) {
+ await server.services.auditLog.createAuditLog({
+ projectId,
+ ...req.auditLogInfo,
+ event: {
+ type: EventType.GET_SECRET_ROTATIONS,
+ metadata: {
+ count: secretRotationsFromEnv.length,
+ rotationIds: secretRotationsFromEnv.map((rotation) => rotation.id),
+ secretPath,
+ environment
+ }
+ }
+ });
+ }
}
const sliceQuickSearch = (array: T[]) => array.slice(0, 25);
@@ -856,6 +1062,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath))
: dynamicSecrets
),
+ secretRotations: sliceQuickSearch(
+ searchPath ? secretRotations.filter((rotation) => rotation.folder.path.endsWith(searchPath)) : secretRotations
+ ),
folders: searchHasTags
? []
: sliceQuickSearch(
diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts
index f854baade..0f53b777a 100644
--- a/backend/src/server/routes/v3/secret-router.ts
+++ b/backend/src/server/routes/v3/secret-router.ts
@@ -7,6 +7,7 @@ import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { secretsLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { BaseSecretNameSchema, SecretNameSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -39,13 +40,6 @@ const SecretReferenceNodeTree: z.ZodType = SecretReference
children: z.lazy(() => SecretReferenceNodeTree.array())
});
-const BaseSecretNameSchema = z.string().trim().min(1);
-
-const SecretNameSchema = BaseSecretNameSchema.refine(
- (el) => !el.includes(" "),
- "Secret name cannot contain spaces."
-).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");
-
export const registerSecretRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
@@ -630,6 +624,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretValue: z
.string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
+ .optional()
.describe(RAW_SECRETS.UPDATE.secretValue),
secretPath: z
.string()
@@ -2049,6 +2044,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretValue: z
.string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
+ .optional()
.describe(RAW_SECRETS.UPDATE.secretValue),
secretPath: z
.string()
diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts
index 9da622541..f5f921c4e 100644
--- a/backend/src/services/app-connection/app-connection-enums.ts
+++ b/backend/src/services/app-connection/app-connection-enums.ts
@@ -5,7 +5,9 @@ export enum AppConnection {
GCP = "gcp",
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
- Humanitec = "humanitec"
+ Humanitec = "humanitec",
+ Postgres = "postgres",
+ MsSql = "mssql"
}
export enum AWSRegion {
diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts
index fe5130ff1..b2d45e71f 100644
--- a/backend/src/services/app-connection/app-connection-fns.ts
+++ b/backend/src/services/app-connection/app-connection-fns.ts
@@ -1,30 +1,22 @@
import { TAppConnections } from "@app/db/schemas/app-connections";
import { generateHash } from "@app/lib/crypto/encryption";
-import { AppConnection } from "@app/services/app-connection/app-connection-enums";
-import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service";
-import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types";
+import { BadRequestError } from "@app/lib/errors";
+import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import {
- AwsConnectionMethod,
- getAwsConnectionListItem,
- validateAwsConnectionCredentials
-} from "@app/services/app-connection/aws";
-import {
- DatabricksConnectionMethod,
- getDatabricksConnectionListItem,
- validateDatabricksConnectionCredentials
-} from "@app/services/app-connection/databricks";
-import {
- GcpConnectionMethod,
- getGcpConnectionListItem,
- validateGcpConnectionCredentials
-} from "@app/services/app-connection/gcp";
-import {
- getGitHubConnectionListItem,
- GitHubConnectionMethod,
- validateGitHubConnectionCredentials
-} from "@app/services/app-connection/github";
+ transferSqlConnectionCredentialsToPlatform,
+ validateSqlConnectionCredentials
+} from "@app/services/app-connection/shared/sql";
import { KmsDataKey } from "@app/services/kms/kms-types";
+import { AppConnection } from "./app-connection-enums";
+import { TAppConnectionServiceFactoryDep } from "./app-connection-service";
+import {
+ TAppConnection,
+ TAppConnectionConfig,
+ TAppConnectionCredentialsValidator,
+ TAppConnectionTransitionCredentialsToPlatform
+} from "./app-connection-types";
+import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws";
import {
AzureAppConfigurationConnectionMethod,
getAzureAppConfigurationConnectionListItem,
@@ -35,11 +27,20 @@ import {
getAzureKeyVaultConnectionListItem,
validateAzureKeyVaultConnectionCredentials
} from "./azure-key-vault";
+import {
+ DatabricksConnectionMethod,
+ getDatabricksConnectionListItem,
+ validateDatabricksConnectionCredentials
+} from "./databricks";
+import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp";
+import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github";
import {
getHumanitecConnectionListItem,
HumanitecConnectionMethod,
validateHumanitecConnectionCredentials
} from "./humanitec";
+import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql";
+import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
export const listAppConnectionOptions = () => {
return [
@@ -49,7 +50,9 @@ export const listAppConnectionOptions = () => {
getAzureKeyVaultConnectionListItem(),
getAzureAppConfigurationConnectionListItem(),
getDatabricksConnectionListItem(),
- getHumanitecConnectionListItem()
+ getHumanitecConnectionListItem(),
+ getPostgresConnectionListItem(),
+ getMsSqlConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -95,30 +98,22 @@ export const decryptAppConnectionCredentials = async ({
return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"];
};
+const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = {
+ [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.AzureAppConfiguration]:
+ validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator
+};
+
export const validateAppConnectionCredentials = async (
appConnection: TAppConnectionConfig
-): Promise => {
- const { app } = appConnection;
- switch (app) {
- case AppConnection.AWS:
- return validateAwsConnectionCredentials(appConnection);
- case AppConnection.Databricks:
- return validateDatabricksConnectionCredentials(appConnection);
- case AppConnection.GitHub:
- return validateGitHubConnectionCredentials(appConnection);
- case AppConnection.GCP:
- return validateGcpConnectionCredentials(appConnection);
- case AppConnection.AzureKeyVault:
- return validateAzureKeyVaultConnectionCredentials(appConnection);
- case AppConnection.AzureAppConfiguration:
- return validateAzureAppConfigurationConnectionCredentials(appConnection);
- case AppConnection.Humanitec:
- return validateHumanitecConnectionCredentials(appConnection);
- default:
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- throw new Error(`Unhandled App Connection ${app}`);
- }
-};
+): Promise => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
export const getAppConnectionMethodName = (method: TAppConnection["method"]) => {
switch (method) {
@@ -136,8 +131,11 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
return "Service Account Impersonation";
case DatabricksConnectionMethod.ServicePrincipal:
return "Service Principal";
- case HumanitecConnectionMethod.API_TOKEN:
+ case HumanitecConnectionMethod.ApiToken:
return "API Token";
+ case PostgresConnectionMethod.UsernameAndPassword:
+ case MsSqlConnectionMethod.UsernameAndPassword:
+ return "Username & Password";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -158,3 +156,24 @@ export const decryptAppConnection = async (
credentialsHash: generateHash(appConnection.encryptedCredentials)
} as TAppConnection;
};
+
+const platformManagedCredentialsNotSupported: TAppConnectionTransitionCredentialsToPlatform = ({ app }) => {
+ throw new BadRequestError({
+ message: `${APP_CONNECTION_NAME_MAP[app]} Connections do not support platform managed credentials.`
+ });
+};
+
+export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
+ AppConnection,
+ TAppConnectionTransitionCredentialsToPlatform
+> = {
+ [AppConnection.AWS]: platformManagedCredentialsNotSupported,
+ [AppConnection.Databricks]: platformManagedCredentialsNotSupported,
+ [AppConnection.GitHub]: platformManagedCredentialsNotSupported,
+ [AppConnection.GCP]: platformManagedCredentialsNotSupported,
+ [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported,
+ [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported,
+ [AppConnection.Humanitec]: platformManagedCredentialsNotSupported,
+ [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
+ [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform
+};
diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts
index 8a6c65426..eb28070d5 100644
--- a/backend/src/services/app-connection/app-connection-maps.ts
+++ b/backend/src/services/app-connection/app-connection-maps.ts
@@ -7,5 +7,7 @@ export const APP_CONNECTION_NAME_MAP: Record = {
[AppConnection.AzureKeyVault]: "Azure Key Vault",
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.Databricks]: "Databricks",
- [AppConnection.Humanitec]: "Humanitec"
+ [AppConnection.Humanitec]: "Humanitec",
+ [AppConnection.Postgres]: "PostgreSQL",
+ [AppConnection.MsSql]: "Microsoft SQL Server"
};
diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts
index ef3c16cf8..0d3968637 100644
--- a/backend/src/services/app-connection/app-connection-schemas.ts
+++ b/backend/src/services/app-connection/app-connection-schemas.ts
@@ -3,6 +3,8 @@ import { z } from "zod";
import { AppConnectionsSchema } from "@app/db/schemas/app-connections";
import { AppConnections } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
+import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
+import { TAppConnectionBaseConfig } from "@app/services/app-connection/app-connection-types";
import { AppConnection } from "./app-connection-enums";
@@ -14,7 +16,10 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({
credentialsHash: z.string().optional()
});
-export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) =>
+export const GenericCreateAppConnectionFieldsSchema = (
+ app: AppConnection,
+ { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {}
+) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name),
description: z
@@ -22,10 +27,16 @@ export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) =>
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
- .describe(AppConnections.CREATE(app).description)
+ .describe(AppConnections.CREATE(app).description),
+ isPlatformManagedCredentials: supportsPlatformManagedCredentials
+ ? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials)
+ : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`)
});
-export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) =>
+export const GenericUpdateAppConnectionFieldsSchema = (
+ app: AppConnection,
+ { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {}
+) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(),
description: z
@@ -33,5 +44,8 @@ export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) =>
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
- .describe(AppConnections.UPDATE(app).description)
+ .describe(AppConnections.UPDATE(app).description),
+ isPlatformManagedCredentials: supportsPlatformManagedCredentials
+ ? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials)
+ : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`)
});
diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts
index e2e55bba0..200b2fe0c 100644
--- a/backend/src/services/app-connection/app-connection-service.ts
+++ b/backend/src/services/app-connection/app-connection-service.ts
@@ -6,25 +6,27 @@ import { generateHash } from "@app/lib/crypto/encryption";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { DiscriminativePick, OrgServiceActor } from "@app/lib/types";
-import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
decryptAppConnection,
encryptAppConnectionCredentials,
getAppConnectionMethodName,
listAppConnectionOptions,
+ TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM,
validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns";
-import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
-import {
- TAppConnection,
- TAppConnectionConfig,
- TCreateAppConnectionDTO,
- TUpdateAppConnectionDTO,
- TValidateAppConnectionCredentials
-} from "@app/services/app-connection/app-connection-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
+import { AppConnection } from "./app-connection-enums";
+import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps";
+import {
+ TAppConnection,
+ TAppConnectionConfig,
+ TAppConnectionRaw,
+ TCreateAppConnectionDTO,
+ TUpdateAppConnectionDTO,
+ TValidateAppConnectionCredentials
+} from "./app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
@@ -37,6 +39,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
+import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql";
+import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@@ -53,7 +57,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record
+ appConnectionDAL.transaction(async (tx) => {
+ const encryptedCredentials = await encryptAppConnectionCredentials({
+ credentials: connectionCredentials,
+ orgId: actor.orgId,
+ kmsService
+ });
+
+ return appConnectionDAL.create(
+ {
+ orgId: actor.orgId,
+ encryptedCredentials,
+ method,
+ app,
+ ...params
+ },
+ tx
+ );
+ });
+
+ let connection: TAppConnectionRaw;
+
+ if (params.isPlatformManagedCredentials) {
+ connection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app](
+ {
+ app,
+ orgId: actor.orgId,
+ credentials: validatedCredentials,
+ method
+ } as TAppConnectionConfig,
+ (platformCredentials) => createTransaction(platformCredentials)
+ );
+ } else {
+ connection = await createTransaction(validatedCredentials);
+ }
return {
...connection,
@@ -213,11 +241,18 @@ export const appConnectionServiceFactory = ({
OrgPermissionSubjects.AppConnections
);
- let encryptedCredentials: undefined | Buffer;
+ // prevent updating credentials or management status if platform managed
+ if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) {
+ throw new BadRequestError({
+ message: "Cannot update credentials or management status for platform managed connections"
+ });
+ }
+
+ let updatedCredentials: undefined | TAppConnection["credentials"];
+
+ const { app, method } = appConnection as DiscriminativePick;
if (credentials) {
- const { app, method } = appConnection as DiscriminativePick;
-
if (
!VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({
method,
@@ -230,29 +265,58 @@ export const appConnectionServiceFactory = ({
} Connection with method ${getAppConnectionMethodName(method)}`
});
- const validatedCredentials = await validateAppConnectionCredentials({
+ updatedCredentials = await validateAppConnectionCredentials({
app,
orgId: actor.orgId,
credentials,
method
} as TAppConnectionConfig);
- if (!validatedCredentials)
+ if (!updatedCredentials)
throw new BadRequestError({ message: "Unable to validate connection - check credentials" });
-
- encryptedCredentials = await encryptAppConnectionCredentials({
- credentials: validatedCredentials,
- orgId: actor.orgId,
- kmsService
- });
}
try {
- const updatedConnection = await appConnectionDAL.updateById(connectionId, {
- orgId: actor.orgId,
- encryptedCredentials,
- ...params
- });
+ const updateTransaction = (connectionCredentials: TAppConnection["credentials"] | undefined) =>
+ appConnectionDAL.transaction(async (tx) => {
+ const encryptedCredentials = connectionCredentials
+ ? await encryptAppConnectionCredentials({
+ credentials: connectionCredentials,
+ orgId: actor.orgId,
+ kmsService
+ })
+ : undefined;
+
+ return appConnectionDAL.updateById(
+ connectionId,
+ {
+ orgId: actor.orgId,
+ encryptedCredentials,
+ ...params
+ },
+ tx
+ );
+ });
+
+ let updatedConnection: TAppConnectionRaw;
+
+ if (params.isPlatformManagedCredentials) {
+ if (!updatedCredentials)
+ // prevent enabling platform managed credentials without re-confirming credentials
+ throw new BadRequestError({ message: "Credentials required to transition to platform managed credentials" });
+
+ updatedConnection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app](
+ {
+ app,
+ orgId: actor.orgId,
+ credentials: updatedCredentials,
+ method
+ } as TAppConnectionConfig,
+ (platformCredentials) => updateTransaction(platformCredentials)
+ );
+ } else {
+ updatedConnection = await updateTransaction(updatedCredentials);
+ }
return await decryptAppConnection(updatedConnection, kmsService);
} catch (err) {
diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts
index 7051ecb11..1690f13da 100644
--- a/backend/src/services/app-connection/app-connection-types.ts
+++ b/backend/src/services/app-connection/app-connection-types.ts
@@ -1,24 +1,9 @@
-import { AWSRegion } from "@app/services/app-connection/app-connection-enums";
-import {
- TAwsConnection,
- TAwsConnectionConfig,
- TAwsConnectionInput,
- TValidateAwsConnectionCredentials
-} from "@app/services/app-connection/aws";
-import {
- TDatabricksConnection,
- TDatabricksConnectionConfig,
- TDatabricksConnectionInput,
- TValidateDatabricksConnectionCredentials
-} from "@app/services/app-connection/databricks";
-import {
- TGitHubConnection,
- TGitHubConnectionConfig,
- TGitHubConnectionInput,
- TValidateGitHubConnectionCredentials
-} from "@app/services/app-connection/github";
+import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
+import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
+import { AWSRegion } from "./app-connection-enums";
+import { TAwsConnection, TAwsConnectionConfig, TAwsConnectionInput, TValidateAwsConnectionCredentials } from "./aws";
import {
TAzureAppConfigurationConnection,
TAzureAppConfigurationConnectionConfig,
@@ -31,13 +16,27 @@ import {
TAzureKeyVaultConnectionInput,
TValidateAzureKeyVaultConnectionCredentials
} from "./azure-key-vault";
+import {
+ TDatabricksConnection,
+ TDatabricksConnectionConfig,
+ TDatabricksConnectionInput,
+ TValidateDatabricksConnectionCredentials
+} from "./databricks";
import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp";
+import {
+ TGitHubConnection,
+ TGitHubConnectionConfig,
+ TGitHubConnectionInput,
+ TValidateGitHubConnectionCredentials
+} from "./github";
import {
THumanitecConnection,
THumanitecConnectionConfig,
THumanitecConnectionInput,
TValidateHumanitecConnectionCredentials
} from "./humanitec";
+import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentials } from "./mssql";
+import { TPostgresConnection, TPostgresConnectionInput, TValidatePostgresConnectionCredentials } from "./postgres";
export type TAppConnection = { id: string } & (
| TAwsConnection
@@ -47,8 +46,14 @@ export type TAppConnection = { id: string } & (
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection
+ | TPostgresConnection
+ | TMsSqlConnection
);
+export type TAppConnectionRaw = NonNullable>>;
+
+export type TSqlConnection = TPostgresConnection | TMsSqlConnection;
+
export type TAppConnectionInput = { id: string } & (
| TAwsConnectionInput
| TGitHubConnectionInput
@@ -57,11 +62,15 @@ export type TAppConnectionInput = { id: string } & (
| TAzureAppConfigurationConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
+ | TPostgresConnectionInput
+ | TMsSqlConnectionInput
);
+export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput;
+
export type TCreateAppConnectionDTO = Pick<
TAppConnectionInput,
- "credentials" | "method" | "name" | "app" | "description"
+ "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials"
>;
export type TUpdateAppConnectionDTO = Partial> & {
@@ -75,7 +84,8 @@ export type TAppConnectionConfig =
| TAzureKeyVaultConnectionConfig
| TAzureAppConfigurationConnectionConfig
| TDatabricksConnectionConfig
- | THumanitecConnectionConfig;
+ | THumanitecConnectionConfig
+ | TSqlConnectionConfig;
export type TValidateAppConnectionCredentials =
| TValidateAwsConnectionCredentials
@@ -84,10 +94,25 @@ export type TValidateAppConnectionCredentials =
| TValidateAzureKeyVaultConnectionCredentials
| TValidateAzureAppConfigurationConnectionCredentials
| TValidateDatabricksConnectionCredentials
- | TValidateHumanitecConnectionCredentials;
+ | TValidateHumanitecConnectionCredentials
+ | TValidatePostgresConnectionCredentials
+ | TValidateMsSqlConnectionCredentials;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
region: AWSRegion;
destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager;
};
+
+export type TAppConnectionCredentialsValidator = (
+ appConnection: TAppConnectionConfig
+) => Promise;
+
+export type TAppConnectionTransitionCredentialsToPlatform = (
+ appConnection: TAppConnectionConfig,
+ callback: (credentials: TAppConnection["credentials"]) => Promise
+) => Promise;
+
+export type TAppConnectionBaseConfig = {
+ supportsPlatformManagedCredentials?: boolean;
+};
diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts
index 00a44745a..767cb82fb 100644
--- a/backend/src/services/app-connection/aws/aws-connection-fns.ts
+++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts
@@ -92,7 +92,7 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec
resp = await sts.getCallerIdentity().promise();
} catch (e: unknown) {
throw new BadRequestError({
- message: `Unable to validate connection - verify credentials`
+ message: `Unable to validate connection: verify credentials`
});
}
diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts
index c06c6f0ed..8cb19ba26 100644
--- a/backend/src/services/app-connection/aws/aws-connection-schemas.ts
+++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts
@@ -48,11 +48,11 @@ export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
- method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method),
+ method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials)
}),
z.object({
- method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method),
+ method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts
index 9ccfc72b6..937a8a84f 100644
--- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts
+++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts
@@ -57,7 +57,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
tokenError = e;
} else {
throw new BadRequestError({
- message: `Unable to validate connection - verify credentials`
+ message: `Unable to validate connection: verify credentials`
});
}
}
diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts
index 12b1b3f3b..8e8a6b2a7 100644
--- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts
+++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts
@@ -129,7 +129,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
tokenError = e;
} else {
throw new BadRequestError({
- message: `Unable to validate connection - verify credentials`
+ message: `Unable to validate connection: verify credentials`
});
}
}
diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts
index a12fe290c..fc8da062d 100644
--- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts
+++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts
@@ -86,7 +86,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa
};
} catch (e: unknown) {
throw new BadRequestError({
- message: `Unable to validate connection - verify credentials`
+ message: `Unable to validate connection: verify credentials`
});
}
};
diff --git a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts
index af1a75127..d876b9749 100644
--- a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts
+++ b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts
@@ -49,7 +49,7 @@ export const ValidateDatabricksConnectionCredentialsSchema = z.discriminatedUnio
z.object({
method: z
.literal(DatabricksConnectionMethod.ServicePrincipal)
- .describe(AppConnections?.CREATE(AppConnection.Databricks).method),
+ .describe(AppConnections.CREATE(AppConnection.Databricks).method),
credentials: DatabricksConnectionServicePrincipalInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Databricks).credentials
)
diff --git a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts
index 8f54735e4..8bde74062 100644
--- a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts
+++ b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts
@@ -4,10 +4,10 @@ import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
+import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { AppConnection } from "../app-connection-enums";
-import { getAppConnectionMethodName } from "../app-connection-fns";
import { GcpConnectionMethod } from "./gcp-connection-enums";
import {
GCPApp,
diff --git a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts
index 3c313f205..3637f06dc 100644
--- a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts
+++ b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts
@@ -37,7 +37,7 @@ export const ValidateGcpConnectionCredentialsSchema = z.discriminatedUnion("meth
z.object({
method: z
.literal(GcpConnectionMethod.ServiceAccountImpersonation)
- .describe(AppConnections?.CREATE(AppConnection.GCP).method),
+ .describe(AppConnections.CREATE(AppConnection.GCP).method),
credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GCP).credentials
)
diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts
index 391ba5f96..6ec675c0f 100644
--- a/backend/src/services/app-connection/github/github-connection-fns.ts
+++ b/backend/src/services/app-connection/github/github-connection-fns.ts
@@ -200,7 +200,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
});
} catch (e: unknown) {
throw new BadRequestError({
- message: `Unable to validate connection - verify credentials`
+ message: `Unable to validate connection: verify credentials`
});
}
diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts
index a3f31ed66..8011999b2 100644
--- a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts
+++ b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts
@@ -1,3 +1,3 @@
export enum HumanitecConnectionMethod {
- API_TOKEN = "api-token"
+ ApiToken = "api-token"
}
diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts
index 0eeb9bfbf..b8d257026 100644
--- a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts
+++ b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts
@@ -18,7 +18,7 @@ export const getHumanitecConnectionListItem = () => {
return {
name: "Humanitec" as const,
app: AppConnection.Humanitec as const,
- methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.API_TOKEN]
+ methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.ApiToken]
};
};
@@ -40,7 +40,7 @@ export const validateHumanitecConnectionCredentials = async (config: THumanitecC
});
}
throw new BadRequestError({
- message: "Unable to validate connection - verify credentials"
+ message: "Unable to validate connection: verify credentials"
});
}
diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts
index 145f78b85..4e6cb0078 100644
--- a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts
+++ b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts
@@ -17,13 +17,13 @@ export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({
const BaseHumanitecConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Humanitec) });
export const HumanitecConnectionSchema = BaseHumanitecConnectionSchema.extend({
- method: z.literal(HumanitecConnectionMethod.API_TOKEN),
+ method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: HumanitecConnectionAccessTokenCredentialsSchema
});
export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", [
BaseHumanitecConnectionSchema.extend({
- method: z.literal(HumanitecConnectionMethod.API_TOKEN),
+ method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({})
})
]);
@@ -31,8 +31,8 @@ export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method",
export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
- .literal(HumanitecConnectionMethod.API_TOKEN)
- .describe(AppConnections?.CREATE(AppConnection.Humanitec).method),
+ .literal(HumanitecConnectionMethod.ApiToken)
+ .describe(AppConnections.CREATE(AppConnection.Humanitec).method),
credentials: HumanitecConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Humanitec).credentials
)
diff --git a/backend/src/services/app-connection/mssql/index.ts b/backend/src/services/app-connection/mssql/index.ts
new file mode 100644
index 000000000..81044d0aa
--- /dev/null
+++ b/backend/src/services/app-connection/mssql/index.ts
@@ -0,0 +1,4 @@
+export * from "./mssql-connection-enums";
+export * from "./mssql-connection-fns";
+export * from "./mssql-connection-schemas";
+export * from "./mssql-connection-types";
diff --git a/backend/src/services/app-connection/mssql/mssql-connection-enums.ts b/backend/src/services/app-connection/mssql/mssql-connection-enums.ts
new file mode 100644
index 000000000..335b00441
--- /dev/null
+++ b/backend/src/services/app-connection/mssql/mssql-connection-enums.ts
@@ -0,0 +1,3 @@
+export enum MsSqlConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
diff --git a/backend/src/services/app-connection/mssql/mssql-connection-fns.ts b/backend/src/services/app-connection/mssql/mssql-connection-fns.ts
new file mode 100644
index 000000000..3b6ecf98a
--- /dev/null
+++ b/backend/src/services/app-connection/mssql/mssql-connection-fns.ts
@@ -0,0 +1,12 @@
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+import { MsSqlConnectionMethod } from "./mssql-connection-enums";
+
+export const getMsSqlConnectionListItem = () => {
+ return {
+ name: "Microsoft SQL Server" as const,
+ app: AppConnection.MsSql as const,
+ methods: Object.values(MsSqlConnectionMethod) as [MsSqlConnectionMethod.UsernameAndPassword],
+ supportsPlatformManagement: true as const
+ };
+};
diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts
new file mode 100644
index 000000000..1b659b658
--- /dev/null
+++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts
@@ -0,0 +1,65 @@
+import z from "zod";
+
+import { AppConnections } from "@app/lib/api-docs";
+import {
+ BaseAppConnectionSchema,
+ GenericCreateAppConnectionFieldsSchema,
+ GenericUpdateAppConnectionFieldsSchema
+} from "@app/services/app-connection/app-connection-schemas";
+
+import { AppConnection } from "../app-connection-enums";
+import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql";
+import { MsSqlConnectionMethod } from "./mssql-connection-enums";
+
+export const MsSqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema;
+
+const BaseMsSqlConnectionSchema = BaseAppConnectionSchema.extend({
+ app: z.literal(AppConnection.MsSql)
+});
+
+export const MsSqlConnectionSchema = BaseMsSqlConnectionSchema.extend({
+ method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
+ credentials: MsSqlConnectionAccessTokenCredentialsSchema
+});
+
+export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [
+ BaseMsSqlConnectionSchema.extend({
+ method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
+ credentials: MsSqlConnectionAccessTokenCredentialsSchema.pick({
+ host: true,
+ database: true,
+ port: true,
+ username: true
+ })
+ })
+]);
+
+export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("method", [
+ z.object({
+ method: z
+ .literal(MsSqlConnectionMethod.UsernameAndPassword)
+ .describe(AppConnections.CREATE(AppConnection.MsSql).method),
+ credentials: MsSqlConnectionAccessTokenCredentialsSchema.describe(
+ AppConnections.CREATE(AppConnection.MsSql).credentials
+ )
+ })
+]);
+
+export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and(
+ GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })
+);
+
+export const UpdateMsSqlConnectionSchema = z
+ .object({
+ credentials: MsSqlConnectionAccessTokenCredentialsSchema.optional().describe(
+ AppConnections.UPDATE(AppConnection.MsSql).credentials
+ )
+ })
+ .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }));
+
+export const MsSqlConnectionListItemSchema = z.object({
+ name: z.literal("Microsoft SQL Server"),
+ app: z.literal(AppConnection.MsSql),
+ methods: z.nativeEnum(MsSqlConnectionMethod).array(),
+ supportsPlatformManagement: z.literal(true)
+});
diff --git a/backend/src/services/app-connection/mssql/mssql-connection-types.ts b/backend/src/services/app-connection/mssql/mssql-connection-types.ts
new file mode 100644
index 000000000..ac8fa8f0f
--- /dev/null
+++ b/backend/src/services/app-connection/mssql/mssql-connection-types.ts
@@ -0,0 +1,16 @@
+import z from "zod";
+
+import { AppConnection } from "../app-connection-enums";
+import {
+ CreateMsSqlConnectionSchema,
+ MsSqlConnectionSchema,
+ ValidateMsSqlConnectionCredentialsSchema
+} from "./mssql-connection-schemas";
+
+export type TMsSqlConnection = z.infer;
+
+export type TMsSqlConnectionInput = z.infer & {
+ app: AppConnection.MsSql;
+};
+
+export type TValidateMsSqlConnectionCredentials = typeof ValidateMsSqlConnectionCredentialsSchema;
diff --git a/backend/src/services/app-connection/postgres/index.ts b/backend/src/services/app-connection/postgres/index.ts
new file mode 100644
index 000000000..23ddbba98
--- /dev/null
+++ b/backend/src/services/app-connection/postgres/index.ts
@@ -0,0 +1,4 @@
+export * from "./postgres-connection-enums";
+export * from "./postgres-connection-fns";
+export * from "./postgres-connection-schemas";
+export * from "./postgres-connection-types";
diff --git a/backend/src/services/app-connection/postgres/postgres-connection-enums.ts b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts
new file mode 100644
index 000000000..a29807987
--- /dev/null
+++ b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts
@@ -0,0 +1,3 @@
+export enum PostgresConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
diff --git a/backend/src/services/app-connection/postgres/postgres-connection-fns.ts b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts
new file mode 100644
index 000000000..39053dbf6
--- /dev/null
+++ b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts
@@ -0,0 +1,12 @@
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+import { PostgresConnectionMethod } from "./postgres-connection-enums";
+
+export const getPostgresConnectionListItem = () => {
+ return {
+ name: "PostgreSQL" as const,
+ app: AppConnection.Postgres as const,
+ methods: Object.values(PostgresConnectionMethod) as [PostgresConnectionMethod.UsernameAndPassword],
+ supportsPlatformManagement: true as const
+ };
+};
diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts
new file mode 100644
index 000000000..3867ea8bf
--- /dev/null
+++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts
@@ -0,0 +1,63 @@
+import z from "zod";
+
+import { AppConnections } from "@app/lib/api-docs";
+import {
+ BaseAppConnectionSchema,
+ GenericCreateAppConnectionFieldsSchema,
+ GenericUpdateAppConnectionFieldsSchema
+} from "@app/services/app-connection/app-connection-schemas";
+
+import { AppConnection } from "../app-connection-enums";
+import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql";
+import { PostgresConnectionMethod } from "./postgres-connection-enums";
+
+export const PostgresConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema;
+
+const BasePostgresConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Postgres) });
+
+export const PostgresConnectionSchema = BasePostgresConnectionSchema.extend({
+ method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
+ credentials: PostgresConnectionAccessTokenCredentialsSchema
+});
+
+export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", [
+ BasePostgresConnectionSchema.extend({
+ method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
+ credentials: PostgresConnectionAccessTokenCredentialsSchema.pick({
+ host: true,
+ database: true,
+ port: true,
+ username: true
+ })
+ })
+]);
+
+export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion("method", [
+ z.object({
+ method: z
+ .literal(PostgresConnectionMethod.UsernameAndPassword)
+ .describe(AppConnections.CREATE(AppConnection.Postgres).method),
+ credentials: PostgresConnectionAccessTokenCredentialsSchema.describe(
+ AppConnections.CREATE(AppConnection.Postgres).credentials
+ )
+ })
+]);
+
+export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and(
+ GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })
+);
+
+export const UpdatePostgresConnectionSchema = z
+ .object({
+ credentials: PostgresConnectionAccessTokenCredentialsSchema.optional().describe(
+ AppConnections.UPDATE(AppConnection.Postgres).credentials
+ )
+ })
+ .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }));
+
+export const PostgresConnectionListItemSchema = z.object({
+ name: z.literal("PostgreSQL"),
+ app: z.literal(AppConnection.Postgres),
+ methods: z.nativeEnum(PostgresConnectionMethod).array(),
+ supportsPlatformManagement: z.literal(true)
+});
diff --git a/backend/src/services/app-connection/postgres/postgres-connection-types.ts b/backend/src/services/app-connection/postgres/postgres-connection-types.ts
new file mode 100644
index 000000000..0c8bcff42
--- /dev/null
+++ b/backend/src/services/app-connection/postgres/postgres-connection-types.ts
@@ -0,0 +1,16 @@
+import z from "zod";
+
+import { AppConnection } from "../app-connection-enums";
+import {
+ CreatePostgresConnectionSchema,
+ PostgresConnectionSchema,
+ ValidatePostgresConnectionCredentialsSchema
+} from "./postgres-connection-schemas";
+
+export type TPostgresConnection = z.infer;
+
+export type TPostgresConnectionInput = z.infer & {
+ app: AppConnection.Postgres;
+};
+
+export type TValidatePostgresConnectionCredentials = typeof ValidatePostgresConnectionCredentialsSchema;
diff --git a/backend/src/services/app-connection/shared/sql/index.ts b/backend/src/services/app-connection/shared/sql/index.ts
new file mode 100644
index 000000000..107929154
--- /dev/null
+++ b/backend/src/services/app-connection/shared/sql/index.ts
@@ -0,0 +1,2 @@
+export * from "./sql-connection-fns";
+export * from "./sql-connection-schemas";
diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts
new file mode 100644
index 000000000..b8a29fa77
--- /dev/null
+++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts
@@ -0,0 +1,113 @@
+import knex, { Knex } from "knex";
+
+import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
+import {
+ TSqlCredentialsRotationGeneratedCredentials,
+ TSqlCredentialsRotationWithConnection
+} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
+import { BadRequestError, DatabaseError } from "@app/lib/errors";
+import { alphaNumericNanoId } from "@app/lib/nanoid";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types";
+import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
+
+const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
+
+const SQL_CONNECTION_CLIENT_MAP = {
+ [AppConnection.Postgres]: "pg",
+ [AppConnection.MsSql]: "mssql"
+};
+
+export const getSqlConnectionClient = async (
+ appConnection: Pick,
+ options?: Record
+) => {
+ const {
+ app,
+ credentials: { host: baseHost, database, port, sslCertificate, password, username }
+ } = appConnection;
+
+ const ssl = sslCertificate ? { rejectUnauthorized: false, ca: sslCertificate } : undefined;
+
+ const [host] = await verifyHostInputValidity(baseHost);
+
+ const client = knex({
+ client: SQL_CONNECTION_CLIENT_MAP[app],
+ connection: {
+ database,
+ port,
+ host,
+ user: username,
+ password,
+ connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
+ ssl,
+ options
+ }
+ });
+
+ return client;
+};
+
+export const validateSqlConnectionCredentials = async (config: TSqlConnectionConfig) => {
+ const { credentials, app } = config;
+
+ const client = await getSqlConnectionClient({ app, credentials });
+
+ try {
+ await client.raw(`Select 1`);
+
+ return credentials;
+ } catch (error) {
+ throw new BadRequestError({
+ message:
+ (error as Error)?.message?.replaceAll(credentials.password, "********************") ??
+ "Unable to validate connection: verify credentials"
+ });
+ } finally {
+ await client.destroy();
+ }
+};
+
+export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record<
+ TSqlCredentialsRotationWithConnection["connection"]["app"],
+ (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding]
+> = {
+ [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]],
+ [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]]
+};
+
+export const transferSqlConnectionCredentialsToPlatform = async (
+ config: TSqlConnectionConfig,
+ callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise
+) => {
+ const { credentials, app } = config;
+
+ const client = await getSqlConnectionClient({ app, credentials });
+
+ const newPassword = alphaNumericNanoId(32);
+
+ try {
+ return await client.transaction(async (tx) => {
+ await tx.raw(
+ ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword })
+ );
+ return callback({
+ ...credentials,
+ password: newPassword
+ });
+ });
+ } catch (error) {
+ // update/create service function will handle
+ if (error instanceof DatabaseError) {
+ throw error;
+ }
+
+ throw new BadRequestError({
+ message:
+ (error as Error)?.message?.replaceAll(newPassword, "********************") ??
+ "Encountered an error transferring credentials to platform"
+ });
+ } finally {
+ await client.destroy();
+ }
+};
diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts
new file mode 100644
index 000000000..9688d8d43
--- /dev/null
+++ b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { AppConnections } from "@app/lib/api-docs";
+
+export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
+ host: z.string().trim().min(1, "Host required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.host),
+ port: z.coerce.number().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.port),
+ database: z.string().trim().min(1, "Database required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.database),
+ username: z.string().trim().min(1, "Username required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.username),
+ password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.password),
+ sslCertificate: z.string().trim().optional().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslCertificate)
+});
diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts
new file mode 100644
index 000000000..bbfe4086c
--- /dev/null
+++ b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts
@@ -0,0 +1,6 @@
+import { DiscriminativePick } from "@app/lib/types";
+import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types";
+
+export type TSqlConnectionConfig = DiscriminativePick & {
+ orgId: string;
+};
diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts
index cc2cd1fcf..617393668 100644
--- a/backend/src/services/secret-sync/secret-sync-dal.ts
+++ b/backend/src/services/secret-sync/secret-sync-dal.ts
@@ -31,7 +31,11 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
- db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt")
+ db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
+ db
+ .ref("isPlatformManagedCredentials")
+ .withSchema(TableName.AppConnection)
+ .as("connectionIsPlatformManagedCredentials")
);
if (filter) {
@@ -60,6 +64,7 @@ const expandSecretSync = (
connectionCreatedAt,
connectionUpdatedAt,
connectionVersion,
+ connectionIsPlatformManagedCredentials,
...el
} = secretSync;
@@ -77,7 +82,8 @@ const expandSecretSync = (
description: connectionDescription,
createdAt: connectionCreatedAt,
updatedAt: connectionUpdatedAt,
- version: connectionVersion
+ version: connectionVersion,
+ isPlatformManagedCredentials: connectionIsPlatformManagedCredentials
},
folder: folder
? {
diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts
index 5c4a7e850..14a1a1cf0 100644
--- a/backend/src/services/secret-sync/secret-sync-service.ts
+++ b/backend/src/services/secret-sync/secret-sync-service.ts
@@ -119,14 +119,10 @@ export const secretSyncServiceFactory = ({
{ destination, syncName, projectId }: TFindSecretSyncByNameDTO,
actor: OrgServiceActor
) => {
- const folders = await folderDAL.findByProjectId(projectId);
-
- // we prevent conflicting names within a project so this will only return one at most
- const [secretSync] = await secretSyncDAL.find({
+ // we prevent conflicting names within a project
+ const secretSync = await secretSyncDAL.findOne({
name: syncName,
- $in: {
- folderId: folders.map((folder) => folder.id)
- }
+ projectId
});
if (!secretSync)
diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts
index 2044c7c17..bd28e1ee7 100644
--- a/backend/src/services/secret-sync/secret-sync-types.ts
+++ b/backend/src/services/secret-sync/secret-sync-types.ts
@@ -1,6 +1,6 @@
import { Job } from "bullmq";
-import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
+import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import {
@@ -129,8 +129,6 @@ export type TDeleteSecretSyncDTO = {
removeSecrets: boolean;
};
-type AuditLogInfo = Pick;
-
export enum SecretSyncStatus {
Pending = "pending",
Running = "running",
diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts
index b4619abd3..8d9e5f0cc 100644
--- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts
+++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts
@@ -35,15 +35,25 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
+ .leftJoin(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretV2}.id`,
+ `${TableName.SecretRotationV2SecretMapping}.secretId`
+ )
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
- .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
-
+ .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
+ .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
const data = sqlNestRelationships({
data: docs,
key: "id",
- parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
+ parentMapper: (el) => ({
+ _id: el.id,
+ ...SecretsV2Schema.parse(el),
+ isRotatedSecret: Boolean(el.rotationId),
+ rotationId: el.rotationId
+ }),
childrenMapper: [
{
key: "tagId",
@@ -79,6 +89,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
+ .leftJoin(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretV2}.id`,
+ `${TableName.SecretRotationV2SecretMapping}.secretId`
+ )
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
@@ -87,7 +102,8 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
- .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
+ .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
+ .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
@@ -98,7 +114,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
const data = sqlNestRelationships({
data: docs,
key: "id",
- parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
+ parentMapper: (el) => ({
+ _id: el.id,
+ ...SecretsV2Schema.parse(el),
+ rotationId: el.rotationId,
+ isRotatedSecret: Boolean(el.rotationId)
+ }),
childrenMapper: [
{
key: "tagId",
@@ -332,6 +353,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
}
const query = (tx || db.replicaNode())(TableName.SecretV2)
+ .leftJoin(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretV2}.id`,
+ `${TableName.SecretRotationV2SecretMapping}.secretId`
+ )
.whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
@@ -414,6 +440,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
+ .leftJoin(
+ TableName.SecretRotationV2SecretMapping,
+ `${TableName.SecretV2}.id`,
+ `${TableName.SecretRotationV2SecretMapping}.secretId`
+ )
.where((qb) => {
if (filters?.metadataFilter && filters.metadataFilter.length > 0) {
filters.metadataFilter.forEach((meta) => {
@@ -444,6 +475,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
+ .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping))
.where((bd) => {
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
@@ -472,7 +504,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
const data = sqlNestRelationships({
data: secs,
key: "id",
- parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
+ parentMapper: (el) => ({
+ _id: el.id,
+ ...SecretsV2Schema.parse(el),
+ rotationId: el.rotationId,
+ isRotatedSecret: Boolean(el.rotationId)
+ }),
childrenMapper: [
{
key: "tagId",
diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts
index b2fe923aa..71fff2949 100644
--- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts
+++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts
@@ -666,6 +666,8 @@ export const reshapeBridgeSecret = (
name: string;
}[];
secretMetadata?: ResourceMetadataDTO;
+ isRotatedSecret?: boolean;
+ rotationId?: string;
},
secretValueHidden: boolean
) => ({
@@ -695,7 +697,8 @@ export const reshapeBridgeSecret = (
secretMetadata: secret.secretMetadata,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
-
+ isRotatedSecret: secret.isRotatedSecret,
+ rotationId: secret.rotationId,
...(secretValueHidden
? {
secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK,
diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts
index dc975854f..dc209a794 100644
--- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts
+++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts
@@ -25,6 +25,7 @@ import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-app
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
+import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { diff, groupBy } from "@app/lib/fn";
import { setKnexStringValue } from "@app/lib/knex";
@@ -414,6 +415,8 @@ export const secretV2BridgeServiceFactory = ({
});
if (!sharedSecretToModify)
throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` });
+ if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue))
+ throw new BadRequestError({ message: "Cannot update rotated secret name or value" });
secretId = sharedSecretToModify.id;
secret = sharedSecretToModify;
}
@@ -624,66 +627,79 @@ export const secretV2BridgeServiceFactory = ({
})
);
- const deletedSecret = await secretDAL.transaction(async (tx) =>
- fnSecretBulkDelete({
- projectId,
- folderId,
- actorId,
- secretDAL,
- secretQueueService,
- inputSecrets: [
- {
- type: inputSecret.type as SecretType,
- secretKey: inputSecret.secretName
- }
- ],
- tx
- })
- );
+ try {
+ const deletedSecret = await secretDAL.transaction(async (tx) =>
+ fnSecretBulkDelete({
+ projectId,
+ folderId,
+ actorId,
+ secretDAL,
+ secretQueueService,
+ inputSecrets: [
+ {
+ type: inputSecret.type as SecretType,
+ secretKey: inputSecret.secretName
+ }
+ ],
+ tx
+ })
+ );
- if (inputSecret.type === SecretType.Shared) {
- await snapshotService.performSnapshot(folderId);
- await secretQueueService.syncSecrets({
- secretPath,
- actorId,
- actor,
- projectId,
- orgId: actorOrgId,
- environmentSlug: folder.environment.slug
+ if (inputSecret.type === SecretType.Shared) {
+ await snapshotService.performSnapshot(folderId);
+ await secretQueueService.syncSecrets({
+ secretPath,
+ actorId,
+ actor,
+ projectId,
+ orgId: actorOrgId,
+ environmentSlug: folder.environment.slug
+ });
+ }
+
+ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
});
- }
- const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
- type: KmsDataKey.SecretManager,
- projectId
- });
+ const secretValueHidden = !hasSecretReadValueOrDescribePermission(
+ permission,
+ ProjectPermissionSecretActions.ReadValue,
+ {
+ environment,
+ secretPath,
+ secretName: secretToDelete.key,
+ secretTags: secretToDelete.tags?.map((el) => el.slug)
+ }
+ );
- const secretValueHidden = !hasSecretReadValueOrDescribePermission(
- permission,
- ProjectPermissionSecretActions.ReadValue,
- {
+ return reshapeBridgeSecret(
+ projectId,
environment,
secretPath,
- secretName: secretToDelete.key,
- secretTags: secretToDelete.tags?.map((el) => el.slug)
+ {
+ ...deletedSecret[0],
+ value: deletedSecret[0].encryptedValue
+ ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString()
+ : "",
+ comment: deletedSecret[0].encryptedComment
+ ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString()
+ : ""
+ },
+ secretValueHidden
+ );
+ } catch (err) {
+ // deferred errors aren't return as DatabaseError
+ const error = err as { code: string; table: string };
+ if (
+ error?.code === DatabaseErrorCode.ForeignKeyViolation &&
+ error?.table === TableName.SecretRotationV2SecretMapping
+ ) {
+ throw new BadRequestError({ message: "Cannot delete rotated secrets" });
}
- );
- return reshapeBridgeSecret(
- projectId,
- environment,
- secretPath,
- {
- ...deletedSecret[0],
- value: deletedSecret[0].encryptedValue
- ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString()
- : "",
- comment: deletedSecret[0].encryptedComment
- ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString()
- : ""
- },
- secretValueHidden
- );
+ throw err;
+ }
};
// get unique secrets count for multiple envs
@@ -946,6 +962,7 @@ export const secretV2BridgeServiceFactory = ({
projectId
});
+ // scott: if any of this changes it also needs to be mirrored in secret rotation for getting dashboard secrets
const decryptedSecrets = secrets
.filter((el) => {
const canDescribeSecret = hasSecretReadValueOrDescribePermission(
@@ -1667,6 +1684,13 @@ export const secretV2BridgeServiceFactory = ({
secretTags: el.tags.map((i) => i.slug)
})
);
+
+ if (el.isRotatedSecret) {
+ const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key);
+
+ if (input && (input.newSecretName || input.secretValue))
+ throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` });
+ }
});
// get all tags
@@ -1969,61 +1993,76 @@ export const secretV2BridgeServiceFactory = ({
);
});
- const secretsDeleted = await secretDAL.transaction(async (tx) =>
- fnSecretBulkDelete({
- secretDAL,
- secretQueueService,
- inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
- secretKey,
- type: type || SecretType.Shared
- })),
- projectId,
- folderId,
- actorId,
- tx
- })
- );
-
- // await snapshotService.performSnapshot(folderId);
- await secretQueueService.syncSecrets({
- actor,
- actorId,
- secretPath,
- projectId,
- orgId: actorOrgId,
- environmentSlug: folder.environment.slug
- });
-
- const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
- type: KmsDataKey.SecretManager,
- projectId
- });
- return secretsDeleted.map((el) => {
- const secretToDeleteMatch = secretsToDelete.find(
- (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type
+ try {
+ const secretsDeleted = await secretDAL.transaction(async (tx) =>
+ fnSecretBulkDelete({
+ secretDAL,
+ secretQueueService,
+ inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
+ secretKey,
+ type: type || SecretType.Shared
+ })),
+ projectId,
+ folderId,
+ actorId,
+ tx
+ })
);
- const secretValueHidden =
- !secretToDeleteMatch ||
- !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
+ await snapshotService.performSnapshot(folderId);
+ await secretQueueService.syncSecrets({
+ actor,
+ actorId,
+ secretPath,
+ projectId,
+ orgId: actorOrgId,
+ environmentSlug: folder.environment.slug
+ });
+
+ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.SecretManager,
+ projectId
+ });
+ return secretsDeleted.map((el) => {
+ const secretToDeleteMatch = secretsToDelete.find(
+ (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type
+ );
+
+ const secretValueHidden =
+ !secretToDeleteMatch ||
+ !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
+ environment,
+ secretPath,
+ secretName: el.key,
+ secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
+ });
+
+ return reshapeBridgeSecret(
+ projectId,
environment,
secretPath,
- secretName: el.key,
- secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
- });
+ {
+ ...el,
+ value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
+ comment: el.encryptedComment
+ ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
+ : ""
+ },
+ secretValueHidden
+ );
+ });
+ } catch (err) {
+ // deferred errors aren't return as DatabaseError
+ const error = err as { code: string; table: string };
+ if (
+ error?.code === DatabaseErrorCode.ForeignKeyViolation &&
+ error?.table === TableName.SecretRotationV2SecretMapping
+ ) {
+ throw new BadRequestError({ message: "Cannot delete rotated secrets" });
+ }
- return reshapeBridgeSecret(
- projectId,
- environment,
- secretPath,
- {
- ...el,
- value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
- comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : ""
- },
- secretValueHidden
- );
- });
+ throw err;
+ }
};
const getSecretVersions = async ({
diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts
index 7f415bff8..1c23db183 100644
--- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts
+++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts
@@ -132,7 +132,7 @@ export type TUpdateManySecretDTO = Omit & {
secrets: {
secretKey: string;
newSecretName?: string;
- secretValue: string;
+ secretValue?: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
tagIds?: string[];
diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts
index 6a5c097ed..5ea290b6a 100644
--- a/backend/src/services/secret/secret-types.ts
+++ b/backend/src/services/secret/secret-types.ts
@@ -297,7 +297,7 @@ export type TUpdateManySecretRawDTO = Omit & {
secrets: {
secretKey: string;
newSecretName?: string;
- secretValue: string;
+ secretValue?: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
tagIds?: string[];
diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts
index 68e0ecd22..0e7483ef8 100644
--- a/backend/src/services/smtp/smtp-service.ts
+++ b/backend/src/services/smtp/smtp-service.ts
@@ -40,7 +40,8 @@ export enum SmtpTemplates {
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
ExternalImportFailed = "externalImportFailed.handlebars",
ExternalImportStarted = "externalImportStarted.handlebars",
- SecretRequestCompleted = "secretRequestCompleted.handlebars"
+ SecretRequestCompleted = "secretRequestCompleted.handlebars",
+ SecretRotationFailed = "secretRotationFailed.handlebars"
}
export enum SmtpHost {
diff --git a/backend/src/services/smtp/templates/secretRotationFailed.handlebars b/backend/src/services/smtp/templates/secretRotationFailed.handlebars
new file mode 100644
index 000000000..728798ce8
--- /dev/null
+++ b/backend/src/services/smtp/templates/secretRotationFailed.handlebars
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Your {{rotationType}} Rotation "{{rotationName}}" Failed to Rotate
+
+
+
+ Infisical
+
+
+
+
+
+
Name : {{rotationName}}
+
Type : {{rotationType}}
+
Project : {{projectName}}
+
Environment : {{environment}}
+
Secret Path : {{secretPath}}
+
+
+ {{emailFooter}}
+
+
+
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/app-connections/mssql/available.mdx b/docs/api-reference/endpoints/app-connections/mssql/available.mdx
new file mode 100644
index 000000000..cb8949c4a
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/available.mdx
@@ -0,0 +1,4 @@
+---
+title: "Available"
+openapi: "GET /api/v1/app-connections/mssql/available"
+---
diff --git a/docs/api-reference/endpoints/app-connections/mssql/create.mdx b/docs/api-reference/endpoints/app-connections/mssql/create.mdx
new file mode 100644
index 000000000..d582d7219
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v1/app-connections/mssql"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/app-connections/mssql/delete.mdx b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx
new file mode 100644
index 000000000..af45cb416
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/app-connections/mssql/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx
new file mode 100644
index 000000000..9eb08c97d
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v1/app-connections/mssql/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx
new file mode 100644
index 000000000..c916d2219
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v1/app-connections/mssql/connection-name/{connectionName}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/mssql/list.mdx b/docs/api-reference/endpoints/app-connections/mssql/list.mdx
new file mode 100644
index 000000000..490bb497b
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v1/app-connections/mssql"
+---
diff --git a/docs/api-reference/endpoints/app-connections/mssql/update.mdx b/docs/api-reference/endpoints/app-connections/mssql/update.mdx
new file mode 100644
index 000000000..84522b9e4
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/mssql/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/app-connections/mssql/{connectionId}"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/app-connections/postgres/available.mdx b/docs/api-reference/endpoints/app-connections/postgres/available.mdx
new file mode 100644
index 000000000..92e360d06
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/available.mdx
@@ -0,0 +1,4 @@
+---
+title: "Available"
+openapi: "GET /api/v1/app-connections/postgres/available"
+---
diff --git a/docs/api-reference/endpoints/app-connections/postgres/create.mdx b/docs/api-reference/endpoints/app-connections/postgres/create.mdx
new file mode 100644
index 000000000..8657954e8
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v1/app-connections/postgres"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/app-connections/postgres/delete.mdx b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx
new file mode 100644
index 000000000..927bfec49
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/app-connections/postgres/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx
new file mode 100644
index 000000000..3ee3f5996
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v1/app-connections/postgres/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx
new file mode 100644
index 000000000..c9b29cb66
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v1/app-connections/postgres/connection-name/{connectionName}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/postgres/list.mdx b/docs/api-reference/endpoints/app-connections/postgres/list.mdx
new file mode 100644
index 000000000..5d1be4664
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v1/app-connections/postgres"
+---
diff --git a/docs/api-reference/endpoints/app-connections/postgres/update.mdx b/docs/api-reference/endpoints/app-connections/postgres/update.mdx
new file mode 100644
index 000000000..19a206958
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/postgres/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/app-connections/postgres/{connectionId}"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secret-rotations/list.mdx b/docs/api-reference/endpoints/secret-rotations/list.mdx
new file mode 100644
index 000000000..8b3e931f0
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/secret-rotations"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx
new file mode 100644
index 000000000..14249aced
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v2/secret-rotations/mssql-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx
new file mode 100644
index 000000000..117948674
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v2/secret-rotations/mssql-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx
new file mode 100644
index 000000000..e0fc208ee
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx
new file mode 100644
index 000000000..442ab5bd7
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v2/secret-rotations/mssql-credentials/rotation-name/{rotationName}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx
new file mode 100644
index 000000000..311715879
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get Credentials by ID"
+openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}/generated-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx
new file mode 100644
index 000000000..e79ee758b
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/secret-rotations/mssql-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx
new file mode 100644
index 000000000..543acb9e3
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx
@@ -0,0 +1,4 @@
+---
+title: "Rotate Secrets"
+openapi: "POST /api/v2/secret-rotations/mssql-credentials/{rotationId}/rotate-secrets"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx
new file mode 100644
index 000000000..e5404d049
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v2/secret-rotations/mssql-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/options.mdx b/docs/api-reference/endpoints/secret-rotations/options.mdx
new file mode 100644
index 000000000..9e1a4e544
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/options.mdx
@@ -0,0 +1,4 @@
+---
+title: "Options"
+openapi: "GET /api/v2/secret-rotations/options"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx
new file mode 100644
index 000000000..76540dfdc
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v2/secret-rotations/postgres-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx
new file mode 100644
index 000000000..7919313b6
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v2/secret-rotations/postgres-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx
new file mode 100644
index 000000000..7914eac7e
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx
new file mode 100644
index 000000000..f215a1d7b
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v2/secret-rotations/postgres-credentials/rotation-name/{rotationName}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx
new file mode 100644
index 000000000..34f308514
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get Credentials by ID"
+openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}/generated-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx
new file mode 100644
index 000000000..6c93a2790
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/secret-rotations/postgres-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx
new file mode 100644
index 000000000..687c15279
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx
@@ -0,0 +1,4 @@
+---
+title: "Rotate Secrets"
+openapi: "POST /api/v2/secret-rotations/postgres-credentials/{rotationId}/rotate-secrets"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx
new file mode 100644
index 000000000..7e4ec0883
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v2/secret-rotations/postgres-credentials/{rotationId}"
+---
diff --git a/docs/mint.json b/docs/mint.json
index 478db0276..e0d15ed8f 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -823,6 +823,39 @@
"api-reference/endpoints/secret-imports/delete"
]
},
+ {
+ "group": "Secret Rotations",
+ "pages": [
+ "api-reference/endpoints/secret-rotations/list",
+ "api-reference/endpoints/secret-rotations/options",
+ {
+ "group": "Microsoft SQL Server Credentials",
+ "pages": [
+ "api-reference/endpoints/secret-rotations/mssql-credentials/create",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/delete",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/list",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets",
+ "api-reference/endpoints/secret-rotations/mssql-credentials/update"
+ ]
+ },
+ {
+ "group": "PostgreSQL Credentials",
+ "pages": [
+ "api-reference/endpoints/secret-rotations/postgres-credentials/create",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/delete",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/list",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets",
+ "api-reference/endpoints/secret-rotations/postgres-credentials/update"
+ ]
+ }
+ ]
+ },
{
"group": "Identity Specific Privilege",
"pages": [
@@ -922,6 +955,30 @@
"api-reference/endpoints/app-connections/humanitec/update",
"api-reference/endpoints/app-connections/humanitec/delete"
]
+ },
+ {
+ "group": "Microsoft SQL Server",
+ "pages": [
+ "api-reference/endpoints/app-connections/mssql/list",
+ "api-reference/endpoints/app-connections/mssql/available",
+ "api-reference/endpoints/app-connections/mssql/get-by-id",
+ "api-reference/endpoints/app-connections/mssql/get-by-name",
+ "api-reference/endpoints/app-connections/mssql/create",
+ "api-reference/endpoints/app-connections/mssql/update",
+ "api-reference/endpoints/app-connections/mssql/delete"
+ ]
+ },
+ {
+ "group": "PostgreSQL",
+ "pages": [
+ "api-reference/endpoints/app-connections/postgres/list",
+ "api-reference/endpoints/app-connections/postgres/available",
+ "api-reference/endpoints/app-connections/postgres/get-by-id",
+ "api-reference/endpoints/app-connections/postgres/get-by-name",
+ "api-reference/endpoints/app-connections/postgres/create",
+ "api-reference/endpoints/app-connections/postgres/update",
+ "api-reference/endpoints/app-connections/postgres/delete"
+ ]
}
]
},
diff --git a/frontend/public/images/integrations/MsSql.png b/frontend/public/images/integrations/MsSql.png
new file mode 100644
index 000000000..108ed60f9
Binary files /dev/null and b/frontend/public/images/integrations/MsSql.png differ
diff --git a/frontend/public/images/integrations/MySql.png b/frontend/public/images/integrations/MySql.png
new file mode 100644
index 000000000..d92befdbc
Binary files /dev/null and b/frontend/public/images/integrations/MySql.png differ
diff --git a/frontend/public/images/integrations/Postgres.png b/frontend/public/images/integrations/Postgres.png
new file mode 100644
index 000000000..b7152860d
Binary files /dev/null and b/frontend/public/images/integrations/Postgres.png differ
diff --git a/frontend/public/images/integrations/SendGrid.png b/frontend/public/images/integrations/SendGrid.png
new file mode 100644
index 000000000..3d2c9a92d
Binary files /dev/null and b/frontend/public/images/integrations/SendGrid.png differ
diff --git a/frontend/public/images/secretRotation/secret-rotations-v2-location.png b/frontend/public/images/secretRotation/secret-rotations-v2-location.png
new file mode 100644
index 000000000..6c0e7d8f1
Binary files /dev/null and b/frontend/public/images/secretRotation/secret-rotations-v2-location.png differ
diff --git a/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx
new file mode 100644
index 000000000..8a824d101
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+
+import { SecretRotationV2Form } from "@app/components/secret-rotations-v2/forms";
+import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
+import { SecretRotationV2Select } from "@app/components/secret-rotations-v2/SecretRotationV2Select";
+import { Modal, ModalContent } from "@app/components/v2";
+import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
+
+type SharedProps = {
+ secretPath: string;
+ environment?: string;
+ environments?: WorkspaceEnv[];
+};
+
+type Props = {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+} & SharedProps;
+
+type ContentProps = {
+ onComplete: (secretRotation: TSecretRotationV2) => void;
+ selectedRotation: SecretRotation | null;
+ setSelectedRotation: (selectedRotation: SecretRotation | null) => void;
+} & SharedProps;
+
+const Content = ({ setSelectedRotation, selectedRotation, ...props }: ContentProps) => {
+ if (selectedRotation) {
+ return (
+ setSelectedRotation(null)}
+ type={selectedRotation}
+ {...props}
+ />
+ );
+ }
+
+ return ;
+};
+
+export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: Props) => {
+ const [selectedRotation, setSelectedRotation] = useState(null);
+
+ return (
+ {
+ if (!open) setSelectedRotation(null);
+ onOpenChange(open);
+ }}
+ >
+
+ ) : (
+ "Add Secret Rotation"
+ )
+ }
+ onPointerDownOutside={(e) => e.preventDefault()}
+ className={selectedRotation ? "max-w-2xl" : "max-w-3xl"}
+ subTitle={
+ selectedRotation ? undefined : "Select a provider to create a secret rotation for."
+ }
+ bodyClassName="overflow-visible"
+ >
+ {
+ setSelectedRotation(null);
+ onOpenChange(false);
+ }}
+ selectedRotation={selectedRotation}
+ setSelectedRotation={setSelectedRotation}
+ {...props}
+ />
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx
new file mode 100644
index 000000000..16f9a67d4
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useState } from "react";
+
+import { createNotification } from "@app/components/notifications";
+import { DeleteActionModal, Switch } from "@app/components/v2";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+import { useDeleteSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
+
+type Props = {
+ secretRotation?: TSecretRotationV2;
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ onComplete?: () => void;
+};
+
+export const DeleteSecretRotationV2Modal = ({
+ isOpen,
+ onOpenChange,
+ secretRotation,
+ onComplete
+}: Props) => {
+ const deleteSecretRotation = useDeleteSecretRotationV2();
+ const [revokeGeneratedCredentials, setRevokeGeneratedCredentials] = useState(false);
+ const [deleteSecrets, setDeleteSecrets] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setRevokeGeneratedCredentials(false);
+ setDeleteSecrets(false);
+ }
+ }, [isOpen]);
+
+ if (!secretRotation) return null;
+
+ const { id: rotationId, name, type, projectId, folder } = secretRotation;
+
+ const handleDeleteSecretRotation = async () => {
+ const rotationType = SECRET_ROTATION_MAP[type].name;
+
+ try {
+ await deleteSecretRotation.mutateAsync({
+ rotationId,
+ type,
+ revokeGeneratedCredentials,
+ deleteSecrets,
+ projectId,
+ secretPath: folder.path
+ });
+
+ createNotification({
+ text: `Successfully deleted ${rotationType} Rotation`,
+ type: "success"
+ });
+
+ if (onComplete) onComplete();
+ onOpenChange(false);
+ } catch {
+ createNotification({
+ text: `Failed to delete ${rotationType} Rotation`,
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+ Revoke Credentials
+
+
+ Generated credentials will {revokeGeneratedCredentials ? "" : "not"} be revoked on deletion
+ {revokeGeneratedCredentials ? "" : " and remain active"}.
+
+
+ Delete Secrets
+
+
+ Rotation secrets will {deleteSecrets ? "" : "not"} be removed from your project on deletion.
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx
new file mode 100644
index 000000000..e9efc27f2
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx
@@ -0,0 +1,34 @@
+import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
+import { Modal, ModalContent } from "@app/components/v2";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+
+import { SecretRotationV2Form } from "./forms";
+
+type Props = {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ secretRotation?: TSecretRotationV2;
+};
+
+export const EditSecretRotationV2Modal = ({ secretRotation, onOpenChange, ...props }: Props) => {
+ if (!secretRotation) return null;
+
+ return (
+
+ }
+ className="max-w-2xl"
+ bodyClassName="overflow-visible"
+ >
+ onOpenChange(false)}
+ onCancel={() => onOpenChange(false)}
+ secretRotation={secretRotation}
+ type={secretRotation.type}
+ secretPath={secretRotation.folder.path}
+ environment={secretRotation.environment.slug}
+ />
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx
new file mode 100644
index 000000000..e2c931d49
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx
@@ -0,0 +1,88 @@
+import { createNotification } from "@app/components/notifications";
+import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+import { useRotateSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
+
+type Props = {
+ secretRotation?: TSecretRotationV2;
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+};
+
+type ContentProps = {
+ secretRotation: TSecretRotationV2;
+ onComplete: () => void;
+};
+
+const Content = ({ secretRotation, onComplete }: ContentProps) => {
+ const rotateSecrets = useRotateSecretRotationV2();
+
+ const { id: rotationId, type, projectId, folder } = secretRotation;
+ const rotationType = SECRET_ROTATION_MAP[type].name;
+
+ const handleRotateSecrets = async () => {
+ try {
+ await rotateSecrets.mutateAsync({
+ rotationId,
+ type,
+ projectId,
+ secretPath: folder.path
+ });
+
+ createNotification({
+ text: `Successfully rotated ${rotationType} secrets`,
+ type: "success"
+ });
+
+ onComplete();
+ } catch (err) {
+ console.error(err);
+
+ createNotification({
+ text: `Failed to rotate ${rotationType} secrets`,
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+ Are you sure you want to rotate the secrets for this {rotationType} Rotation?
+
+
+
+
+ Cancel
+
+
+
+ Rotate Secrets
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+ {isConfigured
+ ? `Edit ${destinationDetails.name} Rotation`
+ : `Rotate ${destinationDetails.name}`}
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx
new file mode 100644
index 000000000..d934dcb65
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx
@@ -0,0 +1,151 @@
+import { faWrench } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { createNotification } from "@app/components/notifications";
+import { Spinner, Tooltip } from "@app/components/v2";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import { SecretRotation, useSecretRotationV2Options } from "@app/hooks/api/secretRotationsV2";
+
+type Props = {
+ onSelect: (type: SecretRotation) => void;
+};
+
+export const SecretRotationV2Select = ({ onSelect }: Props) => {
+ const { isPending, data: secretRotationOptions } = useSecretRotationV2Options();
+
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ 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 (
+
onSelect(type)}
+ className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
+ >
+
+
+ {name}
+
+
+ );
+ })}
+ {/* templates for kubecon, remove once implemented */}
+ {[
+ {
+ name: "MySQL Credentials",
+ image: "MySql.png"
+ },
+ {
+ name: "SendGrid API Key",
+ image: "SendGrid.png"
+ },
+ {
+ name: "AWS IAM User Credentials",
+ image: "Amazon Web Services.png"
+ }
+ ].map(({ name, image }) => {
+ let size: number;
+
+ switch (name) {
+ case "MySQL Credentials":
+ size = 80;
+ break;
+ case "SendGrid API Key":
+ size = 50;
+ break;
+ default:
+ size = 45;
+ }
+
+ return (
+
+ createNotification({
+ type: "info",
+ text: `${name} Rotation is under development. Please check back soon.`
+ })
+ }
+ className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
+ >
+
+
+ {name}
+
+
+ );
+ })}
+
+ 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
+
+ .
+
+ >
+ }
+ >
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx
new file mode 100644
index 000000000..ace829aae
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx
@@ -0,0 +1,119 @@
+import { faBan, faRotate, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { format, formatDistanceToNow } from "date-fns";
+import { twMerge } from "tailwind-merge";
+
+import { Tooltip } from "@app/components/v2";
+import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge";
+import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+
+type Props = {
+ secretRotation: TSecretRotationV2;
+ className?: string;
+};
+
+export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props) => {
+ const { isAutoRotationEnabled, rotationStatus, nextRotationAt, lastRotationMessage } =
+ secretRotation;
+
+ if (rotationStatus === SecretRotationStatus.Failed) {
+ let errorMessage = lastRotationMessage;
+ if (lastRotationMessage) {
+ try {
+ errorMessage = JSON.stringify(JSON.parse(lastRotationMessage), null, 2);
+ } catch {
+ errorMessage = lastRotationMessage;
+ }
+ }
+
+ return (
+
+
+ {nextRotationAt && (
+
+ Next rotation attempt on {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
+ {format(nextRotationAt, "h:mm aa")}.
+
+ )}
+
+ }
+ >
+
+
+
+ Rotation Failed
+
+
+
+ );
+ }
+
+ if (!isAutoRotationEnabled) {
+ return (
+
+
+ Auto-Rotation Disabled
+
+ );
+ }
+
+ const daysToRotation =
+ (new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24);
+
+ let variant: BadgeProps["variant"];
+ let label: string;
+ let tooltipContent: string;
+
+ if (daysToRotation >= 7) {
+ variant = "success";
+ label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
+ tooltipContent = `Rotates ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
+ } else if (daysToRotation < 0) {
+ variant = "primary";
+ label = "Rotating";
+ tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
+ } else if (daysToRotation < 1) {
+ variant = "primary";
+ label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
+ tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
+ } else {
+ variant = "primary";
+ label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
+ tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
+ }
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx
new file mode 100644
index 000000000..79a431bc8
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx
@@ -0,0 +1,96 @@
+import { ReactNode } from "react";
+import { faRotate } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { format } from "date-fns";
+
+import { Modal, ModalContent, Spinner } from "@app/components/v2";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import {
+ SecretRotation,
+ TSecretRotationV2,
+ useViewSecretRotationV2GeneratedCredentials
+} from "@app/hooks/api/secretRotationsV2";
+
+import { ViewSqlRotationGeneratedCredentials } from "./shared";
+
+type Props = {
+ secretRotation?: TSecretRotationV2;
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+};
+
+type ContentProps = {
+ secretRotation: TSecretRotationV2;
+};
+
+const Content = ({ secretRotation }: ContentProps) => {
+ const { id: rotationId, type, nextRotationAt } = secretRotation;
+
+ const { data: generatedCredentialsResponse, isPending } =
+ useViewSecretRotationV2GeneratedCredentials({
+ rotationId,
+ type
+ });
+
+ if (isPending) {
+ return (
+
+
+
Loading generated credentials...
+
+ );
+ }
+
+ let Component: ReactNode;
+ switch (generatedCredentialsResponse!.type) {
+ case SecretRotation.PostgresCredentials:
+ case SecretRotation.MsSqlCredentials:
+ Component = (
+
+ );
+ break;
+ default:
+ throw new Error("Unhandled View Generated Credential Rotation Type");
+ }
+
+ return (
+
+ {Component}
+ {nextRotationAt && (
+
+
+
+ Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
+ {format(nextRotationAt, "h:mm aa")}
+
+
+ )}
+
+ );
+};
+
+export const ViewSecretRotationV2GeneratedCredentialsModal = ({
+ isOpen,
+ onOpenChange,
+ secretRotation
+}: Props) => {
+ if (!secretRotation) return null;
+
+ const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name;
+
+ return (
+
+ {
+ event.preventDefault();
+ }}
+ title="Generated Credentials"
+ subTitle={`View the current and retired ${rotationType}.`}
+ >
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts
new file mode 100644
index 000000000..31aa279b4
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts
@@ -0,0 +1 @@
+export * from "./ViewSecretRotationV2GeneratedCredentials";
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx
new file mode 100644
index 000000000..783510336
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx
@@ -0,0 +1,55 @@
+import { useReducer } from "react";
+import { faCheck, faCopy, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2";
+import { useTimedReset } from "@app/hooks";
+
+type Props = {
+ children?: string;
+ label: string;
+ isSensitive?: boolean;
+};
+
+export const CredentialDisplay = ({ children, label, isSensitive }: Props) => {
+ const [showCredential, toggleShowCredential] = useReducer((prev) => !prev, !isSensitive);
+
+ const [, isCopyingCredential, setCopyCredential] = useTimedReset({
+ initialState: "Copy ID to clipboard"
+ });
+
+ return (
+
+ {children ? (
+
+ {showCredential ? children : "****************************"}
+
+ {
+ setCopyCredential(children);
+ navigator.clipboard.writeText(children);
+ }}
+ ariaLabel="Copy credential"
+ variant="plain"
+ size="xs"
+ >
+
+
+
+ {isSensitive && (
+
+
+
+
+
+ )}
+
+ ) : null}
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx
new file mode 100644
index 000000000..08068b1fc
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx
@@ -0,0 +1,57 @@
+import { faCheck, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
+import { TViewSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2";
+
+type Props = {
+ generatedCredentialsResponse: TViewSecretRotationGeneratedCredentialsResponse;
+};
+
+export const ViewSqlRotationGeneratedCredentials = ({
+ generatedCredentialsResponse: { generatedCredentials, activeIndex }
+}: Props) => {
+ const inactiveIndex = activeIndex === 0 ? 1 : 0;
+
+ const activeCredentials = generatedCredentials[activeIndex];
+ const inactiveCredentials = generatedCredentials[inactiveIndex];
+
+ return (
+ <>
+
+
+
+
+ Current Credentials
+
+
+
+ The active credential set currently mapped to the rotation secrets.
+
+
+ {activeCredentials?.username}
+
+ {activeCredentials?.password}
+
+
+
+
+
+
+
+ Retired Credentials
+
+
+
+ The retired credential set that will be revoked during the next rotation cycle.
+
+
+ {inactiveCredentials?.username}
+
+ {inactiveCredentials?.password}
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts
new file mode 100644
index 000000000..b964f5bba
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts
@@ -0,0 +1 @@
+export * from "./ViewSqlRotationGeneratedCredentials";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx
new file mode 100644
index 000000000..0fd14bb1f
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx
@@ -0,0 +1,124 @@
+import { Controller, useFormContext } from "react-hook-form";
+import { format, setHours, setMinutes } from "date-fns";
+
+import { FilterableSelect, FormControl, Input, Switch } from "@app/components/v2";
+import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
+import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
+
+import { TSecretRotationV2Form } from "./schemas";
+import { SecretRotationV2ConnectionField } from "./SecretRotationV2ConnectionField";
+
+type Props = {
+ isUpdate: boolean;
+ environments?: WorkspaceEnv[];
+};
+
+export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => {
+ const { control, watch } = useFormContext();
+
+ console.log(watch("rotateAtUtc"));
+
+ return (
+ <>
+
+ Configure the connection rotation strategy for this Secret Rotation.
+
+ {!isUpdate && environments && (
+ (
+
+ option?.name}
+ getOptionValue={(option) => option?.id}
+ />
+
+ )}
+ />
+ )}
+
+
+ (
+
+
+
+ )}
+ control={control}
+ name="rotationInterval"
+ />
+ {
+ return (
+
+ {
+ const time = e.target.value;
+ if (time) {
+ const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
+ const newSelectedDate = setHours(setMinutes(new Date(), minutes), hours);
+ onChange({
+ hours: newSelectedDate.getUTCHours(),
+ minutes: newSelectedDate.getUTCMinutes()
+ });
+ }
+ }}
+ className="bg-mineshaft-700 text-white [color-scheme:dark]"
+ />
+
+ );
+ }}
+ control={control}
+ name="rotateAtUtc"
+ />
+ {
+ return (
+
+
+ Auto-Rotation {value ? "Enabled" : "Disabled"}
+
+
+ );
+ }}
+ />
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx
new file mode 100644
index 000000000..7bfa62201
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx
@@ -0,0 +1,85 @@
+import { Controller, useFormContext } from "react-hook-form";
+import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Link } from "@tanstack/react-router";
+
+import { FilterableSelect, FormControl } from "@app/components/v2";
+import { OrgPermissionSubjects, useOrgPermission } from "@app/context";
+import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
+import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
+import { SECRET_ROTATION_CONNECTION_MAP } from "@app/helpers/secretRotationsV2";
+import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
+
+import { TSecretRotationV2Form } from "./schemas";
+
+type Props = {
+ onChange?: VoidFunction;
+ isUpdate: boolean;
+};
+
+export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate }: Props) => {
+ const { permission } = useOrgPermission();
+ const { control, watch } = useFormContext();
+
+ const rotationType = watch("type");
+ const app = SECRET_ROTATION_CONNECTION_MAP[rotationType];
+
+ const { data: availableConnections, isPending } = useListAvailableAppConnections(app);
+
+ const connectionName = APP_CONNECTION_MAP[app].name;
+
+ const canCreateConnection = permission.can(
+ OrgPermissionAppConnectionActions.Create,
+ OrgPermissionSubjects.AppConnections
+ );
+
+ const appName = APP_CONNECTION_MAP[app].name;
+
+ return (
+ <>
+ (
+
+ {
+ onChange(newValue);
+ if (callback) callback();
+ }}
+ isLoading={isPending}
+ options={availableConnections}
+ isDisabled={isUpdate}
+ placeholder="Select connection..."
+ getOptionLabel={(option) => option.name}
+ getOptionValue={(option) => option.id}
+ />
+
+ )}
+ control={control}
+ name="connection"
+ />
+ {!isUpdate && availableConnections?.length === 0 && (
+
+
+ {canCreateConnection ? (
+ <>
+ You do not have access to any {appName} Connections. Create one from the{" "}
+
+ App Connections
+ {" "}
+ page.
+ >
+ ) : (
+ `You do not have access to any ${appName} Connections. Contact an admin to create one.`
+ )}
+
+ )}
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx
new file mode 100644
index 000000000..573217f92
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx
@@ -0,0 +1,51 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { FormControl, Input, TextArea } from "@app/components/v2";
+
+import { TSecretRotationV2Form } from "./schemas";
+
+export const SecretRotationV2DetailsFields = () => {
+ const { control } = useFormContext();
+
+ return (
+ <>
+
+ Provide a name and description for this Secret Rotation.
+
+ (
+
+
+
+ )}
+ control={control}
+ name="name"
+ />
+ (
+
+
+
+ )}
+ control={control}
+ name="description"
+ />
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx
new file mode 100644
index 000000000..46c8e7eac
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx
@@ -0,0 +1,231 @@
+import { useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { Tab } from "@headlessui/react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { twMerge } from "tailwind-merge";
+
+import { createNotification } from "@app/components/notifications";
+import { SecretRotationV2ConfigurationFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields";
+import { SecretRotationV2DetailsFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields";
+import { SecretRotationV2ParametersFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields";
+import { SecretRotationV2ReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields";
+import { SecretRotationV2SecretsMappingFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields";
+import { Button } from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import {
+ SecretRotation,
+ TSecretRotationV2,
+ useSecretRotationV2Option
+} from "@app/hooks/api/secretRotationsV2";
+import {
+ useCreateSecretRotationV2,
+ useUpdateSecretRotationV2
+} from "@app/hooks/api/secretRotationsV2/mutations";
+import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
+
+import { SecretRotationV2FormSchema, TSecretRotationV2Form } from "./schemas";
+
+type Props = {
+ onComplete: (secretRotation: TSecretRotationV2) => void;
+ type: SecretRotation;
+ onCancel: () => void;
+ secretPath: string;
+ environment?: string;
+ environments?: WorkspaceEnv[];
+ secretRotation?: TSecretRotationV2;
+};
+
+const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Form)[] }[] = [
+ {
+ name: "Configuration",
+ key: "configuration",
+ fields: [
+ "isAutoRotationEnabled",
+ "environment",
+ "rotationInterval",
+ "connection",
+ "rotateAtUtc"
+ ]
+ },
+ { name: "Parameters", key: "parameters", fields: ["parameters"] },
+ { name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
+ { name: "Details", key: "details", fields: ["name", "description"] },
+ { name: "Review", key: "review", fields: [] }
+];
+
+const DEFAULT_ROTATION_INTERVAL = 30;
+
+export const SecretRotationV2Form = ({
+ type,
+ onComplete,
+ onCancel,
+ environment: envSlug,
+ secretPath,
+ secretRotation,
+ environments
+}: Props) => {
+ const createSecretRotation = useCreateSecretRotationV2();
+ const updateSecretRotation = useUpdateSecretRotationV2();
+ const { currentWorkspace } = useWorkspace();
+ const { name: rotationType } = SECRET_ROTATION_MAP[type];
+
+ const [selectedTabIndex, setSelectedTabIndex] = useState(0);
+
+ const { rotationOption } = useSecretRotationV2Option(type);
+
+ const formMethods = useForm({
+ resolver: zodResolver(SecretRotationV2FormSchema),
+ defaultValues: secretRotation
+ ? {
+ ...secretRotation,
+ environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
+ secretPath
+ }
+ : {
+ type,
+ isAutoRotationEnabled: true,
+ rotationInterval: DEFAULT_ROTATION_INTERVAL,
+ rotateAtUtc: {
+ hours: 0,
+ minutes: 0
+ },
+ environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
+ secretPath,
+ ...rotationOption!.template
+ },
+ reValidateMode: "onChange"
+ });
+
+ const onSubmit = async ({
+ environment,
+ connection,
+
+ ...formData
+ }: TSecretRotationV2Form) => {
+ const mutation = secretRotation
+ ? updateSecretRotation.mutateAsync({
+ rotationId: secretRotation.id,
+ projectId: secretRotation.projectId,
+ ...formData
+ })
+ : createSecretRotation.mutateAsync({
+ ...formData,
+
+ connectionId: connection.id,
+ environment: environment.slug,
+ projectId: currentWorkspace.id
+ });
+ try {
+ const rotation = await mutation;
+
+ createNotification({
+ text: `Successfully ${secretRotation ? "updated" : "created"} ${rotationType} Rotation`,
+ type: "success"
+ });
+ onComplete(rotation);
+ } catch (err: any) {
+ createNotification({
+ title: `Failed to ${secretRotation ? "update" : "create"} ${rotationType} Rotation`,
+ text: err.message,
+ type: "error"
+ });
+ }
+ };
+
+ const handlePrev = () => {
+ if (selectedTabIndex === 0) {
+ onCancel();
+ return;
+ }
+
+ setSelectedTabIndex((prev) => prev - 1);
+ };
+
+ const { handleSubmit, trigger } = formMethods;
+
+ const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
+
+ const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
+
+ const handleNext = async () => {
+ if (isFinalStep) {
+ handleSubmit(onSubmit)();
+ return;
+ }
+
+ const isValid = await isStepValid(selectedTabIndex);
+
+ if (!isValid) return;
+
+ setSelectedTabIndex((prev) => prev + 1);
+ };
+
+ const isTabEnabled = async (index: number) => {
+ let isEnabled = true;
+ for (let i = index - 1; i >= 0; i -= 1) {
+ // eslint-disable-next-line no-await-in-loop
+ isEnabled = isEnabled && (await isStepValid(i));
+ }
+
+ return isEnabled;
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx
new file mode 100644
index 000000000..abdfd3360
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx
@@ -0,0 +1,28 @@
+import { useFormContext } from "react-hook-form";
+
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+import { TSecretRotationV2Form } from "../schemas";
+import { SqlRotationParametersFields } from "./shared";
+
+const COMPONENT_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: SqlRotationParametersFields,
+ [SecretRotation.MsSqlCredentials]: SqlRotationParametersFields
+};
+
+export const SecretRotationV2ParametersFields = () => {
+ const { watch } = useFormContext();
+
+ const rotationType = watch("type");
+
+ const Component = COMPONENT_MAP[rotationType];
+
+ return (
+ <>
+
+ Configure the required parameters for this Secret Rotation.
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/index.ts
new file mode 100644
index 000000000..2650117b5
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/index.ts
@@ -0,0 +1 @@
+export * from "./SecretRotationV2ParametersFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlRotationParametersFields.tsx
new file mode 100644
index 000000000..bdc8ae091
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlRotationParametersFields.tsx
@@ -0,0 +1,60 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { FormControl, Input } from "@app/components/v2";
+import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
+import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
+
+export const SqlRotationParametersFields = () => {
+ const { control, watch } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
+ }
+ >();
+
+ const type = watch("type");
+
+ const { rotationOption } = useSecretRotationV2Option(type);
+
+ return (
+ <>
+ (
+
+
+
+ )}
+ control={control}
+ name="parameters.username1"
+ />
+ (
+
+
+
+ )}
+ control={control}
+ name="parameters.username2"
+ />
+
+
+ Infisical requires two database users to be created for rotation. Below is an example
+ statement for creating the required users. You may need to modify it to suit your needs.
+
+
+
+ {rotationOption!.template.createUserStatement}
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/index.ts
new file mode 100644
index 000000000..284c201c6
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/index.ts
@@ -0,0 +1 @@
+export * from "./SqlRotationParametersFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx
new file mode 100644
index 000000000..75b8f6ead
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx
@@ -0,0 +1,61 @@
+import { useFormContext } from "react-hook-form";
+import { format } from "date-fns";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { SqlRotationReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared";
+import { GenericFieldLabel } from "@app/components/v2";
+import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+const COMPONENT_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: SqlRotationReviewFields,
+ [SecretRotation.MsSqlCredentials]: SqlRotationReviewFields
+};
+
+export const SecretRotationV2ReviewFields = () => {
+ const { watch } = useFormContext();
+
+ const {
+ environment,
+ secretPath,
+ connection,
+ type,
+ name,
+ description,
+ rotationInterval,
+ rotateAtUtc
+ } = watch();
+
+ const Component = COMPONENT_MAP[type];
+
+ return (
+
+
+
+ Configuration
+
+
+ {connection.name}
+ {environment.name}
+ {secretPath}
+
+ {rotationInterval} Day{rotationInterval > 1 ? "s" : ""}
+
+
+ {format(getRotateAtLocal(rotateAtUtc), "h:mm aa")}
+
+
+
+
+
+
+ Details
+
+
+ {name}
+ {description}
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/index.ts
new file mode 100644
index 000000000..c52ffb40f
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/index.ts
@@ -0,0 +1 @@
+export * from "./SecretRotationReviewFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SqlRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SqlRotationReviewFields.tsx
new file mode 100644
index 000000000..28b4e690e
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SqlRotationReviewFields.tsx
@@ -0,0 +1,41 @@
+import { useFormContext } from "react-hook-form";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { GenericFieldLabel } from "@app/components/v2";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+export const SqlRotationReviewFields = () => {
+ const { watch } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
+ }
+ >();
+
+ const [{ username1, username2 }, { username, password }] = watch([
+ "parameters",
+ "secretsMapping"
+ ]);
+
+ return (
+ <>
+
+
+ Parameters
+
+
+ {username1}
+ {username2}
+
+
+
+
+ Secrets Mapping
+
+
+ {username}
+ {password}
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/index.ts
new file mode 100644
index 000000000..2c0301027
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/index.ts
@@ -0,0 +1 @@
+export * from "./SqlRotationReviewFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx
new file mode 100644
index 000000000..fdc37be69
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx
@@ -0,0 +1,28 @@
+import { useFormContext } from "react-hook-form";
+
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+import { TSecretRotationV2Form } from "../schemas";
+import { SqlRotationSecretsMappingFields } from "./shared";
+
+const COMPONENT_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: SqlRotationSecretsMappingFields,
+ [SecretRotation.MsSqlCredentials]: SqlRotationSecretsMappingFields
+};
+
+export const SecretRotationV2SecretsMappingFields = () => {
+ const { watch } = useFormContext();
+
+ const rotationType = watch("type");
+
+ const Component = COMPONENT_MAP[rotationType];
+
+ return (
+ <>
+
+ Map the rotated credentials to secrets in your Infisical project.
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/index.ts
new file mode 100644
index 000000000..31104ff3d
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/index.ts
@@ -0,0 +1 @@
+export * from "./SecretRotationV2SecretsMappingFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/SqlRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/SqlRotationSecretsMappingFields.tsx
new file mode 100644
index 000000000..9080fc29f
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/SqlRotationSecretsMappingFields.tsx
@@ -0,0 +1,88 @@
+import { Controller, useFormContext } from "react-hook-form";
+import { faArrowRight, faKey } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { Badge, FormControl, FormLabel, Input } from "@app/components/v2";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+export const SqlRotationSecretsMappingFields = () => {
+ const { control } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
+ }
+ >();
+
+ const items = [
+ {
+ name: "Username",
+ input: (
+ (
+
+
+
+ )}
+ control={control}
+ name="secretsMapping.username"
+ />
+ )
+ },
+ {
+ name: "Password",
+ input: (
+ (
+
+
+
+ )}
+ control={control}
+ name="secretsMapping.password"
+ />
+ )
+ }
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {items.map(({ name, input }) => (
+
+
+
+
+
+ {name}
+
+
+
+
+
+
+
+
+ {input}
+
+ ))}
+
+
+
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/index.ts b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/index.ts
new file mode 100644
index 000000000..1ed0b421d
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/shared/index.ts
@@ -0,0 +1 @@
+export * from "./SqlRotationSecretsMappingFields";
diff --git a/frontend/src/components/secret-rotations-v2/forms/index.ts b/frontend/src/components/secret-rotations-v2/forms/index.ts
new file mode 100644
index 000000000..be909dd7c
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/index.ts
@@ -0,0 +1 @@
+export * from "./SecretRotationV2Form";
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema.ts
new file mode 100644
index 000000000..b9dba1805
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+import { slugSchema } from "@app/lib/schemas";
+
+export const BaseSecretRotationSchema = z.object({
+ name: slugSchema({ field: "Name" }),
+ description: z.string().trim().max(256, "Cannot exceed 256 characters").nullish(),
+ connection: z.object({ name: z.string(), id: z.string().uuid() }),
+ environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
+ secretPath: z.string().min(1, "Secret path required"),
+ isAutoRotationEnabled: z.boolean(),
+ rotationInterval: z.coerce.number(),
+ rotateAtUtc: z.object({
+ hours: z.number(),
+ minutes: z.number()
+ })
+});
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts
new file mode 100644
index 000000000..14c274f4c
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
+import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
+
+const SecretRotationUnionSchema = z.discriminatedUnion("type", [
+ PostgresCredentialsRotationSchema,
+ MsSqlCredentialsRotationSchema
+]);
+
+export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
+
+export type TSecretRotationV2Form = z.infer;
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema.ts
new file mode 100644
index 000000000..1c919aa69
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
+import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+export const MsSqlCredentialsRotationSchema = z
+ .object({
+ type: z.literal(SecretRotation.MsSqlCredentials)
+ })
+ .merge(SqlCredentialsRotationSchema)
+ .merge(BaseSecretRotationSchema);
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema.ts
new file mode 100644
index 000000000..8ab06c036
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
+import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+export const PostgresCredentialsRotationSchema = z
+ .object({
+ type: z.literal(SecretRotation.PostgresCredentials)
+ })
+ .merge(SqlCredentialsRotationSchema)
+ .merge(BaseSecretRotationSchema);
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts
new file mode 100644
index 000000000..44b4c194f
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts
@@ -0,0 +1 @@
+export * from "./sql-credentials-rotation-schema";
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/sql-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/sql-credentials-rotation-schema.ts
new file mode 100644
index 000000000..433224998
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/sql-credentials-rotation-schema.ts
@@ -0,0 +1,14 @@
+import { z } from "zod";
+
+import { SecretNameSchema } from "@app/lib/schemas";
+
+export const SqlCredentialsRotationSchema = z.object({
+ parameters: z.object({
+ username1: z.string().trim().min(1, "Database Username 1 Required"),
+ username2: z.string().trim().min(1, "Database Username 2 Required")
+ }),
+ secretsMapping: z.object({
+ username: SecretNameSchema,
+ password: SecretNameSchema
+ })
+});
diff --git a/frontend/src/components/secret-rotations-v2/index.ts b/frontend/src/components/secret-rotations-v2/index.ts
new file mode 100644
index 000000000..03f2298f8
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/index.ts
@@ -0,0 +1 @@
+export * from "./CreateSecretRotationV2Modal";
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx
index 1b005e91c..c324c109f 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -17,9 +17,9 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => {
return (
<>
- {keyId && {keyId} }
+ {keyId && {keyId} }
{tags && tags.length > 0 && (
-
+
{
-
+
)}
{syncSecretMetadataAsTags && (
-
+
Enabled
-
+
)}
>
);
@@ -71,13 +71,13 @@ export const AwsParameterStoreDestinationReviewFields = () => {
return (
<>
-
+
{awsRegion?.name}
{awsRegion?.slug}{" "}
-
- {path}
+
+ {path}
>
);
};
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx
index f492792de..17841f2df 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -24,17 +24,17 @@ export const AwsSecretsManagerSyncReviewFields = () => {
return (
<>
-
+
{awsRegion?.name}
{awsRegion?.slug}{" "}
-
-
+
+
{mappingBehavior}
-
+
{mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && (
- {secretName}
+ {secretName}
)}
>
);
@@ -49,9 +49,9 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => {
return (
<>
- {keyId && {keyId} }
+ {keyId && {keyId} }
{tags && tags.length > 0 && (
-
+
{
-
+
)}
{syncSecretMetadataAsTags && (
-
+
Enabled
-
+
)}
>
);
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx
index e318397ec..cb675be26 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -13,8 +13,8 @@ export const AzureAppConfigurationSyncReviewFields = () => {
return (
<>
- {vaultBaseUrl}
- {label}
+ {vaultBaseUrl}
+ {label}
>
);
};
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx
index 94a0b985e..26b30f2c0 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const AzureKeyVaultSyncReviewFields = () => {
const { watch } = useFormContext();
const vaultBaseUrl = watch("destinationConfig.vaultBaseUrl");
- return {vaultBaseUrl} ;
+ return {vaultBaseUrl} ;
};
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields.tsx
index 76de5ca54..130ff6de3 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields.tsx
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const DatabricksSyncReviewFields = () => {
const { watch } = useFormContext();
const scope = watch("destinationConfig.scope");
- return {scope} ;
+ return {scope} ;
};
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx
index 52776c0e9..000478f5e 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -10,5 +10,5 @@ export const GcpSyncReviewFields = () => {
>();
const projectId = watch("destinationConfig.projectId");
- return {projectId} ;
+ return {projectId} ;
};
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx
index 513a2762f..34207e67f 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
@@ -16,30 +16,30 @@ export const GitHubSyncReviewFields = () => {
switch (config.scope) {
case GitHubSyncScope.Repository:
ScopeComponents = (
-
+
{config.owner}/{config.repo}
-
+
);
break;
case GitHubSyncScope.Organization:
ScopeComponents = (
<>
- {config.org}
-
+ {config.org}
+
{config.visibility}
-
+
>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
ScopeComponents = (
<>
-
+
{config.owner}/{config.repo}
-
-
+
+
{config.env}
-
+
>
);
@@ -52,9 +52,9 @@ export const GitHubSyncReviewFields = () => {
return (
<>
-
+
{config.scope.replace("-", " ")}
-
+
{ScopeComponents}
>
);
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx
index a680041c4..fcb8f35ff 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
@@ -14,10 +14,10 @@ export const HumanitecSyncReviewFields = () => {
return (
<>
- {orgId}
- {appId}
+ {orgId}
+ {appId}
{scope === HumanitecSyncScope.Environment && (
- {envId}
+ {envId}
)}
>
);
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
index 040944298..5816635f6 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
@@ -83,8 +83,8 @@ export const SecretSyncReviewFields = () => {
Source
- {environment.name}
- {secretPath}
+ {environment.name}
+ {secretPath}
@@ -92,7 +92,7 @@ export const SecretSyncReviewFields = () => {
Destination
- {connection.name}
+ {connection.name}
{DestinationFieldsComponent}
@@ -101,21 +101,21 @@ export const SecretSyncReviewFields = () => {
Sync Options
-
+
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
-
-
+
+
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name}
-
+
{/* {prependPrefix}
{appendSuffix} */}
{AdditionalSyncOptionsFieldsComponent}
{disableSecretDeletion && (
-
+
Disabled
-
+
)}
@@ -124,8 +124,8 @@ export const SecretSyncReviewFields = () => {
Details
- {name}
- {description}
+ {name}
+ {description}
diff --git a/frontend/src/components/secret-syncs/index.ts b/frontend/src/components/secret-syncs/index.ts
index 74c1eeca3..e9f8c37bc 100644
--- a/frontend/src/components/secret-syncs/index.ts
+++ b/frontend/src/components/secret-syncs/index.ts
@@ -1,9 +1,9 @@
+export * from "../v2/GenericFieldLabel";
export * from "./CreateSecretSyncModal";
export * from "./DeleteSecretSyncModal";
export * from "./EditSecretSyncModal";
export * from "./SecretSyncImportSecretsModal";
export * from "./SecretSyncImportStatusBadge";
-export * from "./SecretSyncLabel";
export * from "./SecretSyncRemoveSecretsModal";
export * from "./SecretSyncRemoveStatusBadge";
export * from "./SecretSyncStatusBadge";
diff --git a/frontend/src/components/v2/Blur/Blur.tsx b/frontend/src/components/v2/Blur/Blur.tsx
index bd1ded40a..2a72e05b5 100644
--- a/frontend/src/components/v2/Blur/Blur.tsx
+++ b/frontend/src/components/v2/Blur/Blur.tsx
@@ -9,7 +9,7 @@ interface IProps {
export const Blur = ({ className, tooltipText }: IProps) => {
return (
-
+
& {
value?: Date;
onChange: (date?: Date) => void;
@@ -58,7 +61,30 @@ export const DatePicker = ({
{value ? format(value, dateFormat) : "Pick a date and time"}
-
+
+
+
+
-
);
diff --git a/frontend/src/components/secret-syncs/SecretSyncLabel.tsx b/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx
similarity index 84%
rename from frontend/src/components/secret-syncs/SecretSyncLabel.tsx
rename to frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx
index 8528c9bc6..5200e1898 100644
--- a/frontend/src/components/secret-syncs/SecretSyncLabel.tsx
+++ b/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx
@@ -8,7 +8,7 @@ type Props = {
labelClassName?: string;
};
-export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => {
+export const GenericFieldLabel = ({ label, children, className, labelClassName }: Props) => {
return (
{label}
diff --git a/frontend/src/components/v2/GenericFieldLabel/index.ts b/frontend/src/components/v2/GenericFieldLabel/index.ts
new file mode 100644
index 000000000..d08d61e75
--- /dev/null
+++ b/frontend/src/components/v2/GenericFieldLabel/index.ts
@@ -0,0 +1 @@
+export * from "./GenericFieldLabel";
diff --git a/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx b/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx
new file mode 100644
index 000000000..6d8e98c9f
--- /dev/null
+++ b/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+type Props = {
+ title: string;
+ children: ReactNode;
+};
+
+export const NoticeBannerV2 = ({ title, children }: Props) => {
+ return (
+
+
+
+ {title}
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/components/v2/NoticeBannerV2/index.ts b/frontend/src/components/v2/NoticeBannerV2/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx
index 4c6ffd2fd..bd2877915 100644
--- a/frontend/src/components/v2/index.tsx
+++ b/frontend/src/components/v2/index.tsx
@@ -16,12 +16,14 @@ export * from "./EmptyState";
export * from "./FilterableSelect";
export * from "./FontAwesomeSymbol";
export * from "./FormControl";
+export * from "./GenericFieldLabel";
export * from "./HoverCardv2";
export * from "./IconButton";
export * from "./Input";
export * from "./Menu";
export * from "./Modal";
export * from "./NoticeBanner";
+export * from "./NoticeBannerV2";
export * from "./PageHeader";
export * from "./Pagination";
export * from "./Popoverv2";
diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts
index fb74e675c..656d9466b 100644
--- a/frontend/src/context/ProjectPermissionContext/types.ts
+++ b/frontend/src/context/ProjectPermissionContext/types.ts
@@ -75,6 +75,15 @@ export enum ProjectPermissionGroupActions {
GrantPrivileges = "grant-privileges"
}
+export enum ProjectPermissionSecretRotationActions {
+ Read = "read",
+ ReadGeneratedCredentials = "read-generated-credentials",
+ Create = "create",
+ Edit = "edit",
+ Delete = "delete",
+ RotateSecrets = "rotate-secrets"
+}
+
export enum PermissionConditionOperators {
$IN = "$in",
$ALL = "$all",
@@ -169,6 +178,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
+export type SecretRotationSubjectFields = {
+ environment: string;
+ secretPath: string;
+};
+
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -198,6 +212,13 @@ export type ProjectPermissionSet =
| (ForcedSubject
& SecretImportSubjectFields)
)
]
+ | [
+ ProjectPermissionSecretRotationActions,
+ (
+ | ProjectPermissionSub.SecretRotation
+ | (ForcedSubject & SecretRotationSubjectFields)
+ )
+ ]
| [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member]
@@ -210,7 +231,6 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
- | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [
ProjectPermissionActions,
(
diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts
index cf643218b..d75b426b7 100644
--- a/frontend/src/helpers/appConnections.ts
+++ b/frontend/src/helpers/appConnections.ts
@@ -1,17 +1,19 @@
import { faGithub } from "@fortawesome/free-brands-svg-icons";
-import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
+import { faKey, faLock, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AwsConnectionMethod,
AzureAppConfigurationConnectionMethod,
AzureKeyVaultConnectionMethod,
+ DatabricksConnectionMethod,
GcpConnectionMethod,
GitHubConnectionMethod,
+ HumanitecConnectionMethod,
+ MsSqlConnectionMethod,
+ PostgresConnectionMethod,
TAppConnection
} from "@app/hooks/api/appConnections/types";
-import { DatabricksConnectionMethod } from "@app/hooks/api/appConnections/types/databricks-connection";
-import { HumanitecConnectionMethod } from "@app/hooks/api/appConnections/types/humanitec-connection";
export const APP_CONNECTION_MAP: Record = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
@@ -26,7 +28,9 @@ export const APP_CONNECTION_MAP: Record {
@@ -45,8 +49,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
return { name: "Service Account Impersonation", icon: faUser };
case DatabricksConnectionMethod.ServicePrincipal:
return { name: "Service Principal", icon: faUser };
- case HumanitecConnectionMethod.API_TOKEN:
+ case HumanitecConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
+ case PostgresConnectionMethod.UsernameAndPassword:
+ case MsSqlConnectionMethod.UsernameAndPassword:
+ return { name: "Username & Password", icon: faLock };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts
new file mode 100644
index 000000000..d3497d335
--- /dev/null
+++ b/frontend/src/helpers/secretRotationsV2.ts
@@ -0,0 +1,28 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+
+export const SECRET_ROTATION_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: { name: "PostgreSQL Credentials", image: "Postgres.png" },
+ [SecretRotation.MsSqlCredentials]: {
+ name: "Microsoft SQL Server Credentials",
+ image: "MsSql.png"
+ }
+};
+
+export const SECRET_ROTATION_CONNECTION_MAP: Record = {
+ [SecretRotation.PostgresCredentials]: AppConnection.Postgres,
+ [SecretRotation.MsSqlCredentials]: AppConnection.MsSql
+};
+
+export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) =>
+ new Date(
+ Date.UTC(
+ new Date().getUTCFullYear(),
+ new Date().getUTCMonth(),
+ new Date().getUTCDate(),
+ hours,
+ minutes,
+ 0,
+ 0
+ )
+ );
diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts
index c07808af9..6891e7e2c 100644
--- a/frontend/src/hooks/api/appConnections/enums.ts
+++ b/frontend/src/hooks/api/appConnections/enums.ts
@@ -5,5 +5,7 @@ export enum AppConnection {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
- Humanitec = "humanitec"
+ Humanitec = "humanitec",
+ Postgres = "postgres",
+ MsSql = "mssql"
}
diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts
index a323765f7..d3d376e6e 100644
--- a/frontend/src/hooks/api/appConnections/types/app-options.ts
+++ b/frontend/src/hooks/api/appConnections/types/app-options.ts
@@ -3,6 +3,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
export type TAppConnectionOptionBase = {
name: string;
methods: string[];
+ supportsPlatformManagement?: boolean;
};
export type TAwsConnectionOption = TAppConnectionOptionBase & {
@@ -38,6 +39,14 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Humanitec;
};
+export type TPostgresConnectionOption = TAppConnectionOptionBase & {
+ app: AppConnection.Postgres;
+};
+
+export type TMsSqlConnectionOption = TAppConnectionOptionBase & {
+ app: AppConnection.MsSql;
+};
+
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -45,7 +54,9 @@ export type TAppConnectionOption =
| TAzureAppConfigurationConnectionOption
| TAzureKeyVaultConnectionOption
| TDatabricksConnectionOption
- | THumanitecConnectionOption;
+ | THumanitecConnectionOption
+ | TPostgresConnectionOption
+ | TMsSqlConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -55,4 +66,6 @@ export type TAppConnectionOptionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption;
[AppConnection.Databricks]: TDatabricksConnectionOption;
[AppConnection.Humanitec]: THumanitecConnectionOption;
+ [AppConnection.Postgres]: TPostgresConnectionOption;
+ [AppConnection.MsSql]: TMsSqlConnectionOption;
};
diff --git a/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts b/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts
index 2473050cd..672dbc37a 100644
--- a/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts
+++ b/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts
@@ -2,11 +2,11 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum HumanitecConnectionMethod {
- API_TOKEN = "api-token"
+ ApiToken = "api-token"
}
export type THumanitecConnection = TRootAppConnection & { app: AppConnection.Humanitec } & {
- method: HumanitecConnectionMethod.API_TOKEN;
+ method: HumanitecConnectionMethod.ApiToken;
credentials: {
apiToken: string;
};
diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts
index db56839fc..a241e21a1 100644
--- a/frontend/src/hooks/api/appConnections/types/index.ts
+++ b/frontend/src/hooks/api/appConnections/types/index.ts
@@ -1,20 +1,24 @@
-import { AppConnection } from "@app/hooks/api/appConnections/enums";
-import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options";
-import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection";
-import { TDatabricksConnection } from "@app/hooks/api/appConnections/types/databricks-connection";
-import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection";
-import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humanitec-connection";
-
+import { AppConnection } from "../enums";
+import { TAppConnectionOption } from "./app-options";
+import { TAwsConnection } from "./aws-connection";
import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection";
import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
+import { TDatabricksConnection } from "./databricks-connection";
import { TGcpConnection } from "./gcp-connection";
+import { TGitHubConnection } from "./github-connection";
+import { THumanitecConnection } from "./humanitec-connection";
+import { TMsSqlConnection } from "./mssql-connection";
+import { TPostgresConnection } from "./postgres-connection";
export * from "./aws-connection";
export * from "./azure-app-configuration-connection";
export * from "./azure-key-vault-connection";
+export * from "./databricks-connection";
export * from "./gcp-connection";
export * from "./github-connection";
export * from "./humanitec-connection";
+export * from "./mssql-connection";
+export * from "./postgres-connection";
export type TAppConnection =
| TAwsConnection
@@ -23,7 +27,9 @@ export type TAppConnection =
| TAzureKeyVaultConnection
| TAzureAppConfigurationConnection
| TDatabricksConnection
- | THumanitecConnection;
+ | THumanitecConnection
+ | TPostgresConnection
+ | TMsSqlConnection;
export type TAvailableAppConnection = Pick;
@@ -35,11 +41,11 @@ export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppCo
export type TCreateAppConnectionDTO = Pick<
TAppConnection,
- "name" | "credentials" | "method" | "app" | "description"
+ "name" | "credentials" | "method" | "app" | "description" | "isPlatformManagedCredentials"
>;
export type TUpdateAppConnectionDTO = Partial<
- Pick
+ Pick
> & {
connectionId: string;
app: AppConnection;
@@ -58,4 +64,6 @@ export type TAppConnectionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection;
[AppConnection.Databricks]: TDatabricksConnection;
[AppConnection.Humanitec]: THumanitecConnection;
+ [AppConnection.Postgres]: TPostgresConnection;
+ [AppConnection.MsSql]: TMsSqlConnection;
};
diff --git a/frontend/src/hooks/api/appConnections/types/mssql-connection.ts b/frontend/src/hooks/api/appConnections/types/mssql-connection.ts
new file mode 100644
index 000000000..782bad08a
--- /dev/null
+++ b/frontend/src/hooks/api/appConnections/types/mssql-connection.ts
@@ -0,0 +1,13 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
+
+import { TBaseSqlConnectionCredentials } from "./shared";
+
+export enum MsSqlConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
+
+export type TMsSqlConnection = TRootAppConnection & { app: AppConnection.MsSql } & {
+ method: MsSqlConnectionMethod.UsernameAndPassword;
+ credentials: TBaseSqlConnectionCredentials;
+};
diff --git a/frontend/src/hooks/api/appConnections/types/postgres-connection.ts b/frontend/src/hooks/api/appConnections/types/postgres-connection.ts
new file mode 100644
index 000000000..89608068f
--- /dev/null
+++ b/frontend/src/hooks/api/appConnections/types/postgres-connection.ts
@@ -0,0 +1,13 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
+
+import { TBaseSqlConnectionCredentials } from "./shared";
+
+export enum PostgresConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
+
+export type TPostgresConnection = TRootAppConnection & { app: AppConnection.Postgres } & {
+ method: PostgresConnectionMethod.UsernameAndPassword;
+ credentials: TBaseSqlConnectionCredentials;
+};
diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts
index 0dc4a616f..52571d067 100644
--- a/frontend/src/hooks/api/appConnections/types/root-connection.ts
+++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts
@@ -6,4 +6,5 @@ export type TRootAppConnection = {
orgId: string;
createdAt: string;
updatedAt: string;
+ isPlatformManagedCredentials?: boolean;
};
diff --git a/frontend/src/hooks/api/appConnections/types/shared/index.ts b/frontend/src/hooks/api/appConnections/types/shared/index.ts
new file mode 100644
index 000000000..5068989fe
--- /dev/null
+++ b/frontend/src/hooks/api/appConnections/types/shared/index.ts
@@ -0,0 +1 @@
+export * from "./sql-connection";
diff --git a/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts b/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts
new file mode 100644
index 000000000..79a14b520
--- /dev/null
+++ b/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts
@@ -0,0 +1,7 @@
+export type TBaseSqlConnectionCredentials = {
+ host: string;
+ port: number;
+ username: string;
+ password: string;
+ database: string;
+};
diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx
index d348c7bb1..718c25d15 100644
--- a/frontend/src/hooks/api/auditLogs/constants.tsx
+++ b/frontend/src/hooks/api/auditLogs/constants.tsx
@@ -110,7 +110,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.CREATE_APP_CONNECTION]: "Create App Connection",
[EventType.UPDATE_APP_CONNECTION]: "Update App Connection",
[EventType.DELETE_APP_CONNECTION]: "Delete App Connection",
- [EventType.GET_SECRET_SYNCS]: "List Secret Syncs",
+ [EventType.GET_SECRET_SYNCS]: "List secret syncs",
[EventType.GET_SECRET_SYNC]: "Get Secret Sync",
[EventType.CREATE_SECRET_SYNC]: "Create Secret Sync",
[EventType.UPDATE_SECRET_SYNC]: "Update Secret Sync",
@@ -139,7 +139,15 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.KMIP_OPERATION_ACTIVATE]: "KMIP operation activate",
[EventType.KMIP_OPERATION_REVOKE]: "KMIP operation revoke",
[EventType.KMIP_OPERATION_LOCATE]: "KMIP operation locate",
- [EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register"
+ [EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register",
+ [EventType.GET_SECRET_ROTATIONS]: "List Secret Rotations",
+ [EventType.GET_SECRET_ROTATION]: "Get Secret Rotation",
+ [EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS]:
+ "Get Secret Rotation generated credentials",
+ [EventType.CREATE_SECRET_ROTATION]: "Create Secret Rotation",
+ [EventType.UPDATE_SECRET_ROTATION]: "Update Secret Rotation",
+ [EventType.DELETE_SECRET_ROTATION]: "Delete Secret Rotation",
+ [EventType.SECRET_ROTATION_ROTATE_SECRETS]: "Secret Rotation secrets rotated"
};
export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = {
diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx
index 76bb283dc..baedb2d74 100644
--- a/frontend/src/hooks/api/auditLogs/enums.tsx
+++ b/frontend/src/hooks/api/auditLogs/enums.tsx
@@ -151,5 +151,12 @@ export enum EventType {
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
KMIP_OPERATION_REGISTER = "kmip-operation-register",
- SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review"
+ SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review",
+ 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"
}
diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx
index ba417430a..495fd0027 100644
--- a/frontend/src/hooks/api/dashboard/queries.tsx
+++ b/frontend/src/hooks/api/dashboard/queries.tsx
@@ -132,6 +132,21 @@ export const fetchDashboardProjectSecretsByKeys = async ({
return data;
};
+const mergePersonalRotationSecrets = (secrets: (SecretV3Raw | null)[]) => {
+ const actualSecrets: SecretV3Raw[] = [];
+ const dummySecrets: null[] = [];
+
+ secrets.forEach((secret) => {
+ if (secret !== null) {
+ actualSecrets.push(secret);
+ } else {
+ dummySecrets.push(secret);
+ }
+ });
+
+ return [...mergePersonalSecrets(actualSecrets), ...dummySecrets];
+};
+
export const useGetProjectSecretsOverview = (
{
projectId,
@@ -145,6 +160,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
environments
}: TGetDashboardProjectSecretsOverviewDTO,
options?: Omit<
@@ -173,6 +189,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
environments
}),
queryFn: () =>
@@ -188,10 +205,11 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
environments
}),
select: useCallback((data: Awaited>) => {
- const { secrets, ...select } = data;
+ const { secrets, secretRotations, ...select } = data;
const uniqueSecrets = secrets ? unique(secrets, (i) => i.secretKey) : [];
const uniqueFolders = select.folders ? unique(select.folders, (i) => i.name) : [];
@@ -201,14 +219,22 @@ export const useGetProjectSecretsOverview = (
: [];
const uniqueSecretImports = select.imports ? unique(select.imports, (i) => i.id) : [];
+ const uniqueSecretRotations = secretRotations ? unique(secretRotations, (i) => i.name) : [];
return {
...select,
secrets: secrets ? mergePersonalSecrets(secrets) : undefined,
+ secretRotations: secretRotations?.map((rotation) => {
+ return {
+ ...rotation,
+ secrets: mergePersonalRotationSecrets(rotation.secrets)
+ };
+ }),
totalUniqueSecretsInPage: uniqueSecrets.length,
totalUniqueDynamicSecretsInPage: uniqueDynamicSecrets.length,
totalUniqueFoldersInPage: uniqueFolders.length,
- totalUniqueSecretImportsInPage: uniqueSecretImports.length
+ totalUniqueSecretImportsInPage: uniqueSecretImports.length,
+ totalUniqueSecretRotationsInPage: uniqueSecretRotations.length
};
}, []),
placeholderData: (previousData) => previousData
@@ -230,6 +256,7 @@ export const useGetProjectSecretsDetails = (
viewSecretValue,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
tags
}: TGetDashboardProjectSecretsDetailsDTO,
options?: Omit<
@@ -260,6 +287,7 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
tags
}),
queryFn: () =>
@@ -277,12 +305,17 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
+ includeSecretRotations,
tags
}),
select: useCallback(
(data: Awaited>) => ({
...data,
- secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
+ secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined,
+ secretRotations: data.secretRotations?.map((rotation) => ({
+ ...rotation,
+ secrets: mergePersonalRotationSecrets(rotation.secrets)
+ }))
}),
[]
),
@@ -371,7 +404,7 @@ export const useGetProjectSecretsQuickSearch = (
tags
}),
select: useCallback((data: Awaited>) => {
- const { secrets, folders, dynamicSecrets } = data;
+ const { secrets, folders, dynamicSecrets, secretRotations } = data;
const groupedFolders = groupBy(folders, (folder) => folder.path);
const groupedSecrets = groupBy(
@@ -383,11 +416,16 @@ export const useGetProjectSecretsQuickSearch = (
(dynamicSecret) =>
`${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}`
);
+ const groupedRotations = groupBy(
+ secretRotations,
+ (rotation) => `${rotation.folder.path === "/" ? "" : rotation.folder.path}/${rotation.name}`
+ );
return {
folders: groupedFolders,
secrets: groupedSecrets,
- dynamicSecrets: groupedDynamicSecrets
+ dynamicSecrets: groupedDynamicSecrets,
+ secretRotations: groupedRotations
};
}, []),
placeholderData: (previousData) => previousData
diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts
index bdff878cd..8b300f011 100644
--- a/frontend/src/hooks/api/dashboard/types.ts
+++ b/frontend/src/hooks/api/dashboard/types.ts
@@ -3,6 +3,7 @@ import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export type DashboardProjectSecretsOverviewResponse = {
@@ -14,11 +15,16 @@ export type DashboardProjectSecretsOverviewResponse = {
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalImportCount?: number;
+ secretRotations?: (TSecretRotationV2 & {
+ secrets: (SecretV3Raw | null)[];
+ })[];
+ totalSecretRotationCount?: number;
totalCount: number;
totalUniqueSecretsInPage: number;
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
+ totalUniqueSecretRotationsInPage: number;
};
export type DashboardProjectSecretsDetailsResponse = {
@@ -26,10 +32,14 @@ export type DashboardProjectSecretsDetailsResponse = {
folders?: TSecretFolder[];
dynamicSecrets?: TDynamicSecret[];
secrets?: SecretV3Raw[];
+ secretRotations?: (TSecretRotationV2 & {
+ secrets: (SecretV3Raw | null)[];
+ })[];
totalImportCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalSecretCount?: number;
+ totalSecretRotationCount?: number;
totalCount: number;
};
@@ -39,9 +49,12 @@ export type DashboardProjectSecretsByKeys = {
export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
- "secrets"
+ "secrets" | "secretRotations"
> & {
secrets?: SecretV3RawSanitized[];
+ secretRotations?: (TSecretRotationV2 & {
+ secrets: (SecretV3RawSanitized | null)[];
+ })[];
};
export type DashboardProjectSecretsDetails = Omit<
@@ -49,6 +62,9 @@ export type DashboardProjectSecretsDetails = Omit<
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
+ secretRotations?: (TSecretRotationV2 & {
+ secrets: (SecretV3RawSanitized | null)[];
+ })[];
};
export enum DashboardSecretsOrderBy {
@@ -67,6 +83,7 @@ export type TGetDashboardProjectSecretsOverviewDTO = {
includeFolders?: boolean;
includeDynamicSecrets?: boolean;
includeImports?: boolean;
+ includeSecretRotations?: boolean;
environments: string[];
};
@@ -83,6 +100,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
export type TDashboardProjectSecretsQuickSearchResponse = {
folders: (TSecretFolder & { envId: string; path: string })[];
dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[];
+ secretRotations: TSecretRotationV2[];
secrets: SecretV3Raw[];
};
@@ -90,6 +108,7 @@ export type TDashboardProjectSecretsQuickSearch = {
folders: Record;
secrets: Record;
dynamicSecrets: Record;
+ secretRotations: Record;
};
export type TGetDashboardProjectSecretsQuickSearchDTO = {
diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts
new file mode 100644
index 000000000..178a12516
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/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/frontend/src/hooks/api/secretRotationsV2/index.ts b/frontend/src/hooks/api/secretRotationsV2/index.ts
new file mode 100644
index 000000000..fa5e33d2a
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/index.ts
@@ -0,0 +1,3 @@
+export * from "./enums";
+export * from "./queries";
+export * from "./types";
diff --git a/frontend/src/hooks/api/secretRotationsV2/mutations.tsx b/frontend/src/hooks/api/secretRotationsV2/mutations.tsx
new file mode 100644
index 000000000..8343b6768
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/mutations.tsx
@@ -0,0 +1,91 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
+import {
+ TCreateSecretRotationV2DTO,
+ TDeleteSecretRotationV2DTO,
+ TRotateSecretRotationV2DTO,
+ TSecretRotationV2Response,
+ TUpdateSecretRotationV2DTO
+} from "@app/hooks/api/secretRotationsV2/types";
+
+export const useCreateSecretRotationV2 = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ type, ...params }: TCreateSecretRotationV2DTO) => {
+ const { data } = await apiRequest.post(
+ `/api/v2/secret-rotations/${type}`,
+ params
+ );
+
+ return data.secretRotation;
+ },
+ onSuccess: (_, { projectId, secretPath }) =>
+ queryClient.invalidateQueries({
+ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
+ })
+ });
+};
+
+export const useUpdateSecretRotationV2 = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ type, rotationId, ...params }: TUpdateSecretRotationV2DTO) => {
+ const { data } = await apiRequest.patch(
+ `/api/v2/secret-rotations/${type}/${rotationId}`,
+ params
+ );
+
+ return data.secretRotation;
+ },
+ onSuccess: (_, { projectId, secretPath }) =>
+ queryClient.invalidateQueries({
+ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
+ })
+ });
+};
+
+export const useRotateSecretRotationV2 = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ type, rotationId }: TRotateSecretRotationV2DTO) => {
+ const { data } = await apiRequest.post(
+ `/api/v2/secret-rotations/${type}/${rotationId}/rotate-secrets`
+ );
+
+ return data.secretRotation;
+ },
+ onSuccess: (_, { projectId, secretPath }) =>
+ queryClient.invalidateQueries({
+ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
+ }),
+ onError: (_, { projectId, secretPath }) =>
+ queryClient.invalidateQueries({
+ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
+ })
+ });
+};
+
+export const useDeleteSecretRotationV2 = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ type,
+ rotationId,
+ deleteSecrets,
+ revokeGeneratedCredentials
+ }: TDeleteSecretRotationV2DTO) => {
+ const { data } = await apiRequest.delete(
+ `/api/v2/secret-rotations/${type}/${rotationId}`,
+ { params: { deleteSecrets, revokeGeneratedCredentials } }
+ );
+
+ return data.secretRotation;
+ },
+ onSuccess: (_, { projectId, secretPath }) =>
+ queryClient.invalidateQueries({
+ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
+ })
+ });
+};
diff --git a/frontend/src/hooks/api/secretRotationsV2/queries.tsx b/frontend/src/hooks/api/secretRotationsV2/queries.tsx
new file mode 100644
index 000000000..79bdf6a92
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/queries.tsx
@@ -0,0 +1,73 @@
+import { useQuery, UseQueryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2/enums";
+import {
+ TListSecretRotationV2Options,
+ TSecretRotationV2Option,
+ TViewSecretRotationGeneratedCredentialsResponse,
+ TViewSecretRotationV2GeneratedCredentialsDTO
+} from "@app/hooks/api/secretRotationsV2/types";
+
+export const secretRotationV2Keys = {
+ all: ["secret-rotations-v2"] as const,
+ options: () => [...secretRotationV2Keys.all, "options"] as const,
+ viewGeneratedCredentials: ({ type, rotationId }: TViewSecretRotationV2GeneratedCredentialsDTO) =>
+ [...secretRotationV2Keys.all, type, rotationId] as const
+};
+
+export const useSecretRotationV2Options = (
+ options?: Omit<
+ UseQueryOptions<
+ TSecretRotationV2Option[],
+ unknown,
+ TSecretRotationV2Option[],
+ ReturnType
+ >,
+ "queryKey" | "queryFn"
+ >
+) => {
+ return useQuery({
+ queryKey: secretRotationV2Keys.options(),
+ queryFn: async () => {
+ const { data } = await apiRequest.get(
+ "/api/v2/secret-rotations/options"
+ );
+
+ return data.secretRotationOptions;
+ },
+ ...options
+ });
+};
+
+export const useSecretRotationV2Option = (type: SecretRotation) => {
+ const { data: rotationOptions, isPending } = useSecretRotationV2Options();
+ const rotationOption = rotationOptions?.find((option) => option.type === type);
+
+ return { rotationOption, isPending };
+};
+
+export const useViewSecretRotationV2GeneratedCredentials = (
+ { rotationId, type }: TViewSecretRotationV2GeneratedCredentialsDTO,
+ options?: Omit<
+ UseQueryOptions<
+ TViewSecretRotationGeneratedCredentialsResponse,
+ unknown,
+ TViewSecretRotationGeneratedCredentialsResponse,
+ ReturnType
+ >,
+ "queryKey" | "queryFn"
+ >
+) => {
+ return useQuery({
+ queryKey: secretRotationV2Keys.viewGeneratedCredentials({ rotationId, type }),
+ queryFn: async () => {
+ const { data } = await apiRequest.get(
+ `/api/v2/secret-rotations/${type}/${rotationId}/generated-credentials`
+ );
+
+ return data;
+ },
+ ...options
+ });
+};
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts
new file mode 100644
index 000000000..eac648750
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts
@@ -0,0 +1,73 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+import {
+ TMsSqlCredentialsRotation,
+ TMsSqlCredentialsRotationGeneratedCredentialsResponse
+} from "@app/hooks/api/secretRotationsV2/types/mssql-credentials-rotation";
+import {
+ TPostgresCredentialsRotation,
+ TPostgresCredentialsRotationGeneratedCredentialsResponse
+} from "@app/hooks/api/secretRotationsV2/types/postgres-credentials-rotation";
+import { TSqlOptionTemplate } from "@app/hooks/api/secretRotationsV2/types/shared";
+import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
+import { DiscriminativePick } from "@app/types";
+
+export type TSecretRotationV2 = (TPostgresCredentialsRotation | TMsSqlCredentialsRotation) & {
+ secrets: (SecretV3RawSanitized | null)[];
+};
+
+export type TSecretRotationV2Option = {
+ name: string;
+ type: SecretRotation;
+ connection: AppConnection;
+ template: TSqlOptionTemplate;
+};
+
+export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
+
+export type TSecretRotationV2Response = { secretRotation: TSecretRotationV2 };
+
+export type TViewSecretRotationGeneratedCredentialsResponse =
+ | TPostgresCredentialsRotationGeneratedCredentialsResponse
+ | TMsSqlCredentialsRotationGeneratedCredentialsResponse;
+
+export type TCreateSecretRotationV2DTO = DiscriminativePick<
+ TSecretRotationV2,
+ | "name"
+ | "parameters"
+ | "secretsMapping"
+ | "description"
+ | "connectionId"
+ | "type"
+ | "isAutoRotationEnabled"
+ | "rotationInterval"
+ | "rotateAtUtc"
+> & { environment: string; secretPath: string; projectId: string };
+
+export type TUpdateSecretRotationV2DTO = Partial<
+ Omit
+> & {
+ type: SecretRotation;
+ rotationId: string;
+ // required for query invalidation
+ projectId: string;
+ secretPath: string;
+};
+
+export type TRotateSecretRotationV2DTO = {
+ rotationId: string;
+ type: SecretRotation;
+ // required for query invalidation
+ secretPath: string;
+ projectId: string;
+};
+
+export type TDeleteSecretRotationV2DTO = TRotateSecretRotationV2DTO & {
+ revokeGeneratedCredentials: boolean;
+ deleteSecrets: boolean;
+};
+
+export type TViewSecretRotationV2GeneratedCredentialsDTO = {
+ rotationId: string;
+ type: SecretRotation;
+};
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/mssql-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/mssql-credentials-rotation.ts
new file mode 100644
index 000000000..73e2dcf60
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/mssql-credentials-rotation.ts
@@ -0,0 +1,17 @@
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+import {
+ TSecretRotationV2Base,
+ TSecretRotationV2GeneratedCredentialsResponseBase,
+ TSqlCredentialsRotationGeneratedCredentials,
+ TSqlCredentialsRotationProperties
+} from "@app/hooks/api/secretRotationsV2/types/shared";
+
+export type TMsSqlCredentialsRotation = TSecretRotationV2Base & {
+ type: SecretRotation.MsSqlCredentials;
+} & TSqlCredentialsRotationProperties;
+
+export type TMsSqlCredentialsRotationGeneratedCredentialsResponse =
+ TSecretRotationV2GeneratedCredentialsResponseBase<
+ SecretRotation.MsSqlCredentials,
+ TSqlCredentialsRotationGeneratedCredentials
+ >;
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/postgres-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/postgres-credentials-rotation.ts
new file mode 100644
index 000000000..b5faa67d2
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/postgres-credentials-rotation.ts
@@ -0,0 +1,17 @@
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+import {
+ TSecretRotationV2Base,
+ TSecretRotationV2GeneratedCredentialsResponseBase,
+ TSqlCredentialsRotationGeneratedCredentials,
+ TSqlCredentialsRotationProperties
+} from "@app/hooks/api/secretRotationsV2/types/shared";
+
+export type TPostgresCredentialsRotation = TSecretRotationV2Base & {
+ type: SecretRotation.PostgresCredentials;
+} & TSqlCredentialsRotationProperties;
+
+export type TPostgresCredentialsRotationGeneratedCredentialsResponse =
+ TSecretRotationV2GeneratedCredentialsResponseBase<
+ SecretRotation.PostgresCredentials,
+ TSqlCredentialsRotationGeneratedCredentials
+ >;
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/shared/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/shared/index.ts
new file mode 100644
index 000000000..648b7cdd2
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/shared/index.ts
@@ -0,0 +1,2 @@
+export * from "./secret-rotation-base";
+export * from "./sql-credentials-rotation";
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/shared/secret-rotation-base.ts b/frontend/src/hooks/api/secretRotationsV2/types/shared/secret-rotation-base.ts
new file mode 100644
index 000000000..53e912945
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/shared/secret-rotation-base.ts
@@ -0,0 +1,53 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { SecretRotationStatus } from "@app/hooks/api/secretRotationsV2";
+
+export type TSecretRotationV2Base = {
+ id: string;
+ name: string;
+ description?: string | null;
+ folderId: string;
+ connectionId: string;
+ createdAt: string;
+ updatedAt: string;
+ rotationInterval: number;
+ rotateAtUtc: {
+ hours: number;
+ minutes: number;
+ };
+ projectId: string;
+ rotationStatus: SecretRotationStatus | null;
+ lastRotationJobId: string | null;
+ lastRotatedAt: string;
+ lastRotationAttemptedAt: string;
+ lastRotationMessage?: string | null;
+ connection: {
+ app: AppConnection;
+ id: string;
+ name: string;
+ };
+ environment: {
+ id: string;
+ name: string;
+ slug: string;
+ };
+ folder: {
+ id: string;
+ path: string;
+ };
+} & (
+ | {
+ nextRotationAt: string;
+ isAutoRotationEnabled: true;
+ }
+ | {
+ nextRotationAt?: null;
+ isAutoRotationEnabled: false;
+ }
+);
+
+export type TSecretRotationV2GeneratedCredentialsResponseBase = {
+ activeIndex: 0 | 1;
+ generatedCredentials: [T, T | undefined];
+ type: U;
+ rotationId: string;
+};
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts
new file mode 100644
index 000000000..44094e363
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts
@@ -0,0 +1,20 @@
+export type TSqlCredentialsRotationProperties = {
+ parameters: {
+ username1: string;
+ username2: string;
+ };
+ secretsMapping: {
+ username: string;
+ password: string;
+ };
+};
+
+export type TSqlOptionTemplate = {
+ secretsMapping: TSqlCredentialsRotationProperties["secretsMapping"];
+ createUserStatement: string;
+};
+
+export type TSqlCredentialsRotationGeneratedCredentials = {
+ username: string;
+ password: string;
+};
diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx
index 3e82c3b90..b7370a29a 100644
--- a/frontend/src/hooks/api/secrets/queries.tsx
+++ b/frontend/src/hooks/api/secrets/queries.tsx
@@ -85,7 +85,9 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
version: el.version,
skipMultilineEncoding: el.skipMultilineEncoding,
path: el.secretPath,
- secretMetadata: el.secretMetadata
+ secretMetadata: el.secretMetadata,
+ isRotatedSecret: el.isRotatedSecret,
+ rotationId: el.rotationId
};
if (el.type === SecretType.Personal) {
diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts
index 5c4d4ffc6..736335ea7 100644
--- a/frontend/src/hooks/api/secrets/types.ts
+++ b/frontend/src/hooks/api/secrets/types.ts
@@ -54,6 +54,8 @@ export type SecretV3RawSanitized = {
skipMultilineEncoding?: boolean;
secretMetadata?: { key: string; value: string }[];
isReminderEvent?: boolean;
+ isRotatedSecret?: boolean;
+ rotationId?: string;
};
export type SecretV3Raw = {
@@ -76,6 +78,8 @@ export type SecretV3Raw = {
tags?: WsTag[];
createdAt: string;
updatedAt: string;
+ isRotatedSecret?: boolean;
+ rotationId?: string;
};
export type SecretV3RawResponse = {
@@ -166,7 +170,7 @@ export type TUpdateSecretsV3DTO = {
skipMultilineEncoding?: boolean;
newSecretName?: string;
secretKey: string;
- secretValue: string;
+ secretValue?: string;
secretComment?: string;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx
index 75b389612..e136df3f2 100644
--- a/frontend/src/hooks/utils/secrets-overview.tsx
+++ b/frontend/src/hooks/utils/secrets-overview.tsx
@@ -72,6 +72,55 @@ export const useDynamicSecretOverview = (
return { dynamicSecretNames, isDynamicSecretPresentInEnv };
};
+export const useSecretRotationOverview = (
+ secretRotations: DashboardProjectSecretsOverview["secretRotations"]
+) => {
+ const secretRotationNames = useMemo(() => {
+ const names = new Set();
+ secretRotations?.forEach((secretRotation) => {
+ names.add(secretRotation.name);
+ });
+ return [...names];
+ }, [secretRotations]);
+
+ const isSecretRotationPresentInEnv = useCallback(
+ (name: string, env: string) => {
+ return Boolean(
+ secretRotations?.find(
+ ({ name: secretRotationName, environment }) =>
+ secretRotationName === name && environment.slug === env
+ )
+ );
+ },
+ [secretRotations]
+ );
+
+ const getSecretRotationByName = useCallback(
+ (env: string, name: string) => {
+ const secretRotation = secretRotations?.find(
+ (rotation) => rotation.environment.slug === env && rotation.name === name
+ );
+ return secretRotation;
+ },
+ [secretRotations]
+ );
+
+ const getSecretRotationStatusesByName = useCallback(
+ (name: string) =>
+ secretRotations
+ ?.filter((rotation) => rotation.name === name)
+ .map((rotation) => rotation.rotationStatus),
+ [secretRotations]
+ );
+
+ return {
+ secretRotationNames,
+ isSecretRotationPresentInEnv,
+ getSecretRotationByName,
+ getSecretRotationStatusesByName
+ };
+};
+
export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => {
const secKeys = useMemo(() => {
const keys = new Set();
diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx
index e6b39369f..fda82af91 100644
--- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx
@@ -11,8 +11,12 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
-import { useWorkspace } from "@app/context";
-import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
+import { useSubscription, useWorkspace } from "@app/context";
+import {
+ useGetAccessRequestsCount,
+ useGetSecretApprovalRequestCount,
+ useGetSecretRotations
+} from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectSelect } from "./components/ProjectSelect";
@@ -42,6 +46,16 @@ export const ProjectLayout = () => {
options: { enabled: isSecretManager }
});
+ // we only show the secret rotations v1 tab if they have existing rotations
+ const { subscription } = useSubscription();
+ const { data: secretRotations } = useGetSecretRotations({
+ workspaceId,
+ options: {
+ enabled: isSecretManager && Boolean(subscription.secretRotation),
+ refetchOnMount: false
+ }
+ });
+
const pendingRequestsCount =
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
@@ -147,7 +161,7 @@ export const ProjectLayout = () => {
)}
)}
- {isSecretManager && (
+ {isSecretManager && Boolean(secretRotations?.length) && (
!el.includes(" "),
+ "Secret name cannot contain spaces."
+).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");
diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
index b245260c6..61c4f4411 100644
--- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
+++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
We've developed an improved privilege management system to better serve your
security needs. Upgrade to our new permission-based approach that allows you to
- explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
+ explicitly designate who can modify specific access levels, rather than relying on
+ hierarchy comparisons.
void;
@@ -31,7 +33,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[app];
const onSubmit = async (
- formData: DiscriminativePick
+ formData: DiscriminativePick<
+ TAppConnection,
+ "method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
+ >
) => {
try {
const connection = await createAppConnection.mutateAsync(formData);
@@ -65,6 +70,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return ;
case AppConnection.Humanitec:
return ;
+ case AppConnection.Postgres:
+ return ;
+ case AppConnection.MsSql:
+ return ;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -75,7 +84,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const onSubmit = async (
- formData: DiscriminativePick
+ formData: DiscriminativePick<
+ TAppConnection,
+ "method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
+ >
) => {
try {
const connection = await updateAppConnection.mutateAsync({
@@ -112,6 +124,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return ;
case AppConnection.Humanitec:
return ;
+ case AppConnection.Postgres:
+ return ;
+ case AppConnection.MsSql:
+ return ;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx
index d9e25a0a7..19df1f4ac 100644
--- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx
@@ -32,7 +32,8 @@ export const GenericAppConnectionsFields = () => {
isOptional
>
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx
index b119f46ad..b9a41f25c 100644
--- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx
@@ -30,7 +30,7 @@ const rootSchema = genericAppConnectionFieldsSchema.extend({
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
- method: z.literal(HumanitecConnectionMethod.API_TOKEN),
+ method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "Service API Token required")
})
@@ -46,7 +46,7 @@ export const HumanitecConnectionForm = ({ appConnection, onSubmit }: Props) => {
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Humanitec,
- method: HumanitecConnectionMethod.API_TOKEN
+ method: HumanitecConnectionMethod.ApiToken
}
});
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx
new file mode 100644
index 000000000..bcea2f3bc
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx
@@ -0,0 +1,146 @@
+import { useState } from "react";
+import { Controller, FormProvider, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
+import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import {
+ MsSqlConnectionMethod,
+ TMsSqlConnection
+} from "@app/hooks/api/appConnections/types/mssql-connection";
+import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
+
+import {
+ genericAppConnectionFieldsSchema,
+ GenericAppConnectionsFields
+} from "./GenericAppConnectionFields";
+import {
+ BaseSqlUsernameAndPasswordConnectionSchema,
+ PlatformManagedNoticeBanner,
+ SqlConnectionFields
+} from "./shared";
+
+type Props = {
+ appConnection?: TMsSqlConnection;
+ onSubmit: (formData: FormData) => void;
+};
+
+const rootSchema = genericAppConnectionFieldsSchema.extend({
+ app: z.literal(AppConnection.MsSql),
+ isPlatformManagedCredentials: z.boolean().optional()
+});
+
+const formSchema = z.discriminatedUnion("method", [
+ rootSchema.extend({
+ method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
+ credentials: BaseSqlUsernameAndPasswordConnectionSchema
+ })
+]);
+
+type FormData = z.infer;
+
+export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
+ const isUpdate = Boolean(appConnection);
+ const [showConfirmation, setShowConfirmation] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: appConnection ?? {
+ app: AppConnection.MsSql,
+ method: MsSqlConnectionMethod.UsernameAndPassword,
+ credentials: {
+ host: "",
+ port: 1433,
+ database: "default",
+ username: "",
+ password: "",
+ sslCertificate: ""
+ }
+ }
+ });
+
+ const {
+ handleSubmit,
+ control,
+ formState: { isSubmitting, isDirty }
+ } = form;
+
+ const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
+
+ const confirmSubmit = (formData: FormData) => {
+ if (formData.isPlatformManagedCredentials) {
+ setShowConfirmation(true);
+ return;
+ }
+
+ onSubmit(formData);
+ };
+
+ return (
+
+
+ handleSubmit(onSubmit)()}
+ onOpenChange={setShowConfirmation}
+ isOpen={showConfirmation}
+ />
+
+ );
+};
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/PostgresConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/PostgresConnectionForm.tsx
new file mode 100644
index 000000000..bb4b9be95
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/PostgresConnectionForm.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import { Controller, FormProvider, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
+import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
+import { PostgresConnectionMethod, TPostgresConnection } from "@app/hooks/api/appConnections";
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
+
+import {
+ genericAppConnectionFieldsSchema,
+ GenericAppConnectionsFields
+} from "./GenericAppConnectionFields";
+import {
+ BaseSqlUsernameAndPasswordConnectionSchema,
+ PlatformManagedNoticeBanner,
+ SqlConnectionFields
+} from "./shared";
+
+type Props = {
+ appConnection?: TPostgresConnection;
+ onSubmit: (formData: FormData) => void;
+};
+
+const rootSchema = genericAppConnectionFieldsSchema.extend({
+ app: z.literal(AppConnection.Postgres),
+ isPlatformManagedCredentials: z.boolean().optional()
+});
+
+const formSchema = z.discriminatedUnion("method", [
+ rootSchema.extend({
+ method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
+ credentials: BaseSqlUsernameAndPasswordConnectionSchema
+ })
+]);
+
+type FormData = z.infer;
+
+export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
+ const isUpdate = Boolean(appConnection);
+ const [showConfirmation, setShowConfirmation] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: appConnection ?? {
+ app: AppConnection.Postgres,
+ method: PostgresConnectionMethod.UsernameAndPassword,
+ credentials: {
+ host: "",
+ port: 5432,
+ database: "default",
+ username: "",
+ password: "",
+ sslCertificate: ""
+ }
+ }
+ });
+
+ const {
+ handleSubmit,
+ control,
+ formState: { isSubmitting, isDirty }
+ } = form;
+
+ const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
+
+ const confirmSubmit = (formData: FormData) => {
+ if (formData.isPlatformManagedCredentials) {
+ setShowConfirmation(true);
+ return;
+ }
+
+ onSubmit(formData);
+ };
+
+ return (
+
+
+ handleSubmit(onSubmit)()}
+ onOpenChange={setShowConfirmation}
+ isOpen={showConfirmation}
+ />
+
+ );
+};
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal.tsx
new file mode 100644
index 000000000..914d065b0
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal.tsx
@@ -0,0 +1,43 @@
+import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
+import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
+
+type Props = {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ onConfirm: () => void;
+};
+
+export const PlatformManagedConfirmationModal = ({ isOpen, onOpenChange, onConfirm }: Props) => {
+ return (
+
+
+
+
+ Once created, Infisical will update the password of this connection.
+
+
+ You will not be able to access the updated password.
+
+
+
+
+
+ Grant Infisical Ownership
+
+
+
+
+ Cancel
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedNoticeBanner.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedNoticeBanner.tsx
new file mode 100644
index 000000000..b75e4b19c
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedNoticeBanner.tsx
@@ -0,0 +1,9 @@
+import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
+
+export const PlatformManagedNoticeBanner = () => (
+
+
+ This App Connection's credentials are managed by Infisical and cannot be updated.
+
+
+);
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/SqlConnectionFields.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/SqlConnectionFields.tsx
new file mode 100644
index 000000000..7b8b7c532
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/SqlConnectionFields.tsx
@@ -0,0 +1,155 @@
+import { Controller, useFormContext } from "react-hook-form";
+import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { FormControl, Input, SecretInput, Switch, TextArea, Tooltip } from "@app/components/v2";
+
+type Props = {
+ isPlatformManagedCredentials: boolean;
+};
+
+export const SqlConnectionFields = ({ isPlatformManagedCredentials }: Props) => {
+ const { control } = useFormContext();
+
+ return (
+ <>
+
+ (
+
+
+
+ )}
+ />
+ (
+
+
+
+ )}
+ />
+ (
+
+
+
+ )}
+ />
+
+
+ (
+
+
+
+ )}
+ />
+ (
+
+ onChange(e.target.value)}
+ isDisabled={isPlatformManagedCredentials}
+ />
+
+ )}
+ />
+
+ (
+
+
+
+ )}
+ />
+ {!isPlatformManagedCredentials && (
+ (
+
+
+
+ Platform Managed Credentials
+
+ If enabled, Infisical will manage the credentials of this App Connection by
+ updating the password on creation.
+
+ }
+ >
+
+
+
+
+
+ )}
+ />
+ )}
+ >
+ );
+};
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/index.ts b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/index.ts
new file mode 100644
index 000000000..cf1aecd31
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/index.ts
@@ -0,0 +1,3 @@
+export * from "./PlatformManagedNoticeBanner";
+export * from "./sql-connection-schemas";
+export * from "./SqlConnectionFields";
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/sql-connection-schemas.ts b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/sql-connection-schemas.ts
new file mode 100644
index 000000000..8d6821fd8
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/sql-connection-schemas.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
+ host: z.string().trim().min(1, "Host required"),
+ port: z.coerce.number().default(5432),
+ database: z.string().trim().min(1, "Database required").default("default"),
+ username: z.string().trim().min(1, "Username required"),
+ password: z.string().trim().min(1, "Password required"),
+ sslCertificate: z.string().trim().optional()
+});
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx
index a733bae8e..d889547bb 100644
--- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx
@@ -6,6 +6,7 @@ import {
faEdit,
faEllipsisV,
faInfoCircle,
+ faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
+ Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -42,7 +44,7 @@ export const AppConnectionRow = ({
onEditCredentials,
onEditDetails
}: Props) => {
- const { id, name, method, app, description } = appConnection;
+ const { id, name, method, app, description, isPlatformManagedCredentials } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -100,70 +102,82 @@ export const AppConnectionRow = ({
-
-
-
-
-
-
-
-
- }
- onClick={() => handleCopyId()}
- >
- Copy Connection ID
-
-
- {(isAllowed: boolean) => (
- }
- onClick={() => onEditDetails(appConnection)}
- >
- Edit Details
-
- )}
-
-
- {(isAllowed: boolean) => (
- }
- onClick={() => onEditCredentials(appConnection)}
- >
- Edit Credentials
-
- )}
-
-
- {(isAllowed: boolean) => (
- }
- onClick={() => onDelete(appConnection)}
- >
- Delete Connection
-
- )}
-
-
-
-
+
+ {isPlatformManagedCredentials && (
+
+
+
+
+ Platform Managed Credentials
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ }
+ onClick={() => handleCopyId()}
+ >
+ Copy Connection ID
+
+
+ {(isAllowed: boolean) => (
+ }
+ onClick={() => onEditDetails(appConnection)}
+ >
+ Edit Details
+
+ )}
+
+
+ {(isAllowed: boolean) => (
+ }
+ onClick={() => onEditCredentials(appConnection)}
+ >
+ {isPlatformManagedCredentials ? "View" : "Edit"} Credentials
+
+ )}
+
+
+ {(isAllowed: boolean) => (
+ }
+ onClick={() => onDelete(appConnection)}
+ >
+ Delete Connection
+
+ )}
+
+
+
+
+
);
diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx
index e78859344..bd705d1ff 100644
--- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx
+++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx
@@ -28,7 +28,8 @@ type Props = {
type:
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SecretFolders
- | ProjectPermissionSub.SecretImports;
+ | ProjectPermissionSub.SecretImports
+ | ProjectPermissionSub.SecretRotation;
};
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
index 4e578df94..2d77aac83 100644
--- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
+++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
@@ -17,6 +17,7 @@ import {
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionSecretActions,
+ ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
TPermissionCondition,
TPermissionConditionOperators
@@ -66,6 +67,15 @@ const SecretSyncPolicyActionSchema = z.object({
[ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional()
});
+const SecretRotationPolicyActionSchema = z.object({
+ [ProjectPermissionSecretRotationActions.Read]: z.boolean().optional(),
+ [ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: z.boolean().optional(),
+ [ProjectPermissionSecretRotationActions.Create]: z.boolean().optional(),
+ [ProjectPermissionSecretRotationActions.Edit]: z.boolean().optional(),
+ [ProjectPermissionSecretRotationActions.Delete]: z.boolean().optional(),
+ [ProjectPermissionSecretRotationActions.RotateSecrets]: z.boolean().optional()
+});
+
const KmipPolicyActionSchema = z.object({
[ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(),
[ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(),
@@ -209,7 +219,12 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
- [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
+ [ProjectPermissionSub.SecretRotation]: SecretRotationPolicyActionSchema.extend({
+ inverted: z.boolean().optional(),
+ conditions: ConditionSchema
+ })
+ .array()
+ .default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
@@ -226,6 +241,7 @@ type TConditionalFields =
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
+ | ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
export const isConditionalSubjects = (
@@ -235,6 +251,7 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.DynamicSecrets ||
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders ||
+ subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.Identity;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
@@ -298,6 +315,30 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (isConditionalSubjects(subject)) {
if (!formVal[subject]) formVal[subject] = [];
+ if (subject === ProjectPermissionSub.SecretRotation) {
+ const canRead = action.includes(ProjectPermissionSecretRotationActions.Read);
+ const canReadCredentials = action.includes(
+ ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
+ );
+ const canEdit = action.includes(ProjectPermissionSecretRotationActions.Edit);
+ const canDelete = action.includes(ProjectPermissionSecretRotationActions.Delete);
+ const canCreate = action.includes(ProjectPermissionSecretRotationActions.Create);
+ const canRotate = action.includes(ProjectPermissionSecretRotationActions.RotateSecrets);
+
+ // from above statement we are sure it won't be undefined
+ formVal[subject]!.push({
+ [ProjectPermissionSecretRotationActions.Read]: canRead,
+ [ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: canReadCredentials,
+ [ProjectPermissionSecretRotationActions.Create]: canCreate,
+ [ProjectPermissionSecretRotationActions.Edit]: canEdit,
+ [ProjectPermissionSecretRotationActions.Delete]: canDelete,
+ [ProjectPermissionSecretRotationActions.RotateSecrets]: canRotate,
+ conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
+ inverted
+ });
+ return;
+ }
+
if (subject === ProjectPermissionSub.DynamicSecrets) {
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
@@ -890,10 +931,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
[ProjectPermissionSub.SecretRotation]: {
title: "Secret Rotation",
actions: [
- { label: "Read", value: "read" },
- { label: "Create", value: "create" },
- { label: "Modify", value: "edit" },
- { label: "Remove", value: "delete" }
+ { label: "Read Config", value: ProjectPermissionSecretRotationActions.Read },
+ {
+ label: "Read Generated Credentials",
+ value: ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
+ },
+ { label: "Create Config", value: ProjectPermissionSecretRotationActions.Create },
+ { label: "Modify Config", value: ProjectPermissionSecretRotationActions.Edit },
+ { label: "Remove Config", value: ProjectPermissionSecretRotationActions.Delete },
+ { label: "Rotate Secrets", value: ProjectPermissionSecretRotationActions.RotateSecrets }
]
},
[ProjectPermissionSub.SecretRollback]: {
diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx
index 67baecb15..4ee50ed4f 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx
@@ -14,7 +14,8 @@ import {
faFolderPlus,
faKey,
faList,
- faPlus
+ faPlus,
+ faRotate
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
@@ -23,6 +24,11 @@ import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
+import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
+import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal";
+import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal";
+import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal";
+import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials";
import {
Button,
Checkbox,
@@ -57,6 +63,7 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
+import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useCreateFolder,
@@ -71,9 +78,16 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
-import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
+import {
+ useDynamicSecretOverview,
+ useFolderOverview,
+ useSecretOverview,
+ useSecretRotationOverview
+} from "@app/hooks/utils";
+import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow";
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
@@ -99,7 +113,8 @@ enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "secret",
- Import = "import"
+ Import = "import",
+ SecretRotation = "rotation"
}
type Filter = {
@@ -110,7 +125,8 @@ const DEFAULT_FILTER_STATE = {
[RowType.Folder]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true,
- [RowType.Import]: true
+ [RowType.Import]: true,
+ [RowType.SecretRotation]: true
};
export const OverviewPage = () => {
@@ -195,6 +211,15 @@ export const OverviewPage = () => {
})
)
);
+ const userAvailableSecretRotationEnvs = userAvailableEnvs.filter((env) =>
+ permission.can(
+ ProjectPermissionSecretRotationActions.Create,
+ subject(ProjectPermissionSub.SecretRotation, {
+ environment: env.slug,
+ secretPath
+ })
+ )
+ );
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
@@ -224,6 +249,7 @@ export const OverviewPage = () => {
includeDynamicSecrets: filter.dynamic,
includeSecrets: filter.secret,
includeImports: filter.import,
+ includeSecretRotations: filter.rotation,
search: debouncedSearchFilter,
limit,
offset
@@ -235,15 +261,18 @@ export const OverviewPage = () => {
secrets,
folders,
dynamicSecrets,
+ secretRotations,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount,
+ totalSecretRotationCount,
totalImportCount,
totalCount = 0,
totalUniqueFoldersInPage,
totalUniqueSecretsInPage,
totalUniqueSecretImportsInPage,
- totalUniqueDynamicSecretsInPage
+ totalUniqueDynamicSecretsInPage,
+ totalUniqueSecretRotationsInPage
} = overview ?? {};
const secretImportsShaped = secretImports
@@ -273,6 +302,13 @@ export const OverviewPage = () => {
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
+ const {
+ secretRotationNames,
+ isSecretRotationPresentInEnv,
+ getSecretRotationByName,
+ getSecretRotationStatusesByName
+ } = useSecretRotationOverview(secretRotations);
+
const { secKeys, getEnvSecretKeyCount } = useSecretOverview(
secrets?.concat(secretImportsShaped) || []
);
@@ -301,6 +337,11 @@ export const OverviewPage = () => {
"misc",
"updateFolder",
"addDynamicSecret",
+ "addSecretRotation",
+ "editSecretRotation",
+ "rotateSecretRotation",
+ "viewSecretRotationGeneratedCredentials",
+ "deleteSecretRotation",
"upgradePlan"
] as const);
@@ -841,6 +882,21 @@ export const OverviewPage = () => {
Dynamic Secrets
+ {
+ e.preventDefault();
+ handleToggleRowType(RowType.SecretRotation);
+ }}
+ icon={
+ filter[RowType.SecretRotation] &&
+ }
+ iconPos="right"
+ >
+
+
+ Secret Rotations
+
+
{
e.preventDefault();
@@ -952,6 +1008,29 @@ export const OverviewPage = () => {
Add Dynamic Secret
+
+ }
+ onClick={() => {
+ if (subscription?.secretRotation) {
+ handlePopUpOpen("addSecretRotation");
+ handlePopUpClose("misc");
+ return;
+ }
+ handlePopUpOpen("upgradePlan");
+ }}
+ isDisabled={userAvailableSecretRotationEnvs.length === 0}
+ variant="outline_bg"
+ className="h-10 text-left"
+ isFullWidth
+ >
+ Add Secret Rotation
+
+
@@ -1141,6 +1220,29 @@ export const OverviewPage = () => {
key={`overview-${dynamicSecretName}-${index + 1}`}
/>
))}
+ {secretRotationNames.map((secretRotationName, index) => (
+
+ handlePopUpOpen("editSecretRotation", secretRotation)
+ }
+ onRotate={(secretRotation) =>
+ handlePopUpOpen("rotateSecretRotation", secretRotation)
+ }
+ onViewGeneratedCredentials={(secretRotation) =>
+ handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation)
+ }
+ onDelete={(secretRotation) =>
+ handlePopUpOpen("deleteSecretRotation", secretRotation)
+ }
+ />
+ ))}
{secKeys.map((key, index) => (
{
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0) -
- (totalUniqueSecretImportsInPage || 0),
+ (totalUniqueSecretImportsInPage || 0) -
+ (totalUniqueSecretRotationsInPage || 0),
0
)}
/>
@@ -1206,6 +1309,7 @@ export const OverviewPage = () => {
secretCount={totalSecretCount}
folderCount={totalFolderCount}
importCount={totalImportCount}
+ secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
@@ -1277,6 +1381,34 @@ export const OverviewPage = () => {
}
/>
)}
+ handlePopUpToggle("addSecretRotation", isOpen)}
+ />
+ handlePopUpToggle("editSecretRotation", isOpen)}
+ />
+ handlePopUpToggle("rotateSecretRotation", isOpen)}
+ />
+
+ handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen)
+ }
+ />
+ handlePopUpToggle("deleteSecretRotation", isOpen)}
+ />
);
};
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx
new file mode 100644
index 000000000..aab28f809
--- /dev/null
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx
@@ -0,0 +1,318 @@
+import { subject } from "@casl/ability";
+import {
+ faAsterisk,
+ faCheck,
+ faClose,
+ faEdit,
+ faEye,
+ faEyeSlash,
+ faInfoCircle,
+ faRotate,
+ faXmark
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { twMerge } from "tailwind-merge";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge";
+import { Badge, IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2";
+import { Blur } from "@app/components/v2/Blur";
+import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
+import {
+ ProjectPermissionSecretRotationActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import { useToggle } from "@app/hooks";
+import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
+
+type Props = {
+ secretRotationName: string;
+ environments: { name: string; slug: string }[];
+ isSecretRotationInEnv: (name: string, env: string) => boolean;
+ getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined;
+ getSecretRotationStatusesByName: (name: string) => (SecretRotationStatus | null)[] | undefined;
+ scrollOffset: number;
+ onEdit: (secretRotation: TSecretRotationV2) => void;
+ onRotate: (secretRotation: TSecretRotationV2) => void;
+ onViewGeneratedCredentials: (secretRotation: TSecretRotationV2) => void;
+ onDelete: (secretRotation: TSecretRotationV2) => void;
+};
+
+export const SecretOverviewSecretRotationRow = ({
+ secretRotationName,
+ environments = [],
+ isSecretRotationInEnv,
+ scrollOffset,
+ getSecretRotationByName,
+ getSecretRotationStatusesByName,
+ onEdit,
+ onRotate,
+ onViewGeneratedCredentials,
+ onDelete
+}: Props) => {
+ const [isExpanded, setIsExpanded] = useToggle(false);
+ const [isSecretVisible, setIsSecretVisible] = useToggle();
+
+ const totalCols = environments.length + 1; // secret key row
+
+ const statuses = getSecretRotationStatusesByName(secretRotationName);
+
+ return (
+ <>
+
+
+
+
+
+
+
{secretRotationName}
+ {statuses?.some((status) => status === SecretRotationStatus.Failed) && (
+
+
+
+
+ Rotation Failed
+
+
+
+ )}
+
+
+ {environments.map(({ slug }, i) => {
+ const isPresent = isSecretRotationInEnv(secretRotationName, slug);
+
+ return (
+
+
+
+
+
+ );
+ })}
+
+ {isExpanded &&
+ environments.map(({ name: envName, slug }) => {
+ const secretRotation = getSecretRotationByName(slug, secretRotationName);
+
+ if (!secretRotation) return null;
+
+ const { type, secrets, environment, folder, description } = secretRotation;
+
+ const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
+
+ return (
+
+
+
+
+
+
+
+
+
+
{envName}
+
+
+ {rotationType}
+
+ {description && (
+
+
+
+ )}
+
+
+
+
+
+
+ setIsSecretVisible.toggle()}
+ >
+
+
+
+
+ {(isAllowed) => (
+ onViewGeneratedCredentials(secretRotation)}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ onRotate(secretRotation)}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ onEdit(secretRotation)}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ onDelete(secretRotation)}
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+
+
+
+
+
+ {secrets.map((secret, index) => {
+ return (
+
+
+
+
+ {secret?.key ?? "********"}
+
+
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {!secret ? (
+ ********
+ ) : secret.secretValueHidden ? (
+
+ ) : (
+ {}}
+ />
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/index.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/index.tsx
new file mode 100644
index 000000000..d95a2a52b
--- /dev/null
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/index.tsx
@@ -0,0 +1 @@
+export { SecretOverviewSecretRotationRow } from "./SecretOverviewSecretRotationRow";
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx
index 705d7e177..ddb9a99d1 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx
@@ -53,6 +53,7 @@ type Props = {
secretId?: string
) => Promise;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise;
+ isRotatedSecret?: boolean;
};
export const SecretEditRow = ({
@@ -68,7 +69,8 @@ export const SecretEditRow = ({
environment,
secretPath,
isVisible,
- secretId
+ secretId,
+ isRotatedSecret
}: Props) => {
const {
handleSubmit,
@@ -148,7 +150,7 @@ export const SecretEditRow = ({
isOpen={isModalOpen}
onClose={toggleModal}
title="Do you want to delete the selected secret?"
- deleteKey="delete"
+ deleteKey={secretName}
onDeleteApproved={handleDeleteSecret}
/>
@@ -163,7 +165,7 @@ export const SecretEditRow = ({
render={({ field }) => (
{(isAllowed) => (
-
+
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx
index 3a66f6896..15e6753f3 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx
@@ -2,10 +2,12 @@ import { faCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faCheck,
+ faCodeBranch,
faEye,
faEyeSlash,
faFileImport,
faKey,
+ faRotate,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -15,6 +17,7 @@ import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/componen
import { useToggle } from "@app/hooks";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
+import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
@@ -149,14 +152,7 @@ export const SecretOverviewTableRow = ({
isFormExpanded && "border-b-2 border-mineshaft-500"
}`}
>
-
+
)}
+ {secret?.isRotatedSecret && (
+
+
+
+ )}
+ {secret?.valueOverride && (
+
+
+
+ )}
@@ -237,6 +243,7 @@ export const SecretOverviewTableRow = ({
onSecretCreate={onSecretCreate}
onSecretUpdate={onSecretUpdate}
environment={slug}
+ isRotatedSecret={secret?.isRotatedSecret}
/>
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx
index e4bd67aec..a4a3fcde4 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx
@@ -6,7 +6,8 @@ import {
faFingerprint,
faFolder,
faKey,
- faMagnifyingGlass
+ faMagnifyingGlass,
+ faRotate
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -34,6 +35,7 @@ import { useDebounce } from "@app/hooks";
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
import { WsTag } from "@app/hooks/api/tags/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
+import { QuickSearchSecretRotationItem } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretRotationItem";
import { RowType } from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.types";
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
@@ -51,7 +53,11 @@ export type QuickSearchModalProps = {
onOpenChange: (isOpen: boolean) => void;
};
-type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
+type ResourceType =
+ | RowType.Secret
+ | RowType.DynamicSecret
+ | RowType.Folder
+ | RowType.SecretRotation;
const Content = ({
environments,
@@ -67,7 +73,8 @@ const Content = ({
const [showFilter, setShowFilter] = useState
>({
[RowType.Secret]: true,
[RowType.Folder]: true,
- [RowType.DynamicSecret]: true
+ [RowType.DynamicSecret]: true,
+ [RowType.SecretRotation]: true
});
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
const { data, isPending } = useGetProjectSecretsQuickSearch(
@@ -81,12 +88,13 @@ const Content = ({
{ enabled: isEnabled }
);
- const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
+ const { folders = {}, secrets = {}, dynamicSecrets = {}, secretRotations = {} } = data ?? {};
const isEmpty =
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
- (!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
+ (!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0) &&
+ (!showFilter[RowType.SecretRotation] || Object.values(secretRotations).length === 0);
const handleToggleTag = (tag: string) => {
setFilterTags((prev) => {
@@ -161,6 +169,19 @@ const Content = ({
Dynamic Secrets
+ {
+ e.preventDefault();
+ handleToggleShowType(RowType.SecretRotation);
+ }}
+ icon={showFilter[RowType.SecretRotation] && }
+ iconPos="right"
+ >
+
+
+ Secret Rotations
+
+
{
e.preventDefault();
@@ -240,6 +261,14 @@ const Content = ({
key={key}
/>
))}
+ {showFilter[RowType.SecretRotation] &&
+ Object.entries(secretRotations).map(([key, secretRotationGroup]) => (
+
+ ))}
{showFilter[RowType.Secret] &&
Object.entries(secrets).map(([key, secretGroup]) => (
void;
+};
+
+export const QuickSearchSecretRotationItem = ({ secretRotationGroup, onClose }: Props) => {
+ const navigate = useNavigate({
+ from: "/secret-manager/$projectId/overview"
+ });
+
+ const [groupSecretRotation] = secretRotationGroup;
+
+ const handleNavigate = () => {
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ secretPath: groupSecretRotation.folder.path,
+ search: groupSecretRotation.name
+ })
+ });
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ {groupSecretRotation.name}
+
+
+ {" "}
+
+ {reverseTruncate(groupSecretRotation.folder.path)}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx
index 4d6be2574..c98f7d4cf 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx
@@ -1,4 +1,10 @@
-import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
+import {
+ faFileImport,
+ faFingerprint,
+ faFolder,
+ faKey,
+ faRotate
+} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@app/components/v2";
@@ -8,13 +14,15 @@ type Props = {
importCount?: number;
secretCount?: number;
dynamicSecretCount?: number;
+ secretRotationCount?: number;
};
export const SecretTableResourceCount = ({
folderCount = 0,
dynamicSecretCount = 0,
secretCount = 0,
- importCount = 0
+ importCount = 0,
+ secretRotationCount = 0
}: Props) => {
return (
@@ -66,6 +74,22 @@ export const SecretTableResourceCount = ({
)}
+ {secretRotationCount > 0 && (
+
+ Total secret rotation count{" "}
+ (matching filters)
+
+ }
+ >
+
+
+ {secretRotationCount}
+
+
+ )}
{secretCount > 0 && (
+ Object.values(record).some((secret) => secret.isRotatedSecret)
+ );
const selectedCount = selectedFolderCount + selectedKeysCount;
const { currentWorkspace } = useWorkspace();
@@ -121,7 +124,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
})
);
- if (entry && canDeleteSecret) {
+ if (entry && canDeleteSecret && !entry.isRotatedSecret) {
return [
...accum,
{
@@ -186,6 +189,11 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
{selectedCount} Selected
+ {isRotatedSecretSelected && (
+
+ Rotated Secrets will not be affected by action.
+
+ )}
{shouldShowDelete && (
<>
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx
index a81fa3e53..bb7862f09 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx
@@ -156,7 +156,10 @@ const Content = ({
);
Object.values(secrets).forEach((secretRecord) =>
- Object.entries(secretRecord).map(([env, secret]) => secretsByEnv[env].push(secret))
+ Object.entries(secretRecord).forEach(([env, secret]) => {
+ if (secret.isRotatedSecret) return;
+ secretsByEnv[env].push(secret);
+ })
);
// eslint-disable-next-line no-restricted-syntax
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/utils/index.ts b/frontend/src/pages/secret-manager/OverviewPage/components/utils/index.ts
new file mode 100644
index 000000000..fdda0974e
--- /dev/null
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/utils/index.ts
@@ -0,0 +1,5 @@
+export const getExpandedRowStyle = (scrollOffset: number) => ({
+ marginLeft: scrollOffset,
+ width: "calc(100vw - 355px)", // 350px accounts for sidebar and margin
+ maxWidth: "1270px" // largest width of table on ultra-wide
+});
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx
index 4b90e1fbb..cd2a1aee1 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx
@@ -25,7 +25,10 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
-import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
+import {
+ ProjectPermissionSecretActions,
+ ProjectPermissionSecretRotationActions
+} from "@app/context/ProjectPermissionContext/types";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useGetImportedSecretsSingleEnv,
@@ -39,6 +42,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
+import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView";
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
@@ -137,6 +141,11 @@ const Page = () => {
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
);
+ const canReadSecretRotations = permission.can(
+ ProjectPermissionSecretRotationActions.Read,
+ subject(ProjectPermissionSub.SecretRotation, { environment, secretPath })
+ );
+
const canDoReadRollback = permission.can(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
@@ -150,7 +159,8 @@ const Page = () => {
[RowType.Folder]: true,
[RowType.Import]: true,
[RowType.DynamicSecret]: true,
- [RowType.Secret]: true
+ [RowType.Secret]: true,
+ [RowType.SecretRotation]: true
}
};
@@ -194,6 +204,7 @@ const Page = () => {
viewSecretValue: canReadSecretValue,
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic,
includeSecrets: canReadSecret && filter.include.secret,
+ includeSecretRotations: canReadSecretRotations && filter.include.rotation,
tags: filter.tags
});
@@ -201,12 +212,14 @@ const Page = () => {
imports,
folders,
dynamicSecrets,
+ secretRotations,
secrets,
totalImportCount = 0,
totalFolderCount = 0,
totalDynamicSecretCount = 0,
totalSecretCount = 0,
- totalCount = 0
+ totalCount = 0,
+ totalSecretRotationCount = 0
} = data ?? {};
useResetPageHelper({
@@ -266,7 +279,8 @@ const Page = () => {
(imports?.length || 0) -
(folders?.length || 0) -
(secrets?.length || 0) -
- (dynamicSecrets?.length || 0),
+ (dynamicSecrets?.length || 0) -
+ (secretRotations?.length || 0),
0
);
const isNotEmpty = Boolean(
@@ -274,6 +288,7 @@ const Page = () => {
folders?.length ||
imports?.length ||
dynamicSecrets?.length ||
+ secretRotations?.length ||
noAccessSecretCount
);
@@ -363,6 +378,8 @@ const Page = () => {
const selectedSecretActions = useSelectedSecretActions();
const allRowsSelectedOnPage = useMemo(() => {
+ if (!secrets?.length) return { isChecked: false, isIndeterminate: false };
+
if (secrets?.every((secret) => selectedSecrets[secret.id]))
return { isChecked: true, isIndeterminate: false };
@@ -498,6 +515,9 @@ const Page = () => {
dynamicSecrets={dynamicSecrets}
/>
)}
+ {canReadSecretRotations && Boolean(secretRotations?.length) && (
+
+ )}
{canReadSecret && Boolean(secrets?.length) && (
{
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
+ secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts
index 848e8b91a..979f8de42 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts
@@ -10,5 +10,6 @@ export enum RowType {
Folder = "folder",
Import = "import",
DynamicSecret = "dynamic",
- Secret = "secret"
+ Secret = "secret",
+ SecretRotation = "rotation"
}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx
index 681ad99e3..c665cea88 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx
@@ -18,6 +18,7 @@ import {
faLock,
faMinusSquare,
faPlus,
+ faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -28,6 +29,7 @@ import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
+import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
import {
Button,
DeleteActionModal,
@@ -52,6 +54,7 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
+import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
@@ -112,6 +115,7 @@ export const ActionBar = ({
"addDynamicSecret",
"addSecretImport",
"bulkDeleteSecrets",
+ "addSecretRotation",
"moveSecrets",
"misc",
"upgradePlan"
@@ -364,6 +368,23 @@ export const ActionBar = ({
Dynamic Secrets
+ {
+ e.preventDefault();
+ onToggleRowType(RowType.SecretRotation);
+ }}
+ icon={
+ filter?.include[RowType.SecretRotation] && (
+
+ )
+ }
+ iconPos="right"
+ >
+
+
+ Secret Rotations
+
+
{
e.preventDefault();
@@ -547,6 +568,33 @@ export const ActionBar = ({
)}
+
+ {(isAllowed) => (
+ }
+ onClick={() => {
+ if (subscription && subscription.secretRotation) {
+ handlePopUpOpen("addSecretRotation");
+ handlePopUpClose("misc");
+ return;
+ }
+ handlePopUpOpen("upgradePlan");
+ }}
+ variant="outline_bg"
+ className="h-10 text-left"
+ isFullWidth
+ isDisabled={!isAllowed}
+ >
+ Add Secret Rotation
+
+ )}
+
+ handlePopUpToggle("addSecretRotation", isOpen)}
+ />
handlePopUpToggle("addFolder", isOpen)}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
index 81785999d..6c9c549af 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
@@ -366,7 +366,7 @@ export const SecretDetailSidebar = ({
>
-
-
- setValue("value", secretValue)}
- >
-
-
-
-
+ {!secret?.isRotatedSecret && (
+
+
+ setValue("value", secretValue)}
+ >
+
+
+
+
+ )}
)
)}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
index 01d477097..5c431427c 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
@@ -48,6 +48,8 @@ import {
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { Blur } from "@app/components/v2/Blur";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeSpriteName,
formSchema,
@@ -91,6 +93,7 @@ export const SecretItem = memo(
}: Props) => {
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
+ const { isRotatedSecret } = secret;
const {
handleSubmit,
@@ -151,7 +154,6 @@ export const SecretItem = memo(
secretTags: selectedTagSlugs
})
);
-
const { secretValueHidden } = secret;
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
@@ -221,29 +223,43 @@ export const SecretItem = memo(
-
onToggleSecretSelect(secret)}
- className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
- />
-
+ {secret.isRotatedSecret ? (
+
+
+
+
+ ) : (
+ <>
+ onToggleSecretSelect(secret)}
+ className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
+ />
+
+ >
+ )}
(
(
{(isAllowed) => (
onDeleteSecret(secret)}
- isDisabled={!isAllowed}
+ isDisabled={!isAllowed || isRotatedSecret}
>
= {}
) => {
if (operation === "delete") {
@@ -110,14 +112,16 @@ export const SecretListView = ({
workspaceId,
secretPath,
secretKey: key,
- secretValue: value || "",
+ ...(!isRotatedSecret && {
+ newSecretName: newKey,
+ secretValue: value || ""
+ }),
type,
tagIds: tags,
secretComment: comment,
secretReminderRepeatDays: reminderRepeatDays,
secretReminderNote: reminderNote,
skipMultilineEncoding,
- newSecretName: newKey,
secretMetadata
});
return;
@@ -213,7 +217,8 @@ export const SecretListView = ({
secretId: orgSecret.id,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
- secretMetadata
+ secretMetadata,
+ isRotatedSecret: orgSecret.isRotatedSecret
});
if (cb) cb();
}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretNoAccessListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretNoAccessListView.tsx
index 92bbc157c..fc7422cd5 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretNoAccessListView.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretNoAccessListView.tsx
@@ -1,3 +1,7 @@
+import { faLock, faRotate } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { twMerge } from "tailwind-merge";
+
import { FontAwesomeSymbol, Input, Tooltip } from "@app/components/v2";
import { Blur } from "@app/components/v2/Blur";
@@ -5,9 +9,10 @@ import { FontAwesomeSpriteName } from "./SecretListView.utils";
type Props = {
count: number;
+ isRotationView?: boolean;
};
-export const SecretNoAccessListView = ({ count }: Props) => {
+export const SecretNoAccessListView = ({ count, isRotationView }: Props) => {
return (
<>
{Array.from(Array(count)).map((_, i) => (
@@ -17,15 +22,34 @@ export const SecretNoAccessListView = ({ count }: Props) => {
content="You do not have permission to view this secret"
key={`no-access-secret-${i + 1}`}
>
-
+
-
+ {isRotationView ? (
+
+
+
+
+ ) : (
+
+ )}
-
-
+
{
className="w-full px-0 blur-sm placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
-
+
))}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx
new file mode 100644
index 000000000..fd15a38f2
--- /dev/null
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx
@@ -0,0 +1,261 @@
+import { useState } from "react";
+import { subject } from "@casl/ability";
+import {
+ faAsterisk,
+ faClose,
+ faEdit,
+ faInfoCircle,
+ faKey,
+ faRotate
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { AnimatePresence, motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge";
+import { IconButton, Modal, ModalContent, TableContainer, Tag, Tooltip } from "@app/components/v2";
+import { Blur } from "@app/components/v2/Blur";
+import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
+import { ProjectPermissionSub } from "@app/context";
+import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
+import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+
+type Props = {
+ secretRotation: TSecretRotationV2;
+ onEdit: () => void;
+ onRotate: () => void;
+ onViewGeneratedCredentials: () => void;
+ onDelete: () => void;
+};
+
+export const SecretRotationItem = ({
+ secretRotation,
+ onEdit,
+ onRotate,
+ onViewGeneratedCredentials,
+ onDelete
+}: Props) => {
+ const { name, type, environment, folder, secrets, description } = secretRotation;
+
+ const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
+ const [showSecrets, setShowSecrets] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+
+
{name}
+
+
+ {rotationType}
+
+ {description && (
+
+
+
+ )}
+
+
+
+
+ {
+ e.stopPropagation();
+ setShowSecrets(true);
+ }}
+ >
+
+
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ onViewGeneratedCredentials();
+ }}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ onRotate();
+ }}
+ >
+
+
+ )}
+
+
+
+
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ onEdit();
+ }}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ onDelete();
+ }}
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+
+
+
+
+ e.preventDefault()}
+ className="max-w-3xl"
+ title="Rotation Secrets"
+ >
+
+
+
+ {secrets.map((secret, index) => {
+ return (
+
+
+
+
+ {secret?.key ?? "********"}
+
+
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {!secret ? (
+ ********
+ ) : secret.secretValueHidden ? (
+
+ ) : (
+ {}}
+ />
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx
new file mode 100644
index 000000000..1682a79df
--- /dev/null
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx
@@ -0,0 +1,60 @@
+import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal";
+import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal";
+import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal";
+import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials";
+import { usePopUp } from "@app/hooks";
+import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
+
+import { SecretRotationItem } from "./SecretRotationItem";
+
+type Props = {
+ secretRotations?: TSecretRotationV2[];
+};
+
+export const SecretRotationListView = ({ secretRotations }: Props) => {
+ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
+ "editSecretRotation",
+ "rotateSecretRotation",
+ "viewSecretRotationGeneratedCredentials",
+ "deleteSecretRotation"
+ ] as const);
+
+ return (
+ <>
+ {secretRotations?.map((secretRotation) => (
+
handlePopUpOpen("editSecretRotation", secretRotation)}
+ onRotate={() => handlePopUpOpen("rotateSecretRotation", secretRotation)}
+ onViewGeneratedCredentials={() =>
+ handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation)
+ }
+ onDelete={() => handlePopUpOpen("deleteSecretRotation", secretRotation)}
+ />
+ ))}
+ handlePopUpToggle("editSecretRotation", isOpen)}
+ />
+ handlePopUpToggle("rotateSecretRotation", isOpen)}
+ />
+
+ handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen)
+ }
+ />
+ handlePopUpToggle("deleteSecretRotation", isOpen)}
+ />
+ >
+ );
+};
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/index.tsx
new file mode 100644
index 000000000..1421bf4a7
--- /dev/null
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/index.tsx
@@ -0,0 +1 @@
+export { SecretRotationListView } from "./SecretRotationListView";
diff --git a/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx b/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx
index c27d34cc4..e269225ab 100644
--- a/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx
+++ b/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx
@@ -11,15 +11,19 @@ import {
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Link, useNavigate } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
+ Button,
DeleteActionModal,
EmptyState,
IconButton,
+ Modal,
+ ModalContent,
PageHeader,
Skeleton,
Spinner,
@@ -33,13 +37,9 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
-import {
- ProjectPermissionActions,
- ProjectPermissionSub,
- useProjectPermission,
- useSubscription,
- useWorkspace
-} from "@app/context";
+import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
+import { ProjectPermissionSub, useWorkspace } from "@app/context";
+import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import {
useDeleteSecretRotation,
@@ -47,26 +47,20 @@ import {
useGetSecretRotations,
useRestartSecretRotation
} from "@app/hooks/api";
-import { TSecretRotationProviderTemplate } from "@app/hooks/api/types";
-
-import { CreateRotationForm } from "./components/CreateRotationForm";
+import { ProjectType } from "@app/hooks/api/workspace/types";
const Page = () => {
const { currentWorkspace } = useWorkspace();
- const { permission } = useProjectPermission();
+
+ const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
- "createRotation",
"activeBot",
"deleteRotation",
- "upgradePlan"
+ "upgradePlan",
+ "secretRotationV2"
] as const);
const workspaceId = currentWorkspace?.id || "";
- const canCreateRotation = permission.can(
- ProjectPermissionActions.Create,
- ProjectPermissionSub.SecretRotation
- );
- const { subscription } = useSubscription();
const { data: secretRotationProviders, isPending: isRotationProviderLoading } =
useGetSecretRotationProviders({ workspaceId });
@@ -125,18 +119,6 @@ const Page = () => {
}
};
- const handleCreateRotation = async (provider: TSecretRotationProviderTemplate) => {
- if (subscription && !subscription?.secretRotation) {
- handlePopUpOpen("upgradePlan");
- return;
- }
- if (!canCreateRotation) {
- createNotification({ type: "error", text: "Access permission denied!!" });
- return;
- }
- handlePopUpOpen("createRotation", provider);
- };
-
return (
{
+
+
+ Infisical is revamping it's Secret Rotation experience.
+
+
+ Secret Rotations can now be created from the{" "}
+
+ Secret Manager Dashboard
+ {" "}
+ from the actions dropdown.
+
+
Rotated Secrets
@@ -241,7 +239,7 @@ const Page = () => {
{
)}
{
key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0}
role="button"
- onKeyDown={(evt) => {
- if (evt.key === "Enter") handlePopUpOpen("createRotation", provider);
+ onKeyDown={() => {
+ handlePopUpOpen("secretRotationV2", provider.title);
+ }}
+ onClick={() => {
+ handlePopUpOpen("secretRotationV2", provider.title);
}}
- onClick={() => handleCreateRotation(provider)}
>
{
- handlePopUpToggle("createRotation", isOpen)}
- provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
- />
{
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret rotation if you switch to Infisical's Team plan."
/>
+ handlePopUpToggle("secretRotationV2", isOpen)}
+ >
+
+
+
+ Infisical is revamping it's Secret Rotation experience. Navigate to the{" "}
+
+ Secret Manager Dashboard
+ {" "}
+ to create a Secret Rotations.
+
+
+
+
+
+
+ navigate({
+ to: `/${ProjectType.SecretManager}/$projectId/overview` as const,
+ params: { projectId: currentWorkspace.id }
+ })
+ }
+ colorSchema="secondary"
+ >
+ Navigate to Secret Manager
+
+
+
+
+
);
};
@@ -377,7 +410,7 @@ export const SecretRotationPage = () => {
-
+
{awsRegion?.name}
{awsRegion?.slug}{" "}
-
- {path}
+
+ {path}
>
);
};
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection.tsx
index 5b73a4492..b2908535c 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection.tsx
@@ -1,4 +1,4 @@
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import {
@@ -17,17 +17,17 @@ export const AwsSecretsManagerSyncDestinationSection = ({ secretSync }: Props) =
return (
<>
-
+
{awsRegion?.name}
{awsRegion?.slug}{" "}
-
-
+
+
{destinationConfig.mappingBehavior}
-
+
{destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && (
- {destinationConfig.secretName}
+ {destinationConfig.secretName}
)}
>
);
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx
index 7db01f7fe..a46ff66cd 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx
@@ -1,4 +1,4 @@
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TAzureAppConfigurationSync } from "@app/hooks/api/secretSyncs/types/azure-app-configuration-sync";
type Props = {
@@ -12,8 +12,8 @@ export const AzureAppConfigurationSyncDestinationSection = ({ secretSync }: Prop
return (
<>
- {configurationUrl}
- {label}
+ {configurationUrl}
+ {label}
>
);
};
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx
index 4a30e6e08..6df27c16b 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx
@@ -1,4 +1,4 @@
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TAzureKeyVaultSync } from "@app/hooks/api/secretSyncs/types/azure-key-vault-sync";
type Props = {
@@ -10,5 +10,5 @@ export const AzureKeyVaultSyncDestinationSection = ({ secretSync }: Props) => {
destinationConfig: { vaultBaseUrl }
} = secretSync;
- return {vaultBaseUrl} ;
+ return {vaultBaseUrl} ;
};
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection.tsx
index ecc844471..ce206c113 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection.tsx
@@ -1,4 +1,4 @@
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TDatabricksSync } from "@app/hooks/api/secretSyncs/types/databricks-sync";
type Props = {
@@ -10,5 +10,5 @@ export const DatabricksSyncDestinationSection = ({ secretSync }: Props) => {
destinationConfig: { scope }
} = secretSync;
- return {scope} ;
+ return {scope} ;
};
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GcpSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GcpSyncDestinationSection.tsx
index 69821ddfd..cffefcfd3 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GcpSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GcpSyncDestinationSection.tsx
@@ -1,4 +1,4 @@
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TGcpSync } from "@app/hooks/api/secretSyncs/types/gcp-sync";
type Props = {
@@ -10,5 +10,5 @@ export const GcpSyncDestinationSection = ({ secretSync }: Props) => {
destinationConfig: { projectId }
} = secretSync;
- return {projectId} ;
+ return {projectId} ;
};
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx
index 298fd3670..0fb7e5619 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx
@@ -2,7 +2,7 @@ import { ReactNode } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github";
import { Tooltip } from "@app/components/v2";
import {
@@ -23,12 +23,12 @@ export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
case GitHubSyncScope.Organization:
Components = (
<>
- {destinationConfig.org}
-
+ {destinationConfig.org}
+
{destinationConfig.visibility} Repositories
-
+
{destinationConfig.visibility === GitHubSyncVisibility.Selected && (
-
+
{destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories
{
>
-
+
)}
>
);
break;
case GitHubSyncScope.Repository:
Components = (
-
+
{destinationConfig.owner}/{destinationConfig.repo}
-
+
);
break;
case GitHubSyncScope.RepositoryEnvironment:
Components = (
<>
-
+
{destinationConfig.owner}/{destinationConfig.repo}
-
- {destinationConfig.env}
+
+ {destinationConfig.env}
>
);
break;
@@ -66,9 +66,9 @@ export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
return (
<>
-
+
{destinationConfig.scope.replace("-", " ")}
-
+
{Components}
>
);
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx
index f750ce885..1c9c4c330 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from "react";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import {
HumanitecSyncScope,
THumanitecSync
@@ -18,17 +18,17 @@ export const HumanitecSyncDestinationSection = ({ secretSync }: Props) => {
case HumanitecSyncScope.Application:
Components = (
<>
- {destinationConfig.app}
- {destinationConfig.org}
+ {destinationConfig.app}
+ {destinationConfig.org}
>
);
break;
case HumanitecSyncScope.Environment:
Components = (
<>
- {destinationConfig.app}
- {destinationConfig.org}
- {destinationConfig.env}
+ {destinationConfig.app}
+ {destinationConfig.org}
+ {destinationConfig.env}
>
);
break;
@@ -40,9 +40,9 @@ export const HumanitecSyncDestinationSection = ({ secretSync }: Props) => {
return (
<>
-
+
{destinationConfig.scope.replace("-", " ")}
-
+
{Components}
>
);
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx
index c1dee10c2..49892a0a2 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx
@@ -3,7 +3,7 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -83,7 +83,7 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
- {connection.name}
+ {connection.name}
{DestinationComponents}
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx
index 609780362..3bf5f3d22 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
-import { SecretSyncLabel, SecretSyncStatusBadge } from "@app/components/secret-syncs";
+import { GenericFieldLabel, SecretSyncStatusBadge } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -55,22 +55,22 @@ export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) =
-
{name}
-
{description}
+
{name}
+
{description}
{syncStatus && (
-
+
-
+
)}
{lastSyncedAt && (
-
+
{format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")}
-
+
)}
{syncStatus === SecretSyncStatus.Failed && failureMessage && (
-
+
{failureMessage}
-
+
)}
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx
index 1b898bc70..1a3db1c64 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx
@@ -1,7 +1,7 @@
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
@@ -16,9 +16,9 @@ export const AwsParameterStoreSyncOptionsSection = ({ secretSync }: Props) => {
return (
<>
- {keyId && {keyId} }
+ {keyId && {keyId} }
{tags && tags.length > 0 && (
-
+
{
-
+
)}
{syncSecretMetadataAsTags && (
-
+
Enabled
-
+
)}
>
);
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx
index 8e103ba18..3d49b32e1 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx
@@ -1,7 +1,7 @@
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { TAwsSecretsManagerSync } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
@@ -16,9 +16,9 @@ export const AwsSecretsManagerSyncOptionsSection = ({ secretSync }: Props) => {
return (
<>
- {keyId && {keyId} }
+ {keyId && {keyId} }
{tags && tags.length > 0 && (
-
+
{
-
+
)}
{syncSecretMetadataAsTags && (
-
+
Enabled
-
+
)}
>
);
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx
index 351cb70e1..9349e7c63 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx
@@ -3,7 +3,7 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge, IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -78,16 +78,16 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
-
+
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name}
-
+
{/* {prependPrefix}
{appendSuffix} */}
{AdditionalSyncOptionsComponent}
{disableSecretDeletion && (
-
+
Disabled
-
+
)}
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx
index 92eff2e32..49717f7e0 100644
--- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx
+++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx
@@ -2,7 +2,7 @@ import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
-import { SecretSyncLabel } from "@app/components/secret-syncs";
+import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -55,8 +55,8 @@ export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) =>
- {environment?.name}
- {folder?.path}
+ {environment?.name}
+ {folder?.path}