feat(secret-rotation): added db ssl support

This commit is contained in:
Akhil Mohan
2023-11-01 22:21:31 +05:30
parent 97e4338335
commit c9c40521b2
19 changed files with 387 additions and 412 deletions
@@ -140,7 +140,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
query: { secretPath, environment, workspaceId } query: { secretPath, environment, workspaceId }
} = validatedData; } = validatedData;
const { const {
query: { folderId, include_imports: includeImports } query: { include_imports: includeImports }
} = validatedData; } = validatedData;
// if the service token has single scope, it will get all secrets for that scope by default // if the service token has single scope, it will get all secrets for that scope by default
@@ -156,13 +156,6 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
workspaceId = serviceTokenDetails.workspace.toString(); workspaceId = serviceTokenDetails.workspace.toString();
} }
if (folderId && folderId !== "root") {
const folder = await Folder.findOne({ workspace: workspaceId, environment });
if (!folder) throw BadRequestError({ message: "Folder not found" });
secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath;
}
if (!environment || !workspaceId) if (!environment || !workspaceId)
throw BadRequestError({ message: "Missing environment or workspace id" }); throw BadRequestError({ message: "Missing environment or workspace id" });
@@ -177,7 +170,6 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
const secrets = await SecretService.getSecrets({ const secrets = await SecretService.getSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
folderId,
secretPath, secretPath,
authData: req.authData authData: req.authData
}); });
@@ -467,20 +459,13 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => {
export const getSecrets = async (req: Request, res: Response) => { export const getSecrets = async (req: Request, res: Response) => {
const validatedData = await validateRequest(reqValidator.GetSecretsV3, req); const validatedData = await validateRequest(reqValidator.GetSecretsV3, req);
const { const {
query: { environment, workspaceId, include_imports: includeImports, folderId } query: { environment, workspaceId, include_imports: includeImports }
} = validatedData; } = validatedData;
let { let {
query: { secretPath } query: { secretPath }
} = validatedData; } = validatedData;
if (folderId && folderId !== "root") {
const folder = await Folder.findOne({ workspace: workspaceId, environment });
if (!folder) return res.send({ secrets: [] });
secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath;
}
const { authVerifier: permissionCheckFn } = await checkSecretsPermission({ const { authVerifier: permissionCheckFn } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
@@ -492,7 +477,6 @@ export const getSecrets = async (req: Request, res: Response) => {
const secrets = await SecretService.getSecrets({ const secrets = await SecretService.getSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
folderId,
secretPath, secretPath,
authData: req.authData authData: req.authData
}); });
@@ -1,215 +0,0 @@
import {
ISecretRotationProviderTemplate,
TProviderFunctionTypes,
TDbProviderClients,
TAssignOp
} from "./types";
const SENDGRID_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_api_key: { type: "string" as const },
scopes: { type: "array", items: { type: "string" as const } }
},
required: ["admin_api_key", "scopes"],
additionalProperties: false
},
outputs: {
api_key: { type: "string" }
},
internal: {
api_key_id: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys",
method: "POST",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
body: {
name: "infisical-${random | 16}",
scopes: { ref: "inputs.scopes" }
},
setter: {
"outputs.api_key": {
assign: TAssignOp.JmesPath as const,
path: "api_key"
},
"internal.api_key_id": {
assign: TAssignOp.JmesPath as const,
path: "api_key_id"
}
}
},
remove: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "DELETE"
},
test: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "GET"
}
}
};
const POSTGRES_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "5432" },
username1: { type: "string", default: "infisical-pg-user1" },
username2: { type: "string", default: "infisical-pg-user2" }
},
required: ["admin_username", "admin_password", "host", "database"],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
},
internal: {
rotated_password: { type: "string" },
username: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Pg,
username: "${inputs.admin_username}",
password: "${inputs.admin_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query: "ALTER USER ${internal.username} WITH PASSWORD '${internal.rotated_password}'",
setter: {
"outputs.db_username": {
assign: TAssignOp.Direct as const,
value: "${internal.username}"
},
"outputs.db_password": {
assign: TAssignOp.Direct as const,
value: "${internal.rotated_password}"
}
},
pre: {
"internal.rotated_password": {
assign: TAssignOp.Direct as const,
value: "${random | 32}"
}
}
},
test: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Pg,
username: "${internal.username}",
password: "${internal.rotated_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query: "SELECT NOW()"
}
}
};
const MYSQL_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "3306" },
username1: { type: "string", default: "infisical-sql-user1" },
username2: { type: "string", default: "infisical-sql-user2" }
},
required: ["admin_username", "admin_password", "host", "database"],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
},
internal: {
rotated_password: { type: "string" },
username: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Sql,
username: "${inputs.admin_username}",
password: "${inputs.admin_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query:
"ALTER USER ${internal.username} IDENTIFIED WITH mysql_native_password BY '${internal.rotated_password}'",
setter: {
"outputs.db_username": {
assign: TAssignOp.Direct as const,
value: "${internal.username}"
},
"outputs.db_password": {
assign: TAssignOp.Direct as const,
value: "${internal.rotated_password}"
}
},
pre: {
"internal.rotated_password": {
assign: TAssignOp.Direct as const,
value: "${random | 32}"
}
}
},
test: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Sql,
username: "${internal.username}",
password: "${internal.rotated_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query: "SELECT NOW()"
}
}
};
export const providerRotationTemplates: ISecretRotationProviderTemplate[] = [
{
name: "sendgrid",
title: "Twilio Sendgrid",
image: "sendgrid.png",
description: "Rotate Twilio Sendgrid API keys",
template: SENDGRID_TEMPLATE
},
{
name: "postgres",
title: "PostgreSQL",
image: "postgres.png",
description: "Rotate PostgreSQL/CockroachDB user credentials",
template: POSTGRES_TEMPLATE
},
{
name: "mysql",
title: "MySQL",
image: "mysql.png",
description: "Rotate MySQL@7/MariaDB user credentials",
template: MYSQL_TEMPLATE
}
];
+7 -5
View File
@@ -8,7 +8,7 @@ import mysql from "mysql";
import { client, getRootEncryptionKey } from "../../config"; import { client, getRootEncryptionKey } from "../../config";
import { BotService, TelemetryService } from "../../services"; import { BotService, TelemetryService } from "../../services";
import { SecretRotation } from "./models"; import { SecretRotation } from "./models";
import { providerRotationTemplates } from "./providerTemplates"; import { rotationTemplates } from "./templates";
import { import {
ISecretRotationData, ISecretRotationData,
ISecretRotationEncData, ISecretRotationEncData,
@@ -88,13 +88,14 @@ const secretRotationHttpFn = async (
const secretRotationDbFn = async (func: TDbProviderFunction, variables: ISecretRotationData) => { const secretRotationDbFn = async (func: TDbProviderFunction, variables: ISecretRotationData) => {
const { type, client, pre, ...dbConnection } = func; const { type, client, pre, ...dbConnection } = func;
const { username, password, host, database, port, query } = interpolate( const { username, password, host, database, port, query, ca } = interpolate(
dbConnection, dbConnection,
getInterpolationValue(variables) getInterpolationValue(variables)
); );
const ssl = ca ? { rejectUnauthorized: false, ca } : undefined;
if (host === "localhost" || host === "127.0.0.1") throw new Error("Invalid db host"); if (host === "localhost" || host === "127.0.0.1") throw new Error("Invalid db host");
if (client === TDbProviderClients.Pg) { if (client === TDbProviderClients.Pg) {
const pgClient = new PgClient({ user: username, password, host, database, port }); const pgClient = new PgClient({ user: username, password, host, database, port, ssl });
await pgClient.connect(); await pgClient.connect();
const res = await pgClient.query(query); const res = await pgClient.query(query);
await pgClient.end(); await pgClient.end();
@@ -106,7 +107,8 @@ const secretRotationDbFn = async (func: TDbProviderFunction, variables: ISecretR
host, host,
database, database,
port, port,
connectionLimit: 1 connectionLimit: 1,
ssl
}); });
const res = await new Promise((resolve, reject) => { const res = await new Promise((resolve, reject) => {
sqlClient.query(query, (err, data) => { sqlClient.query(query, (err, data) => {
@@ -193,7 +195,7 @@ secretRotationQueue.process(async (job: Job) => {
]; ];
}>("outputs.secret"); }>("outputs.secret");
const infisicalRotationProvider = providerRotationTemplates.find( const infisicalRotationProvider = rotationTemplates.find(
({ name }) => name === secretRotation?.provider ({ name }) => name === secretRotation?.provider
); );
+4 -12
View File
@@ -1,13 +1,5 @@
import { import { ISecretRotationEncData, TCreateSecretRotation, TGetProviderTemplates } from "./types";
ISecretRotationEncData, import { rotationTemplates } from "./templates";
ISecretRotationProviderTemplate,
TCreateSecretRotation,
TGetProviderTemplates
} from "./types";
import {
providerRotationTemplates as infisicalRotationTemplates,
providerRotationTemplates
} from "./providerTemplates";
import { SecretRotation } from "./models"; import { SecretRotation } from "./models";
import { client, getRootEncryptionKey } from "../../config"; import { client, getRootEncryptionKey } from "../../config";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
@@ -19,7 +11,7 @@ const ajv = new Ajv();
export const getProviderTemplate = async ({ workspaceId }: TGetProviderTemplates) => { export const getProviderTemplate = async ({ workspaceId }: TGetProviderTemplates) => {
return { return {
custom: [], custom: [],
providers: infisicalRotationTemplates providers: rotationTemplates
}; };
}; };
@@ -32,7 +24,7 @@ export const createSecretRotation = async ({
inputs, inputs,
outputs outputs
}: TCreateSecretRotation) => { }: TCreateSecretRotation) => {
const rotationTemplate = providerRotationTemplates.find(({ name }) => name === provider); const rotationTemplate = rotationTemplates.find(({ name }) => name === provider);
if (!rotationTemplate) throw BadRequestError({ message: "Provider not found" }); if (!rotationTemplate) throw BadRequestError({ message: "Provider not found" });
const formattedInputs: Record<string, unknown> = {}; const formattedInputs: Record<string, unknown> = {};
@@ -0,0 +1,28 @@
import { ISecretRotationProviderTemplate } from "../types";
import { MYSQL_TEMPLATE } from "./mysql";
import { POSTGRES_TEMPLATE } from "./postgres";
import { SENDGRID_TEMPLATE } from "./sendgrid";
export const rotationTemplates: ISecretRotationProviderTemplate[] = [
{
name: "sendgrid",
title: "Twilio Sendgrid",
image: "sendgrid.png",
description: "Rotate Twilio Sendgrid API keys",
template: SENDGRID_TEMPLATE
},
{
name: "postgres",
title: "PostgreSQL",
image: "postgres.png",
description: "Rotate PostgreSQL/CockroachDB user credentials",
template: POSTGRES_TEMPLATE
},
{
name: "mysql",
title: "MySQL",
image: "mysql.png",
description: "Rotate MySQL@7/MariaDB user credentials",
template: MYSQL_TEMPLATE
}
];
@@ -0,0 +1,75 @@
import { TProviderFunctionTypes, TDbProviderClients, TAssignOp } from "../types";
export const MYSQL_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "3306" },
username1: { type: "string", default: "infisical-sql-user1" },
username2: { type: "string", default: "infisical-sql-user2" },
ca: { type: "string" }
},
required: [
"admin_username",
"admin_password",
"host",
"database",
"username1",
"username2",
"port"
],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
},
internal: {
rotated_password: { type: "string" },
username: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Sql,
username: "${inputs.admin_username}",
password: "${inputs.admin_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
ca: "${inputs.ca}",
query:
"ALTER USER ${internal.username} IDENTIFIED WITH mysql_native_password BY '${internal.rotated_password}'",
setter: {
"outputs.db_username": {
assign: TAssignOp.Direct as const,
value: "${internal.username}"
},
"outputs.db_password": {
assign: TAssignOp.Direct as const,
value: "${internal.rotated_password}"
}
},
pre: {
"internal.rotated_password": {
assign: TAssignOp.Direct as const,
value: "${random | 32}"
}
}
},
test: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Sql,
username: "${internal.username}",
password: "${internal.rotated_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query: "SELECT NOW()"
}
}
};
@@ -0,0 +1,74 @@
import { TProviderFunctionTypes, TDbProviderClients, TAssignOp } from "../types";
export const POSTGRES_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "5432" },
username1: { type: "string", default: "infisical-pg-user1" },
username2: { type: "string", default: "infisical-pg-user2" },
ca: { type: "string" }
},
required: [
"admin_username",
"admin_password",
"host",
"database",
"username1",
"username2",
"port"
],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
},
internal: {
rotated_password: { type: "string" },
username: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Pg,
username: "${inputs.admin_username}",
password: "${inputs.admin_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
ca: "${inputs.ca}",
query: "ALTER USER ${internal.username} WITH PASSWORD '${internal.rotated_password}'",
setter: {
"outputs.db_username": {
assign: TAssignOp.Direct as const,
value: "${internal.username}"
},
"outputs.db_password": {
assign: TAssignOp.Direct as const,
value: "${internal.rotated_password}"
}
},
pre: {
"internal.rotated_password": {
assign: TAssignOp.Direct as const,
value: "${random | 32}"
}
}
},
test: {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Pg,
username: "${internal.username}",
password: "${internal.rotated_password}",
host: "${inputs.host}",
database: "${inputs.database}",
port: "${inputs.port}",
query: "SELECT NOW()"
}
}
};
@@ -0,0 +1,59 @@
import { TProviderFunctionTypes, TAssignOp } from "../types";
export const SENDGRID_TEMPLATE = {
inputs: {
type: "object" as const,
properties: {
admin_api_key: { type: "string" as const },
scopes: { type: "array", items: { type: "string" as const } }
},
required: ["admin_api_key", "scopes"],
additionalProperties: false
},
outputs: {
api_key: { type: "string" }
},
internal: {
api_key_id: { type: "string" }
},
functions: {
set: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys",
method: "POST",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
body: {
name: "infisical-${random | 16}",
scopes: { ref: "inputs.scopes" }
},
setter: {
"outputs.api_key": {
assign: TAssignOp.JmesPath as const,
path: "api_key"
},
"internal.api_key_id": {
assign: TAssignOp.JmesPath as const,
path: "api_key_id"
}
}
},
remove: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "DELETE"
},
test: {
type: TProviderFunctionTypes.HTTP as const,
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "GET"
}
}
};
+11 -3
View File
@@ -553,14 +553,22 @@ export const getSecretsHelper = async ({
workspaceId, workspaceId,
environment, environment,
authData, authData,
folderId,
secretPath = "/" secretPath = "/"
}: GetSecretsParams) => { }: GetSecretsParams) => {
let secrets: ISecret[] = []; let secrets: ISecret[] = [];
// if using service token filter towards the folderId by secretpath // if using service token filter towards the folderId by secretpath
if (!folderId) { const folders = await Folder.findOne({
folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); workspace: workspaceId,
environment
});
let folderId = "root";
if (!folders && folderId !== "root") return [];
// get folder from folder tree
if (folders) {
const folder = getFolderByPath(folders.nodes, secretPath);
if (!folder) return [];
folderId = folder?.id;
} }
// get personal secrets first // get personal secrets first
@@ -26,7 +26,6 @@ export interface CreateSecretParams {
export interface GetSecretsParams { export interface GetSecretsParams {
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
folderId?: string;
secretPath: string; secretPath: string;
authData: AuthData; authData: AuthData;
} }
-2
View File
@@ -228,7 +228,6 @@ export const GetSecretsRawV3 = z.object({
workspaceId: z.string().trim().optional(), workspaceId: z.string().trim().optional(),
environment: z.string().trim().optional(), environment: z.string().trim().optional(),
secretPath: z.string().trim().default("/"), secretPath: z.string().trim().default("/"),
folderId: z.string().trim().optional(),
include_imports: z include_imports: z
.enum(["true", "false"]) .enum(["true", "false"])
.default("false") .default("false")
@@ -302,7 +301,6 @@ export const GetSecretsV3 = z.object({
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
environment: z.string().trim(), environment: z.string().trim(),
secretPath: z.string().trim().default("/"), secretPath: z.string().trim().default("/"),
folderId: z.string().trim().optional(),
include_imports: z include_imports: z
.enum(["true", "false"]) .enum(["true", "false"])
.default("false") .default("false")
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@ export const Stepper = ({ activeStep, children, direction, className }: StepperP
<div className="flex items-center space-x-2 flex-shrink-0"> <div className="flex items-center space-x-2 flex-shrink-0">
<div <div
className={twMerge( className={twMerge(
"w-6 h-6 flex items-center justify-center font-medium text-mineshaft-800 text-sm rounded-full transition-all", "w-7 h-7 flex items-center justify-center font-medium text-mineshaft-800 text-sm rounded-full transition-all",
isCompleted ? "bg-primary" : "border text-bunker-300 border-primary/30", isCompleted ? "bg-primary" : "border text-bunker-300 border-primary/30",
isActive && "bg-primary text-mineshaft-800" isActive && "bg-primary text-mineshaft-800"
)} )}
@@ -70,7 +70,7 @@ export type StepProps = {
export const Step = ({ title, description }: StepProps) => { export const Step = ({ title, description }: StepProps) => {
return ( return (
<div className="flex flex-col space-y-1 text-gray-300"> <div className="flex flex-col text-gray-300">
<div className="font-medium text-sm">{title}</div> <div className="font-medium text-sm">{title}</div>
{description && <div className="text-xs">{description}</div>} {description && <div className="text-xs">{description}</div>}
</div> </div>
+1 -1
View File
@@ -503,7 +503,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
isSelected={ isSelected={
router.asPath === `/project/${currentWorkspace?._id}/secret-rotation` router.asPath === `/project/${currentWorkspace?._id}/secret-rotation`
} }
icon="system-outline-189-domain-verification" icon="rotation"
> >
Secret rotation Secret rotation
</MenuItem> </MenuItem>
@@ -163,10 +163,10 @@ export const SecretRotationPage = withProjectPermission(
}; };
const handleCreateRotation = async (provider: TSecretRotationProvider) => { const handleCreateRotation = async (provider: TSecretRotationProvider) => {
if (subscription && !subscription?.secretRotation) { // if (subscription && !subscription?.secretRotation) {
handlePopUpOpen("upgradePlan"); // handlePopUpOpen("upgradePlan");
return; // return;
} // }
if (!canCreateRotation) { if (!canCreateRotation) {
createNotification({ type: "error", text: "Access permission denied!!" }); createNotification({ type: "error", text: "Access permission denied!!" });
return; return;
@@ -7,7 +7,6 @@ import { TSecretRotationProvider } from "@app/hooks/api/types";
import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider";
import { GeneralDetailsForm, TFormSchema as TGeneralFormSchema } from "./steps/GeneralDetailsForm";
import { RotationInputForm } from "./steps/RotationInputForm"; import { RotationInputForm } from "./steps/RotationInputForm";
import { import {
RotationOutputForm, RotationOutputForm,
@@ -16,13 +15,12 @@ import {
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ {
title: "General" title: "Inputs",
description: "Provider secrets"
}, },
{ {
title: "Inputs" title: "Outputs",
}, description: "Map rotated secrets to keys"
{
title: "Secret Mapping"
} }
]; ];
@@ -43,7 +41,6 @@ export const CreateRotationForm = ({
}: Props) => { }: Props) => {
const [wizardStep, setWizardStep] = useState(0); const [wizardStep, setWizardStep] = useState(0);
const wizardData = useRef<{ const wizardData = useRef<{
general?: TGeneralFormSchema;
input?: Record<string, string>; input?: Record<string, string>;
output?: TRotationOutputSchema; output?: TRotationOutputSchema;
}>({}); }>({});
@@ -58,18 +55,17 @@ export const CreateRotationForm = ({
}; };
const handleFormSubmit = async () => { const handleFormSubmit = async () => {
if (!wizardData.current.general || !wizardData.current.input || !wizardData.current.output) if (!wizardData.current.input || !wizardData.current.output) return;
return;
try { try {
await createSecretRotation({ await createSecretRotation({
workspaceId, workspaceId,
provider: provider.name, provider: provider.name,
customProvider, customProvider,
secretPath: wizardData.current.general.secretPath, secretPath: wizardData.current.output.secretPath,
environment: wizardData.current.general.environment, environment: wizardData.current.output.environment,
interval: wizardData.current.general.interval, interval: wizardData.current.output.interval,
inputs: wizardData.current.input, inputs: wizardData.current.input,
outputs: wizardData.current.output outputs: wizardData.current.output.secrets
}); });
setWizardStep(0); setWizardStep(0);
onToggle(false); onToggle(false);
@@ -98,49 +94,50 @@ export const CreateRotationForm = ({
className="max-w-2xl" className="max-w-2xl"
> >
<Stepper activeStep={wizardStep} direction="horizontal" className="mb-4"> <Stepper activeStep={wizardStep} direction="horizontal" className="mb-4">
{WIZARD_STEPS.map(({ title }, index) => ( {WIZARD_STEPS.map(({ title, description }, index) => (
<Step title={title} key={`wizard-stepper-rotation-${index + 1}`} /> <Step
title={title}
description={description}
key={`wizard-stepper-rotation-${index + 1}`}
/>
))} ))}
</Stepper> </Stepper>
<AnimatePresence exitBeforeEnter> <AnimatePresence exitBeforeEnter>
{wizardStep === 0 && ( {wizardStep === 0 && (
<motion.div <motion.div
key="general-step" key="input-step"
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }} initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }} animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }} exit={{ opacity: 0, translateX: -30 }}
> >
<GeneralDetailsForm <RotationInputForm
onCancel={handleFormCancel} onCancel={handleFormCancel}
onSubmit={(data) => { onSubmit={(data) => {
wizardData.current.general = data; wizardData.current.input = data;
setWizardStep((state) => state + 1); setWizardStep((state) => state + 1);
}} }}
inputSchema={provider.template?.inputs || {}}
/> />
</motion.div> </motion.div>
)} )}
{wizardStep === 1 && ( {wizardStep === 1 && (
<RotationInputForm <motion.div
onCancel={handleFormCancel} key="output-step"
onSubmit={(data) => { transition={{ duration: 0.1 }}
wizardData.current.input = data; initial={{ opacity: 0, translateX: 30 }}
setWizardStep((state) => state + 1); animate={{ opacity: 1, translateX: 0 }}
}} exit={{ opacity: 0, translateX: -30 }}
inputSchema={provider.template?.inputs || {}} >
/> <RotationOutputForm
)} outputSchema={provider.template?.outputs || {}}
{wizardStep === 2 && ( onCancel={handleFormCancel}
<RotationOutputForm onSubmit={async (data) => {
environment={wizardData.current.general?.environment || ""} wizardData.current.output = data;
secretPath={wizardData.current.general?.secretPath || "/"} await handleFormSubmit();
outputSchema={provider.template?.outputs || {}} }}
onCancel={handleFormCancel} />
onSubmit={async (data) => { </motion.div>
wizardData.current.output = data;
await handleFormSubmit();
}}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</ModalContent> </ModalContent>
@@ -1,93 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
const formSchema = z.object({
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
interval: z.number()
});
export type TFormSchema = z.infer<typeof formSchema>;
type Props = {
onSubmit: (data: TFormSchema) => void;
onCancel: () => void;
};
export const GeneralDetailsForm = ({ onSubmit, onCancel }: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { value, onChange } }) => (
<FormControl label="Environment">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field }) => (
<FormControl className="capitalize" label="Secret path">
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="interval"
defaultValue={15}
render={({ field }) => (
<FormControl className="capitalize" label="Rotation Interval (Days)">
<Input
{...field}
min={1}
type="number"
onChange={(evt) => field.onChange(parseInt(evt.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Next
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};
@@ -34,7 +34,6 @@ export const RotationInputForm = ({ onSubmit, onCancel, inputSchema }: Props) =>
defaultValue={inputSchema.properties[inputName]?.default} defaultValue={inputSchema.properties[inputName]?.default}
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
className="capitalize"
key={`provider-input-${inputName}`} key={`provider-input-${inputName}`}
label={inputName.replaceAll("_", " ")} label={inputName.replaceAll("_", " ")}
helperText={inputSchema.properties[inputName]?.helperText} helperText={inputSchema.properties[inputName]?.helperText}
@@ -2,40 +2,42 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, FormControl, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api";
const formSchema = z.record(z.string()); const formSchema = z.object({
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
interval: z.number().min(1),
secrets: z.record(z.string())
});
export type TFormSchema = z.infer<typeof formSchema>; export type TFormSchema = z.infer<typeof formSchema>;
type Props = { type Props = {
environment: string;
secretPath: string;
outputSchema: Record<string, unknown>; outputSchema: Record<string, unknown>;
onSubmit: (data: TFormSchema) => void; onSubmit: (data: TFormSchema) => void;
onCancel: () => void; onCancel: () => void;
}; };
export const RotationOutputForm = ({ export const RotationOutputForm = ({ onSubmit, onCancel, outputSchema = {} }: Props) => {
onSubmit,
onCancel,
environment,
secretPath,
outputSchema = {}
}: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const workspaceId = currentWorkspace?._id || ""; const workspaceId = currentWorkspace?._id || "";
const { const {
control, control,
handleSubmit, handleSubmit,
watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema) resolver: zodResolver(formSchema)
}); });
const environment = watch("environment", environments?.[0]?.slug);
const secretPath = watch("secretPath");
const { data: userWsKey } = useGetUserWsKey(workspaceId); const { data: userWsKey } = useGetUserWsKey(workspaceId);
const { data: secrets } = useGetProjectSecrets({ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId, workspaceId,
environment, environment,
secretPath, secretPath,
@@ -44,11 +46,65 @@ export const RotationOutputForm = ({
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { value, onChange } }) => (
<FormControl label="Environment">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field }) => (
<FormControl className="capitalize" label="Secret path">
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="interval"
defaultValue={15}
render={({ field }) => (
<FormControl className="capitalize" label="Rotation Interval (Days)">
<Input
{...field}
min={1}
type="number"
onChange={(evt) => field.onChange(parseInt(evt.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="flex flex-col mt-4 pt-4 mb-2 border-t border-bunker-300/30">
<div>Mapping</div>
<div className="text-bunker-300 text-sm">Select keys for rotated value to get saved</div>
</div>
{Object.keys(outputSchema).map((outputName) => ( {Object.keys(outputSchema).map((outputName) => (
<Controller <Controller
key={`provider-output-${outputName}`} key={`provider-output-${outputName}`}
control={control} control={control}
name={outputName} name={`secrets.${outputName}`}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<FormControl className="uppercase" label={outputName.replaceAll("_", " ")} isRequired> <FormControl className="uppercase" label={outputName.replaceAll("_", " ")} isRequired>
<Select <Select
@@ -57,11 +113,22 @@ export const RotationOutputForm = ({
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
position="popper" position="popper"
> >
{secrets?.map(({ key, _id }) => ( {!isSecretsLoading &&
<SelectItem value={_id} key={_id}> secrets?.map(({ key, _id }) => (
{key} <SelectItem value={_id} key={_id}>
{key}
</SelectItem>
))}
{isSecretsLoading && (
<SelectItem value="Loading" isDisabled>
<Spinner size="xs" />
</SelectItem> </SelectItem>
))} )}
{!isSecretsLoading && secrets?.length === 0 && (
<SelectItem value="Empty" isDisabled>
No secrets found
</SelectItem>
)}
</Select> </Select>
</FormControl> </FormControl>
)} )}