mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 18:27:19 +00:00
feat: secret v2 architecture for secret rotation
This commit is contained in:
Vendored
+8
@@ -239,6 +239,9 @@ import {
|
|||||||
TSecretRotationOutputs,
|
TSecretRotationOutputs,
|
||||||
TSecretRotationOutputsInsert,
|
TSecretRotationOutputsInsert,
|
||||||
TSecretRotationOutputsUpdate,
|
TSecretRotationOutputsUpdate,
|
||||||
|
TSecretRotationOutputV2,
|
||||||
|
TSecretRotationOutputV2Insert,
|
||||||
|
TSecretRotationOutputV2Update,
|
||||||
TSecretRotations,
|
TSecretRotations,
|
||||||
TSecretRotationsInsert,
|
TSecretRotationsInsert,
|
||||||
TSecretRotationsUpdate,
|
TSecretRotationsUpdate,
|
||||||
@@ -718,6 +721,11 @@ declare module "knex/types/tables" {
|
|||||||
TSecretApprovalRequestSecretTagsV2Insert,
|
TSecretApprovalRequestSecretTagsV2Insert,
|
||||||
TSecretApprovalRequestSecretTagsV2Update
|
TSecretApprovalRequestSecretTagsV2Update
|
||||||
>;
|
>;
|
||||||
|
[TableName.SecretRotationOutputV2]: KnexOriginal.CompositeTableType<
|
||||||
|
TSecretRotationOutputV2,
|
||||||
|
TSecretRotationOutputV2Insert,
|
||||||
|
TSecretRotationOutputV2Update
|
||||||
|
>;
|
||||||
// KMS service
|
// KMS service
|
||||||
[TableName.KmsServerRootConfig]: KnexOriginal.CompositeTableType<
|
[TableName.KmsServerRootConfig]: KnexOriginal.CompositeTableType<
|
||||||
TKmsRootConfig,
|
TKmsRootConfig,
|
||||||
|
|||||||
@@ -128,7 +128,18 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
if (!hasEncryptedAccess) t.binary("encryptedAccess");
|
if (!hasEncryptedAccess) t.binary("encryptedAccess");
|
||||||
if (!hasEncryptedAccessId) t.binary("encryptedAccessId");
|
if (!hasEncryptedAccessId) t.binary("encryptedAccessId");
|
||||||
if (!hasEncryptedRefresh) t.binary("encryptedRefresh");
|
if (!hasEncryptedRefresh) t.binary("encryptedRefresh");
|
||||||
if (!hasEncryptedAwsIamAssumRole) t.binary("encryptedAwsIamAssumRole");
|
if (!hasEncryptedAwsIamAssumRole) t.binary("encryptedAwsAssumeIamRoleArn");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasTable(TableName.SecretRotationOutputV2))) {
|
||||||
|
await knex.schema.createTable(TableName.SecretRotationOutputV2, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.string("key").notNullable();
|
||||||
|
t.uuid("secretId").notNullable();
|
||||||
|
t.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE");
|
||||||
|
t.uuid("rotationId").notNullable();
|
||||||
|
t.foreign("rotationId").references("id").inTable(TableName.SecretRotation).onDelete("CASCADE");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,13 +152,15 @@ export async function down(knex: Knex): Promise<void> {
|
|||||||
await knex.schema.dropTableIfExists(TableName.SecretV2JnTag);
|
await knex.schema.dropTableIfExists(TableName.SecretV2JnTag);
|
||||||
await knex.schema.dropTableIfExists(TableName.SecretReferenceV2);
|
await knex.schema.dropTableIfExists(TableName.SecretReferenceV2);
|
||||||
|
|
||||||
await dropOnUpdateTrigger(knex, TableName.SecretV2);
|
await knex.schema.dropTableIfExists(TableName.SecretRotationOutputV2);
|
||||||
await knex.schema.dropTableIfExists(TableName.SecretV2);
|
|
||||||
|
|
||||||
await dropOnUpdateTrigger(knex, TableName.SecretVersionV2);
|
await dropOnUpdateTrigger(knex, TableName.SecretVersionV2);
|
||||||
await knex.schema.dropTableIfExists(TableName.SecretVersionV2Tag);
|
await knex.schema.dropTableIfExists(TableName.SecretVersionV2Tag);
|
||||||
await knex.schema.dropTableIfExists(TableName.SecretVersionV2);
|
await knex.schema.dropTableIfExists(TableName.SecretVersionV2);
|
||||||
|
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.SecretV2);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.SecretV2);
|
||||||
|
|
||||||
if (await knex.schema.hasTable(TableName.IntegrationAuth)) {
|
if (await knex.schema.hasTable(TableName.IntegrationAuth)) {
|
||||||
const hasEncryptedAccess = await knex.schema.hasColumn(TableName.IntegrationAuth, "encryptedAccess");
|
const hasEncryptedAccess = await knex.schema.hasColumn(TableName.IntegrationAuth, "encryptedAccess");
|
||||||
const hasEncryptedAccessId = await knex.schema.hasColumn(TableName.IntegrationAuth, "encryptedAccessId");
|
const hasEncryptedAccessId = await knex.schema.hasColumn(TableName.IntegrationAuth, "encryptedAccessId");
|
||||||
@@ -160,7 +173,7 @@ export async function down(knex: Knex): Promise<void> {
|
|||||||
if (hasEncryptedAccess) t.dropColumn("encryptedAccess");
|
if (hasEncryptedAccess) t.dropColumn("encryptedAccess");
|
||||||
if (hasEncryptedAccessId) t.dropColumn("encryptedAccessId");
|
if (hasEncryptedAccessId) t.dropColumn("encryptedAccessId");
|
||||||
if (hasEncryptedRefresh) t.dropColumn("encryptedRefresh");
|
if (hasEncryptedRefresh) t.dropColumn("encryptedRefresh");
|
||||||
if (hasEncryptedAwsIamAssumRole) t.dropColumn("encryptedAwsIamAssumRole");
|
if (hasEncryptedAwsIamAssumRole) t.dropColumn("encryptedAwsAssumeIamRoleArn");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,3 +103,4 @@ export * from "./user-encryption-keys";
|
|||||||
export * from "./user-group-membership";
|
export * from "./user-group-membership";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./webhooks";
|
export * from "./webhooks";
|
||||||
|
export * from "./secret-rotation-output-v2";
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ export const IntegrationAuthsSchema = z.object({
|
|||||||
awsAssumeIamRoleArnCipherText: z.string().nullable().optional(),
|
awsAssumeIamRoleArnCipherText: z.string().nullable().optional(),
|
||||||
awsAssumeIamRoleArnIV: z.string().nullable().optional(),
|
awsAssumeIamRoleArnIV: z.string().nullable().optional(),
|
||||||
awsAssumeIamRoleArnTag: z.string().nullable().optional(),
|
awsAssumeIamRoleArnTag: z.string().nullable().optional(),
|
||||||
|
encryptedAwsIamAssumRole: zodBuffer.nullable().optional(),
|
||||||
encryptedAccess: zodBuffer.nullable().optional(),
|
encryptedAccess: zodBuffer.nullable().optional(),
|
||||||
encryptedAccessId: zodBuffer.nullable().optional(),
|
encryptedAccessId: zodBuffer.nullable().optional(),
|
||||||
encryptedRefresh: zodBuffer.nullable().optional(),
|
encryptedRefresh: zodBuffer.nullable().optional(),
|
||||||
encryptedAwsIamAssumRole: zodBuffer.nullable().optional()
|
encryptedAwsAssumeIamRoleArn: zodBuffer.nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TIntegrationAuths = z.infer<typeof IntegrationAuthsSchema>;
|
export type TIntegrationAuths = z.infer<typeof IntegrationAuthsSchema>;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export enum TableName {
|
|||||||
JnSecretTag = "secret_tag_junction",
|
JnSecretTag = "secret_tag_junction",
|
||||||
SecretVersionTag = "secret_version_tag_junction",
|
SecretVersionTag = "secret_version_tag_junction",
|
||||||
SecretVersionV2Tag = "secret_version_v2_tag_junction",
|
SecretVersionV2Tag = "secret_version_v2_tag_junction",
|
||||||
|
SecretRotationOutputV2 = "secret_rotation_output_v2",
|
||||||
// KMS Service
|
// KMS Service
|
||||||
KmsServerRootConfig = "kms_root_config",
|
KmsServerRootConfig = "kms_root_config",
|
||||||
KmsKey = "kms_keys",
|
KmsKey = "kms_keys",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 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 SecretRotationOutputV2Schema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
key: z.string(),
|
||||||
|
secretId: z.string().uuid(),
|
||||||
|
rotationId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSecretRotationOutputV2 = z.infer<typeof SecretRotationOutputV2Schema>;
|
||||||
|
export type TSecretRotationOutputV2Insert = Omit<z.input<typeof SecretRotationOutputV2Schema>, TImmutableDBKeys>;
|
||||||
|
export type TSecretRotationOutputV2Update = Partial<
|
||||||
|
Omit<z.input<typeof SecretRotationOutputV2Schema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SecretRotationOutputsSchema, SecretRotationsSchema, SecretsSchema } from "@app/db/schemas";
|
import { SecretRotationOutputsSchema, SecretRotationsSchema } from "@app/db/schemas";
|
||||||
import { removeTrailingSlash } from "@app/lib/fn";
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
@@ -112,18 +112,10 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) =
|
|||||||
outputs: z
|
outputs: z
|
||||||
.object({
|
.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
secret: SecretsSchema.pick({
|
secret: z.object({
|
||||||
id: true,
|
secretKey: z.string(),
|
||||||
version: true,
|
id: z.string(),
|
||||||
secretKeyIV: true,
|
version: z.number()
|
||||||
secretKeyTag: true,
|
|
||||||
secretKeyCiphertext: true,
|
|
||||||
secretValueIV: true,
|
|
||||||
secretValueTag: true,
|
|
||||||
secretValueCiphertext: true,
|
|
||||||
secretCommentIV: true,
|
|
||||||
secretCommentTag: true,
|
|
||||||
secretCommentCiphertext: true
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type TSecretRotationDALFactory = ReturnType<typeof secretRotationDALFacto
|
|||||||
export const secretRotationDALFactory = (db: TDbClient) => {
|
export const secretRotationDALFactory = (db: TDbClient) => {
|
||||||
const secretRotationOrm = ormify(db, TableName.SecretRotation);
|
const secretRotationOrm = ormify(db, TableName.SecretRotation);
|
||||||
const secretRotationOutputOrm = ormify(db, TableName.SecretRotationOutput);
|
const secretRotationOutputOrm = ormify(db, TableName.SecretRotationOutput);
|
||||||
|
const secretRotationOutputV2Orm = ormify(db, TableName.SecretRotationOutputV2);
|
||||||
|
|
||||||
const findQuery = (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx: Knex) =>
|
const findQuery = (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx: Knex) =>
|
||||||
tx(TableName.SecretRotation)
|
tx(TableName.SecretRotation)
|
||||||
@@ -31,13 +32,7 @@ export const secretRotationDALFactory = (db: TDbClient) => {
|
|||||||
.select(tx.ref("version").withSchema(TableName.Secret).as("secVersion"))
|
.select(tx.ref("version").withSchema(TableName.Secret).as("secVersion"))
|
||||||
.select(tx.ref("secretKeyIV").withSchema(TableName.Secret))
|
.select(tx.ref("secretKeyIV").withSchema(TableName.Secret))
|
||||||
.select(tx.ref("secretKeyTag").withSchema(TableName.Secret))
|
.select(tx.ref("secretKeyTag").withSchema(TableName.Secret))
|
||||||
.select(tx.ref("secretKeyCiphertext").withSchema(TableName.Secret))
|
.select(tx.ref("secretKeyCiphertext").withSchema(TableName.Secret));
|
||||||
.select(tx.ref("secretValueIV").withSchema(TableName.Secret))
|
|
||||||
.select(tx.ref("secretValueTag").withSchema(TableName.Secret))
|
|
||||||
.select(tx.ref("secretValueCiphertext").withSchema(TableName.Secret))
|
|
||||||
.select(tx.ref("secretCommentIV").withSchema(TableName.Secret))
|
|
||||||
.select(tx.ref("secretCommentTag").withSchema(TableName.Secret))
|
|
||||||
.select(tx.ref("secretCommentCiphertext").withSchema(TableName.Secret));
|
|
||||||
|
|
||||||
const find = async (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx?: Knex) => {
|
const find = async (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
@@ -54,33 +49,65 @@ export const secretRotationDALFactory = (db: TDbClient) => {
|
|||||||
{
|
{
|
||||||
key: "secId",
|
key: "secId",
|
||||||
label: "outputs" as const,
|
label: "outputs" as const,
|
||||||
mapper: ({
|
mapper: ({ secId, outputKey, secVersion, secretKeyIV, secretKeyTag, secretKeyCiphertext }) => ({
|
||||||
secId,
|
|
||||||
outputKey,
|
|
||||||
secVersion,
|
|
||||||
secretKeyIV,
|
|
||||||
secretKeyTag,
|
|
||||||
secretKeyCiphertext,
|
|
||||||
secretValueTag,
|
|
||||||
secretValueIV,
|
|
||||||
secretValueCiphertext,
|
|
||||||
secretCommentIV,
|
|
||||||
secretCommentTag,
|
|
||||||
secretCommentCiphertext
|
|
||||||
}) => ({
|
|
||||||
key: outputKey,
|
key: outputKey,
|
||||||
secret: {
|
secret: {
|
||||||
id: secId,
|
id: secId,
|
||||||
version: secVersion,
|
version: secVersion,
|
||||||
secretKeyIV,
|
secretKeyIV,
|
||||||
secretKeyTag,
|
secretKeyTag,
|
||||||
secretKeyCiphertext,
|
secretKeyCiphertext
|
||||||
secretValueTag,
|
}
|
||||||
secretValueIV,
|
})
|
||||||
secretValueCiphertext,
|
}
|
||||||
secretCommentIV,
|
]
|
||||||
secretCommentTag,
|
});
|
||||||
secretCommentCiphertext
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "SecretRotationFind" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findQuerySecretV2 = (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx: Knex) =>
|
||||||
|
tx(TableName.SecretRotation)
|
||||||
|
.where(filter)
|
||||||
|
.join(TableName.Environment, `${TableName.SecretRotation}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.SecretRotationOutputV2,
|
||||||
|
`${TableName.SecretRotation}.id`,
|
||||||
|
`${TableName.SecretRotationOutputV2}.rotationId`
|
||||||
|
)
|
||||||
|
.join(TableName.SecretV2, `${TableName.SecretRotationOutputV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.SecretRotation))
|
||||||
|
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
|
||||||
|
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
|
||||||
|
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
|
||||||
|
.select(tx.ref("projectId").withSchema(TableName.Environment))
|
||||||
|
.select(tx.ref("key").withSchema(TableName.SecretRotationOutputV2).as("outputKey"))
|
||||||
|
.select(tx.ref("id").withSchema(TableName.SecretV2).as("secId"))
|
||||||
|
.select(tx.ref("version").withSchema(TableName.SecretV2).as("secVersion"))
|
||||||
|
.select(tx.ref("key").withSchema(TableName.SecretV2).as("secretKey"));
|
||||||
|
|
||||||
|
const findSecretV2 = async (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const data = await findQuerySecretV2(filter, tx || db.replicaNode());
|
||||||
|
return sqlNestRelationships({
|
||||||
|
data,
|
||||||
|
key: "id",
|
||||||
|
parentMapper: (el) => ({
|
||||||
|
...SecretRotationsSchema.parse(el),
|
||||||
|
projectId: el.projectId,
|
||||||
|
environment: { id: el.envId, name: el.envName, slug: el.envSlug }
|
||||||
|
}),
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
key: "secId",
|
||||||
|
label: "outputs" as const,
|
||||||
|
mapper: ({ secId, outputKey, secVersion, secretKey }) => ({
|
||||||
|
key: outputKey,
|
||||||
|
secret: {
|
||||||
|
id: secId,
|
||||||
|
version: secVersion,
|
||||||
|
secretKey
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,12 +141,17 @@ export const secretRotationDALFactory = (db: TDbClient) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const findRotationOutputsByRotationId = async (rotationId: string) => secretRotationOutputOrm.find({ rotationId });
|
const findRotationOutputsByRotationId = async (rotationId: string) => secretRotationOutputOrm.find({ rotationId });
|
||||||
|
const findRotationOutputsV2ByRotationId = async (rotationId: string) =>
|
||||||
|
secretRotationOutputV2Orm.find({ rotationId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...secretRotationOrm,
|
...secretRotationOrm,
|
||||||
find,
|
find,
|
||||||
|
findSecretV2,
|
||||||
findById,
|
findById,
|
||||||
secretOutputInsertMany: secretRotationOutputOrm.insertMany,
|
secretOutputInsertMany: secretRotationOutputOrm.insertMany,
|
||||||
findRotationOutputsByRotationId
|
secretOutputV2InsertMany: secretRotationOutputV2Orm.insertMany,
|
||||||
|
findRotationOutputsByRotationId,
|
||||||
|
findRotationOutputsV2ByRotationId
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-2
@@ -121,7 +121,13 @@ export const secretRotationQueueFactory = ({
|
|||||||
try {
|
try {
|
||||||
if (!rotationProvider || !secretRotation) throw new DisableRotationErrors({ message: "Provider not found" });
|
if (!rotationProvider || !secretRotation) throw new DisableRotationErrors({ message: "Provider not found" });
|
||||||
|
|
||||||
const rotationOutputs = await secretRotationDAL.findRotationOutputsByRotationId(rotationId);
|
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(secretRotation.projectId);
|
||||||
|
let rotationOutputs;
|
||||||
|
if (shouldUseSecretV2Bridge) {
|
||||||
|
rotationOutputs = await secretRotationDAL.findRotationOutputsV2ByRotationId(rotationId);
|
||||||
|
} else {
|
||||||
|
rotationOutputs = await secretRotationDAL.findRotationOutputsByRotationId(rotationId);
|
||||||
|
}
|
||||||
if (!rotationOutputs.length) throw new DisableRotationErrors({ message: "Secrets not found" });
|
if (!rotationOutputs.length) throw new DisableRotationErrors({ message: "Secrets not found" });
|
||||||
|
|
||||||
// deep copy
|
// deep copy
|
||||||
@@ -277,7 +283,6 @@ export const secretRotationQueueFactory = ({
|
|||||||
internal: newCredential.internal
|
internal: newCredential.internal
|
||||||
});
|
});
|
||||||
const encVarData = infisicalSymmetricEncypt(JSON.stringify(variables));
|
const encVarData = infisicalSymmetricEncypt(JSON.stringify(variables));
|
||||||
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(secretRotation.projectId);
|
|
||||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
type: KmsDataKey.SecretManager,
|
type: KmsDataKey.SecretManager,
|
||||||
projectId: secretRotation.projectId
|
projectId: secretRotation.projectId
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { ForbiddenError, subject } from "@casl/ability";
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
|
|
||||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { ProjectVersion } from "@app/db/schemas";
|
||||||
|
import { decryptSymmetric128BitHexKeyUTF8, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
|
import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
|
|
||||||
import { TLicenseServiceFactory } from "../license/license-service";
|
import { TLicenseServiceFactory } from "../license/license-service";
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||||
@@ -22,9 +25,11 @@ type TSecretRotationServiceFactoryDep = {
|
|||||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||||
|
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
secretRotationQueue: TSecretRotationQueueFactory;
|
secretRotationQueue: TSecretRotationQueueFactory;
|
||||||
|
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSecretRotationServiceFactory = ReturnType<typeof secretRotationServiceFactory>;
|
export type TSecretRotationServiceFactory = ReturnType<typeof secretRotationServiceFactory>;
|
||||||
@@ -37,7 +42,9 @@ export const secretRotationServiceFactory = ({
|
|||||||
licenseService,
|
licenseService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
secretDAL
|
secretDAL,
|
||||||
|
projectBotService,
|
||||||
|
secretV2BridgeDAL
|
||||||
}: TSecretRotationServiceFactoryDep) => {
|
}: TSecretRotationServiceFactoryDep) => {
|
||||||
const getProviderTemplates = async ({
|
const getProviderTemplates = async ({
|
||||||
actor,
|
actor,
|
||||||
@@ -92,15 +99,25 @@ export const secretRotationServiceFactory = ({
|
|||||||
ProjectPermissionActions.Edit,
|
ProjectPermissionActions.Edit,
|
||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSecrets = await secretDAL.find({
|
|
||||||
folderId: folder.id,
|
|
||||||
$in: { id: Object.values(outputs) }
|
|
||||||
});
|
|
||||||
if (selectedSecrets.length !== Object.values(outputs).length)
|
|
||||||
throw new BadRequestError({ message: "Secrets not found" });
|
|
||||||
|
|
||||||
const project = await projectDAL.findById(projectId);
|
const project = await projectDAL.findById(projectId);
|
||||||
|
const shouldUseBridge = project.version === ProjectVersion.V3;
|
||||||
|
|
||||||
|
if (shouldUseBridge) {
|
||||||
|
const selectedSecrets = await secretV2BridgeDAL.find({
|
||||||
|
folderId: folder.id,
|
||||||
|
$in: { id: Object.values(outputs) }
|
||||||
|
});
|
||||||
|
if (selectedSecrets.length !== Object.values(outputs).length)
|
||||||
|
throw new BadRequestError({ message: "Secrets not found" });
|
||||||
|
} else {
|
||||||
|
const selectedSecrets = await secretDAL.find({
|
||||||
|
folderId: folder.id,
|
||||||
|
$in: { id: Object.values(outputs) }
|
||||||
|
});
|
||||||
|
if (selectedSecrets.length !== Object.values(outputs).length)
|
||||||
|
throw new BadRequestError({ message: "Secrets not found" });
|
||||||
|
}
|
||||||
|
|
||||||
const plan = await licenseService.getPlan(project.orgId);
|
const plan = await licenseService.getPlan(project.orgId);
|
||||||
if (!plan.secretRotation)
|
if (!plan.secretRotation)
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
@@ -148,10 +165,18 @@ export const secretRotationServiceFactory = ({
|
|||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
const outputSecretMapping = await secretRotationDAL.secretOutputInsertMany(
|
let outputSecretMapping;
|
||||||
Object.entries(outputs).map(([key, secretId]) => ({ key, secretId, rotationId: doc.id })),
|
if (shouldUseBridge) {
|
||||||
tx
|
outputSecretMapping = await secretRotationDAL.secretOutputV2InsertMany(
|
||||||
);
|
Object.entries(outputs).map(([key, secretId]) => ({ key, secretId, rotationId: doc.id })),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
outputSecretMapping = await secretRotationDAL.secretOutputInsertMany(
|
||||||
|
Object.entries(outputs).map(([key, secretId]) => ({ key, secretId, rotationId: doc.id })),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
return { ...doc, outputs: outputSecretMapping, environment: folder.environment };
|
return { ...doc, outputs: outputSecretMapping, environment: folder.environment };
|
||||||
});
|
});
|
||||||
await secretRotationQueue.addToQueue(secretRotation.id, secretRotation.interval);
|
await secretRotationQueue.addToQueue(secretRotation.id, secretRotation.interval);
|
||||||
@@ -167,8 +192,30 @@ export const secretRotationServiceFactory = ({
|
|||||||
actorOrgId
|
actorOrgId
|
||||||
);
|
);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
|
||||||
const doc = await secretRotationDAL.find({ projectId });
|
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||||
return doc;
|
if (shouldUseSecretV2Bridge) {
|
||||||
|
const docs = await secretRotationDAL.findSecretV2({ projectId });
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botKey) throw new BadRequestError({ message: "bot not found" });
|
||||||
|
const docs = await secretRotationDAL.find({ projectId });
|
||||||
|
return docs.map((el) => ({
|
||||||
|
...el,
|
||||||
|
outputs: el.outputs.map((output) => ({
|
||||||
|
...output,
|
||||||
|
secret: {
|
||||||
|
id: output.secret.id,
|
||||||
|
version: output.secret.version,
|
||||||
|
secretKey: decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: output.secret.secretKeyCiphertext,
|
||||||
|
iv: output.secret.secretKeyIV,
|
||||||
|
tag: output.secret.secretKeyTag,
|
||||||
|
key: botKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const restartById = async ({ actor, actorId, actorOrgId, actorAuthMethod, rotationId }: TRestartDTO) => {
|
const restartById = async ({ actor, actorId, actorOrgId, actorAuthMethod, rotationId }: TRestartDTO) => {
|
||||||
|
|||||||
@@ -869,7 +869,9 @@ export const registerRoutes = async (
|
|||||||
projectDAL,
|
projectDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
secretDAL,
|
secretDAL,
|
||||||
folderDAL
|
folderDAL,
|
||||||
|
projectBotService,
|
||||||
|
secretV2BridgeDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const integrationService = integrationServiceFactory({
|
const integrationService = integrationServiceFactory({
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
|
||||||
decryptAssymmetric,
|
|
||||||
decryptSymmetric
|
|
||||||
} from "@app/components/utilities/cryptography/crypto";
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TGetSecretRotationList,
|
TGetSecretRotationListDTO,
|
||||||
TGetSecretRotationProviders,
|
TGetSecretRotationProviders,
|
||||||
TSecretRotation,
|
TSecretRotation,
|
||||||
TSecretRotationProviderList
|
TSecretRotationProviderList
|
||||||
@@ -19,7 +14,7 @@ export const secretRotationKeys = {
|
|||||||
{ workspaceId },
|
{ workspaceId },
|
||||||
"secret-rotation-providers"
|
"secret-rotation-providers"
|
||||||
],
|
],
|
||||||
list: ({ workspaceId }: Omit<TGetSecretRotationList, "decryptFileKey">) =>
|
list: ({ workspaceId }: Omit<TGetSecretRotationListDTO, "decryptFileKey">) =>
|
||||||
[{ workspaceId }, "secret-rotations"] as const
|
[{ workspaceId }, "secret-rotations"] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +48,7 @@ export const useGetSecretRotationProviders = ({
|
|||||||
|
|
||||||
const fetchSecretRotations = async ({
|
const fetchSecretRotations = async ({
|
||||||
workspaceId
|
workspaceId
|
||||||
}: Omit<TGetSecretRotationList, "decryptFileKey">) => {
|
}: Omit<TGetSecretRotationListDTO, "decryptFileKey">) => {
|
||||||
const { data } = await apiRequest.get<{ secretRotations: TSecretRotation[] }>(
|
const { data } = await apiRequest.get<{ secretRotations: TSecretRotation[] }>(
|
||||||
"/api/v1/secret-rotations",
|
"/api/v1/secret-rotations",
|
||||||
{ params: { workspaceId } }
|
{ params: { workspaceId } }
|
||||||
@@ -63,14 +58,13 @@ const fetchSecretRotations = async ({
|
|||||||
|
|
||||||
export const useGetSecretRotations = ({
|
export const useGetSecretRotations = ({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
decryptFileKey,
|
|
||||||
options = {}
|
options = {}
|
||||||
}: TGetSecretRotationList & {
|
}: TGetSecretRotationListDTO & {
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<
|
UseQueryOptions<
|
||||||
TSecretRotation[],
|
TSecretRotation[],
|
||||||
unknown,
|
unknown,
|
||||||
TSecretRotation<{ key: string }>[],
|
TSecretRotation[],
|
||||||
ReturnType<typeof secretRotationKeys.list>
|
ReturnType<typeof secretRotationKeys.list>
|
||||||
>,
|
>,
|
||||||
"queryKey" | "queryFn"
|
"queryKey" | "queryFn"
|
||||||
@@ -80,31 +74,5 @@ export const useGetSecretRotations = ({
|
|||||||
...options,
|
...options,
|
||||||
queryKey: secretRotationKeys.list({ workspaceId }),
|
queryKey: secretRotationKeys.list({ workspaceId }),
|
||||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||||
queryFn: async () => fetchSecretRotations({ workspaceId }),
|
queryFn: async () => fetchSecretRotations({ workspaceId })
|
||||||
select: useCallback(
|
|
||||||
(data: TSecretRotation[]) => {
|
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
|
||||||
const decryptKey = decryptAssymmetric({
|
|
||||||
ciphertext: decryptFileKey.encryptedKey,
|
|
||||||
nonce: decryptFileKey.nonce,
|
|
||||||
publicKey: decryptFileKey.sender.publicKey,
|
|
||||||
privateKey: PRIVATE_KEY
|
|
||||||
});
|
|
||||||
return data.map((el) => ({
|
|
||||||
...el,
|
|
||||||
outputs: el.outputs.map(({ key, secret }) => ({
|
|
||||||
key,
|
|
||||||
secret: {
|
|
||||||
key: decryptSymmetric({
|
|
||||||
ciphertext: secret.secretValueCiphertext,
|
|
||||||
iv: secret.secretValueIV,
|
|
||||||
tag: secret.secretValueTag,
|
|
||||||
key: decryptKey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[decryptFileKey]
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { UserWsKeyPair } from "../keys/types";
|
|
||||||
import { EncryptedSecret } from "../secrets/types";
|
|
||||||
import { WorkspaceEnv } from "../workspace/types";
|
import { WorkspaceEnv } from "../workspace/types";
|
||||||
|
|
||||||
export enum TProviderFunctionTypes {
|
export enum TProviderFunctionTypes {
|
||||||
@@ -74,7 +72,7 @@ export type TDbProviderTemplate = {
|
|||||||
outputs: Record<string, unknown>;
|
outputs: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSecretRotation<T extends unknown = EncryptedSecret> = {
|
export type TSecretRotation = {
|
||||||
id: string;
|
id: string;
|
||||||
interval: number;
|
interval: number;
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -85,7 +83,11 @@ export type TSecretRotation<T extends unknown = EncryptedSecret> = {
|
|||||||
secretPath: string;
|
secretPath: string;
|
||||||
outputs: Array<{
|
outputs: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
secret: T;
|
secret: {
|
||||||
|
version: number;
|
||||||
|
id: string;
|
||||||
|
secretKey: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
status?: "success" | "failed";
|
status?: "success" | "failed";
|
||||||
lastRotatedAt?: string;
|
lastRotatedAt?: string;
|
||||||
@@ -103,9 +105,8 @@ export type TGetSecretRotationProviders = {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGetSecretRotationList = {
|
export type TGetSecretRotationListDTO = {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
decryptFileKey: UserWsKeyPair;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreateSecretRotationDTO = {
|
export type TCreateSecretRotationDTO = {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
faArrowsSpin,
|
faArrowsSpin,
|
||||||
@@ -16,12 +15,9 @@ import { formatDistance } from "date-fns";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
Modal,
|
|
||||||
ModalContent,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Table,
|
||||||
@@ -48,22 +44,17 @@ import {
|
|||||||
useDeleteSecretRotation,
|
useDeleteSecretRotation,
|
||||||
useGetSecretRotationProviders,
|
useGetSecretRotationProviders,
|
||||||
useGetSecretRotations,
|
useGetSecretRotations,
|
||||||
useGetUserWsKey,
|
useRestartSecretRotation
|
||||||
useGetWorkspaceBot,
|
|
||||||
useRestartSecretRotation,
|
|
||||||
useUpdateBotActiveStatus
|
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { TSecretRotationProviderTemplate } from "@app/hooks/api/types";
|
import { TSecretRotationProviderTemplate } from "@app/hooks/api/types";
|
||||||
|
|
||||||
import { CreateRotationForm } from "./components/CreateRotationForm";
|
import { CreateRotationForm } from "./components/CreateRotationForm";
|
||||||
import { generateBotKey } from "./SecretRotationPage.utils";
|
|
||||||
|
|
||||||
export const SecretRotationPage = withProjectPermission(
|
export const SecretRotationPage = withProjectPermission(
|
||||||
() => {
|
() => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||||
"createRotation",
|
"createRotation",
|
||||||
"activeBot",
|
"activeBot",
|
||||||
@@ -77,13 +68,10 @@ export const SecretRotationPage = withProjectPermission(
|
|||||||
);
|
);
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const { data: userWsKey } = useGetUserWsKey(workspaceId);
|
|
||||||
|
|
||||||
const { data: secretRotationProviders, isLoading: isRotationProviderLoading } =
|
const { data: secretRotationProviders, isLoading: isRotationProviderLoading } =
|
||||||
useGetSecretRotationProviders({ workspaceId });
|
useGetSecretRotationProviders({ workspaceId });
|
||||||
const { data: secretRotations, isLoading: isRotationLoading } = useGetSecretRotations({
|
const { data: secretRotations, isLoading: isRotationLoading } = useGetSecretRotations({
|
||||||
workspaceId,
|
workspaceId
|
||||||
decryptFileKey: userWsKey!
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -97,11 +85,6 @@ export const SecretRotationPage = withProjectPermission(
|
|||||||
isLoading: isRestartingRotation
|
isLoading: isRestartingRotation
|
||||||
} = useRestartSecretRotation();
|
} = useRestartSecretRotation();
|
||||||
|
|
||||||
const { data: bot } = useGetWorkspaceBot(workspaceId);
|
|
||||||
const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus();
|
|
||||||
|
|
||||||
const isBotActive = Boolean(bot?.isActive);
|
|
||||||
|
|
||||||
const handleDeleteRotation = async () => {
|
const handleDeleteRotation = async () => {
|
||||||
const { id } = popUp.deleteRotation.data as { id: string };
|
const { id } = popUp.deleteRotation.data as { id: string };
|
||||||
try {
|
try {
|
||||||
@@ -142,29 +125,6 @@ export const SecretRotationPage = withProjectPermission(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserAcceptBotCondition = async () => {
|
|
||||||
const provider = popUp.activeBot?.data as TSecretRotationProviderTemplate;
|
|
||||||
try {
|
|
||||||
if (bot?.id) {
|
|
||||||
const botKey = generateBotKey(bot.publicKey, userWsKey!);
|
|
||||||
await updateBotActiveStatus({
|
|
||||||
isActive: true,
|
|
||||||
botId: bot.id,
|
|
||||||
workspaceId,
|
|
||||||
botKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
handlePopUpOpen("createRotation", provider);
|
|
||||||
handlePopUpClose("activeBot");
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
createNotification({
|
|
||||||
type: "error",
|
|
||||||
text: "Failed to create bot"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateRotation = async (provider: TSecretRotationProviderTemplate) => {
|
const handleCreateRotation = async (provider: TSecretRotationProviderTemplate) => {
|
||||||
if (subscription && !subscription?.secretRotation) {
|
if (subscription && !subscription?.secretRotation) {
|
||||||
handlePopUpOpen("upgradePlan");
|
handlePopUpOpen("upgradePlan");
|
||||||
@@ -174,11 +134,7 @@ export const SecretRotationPage = withProjectPermission(
|
|||||||
createNotification({ type: "error", text: "Access permission denied!!" });
|
createNotification({ type: "error", text: "Access permission denied!!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isBotActive) {
|
handlePopUpOpen("createRotation", provider);
|
||||||
handlePopUpOpen("createRotation", provider);
|
|
||||||
} else {
|
|
||||||
handlePopUpOpen("activeBot", provider);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -391,30 +347,6 @@ export const SecretRotationPage = withProjectPermission(
|
|||||||
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
|
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
|
||||||
provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
|
provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
|
||||||
isOpen={popUp.activeBot?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("activeBot", isOpen)}
|
|
||||||
>
|
|
||||||
<ModalContent
|
|
||||||
title={t("integrations.grant-access-to-secrets") as string}
|
|
||||||
footerContent={
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button onClick={() => handleUserAcceptBotCondition()}>
|
|
||||||
{t("integrations.grant-access-button") as string}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handlePopUpClose("activeBot")}
|
|
||||||
variant="outline_bg"
|
|
||||||
colorSchema="secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("integrations.why-infisical-needs-access")}
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteRotation.isOpen}
|
isOpen={popUp.deleteRotation.isOpen}
|
||||||
title="Are you sure want to delete this rotation?"
|
title="Are you sure want to delete this rotation?"
|
||||||
|
|||||||
Reference in New Issue
Block a user