From 27efc908e2189234d197ac05da2b0f9a410875ba Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Mon, 27 Jan 2025 15:53:07 +0100 Subject: [PATCH 01/12] feat(audit-logs): query by secret path --- .../ee/services/audit-log/audit-log-dal.ts | 6 ++ .../services/audit-log/audit-log-service.ts | 5 +- .../ee/services/audit-log/audit-log-types.ts | 1 + backend/src/lib/api-docs/constants.ts | 2 + .../server/routes/v1/organization-router.ts | 8 ++- frontend/src/hooks/api/auditLogs/types.tsx | 1 + .../AuditLogsPage/components/LogsFilter.tsx | 70 ++++++++++++------- .../AuditLogsPage/components/LogsSection.tsx | 5 ++ .../AuditLogsPage/components/types.tsx | 3 +- 9 files changed, 71 insertions(+), 30 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index bcef06e10..21f785835 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -39,11 +39,13 @@ export const auditLogDALFactory = (db: TDbClient) => { offset = 0, actorId, actorType, + secretPath, eventType, eventMetadata }: Omit & { actorId?: string; actorType?: ActorType; + secretPath?: string; eventType?: EventType[]; eventMetadata?: Record; }, @@ -88,6 +90,10 @@ export const auditLogDALFactory = (db: TDbClient) => { }); } + if (projectId && secretPath) { + void sqlQuery.whereRaw(`"eventMetadata" @> jsonb_build_object('secretPath', ?::text)`, [secretPath]); + } + // Filter by actor type if (actorType) { void sqlQuery.where("actor", actorType); diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index ff7dede5f..3b860864f 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -46,10 +46,6 @@ export const auditLogServiceFactory = ({ actorOrgId ); - /** - * NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved - * to the organization level ✅ - */ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); } @@ -64,6 +60,7 @@ export const auditLogServiceFactory = ({ actorId: filter.auditLogActorId, actorType: filter.actorType, eventMetadata: filter.eventMetadata, + secretPath: filter.secretPath, ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) }); 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 9c19cd3cc..6e8314731 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -32,6 +32,7 @@ export type TListProjectAuditLogDTO = { projectId?: string; auditLogActorId?: string; actorType?: ActorType; + secretPath?: string; eventMetadata?: Record; }; } & Omit; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 800788179..eca11e983 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -828,6 +828,8 @@ export const AUDIT_LOGS = { projectId: "Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.", eventType: "The type of the event to export.", + secretPath: + "The path of the secret to query audit logs for. Note that the projectId parameter must also be provided.", userAgentType: "Choose which consuming application to export audit logs for.", eventMetadata: "Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.", diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 104898099..db0008ebe 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -11,7 +11,7 @@ import { } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; -import { getLastMidnightDateISO } from "@app/lib/fn"; +import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -113,6 +113,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { querystring: z.object({ projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), actorType: z.nativeEnum(ActorType).optional(), + secretPath: z + .string() + .optional() + .transform((val) => (!val ? val : removeTrailingSlash(val))) + .describe(AUDIT_LOGS.EXPORT.secretPath), + // eventType is split with , for multiple values, we need to transform it to array eventType: z .string() diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 3a5070ef5..338671cab 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -10,6 +10,7 @@ export type TGetAuditLogsFilter = { actorType?: ActorType; projectId?: string; actor?: string; // user ID format + secretPath?: string; startDate?: Date; endDate?: Date; limit: number; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 3a21958ae..a1246c695 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, FilterableSelect, FormControl, + Input, Select, SelectItem } from "@app/components/v2"; @@ -50,6 +51,7 @@ export const LogsFilter = ({ className, control, reset, + setValue, watch }: Props) => { const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); @@ -101,6 +103,7 @@ export const LogsFilter = ({ }; const selectedEventTypes = watch("eventType") as EventType[] | undefined; + const selectedProjectId = watch("project")?.id; return (
- {isOrgAuditLogs && workspacesInOrg.length > 0 && ( - ( - - ({ name, id }))} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - - )} - /> - )} +
+ {isOrgAuditLogs && workspacesInOrg.length > 0 && ( + ( + + { + console.log(e); + if (e === null) { + setValue("secretPath", ""); + } + onChange(e); + }} + placeholder="Select a project..." + options={workspacesInOrg.map(({ name, id }) => ({ name, id }))} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + )} + {selectedProjectId && ( + ( + + onChange(e.target.value)} /> + + )} + /> + )} +
(secretPath!, 500); + return (
{showFilters && ( @@ -90,6 +94,7 @@ export const LogsSection = withPermission( isOrgAuditLogs={isOrgAuditLogs} showActorColumn={!!showActorColumn} filter={{ + secretPath: debouncedSecretPath || undefined, eventMetadata: presets?.eventMetadata, projectId, actorType: presets?.actorType, diff --git a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx index 05c44fa4d..0854bd8cf 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx @@ -12,7 +12,8 @@ export const auditLogFilterFormSchema = z startDate: z.date().optional(), endDate: z.date().optional(), page: z.coerce.number().optional(), - perPage: z.coerce.number().optional() + perPage: z.coerce.number().optional(), + secretPath: z.string().optional() }) .superRefine((el, ctx) => { if (el.endDate && el.startDate && el.endDate < el.startDate) { From 10c10642a10af799ef6b493a6671e854cbda0cc3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Mon, 27 Jan 2025 23:08:47 +0100 Subject: [PATCH 02/12] feat(integrations/vercel): custom environments support --- .../routes/v1/integration-auth-router.ts | 44 +++++++++++++++++ .../integration-auth/integration-app-list.ts | 27 +++++++++-- .../integration-auth-service.ts | 41 +++++++++++++++- .../integration-auth-types.ts | 5 ++ .../integration-sync-secret.ts | 38 +++++++++++++-- .../integration-auth/integration-token.ts | 24 ++++++---- .../src/hooks/api/integrationAuth/index.tsx | 1 + .../src/hooks/api/integrationAuth/queries.tsx | 48 ++++++++++++++++++- .../src/hooks/api/integrationAuth/types.ts | 5 ++ .../VercelConfigurePage.tsx | 46 ++++++++++++++++-- 10 files changed, 255 insertions(+), 24 deletions(-) diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index ffeb748b8..185738de6 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -1151,6 +1151,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "GET", + url: "/:integrationAuthId/vercel/custom-environments", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + teamId: z.string().trim() + }), + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + environments: z + .object({ + appId: z.string(), + customEnvironments: z + .object({ + id: z.string(), + slug: z.string() + }) + .array() + }) + .array() + }) + } + }, + handler: async (req) => { + const environments = await server.services.integrationAuth.getVercelCustomEnvironments({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + teamId: req.query.teamId + }); + + return { environments }; + } + }); + server.route({ method: "GET", url: "/:integrationAuthId/octopus-deploy/spaces", diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 3b8078cc1..804f05219 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -132,16 +132,26 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { /** * Return list of names of apps for Vercel integration + * This is re-used for getting custom environments for Vercel */ -const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => { - const apps: Array<{ name: string; appId: string }> = []; +export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => { + const apps: Array<{ name: string; appId: string; customEnvironments: Array<{ slug: string; id: string }> }> = []; const limit = "20"; let hasMorePages = true; let next: number | null = null; interface Response { - projects: { name: string; id: string }[]; + projects: { + name: string; + id: string; + customEnvironments?: { + id: string; + type: string; + description: string; + slug: string; + }[]; + }[]; pagination: { count: number; next: number | null; @@ -170,10 +180,19 @@ const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; } }); + for (const project of data.projects) { + console.log(project.customEnvironments); + } + data.projects.forEach((a) => { apps.push({ name: a.name, - appId: a.id + appId: a.id, + customEnvironments: + a.customEnvironments?.map((env) => ({ + slug: env.slug, + id: env.id + })) ?? [] }); }); diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index d96643a19..61f753348 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -25,11 +25,12 @@ import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; -import { getApps } from "./integration-app-list"; +import { getApps, getAppsVercel } from "./integration-app-list"; import { TCircleCIContext } from "./integration-app-types"; import { TIntegrationAuthDALFactory } from "./integration-auth-dal"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { + GetVercelCustomEnvironmentsDTO, OctopusDeployScope, TBitbucketEnvironment, TBitbucketWorkspace, @@ -1825,6 +1826,41 @@ export const integrationAuthServiceFactory = ({ return integrationAuthDAL.create(newIntegrationAuth); }; + const getVercelCustomEnvironments = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + teamId, + id + }: GetVercelCustomEnvironmentsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: integrationAuth.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey); + + const vercelApps = await getAppsVercel({ + accessToken, + teamId + }); + + return vercelApps.map((app) => ({ + customEnvironments: app.customEnvironments, + appId: app.appId + })); + }; + const getOctopusDeploySpaces = async ({ actorId, actor, @@ -1944,6 +1980,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationAccessToken, duplicateIntegrationAuth, getOctopusDeploySpaces, - getOctopusDeployScopeValues + getOctopusDeployScopeValues, + getVercelCustomEnvironments }; }; diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 68d7bf5b9..8efe6b851 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -284,3 +284,8 @@ export type TOctopusDeployVariableSet = { Self: string; }; }; + +export type GetVercelCustomEnvironmentsDTO = { + teamId: string; + id: string; +} & Omit; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 96e8ec598..83bf2616c 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1450,9 +1450,13 @@ const syncSecretsVercel = async ({ secrets: Record; accessToken: string; }) => { + const isCustomEnvironment = !["development", "preview", "production"].includes( + integration.targetEnvironment as string + ); interface VercelSecret { id?: string; type: string; + customEnvironmentIds?: string[]; key: string; value: string; target: string[]; @@ -1486,6 +1490,16 @@ const syncSecretsVercel = async ({ } ) ).data.envs.filter((secret) => { + if (isCustomEnvironment) { + if (!secret.customEnvironmentIds?.includes(integration.targetEnvironment as string)) { + // case: secret does not have the same custom environment + return false; + } + + // no need to check for preview environment, as custom environments are not available in preview + return true; + } + if (!secret.target.includes(integration.targetEnvironment as string)) { // case: secret does not have the same target environment return false; @@ -1583,7 +1597,13 @@ const syncSecretsVercel = async ({ key, value: infisicalSecrets[key]?.value, type: "encrypted", - target: [integration.targetEnvironment as string], + ...(isCustomEnvironment + ? { + customEnvironmentIds: [integration.targetEnvironment as string] + } + : { + target: [integration.targetEnvironment as string] + }), ...(integration.path ? { gitBranch: integration.path @@ -1607,9 +1627,19 @@ const syncSecretsVercel = async ({ key, value: infisicalSecrets[key]?.value, type: res[key].type, - target: res[key].target.includes(integration.targetEnvironment as string) - ? [...res[key].target] - : [...res[key].target, integration.targetEnvironment as string], + + ...(!isCustomEnvironment + ? { + target: res[key].target.includes(integration.targetEnvironment as string) + ? [...res[key].target] + : [...res[key].target, integration.targetEnvironment as string] + } + : { + customEnvironmentIds: res[key].customEnvironmentIds?.includes(integration.targetEnvironment as string) + ? [...res[key].customEnvironmentIds] + : [...(res[key]?.customEnvironmentIds || []), integration.targetEnvironment as string] + }), + ...(integration.path ? { gitBranch: integration.path diff --git a/backend/src/services/integration-auth/integration-token.ts b/backend/src/services/integration-auth/integration-token.ts index 362b20a07..36e76b231 100644 --- a/backend/src/services/integration-auth/integration-token.ts +++ b/backend/src/services/integration-auth/integration-token.ts @@ -5,6 +5,7 @@ import { request } from "@app/lib/config/request"; import { BadRequestError, ForbiddenRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { Integrations, IntegrationUrls } from "./integration-list"; +import { AxiosError } from "axios"; type ExchangeCodeAzureResponse = { token_type: string; @@ -197,15 +198,22 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => { } const res = ( - await request.post( - IntegrationUrls.VERCEL_TOKEN_URL, - new URLSearchParams({ - code, - client_id: appCfg.CLIENT_ID_VERCEL, - client_secret: appCfg.CLIENT_SECRET_VERCEL, - redirect_uri: `${appCfg.SITE_URL}/integrations/vercel/oauth2/callback` + await request + .post( + IntegrationUrls.VERCEL_TOKEN_URL, + new URLSearchParams({ + code, + client_id: appCfg.CLIENT_ID_VERCEL, + client_secret: appCfg.CLIENT_SECRET_VERCEL, + redirect_uri: `${appCfg.SITE_URL}/integrations/vercel/oauth2/callback` + }) + ) + .catch((e) => { + if (e instanceof AxiosError) { + console.log(e.response?.data); + } + throw e; }) - ) ).data; return { diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index e7ee5928a..eab946ba6 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -16,5 +16,6 @@ export { useGetIntegrationAuthTeamCityBuildConfigs, useGetIntegrationAuthTeams, useGetIntegrationAuthVercelBranches, + useGetIntegrationAuthVercelCustomEnvironments, useSaveIntegrationAccessToken } from "./queries"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 44b199a24..f62887043 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -21,7 +21,8 @@ import { Team, TeamCityBuildConfig, TGetIntegrationAuthOctopusDeployScopeValuesDTO, - TOctopusDeployVariableSetScopeValues + TOctopusDeployVariableSetScopeValues, + VercelEnvironment } from "./types"; const integrationAuthKeys = { @@ -132,7 +133,9 @@ const integrationAuthKeys = { }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const, getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) => - [{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const + [{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const, + getIntegrationAuthVercelCustomEnv: (integrationAuthId: string, teamId: string) => + [{ integrationAuthId, teamId }, "integrationAuthVercelCustomEnv"] as const }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -362,6 +365,29 @@ const fetchIntegrationAuthQoveryScopes = async ({ return undefined; }; +const fetchIntegrationAuthVercelCustomEnvironments = async ({ + integrationAuthId, + teamId +}: { + integrationAuthId: string; + teamId: string; +}) => { + const { + data: { environments } + } = await apiRequest.get<{ + environments: { + appId: string; + customEnvironments: VercelEnvironment[]; + }[]; + }>(`/api/v1/integration-auth/${integrationAuthId}/vercel/custom-environments`, { + params: { + teamId + } + }); + + return environments; +}; + const fetchIntegrationAuthHerokuPipelines = async ({ integrationAuthId }: { @@ -730,6 +756,24 @@ export const useGetIntegrationAuthQoveryScopes = ({ }); }; +export const useGetIntegrationAuthVercelCustomEnvironments = ({ + integrationAuthId, + teamId +}: { + integrationAuthId: string; + teamId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthVercelCustomEnv(integrationAuthId, teamId), + queryFn: () => + fetchIntegrationAuthVercelCustomEnvironments({ + integrationAuthId, + teamId + }), + enabled: Boolean(teamId && integrationAuthId) + }); +}; + export const useGetIntegrationAuthHerokuPipelines = ({ integrationAuthId }: { diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index e2dee6067..b57e5aeca 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -43,6 +43,11 @@ export type Environment = { environmentId: string; }; +export type VercelEnvironment = { + id: string; + slug: string; +}; + export type ChecklyGroup = { name: string; groupId: number; diff --git a/frontend/src/pages/secret-manager/integrations/VercelConfigurePage/VercelConfigurePage.tsx b/frontend/src/pages/secret-manager/integrations/VercelConfigurePage/VercelConfigurePage.tsx index a113dba15..481d3601a 100644 --- a/frontend/src/pages/secret-manager/integrations/VercelConfigurePage/VercelConfigurePage.tsx +++ b/frontend/src/pages/secret-manager/integrations/VercelConfigurePage/VercelConfigurePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { faArrowUpRightFromSquare, @@ -25,7 +25,8 @@ import { useCreateIntegration } from "@app/hooks/api"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById, - useGetIntegrationAuthVercelBranches + useGetIntegrationAuthVercelBranches, + useGetIntegrationAuthVercelCustomEnvironments } from "@app/hooks/api/integrationAuth"; import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; @@ -75,6 +76,11 @@ export const VercelConfigurePage = () => { teamId: integrationAuth?.teamId as string }); + const { data: customEnvironments } = useGetIntegrationAuthVercelCustomEnvironments({ + teamId: integrationAuth?.teamId as string, + integrationAuthId: integrationAuthId as string + }); + const { data: branches } = useGetIntegrationAuthVercelBranches({ integrationAuthId: integrationAuthId as string, appId: targetAppId @@ -135,6 +141,31 @@ export const VercelConfigurePage = () => { } }; + const selectedVercelEnvironments = useMemo(() => { + // Structure looks like: + // {appId: string, environments: [{id: string, name: string}]}[] + + let selectedEnvironments = vercelEnvironments; + + const environments = customEnvironments?.find( + (e) => e.appId === targetAppId + )?.customEnvironments; + + if (environments && environments.length > 0) { + selectedEnvironments = [ + ...selectedEnvironments, + ...environments.map((env) => ({ + name: env.slug, + slug: env.id + })) + ]; + } + + return selectedEnvironments; + }, [targetAppId, customEnvironments]); + + console.log("selectedVercelEnvironments", selectedVercelEnvironments); + return integrationAuth && selectedSourceEnvironment && integrationAuthApps && @@ -210,7 +241,14 @@ export const VercelConfigurePage = () => { > { - setTargetAppId(val); - - // Reset the target environment if it's not a default environment if (vercelEnvironments.every((env) => env.slug !== targetEnvironment)) { setTargetEnvironment(vercelEnvironments[0].slug); } + + setTargetAppId(val); }} className="w-full border border-mineshaft-500" isDisabled={integrationAuthApps.length === 0} From 27af943ee11ab7f618c381245d219e24e374e802 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Mon, 27 Jan 2025 23:18:46 +0100 Subject: [PATCH 05/12] Update integration-sync-secret.ts --- .../src/services/integration-auth/integration-sync-secret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 83bf2616c..249307f8d 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1636,7 +1636,7 @@ const syncSecretsVercel = async ({ } : { customEnvironmentIds: res[key].customEnvironmentIds?.includes(integration.targetEnvironment as string) - ? [...res[key].customEnvironmentIds] + ? [...(res[key].customEnvironmentIds || [])] : [...(res[key]?.customEnvironmentIds || []), integration.targetEnvironment as string] }), From 939ee892e030ba9c0fe4ddd475b107b854cbf934 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 28 Jan 2025 01:02:18 +0100 Subject: [PATCH 06/12] chore: cleanup --- .../organization/AuditLogsPage/components/LogsFilter.tsx | 1 - .../src/pages/organization/AuditLogsPage/components/types.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index a1246c695..8c7ef3446 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -128,7 +128,6 @@ export const LogsFilter = ({ value={value} isClearable onChange={(e) => { - console.log(e); if (e === null) { setValue("secretPath", ""); } diff --git a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx index 0854bd8cf..533870040 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx @@ -9,11 +9,11 @@ export const auditLogFilterFormSchema = z eventType: z.nativeEnum(EventType).array(), actor: z.string().optional(), userAgentType: z.nativeEnum(UserAgentType), + secretPath: z.string().optional(), startDate: z.date().optional(), endDate: z.date().optional(), page: z.coerce.number().optional(), - perPage: z.coerce.number().optional(), - secretPath: z.string().optional() + perPage: z.coerce.number().optional() }) .superRefine((el, ctx) => { if (el.endDate && el.startDate && el.endDate < el.startDate) { From 72468d5428e05327788432d714242dc3a6cb8588 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 27 Jan 2025 18:51:35 -0800 Subject: [PATCH 07/12] feature: setup password --- .../src/server/routes/v1/password-router.ts | 67 +++- .../services/auth-token/auth-token-service.ts | 6 + .../services/auth-token/auth-token-types.ts | 1 + .../services/auth/auth-password-service.ts | 125 ++++++- .../src/services/auth/auth-password-type.ts | 14 + backend/src/services/smtp/smtp-service.ts | 1 + .../smtp/templates/passwordSetup.handlebars | 16 + frontend/src/const/routes.ts | 3 +- frontend/src/hooks/api/auth/queries.tsx | 24 +- frontend/src/hooks/api/auth/types.ts | 14 + .../PasswordResetPage/PasswordResetPage.tsx | 3 +- .../PasswordSetupPage/PasswordSetupPage.tsx | 349 ++++++++++++++++++ .../pages/auth/PasswordSetupPage/route.tsx | 15 + .../src/pages/middlewares/authenticate.tsx | 5 +- .../ChangePasswordSection.tsx | 30 ++ frontend/src/routeTree.gen.ts | 29 ++ frontend/src/routes.ts | 1 + 17 files changed, 691 insertions(+), 12 deletions(-) create mode 100644 backend/src/services/smtp/templates/passwordSetup.handlebars create mode 100644 frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx create mode 100644 frontend/src/pages/auth/PasswordSetupPage/route.tsx diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 316ddcb53..e96a577d9 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -203,7 +203,8 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyIV: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), - verifier: z.string().trim() + verifier: z.string().trim(), + password: z.string().trim() }), response: { 200: z.object({ @@ -218,7 +219,69 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { userId: token.userId }); - return { message: "Successfully updated backup private key" }; + return { message: "Successfully reset password" }; + } + }); + + server.route({ + method: "POST", + url: "/email/password-setup", + config: { + rateLimit: authRateLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.password.sendPasswordSetupEmail(req.permission); + + return { + message: "A password setup link has been sent" + }; + } + }); + + server.route({ + method: "POST", + url: "/password-setup", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim(), + password: z.string().trim(), + token: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req, res) => { + await server.services.password.setupPassword(req.body, req.permission); + + const appCfg = getConfig(); + void res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + return { message: "Successfully setup password" }; } }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index c0bb7dc17..d15fa4543 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -57,6 +57,12 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_PASSWORD_SETUP: { + // generate random hex + const token = crypto.randomBytes(16).toString("hex"); + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, expiresAt }; + } case TokenType.TOKEN_USER_UNLOCK: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(new Date().getTime() + 259200000); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 65d16850a..5f5843bc6 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -6,6 +6,7 @@ export enum TokenType { TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", + TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup", TOKEN_USER_UNLOCK = "userUnlock" } diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 9ed9951fe..aef8eafb9 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -4,6 +4,8 @@ import jwt from "jsonwebtoken"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; @@ -11,8 +13,13 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TAuthDALFactory } from "./auth-dal"; -import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type"; -import { AuthTokenType } from "./auth-type"; +import { + TChangePasswordDTO, + TCreateBackupPrivateKeyDTO, + TResetPasswordViaBackupKeyDTO, + TSetupPasswordViaBackupKeyDTO +} from "./auth-password-type"; +import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; @@ -169,8 +176,13 @@ export const authPaswordServiceFactory = ({ verifier, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - userId + userId, + password }: TResetPasswordViaBackupKeyDTO) => { + const cfg = getConfig(); + + const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); + await userDAL.updateUserEncryptionByUserId(userId, { encryptionVersion: 2, protectedKey, @@ -180,7 +192,8 @@ export const authPaswordServiceFactory = ({ iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, salt, - verifier + verifier, + hashedPassword }); await userDAL.updateById(userId, { @@ -267,6 +280,106 @@ export const authPaswordServiceFactory = ({ return backupKey; }; + const sendPasswordSetupEmail = async (actor: OrgServiceActor) => { + if (actor.type !== ActorType.USER) + throw new BadRequestError({ message: `Actor of type ${actor.type} cannot set password` }); + + const user = await userDAL.findById(actor.id); + + if (!user) throw new BadRequestError({ message: `Could not find user with ID ${actor.id}` }); + + if (!user.isAccepted || !user.authMethods) + throw new BadRequestError({ message: `You must complete signup to set a password` }); + + const cfg = getConfig(); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_SETUP, + userId: user.id + }); + + const email = user.email ?? user.username; + + await smtpService.sendMail({ + template: SmtpTemplates.SetupPassword, + recipients: [email], + subjectLine: "Infisical Password Setup", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-setup` : "" + } + }); + }; + + const setupPassword = async ( + { + encryptedPrivateKey, + protectedKeyTag, + protectedKey, + protectedKeyIV, + salt, + verifier, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + password, + token + }: TSetupPasswordViaBackupKeyDTO, + actor: OrgServiceActor + ) => { + try { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_SETUP, + userId: actor.id, + code: token + }); + } catch (e) { + throw new BadRequestError({ message: "Expired or invalid token. Please try again." }); + } + + await userDAL.transaction(async (tx) => { + const user = await userDAL.findById(actor.id, tx); + + if (!user) throw new BadRequestError({ message: `Could not find user with ID ${actor.id}` }); + + if (!user.isAccepted || !user.authMethods) + throw new BadRequestError({ message: `You must complete signup to set a password` }); + + await userDAL.updateById( + actor.id, + { + authMethods: [...user.authMethods, AuthMethod.EMAIL] + }, + tx + ); + + const cfg = getConfig(); + + const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); + + await userDAL.updateUserEncryptionByUserId( + actor.id, + { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + salt, + verifier, + hashedPassword, + serverPrivateKey: null, + clientPublicKey: null + }, + tx + ); + }); + + await tokenService.revokeAllMySessions(actor.id); + }; + return { generateServerPubKey, changePassword, @@ -274,6 +387,8 @@ export const authPaswordServiceFactory = ({ sendPasswordResetEmail, verifyPasswordResetEmail, createBackupPrivateKey, - getBackupPrivateKeyOfUser + getBackupPrivateKeyOfUser, + sendPasswordSetupEmail, + setupPassword }; }; diff --git a/backend/src/services/auth/auth-password-type.ts b/backend/src/services/auth/auth-password-type.ts index a52374506..7c67c0934 100644 --- a/backend/src/services/auth/auth-password-type.ts +++ b/backend/src/services/auth/auth-password-type.ts @@ -23,6 +23,20 @@ export type TResetPasswordViaBackupKeyDTO = { encryptedPrivateKeyTag: string; salt: string; verifier: string; + password: string; +}; + +export type TSetupPasswordViaBackupKeyDTO = { + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + password: string; + token: string; }; export type TCreateBackupPrivateKeyDTO = { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index d997d52f4..67168f1dd 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -30,6 +30,7 @@ export enum SmtpTemplates { NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", ResetPassword = "passwordReset.handlebars", + SetupPassword = "passwordSetup.handlebars", SecretLeakIncident = "secretLeakIncident.handlebars", WorkspaceInvite = "workspaceInvitation.handlebars", ScimUserProvisioned = "scimUserProvisioned.handlebars", diff --git a/backend/src/services/smtp/templates/passwordSetup.handlebars b/backend/src/services/smtp/templates/passwordSetup.handlebars new file mode 100644 index 000000000..1d3a5f72d --- /dev/null +++ b/backend/src/services/smtp/templates/passwordSetup.handlebars @@ -0,0 +1,16 @@ + + + + + Password Setup + + +

Setup your password

+

Someone requested to set up a password for your account. Make sure you are already logged in to Infisical in the current browser before clicking the link below.

+ Setup password +

If you didn't initiate this request, please contact + {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

+ + {{emailFooter}} + + \ No newline at end of file diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 03e077a8d..42823af4d 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -13,7 +13,8 @@ export const ROUTE_PATHS = Object.freeze({ "/_restrict-login-signup/login/provider/success" ), SignUpSsoPage: setRoute("/signup/sso", "/_restrict-login-signup/signup/sso"), - PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset") + PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset"), + PasswordSetupPage: setRoute("/password-setup", "/_authenticate/password-setup") }, Organization: { SecretScanning: setRoute( diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 8d8ee0c3f..d7703e3b3 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -23,6 +23,7 @@ import { MfaMethod, ResetPasswordDTO, SendMfaTokenDTO, + SetupPasswordDTO, SRP1DTO, SRPR1Res, TOauthTokenExchangeDTO, @@ -286,7 +287,8 @@ export const useResetPassword = () => { encryptedPrivateKeyIV: details.encryptedPrivateKeyIV, encryptedPrivateKeyTag: details.encryptedPrivateKeyTag, salt: details.salt, - verifier: details.verifier + verifier: details.verifier, + password: details.password }, { headers: { @@ -336,3 +338,23 @@ export const checkUserTotpMfa = async () => { return data.isVerified; }; + +export const useSendPasswordSetupEmail = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/password/email/password-setup"); + + return data; + } + }); +}; + +export const useSetupPassword = () => { + return useMutation({ + mutationFn: async ({ verificationToken, ...payload }: SetupPasswordDTO) => { + const { data } = await apiRequest.post("/api/v1/password/password-setup", payload); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index d0c718e48..036897fed 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -133,6 +133,20 @@ export type ResetPasswordDTO = { salt: string; verifier: string; verificationToken: string; + password: string; +}; + +export type SetupPasswordDTO = { + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + token: string; + password: string; }; export type IssueBackupPrivateKeyDTO = { diff --git a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx index ba28c3871..3361dd961 100644 --- a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -136,7 +136,8 @@ export const PasswordResetPage = () => { encryptedPrivateKeyTag, salt: result.salt, verifier: result.verifier, - verificationToken + verificationToken, + password: newPassword }); navigate({ to: "/login" }); diff --git a/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx new file mode 100644 index 000000000..311d613ef --- /dev/null +++ b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx @@ -0,0 +1,349 @@ +import crypto from "crypto"; + +import { FormEvent, useState } from "react"; +import { faCheck, faEye, faEyeSlash, faKey, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import jsrp from "jsrp"; + +import { createNotification } from "@app/components/notifications"; +import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck"; +import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; +import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; +import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useSetupPassword } from "@app/hooks/api/auth/queries"; + +// eslint-disable-next-line new-cap +const client = new jsrp.client(); + +export const PasswordSetupPage = () => { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwordsMatch, setPasswordsMatch] = useState(true); + const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(true); + const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false); + const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(true); + const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(true); + const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false); + const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false); + const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false); + const [passwordErrorBreached, setPasswordErrorBreached] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + + const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordSetupPage.id }); + + const navigate = useNavigate(); + + const setupPassword = useSetupPassword(); + + const parsedUrl = search; + const token = parsedUrl.token as string; + const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); + + const handleSetPassword = async (e: FormEvent) => { + e.preventDefault(); + const errorCheck = await passwordCheck({ + password, + setPasswordErrorTooShort, + setPasswordErrorTooLong, + setPasswordErrorNoLetterChar, + setPasswordErrorNoNumOrSpecialChar, + setPasswordErrorRepeatedChar, + setPasswordErrorEscapeChar, + setPasswordErrorLowEntropy, + setPasswordErrorBreached + }); + + if (password !== confirmPassword) { + setPasswordsMatch(false); + return; + } + + setPasswordsMatch(true); + + if (!errorCheck) { + client.init( + { + username: email, + password + }, + async () => { + client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { + const derivedKey = await deriveArgonKey({ + password, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error("Failed to derive key from password"); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: localStorage.getItem("PRIVATE_KEY") as string, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString("hex"), + secret: Buffer.from(derivedKey.hash) + }); + + try { + await setupPassword.mutateAsync({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + token, + password + }); + + setIsRedirecting(true); + + createNotification({ + type: "success", + title: "Password successfully set", + text: "Redirecting to login..." + }); + + setTimeout(() => { + window.location.href = "/login"; + }, 3000); + } catch (error) { + createNotification({ + type: "error", + text: (error as Error).message ?? "Error setting password" + }); + navigate({ to: "/personal-settings" }); + } + }); + } + ); + } + }; + + const isInvalidPassword = + passwordErrorTooShort || + passwordErrorTooLong || + passwordErrorNoLetterChar || + passwordErrorNoNumOrSpecialChar || + passwordErrorRepeatedChar || + passwordErrorEscapeChar || + passwordErrorLowEntropy || + passwordErrorBreached; + + return ( +
+
+ + +
+
+ +
+ Set Password +
+
+ + { + setPassword(e.target.value); + passwordCheck({ + password: e.target.value, + setPasswordErrorTooShort, + setPasswordErrorTooLong, + setPasswordErrorNoLetterChar, + setPasswordErrorNoNumOrSpecialChar, + setPasswordErrorRepeatedChar, + setPasswordErrorEscapeChar, + setPasswordErrorLowEntropy, + setPasswordErrorBreached + }); + }} + rightIcon={ + + } + /> + + + setConfirmPassword(e.target.value)} + rightIcon={ + + } + /> + +
+
Password must contain:
+
+ {passwordErrorTooShort ? ( + + ) : ( + + )} +
+ at least 14 characters +
+
+
+ {passwordErrorTooLong ? ( + + ) : ( + + )} +
+ at most 100 characters +
+
+
+ {passwordErrorNoLetterChar ? ( + + ) : ( + + )} +
+ at least 1 letter character +
+
+
+ {passwordErrorNoNumOrSpecialChar ? ( + + ) : ( + + )} +
+ at least 1 number or special character +
+
+
+ {passwordErrorRepeatedChar ? ( + + ) : ( + + )} +
+ at most 3 repeated, consecutive characters +
+
+
+ {passwordErrorEscapeChar ? ( + + ) : ( + + )} +
+ no escape characters +
+
+
+ {passwordErrorLowEntropy ? ( + + ) : ( + + )} +
+ no personal information +
+
+
+ {passwordErrorBreached ? ( + + ) : ( + + )} +
+ password not found in a data breach. +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/pages/auth/PasswordSetupPage/route.tsx b/frontend/src/pages/auth/PasswordSetupPage/route.tsx new file mode 100644 index 000000000..5224feedb --- /dev/null +++ b/frontend/src/pages/auth/PasswordSetupPage/route.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { PasswordSetupPage } from "./PasswordSetupPage"; + +const PasswordSetupPageQueryParamsSchema = z.object({ + token: z.string(), + to: z.string() +}); + +export const Route = createFileRoute("/_authenticate/password-setup")({ + component: PasswordSetupPage, + validateSearch: zodValidator(PasswordSetupPageQueryParamsSchema) +}); diff --git a/frontend/src/pages/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 7810690e5..cb9690837 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -1,12 +1,13 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { createNotification } from "@app/components/notifications"; +import { ROUTE_PATHS } from "@app/const/routes"; import { userKeys } from "@app/hooks/api"; import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries"; import { fetchUserDetails } from "@app/hooks/api/users/queries"; export const Route = createFileRoute("/_authenticate")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, location }) => { if (!context.serverConfig.initialized) { throw redirect({ to: "/admin/signup" }); } @@ -26,7 +27,7 @@ export const Route = createFileRoute("/_authenticate")({ }); }); - if (!data.organizationId) { + if (!data.organizationId && location.pathname !== ROUTE_PATHS.Auth.PasswordSetupPage.path) { throw redirect({ to: "/login/select-organization" }); } diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx index efe07961e..9c34693df 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx @@ -11,6 +11,7 @@ import attemptChangePassword from "@app/components/utilities/attemptChangePasswo import checkPassword from "@app/components/utilities/checks/password/checkPassword"; import { Button, FormControl, Input } from "@app/components/v2"; import { useUser } from "@app/context"; +import { useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries"; type Errors = { tooShort?: string; @@ -45,6 +46,7 @@ export const ChangePasswordSection = () => { }); const [errors, setErrors] = useState({}); const [isLoading, setIsLoading] = useState(false); + const sendSetupPasswordEmail = useSendPasswordSetupEmail(); const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => { try { @@ -80,6 +82,24 @@ export const ChangePasswordSection = () => { } }; + const onSetupPassword = async () => { + try { + await sendSetupPasswordEmail.mutateAsync(); + + createNotification({ + title: "Password setup verification email sent", + text: "Check your email to confirm password setup", + type: "info" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to send password setup email", + type: "error" + }); + } + }; + return (
{ +

+ Need to setup a password?{" "} + +

); }; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7c5a749a4..cc89058c3 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as authSignUpInvitePageRouteImport } from './pages/auth/SignUpInv import { Route as authRequestNewInvitePageRouteImport } from './pages/auth/RequestNewInvitePage/route' import { Route as authPasswordResetPageRouteImport } from './pages/auth/PasswordResetPage/route' import { Route as authEmailNotVerifiedPageRouteImport } from './pages/auth/EmailNotVerifiedPage/route' +import { Route as authPasswordSetupPageRouteImport } from './pages/auth/PasswordSetupPage/route' import { Route as userLayoutImport } from './pages/user/layout' import { Route as organizationLayoutImport } from './pages/organization/layout' import { Route as publicViewSharedSecretByIDPageRouteImport } from './pages/public/ViewSharedSecretByIDPage/route' @@ -310,6 +311,14 @@ const authEmailNotVerifiedPageRouteRoute = getParentRoute: () => middlewaresRestrictLoginSignupRoute, } as any) +const authPasswordSetupPageRouteRoute = authPasswordSetupPageRouteImport.update( + { + id: '/password-setup', + path: '/password-setup', + getParentRoute: () => middlewaresAuthenticateRoute, + } as any, +) + const userLayoutRoute = userLayoutImport.update({ id: '/_layout', getParentRoute: () => AuthenticatePersonalSettingsRoute, @@ -1577,6 +1586,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof middlewaresRestrictLoginSignupImport parentRoute: typeof rootRoute } + '/_authenticate/password-setup': { + id: '/_authenticate/password-setup' + path: '/password-setup' + fullPath: '/password-setup' + preLoaderRoute: typeof authPasswordSetupPageRouteImport + parentRoute: typeof middlewaresAuthenticateImport + } '/_restrict-login-signup/email-not-verified': { id: '/_restrict-login-signup/email-not-verified' path: '/email-not-verified' @@ -3397,12 +3413,14 @@ const AuthenticatePersonalSettingsRouteWithChildren = ) interface middlewaresAuthenticateRouteChildren { + authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren } const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren = { + authPasswordSetupPageRouteRoute: authPasswordSetupPageRouteRoute, middlewaresInjectOrgDetailsRoute: middlewaresInjectOrgDetailsRouteWithChildren, AuthenticatePersonalSettingsRoute: @@ -3487,6 +3505,7 @@ export interface FileRoutesByFullPath { '/cli-redirect': typeof authCliRedirectPageRouteRoute '/share-secret': typeof publicShareSecretPageRouteRoute '': typeof organizationLayoutRouteWithChildren + '/password-setup': typeof authPasswordSetupPageRouteRoute '/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute '/password-reset': typeof authPasswordResetPageRouteRoute '/requestnewinvite': typeof authRequestNewInvitePageRouteRoute @@ -3657,6 +3676,7 @@ export interface FileRoutesByTo { '/cli-redirect': typeof authCliRedirectPageRouteRoute '/share-secret': typeof publicShareSecretPageRouteRoute '': typeof organizationLayoutRouteWithChildren + '/password-setup': typeof authPasswordSetupPageRouteRoute '/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute '/password-reset': typeof authPasswordResetPageRouteRoute '/requestnewinvite': typeof authRequestNewInvitePageRouteRoute @@ -3824,6 +3844,7 @@ export interface FileRoutesById { '/share-secret': typeof publicShareSecretPageRouteRoute '/_authenticate': typeof middlewaresAuthenticateRouteWithChildren '/_restrict-login-signup': typeof middlewaresRestrictLoginSignupRouteWithChildren + '/_authenticate/password-setup': typeof authPasswordSetupPageRouteRoute '/_restrict-login-signup/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute '/_restrict-login-signup/password-reset': typeof authPasswordResetPageRouteRoute '/_restrict-login-signup/requestnewinvite': typeof authRequestNewInvitePageRouteRoute @@ -4004,6 +4025,7 @@ export interface FileRouteTypes { | '/cli-redirect' | '/share-secret' | '' + | '/password-setup' | '/email-not-verified' | '/password-reset' | '/requestnewinvite' @@ -4173,6 +4195,7 @@ export interface FileRouteTypes { | '/cli-redirect' | '/share-secret' | '' + | '/password-setup' | '/email-not-verified' | '/password-reset' | '/requestnewinvite' @@ -4338,6 +4361,7 @@ export interface FileRouteTypes { | '/share-secret' | '/_authenticate' | '/_restrict-login-signup' + | '/_authenticate/password-setup' | '/_restrict-login-signup/email-not-verified' | '/_restrict-login-signup/password-reset' | '/_restrict-login-signup/requestnewinvite' @@ -4562,6 +4586,7 @@ export const routeTree = rootRoute "/_authenticate": { "filePath": "middlewares/authenticate.tsx", "children": [ + "/_authenticate/password-setup", "/_authenticate/_inject-org-details", "/_authenticate/personal-settings" ] @@ -4579,6 +4604,10 @@ export const routeTree = rootRoute "/_restrict-login-signup/admin/signup" ] }, + "/_authenticate/password-setup": { + "filePath": "auth/PasswordSetupPage/route.tsx", + "parent": "/_authenticate" + }, "/_restrict-login-signup/email-not-verified": { "filePath": "auth/EmailNotVerifiedPage/route.tsx", "parent": "/_restrict-login-signup" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b695f0c3a..5a7f3645b 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -335,6 +335,7 @@ export const routes = rootRoute("root.tsx", [ route("/verify-email", "auth/VerifyEmailPage/route.tsx") ]), middleware("authenticate.tsx", [ + route("/password-setup", "auth/PasswordSetupPage/route.tsx"), route("/personal-settings", [ layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")]) ]), From 6af7c5c371ffa7e20731f550cbb970b53bdba72a Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 27 Jan 2025 19:12:05 -0800 Subject: [PATCH 08/12] improvements: remove removed property reference and remove excess padding/margin on secret sync pages --- frontend/src/hooks/api/auth/queries.tsx | 2 +- .../IntegrationsListPage.tsx | 16 +++++++--------- .../SecretSyncDetailsByIDPage.tsx | 3 +-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index d7703e3b3..9b8afbac7 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -351,7 +351,7 @@ export const useSendPasswordSetupEmail = () => { export const useSetupPassword = () => { return useMutation({ - mutationFn: async ({ verificationToken, ...payload }: SetupPasswordDTO) => { + mutationFn: async (payload: SetupPasswordDTO) => { const { data } = await apiRequest.post("/api/v1/password/password-setup", payload); return data; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx index aec636da7..241e673ca 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { IntegrationsListPageTabs } from "@app/types/integrations"; @@ -45,14 +45,12 @@ export const IntegrationsListPage = () => {
-
-
-

Integrations

-

- Manage integrations with third-party services. -

-
-
+
+ +
Integrations Update diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx index 1a67e98a0..db3787b49 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx @@ -73,7 +73,7 @@ const PageContent = () => { return ( <>
-
+
From d74b819f572e647c01b533a4dd475502458b2705 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 28 Jan 2025 09:53:40 -0800 Subject: [PATCH 09/12] improvements: make logged in status disclaimer in email more prominent and only add email auth method if not already present --- .../src/services/auth/auth-password-service.ts | 16 +++++++++------- .../smtp/templates/passwordSetup.handlebars | 3 ++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index aef8eafb9..9f004eafc 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -345,13 +345,15 @@ export const authPaswordServiceFactory = ({ if (!user.isAccepted || !user.authMethods) throw new BadRequestError({ message: `You must complete signup to set a password` }); - await userDAL.updateById( - actor.id, - { - authMethods: [...user.authMethods, AuthMethod.EMAIL] - }, - tx - ); + if (!user.authMethods.includes(AuthMethod.EMAIL)) { + await userDAL.updateById( + actor.id, + { + authMethods: [...user.authMethods, AuthMethod.EMAIL] + }, + tx + ); + } const cfg = getConfig(); diff --git a/backend/src/services/smtp/templates/passwordSetup.handlebars b/backend/src/services/smtp/templates/passwordSetup.handlebars index 1d3a5f72d..1059a7446 100644 --- a/backend/src/services/smtp/templates/passwordSetup.handlebars +++ b/backend/src/services/smtp/templates/passwordSetup.handlebars @@ -6,7 +6,8 @@

Setup your password

-

Someone requested to set up a password for your account. Make sure you are already logged in to Infisical in the current browser before clicking the link below.

+

Someone requested to set up a password for your account.

+

Make sure you are already logged in to Infisical in the current browser before clicking the link below.

Setup password

If you didn't initiate this request, please contact {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

From a24ef46d7d22285e3664a3b64995a2d05b263664 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 28 Jan 2025 20:44:45 +0100 Subject: [PATCH 10/12] requested changes --- .../organization/AuditLogsPage/components/LogsFilter.tsx | 7 ++++--- .../pages/organization/AuditLogsPage/components/types.tsx | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 8c7ef3446..5c7e020b7 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -23,6 +23,7 @@ import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/ import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums"; import { Actor } from "@app/hooks/api/auditLogs/types"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { AuditLogFilterFormData } from "./types"; @@ -103,7 +104,7 @@ export const LogsFilter = ({ }; const selectedEventTypes = watch("eventType") as EventType[] | undefined; - const selectedProjectId = watch("project")?.id; + const selectedProject = watch("project"); return (
({ name, id }))} + options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} /> @@ -142,7 +143,7 @@ export const LogsFilter = ({ )} /> )} - {selectedProjectId && ( + {selectedProject?.type === ProjectType.SecretManager && ( Date: Tue, 28 Jan 2025 12:51:24 -0800 Subject: [PATCH 11/12] improvements: change integration nav bar order and correct azure integrations image references --- docs/mint.json | 56 +++++++++---------- .../AzureDevopsAuthorizePage.tsx | 2 +- .../AzureDevopsConfigurePage.tsx | 2 +- .../AzureKeyVaultAuthorizePage.tsx | 7 ++- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/docs/mint.json b/docs/mint.json index 7a0952c2c..31c5d7b14 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -344,34 +344,6 @@ "cli/faq" ] }, - { - "group": "App Connections", - "pages": [ - "integrations/app-connections/overview", - { - "group": "Connections", - "pages": [ - "integrations/app-connections/aws", - "integrations/app-connections/github", - "integrations/app-connections/gcp" - ] - } - ] - }, - { - "group": "Secret Syncs", - "pages": [ - "integrations/secret-syncs/overview", - { - "group": "Syncs", - "pages": [ - "integrations/secret-syncs/aws-parameter-store", - "integrations/secret-syncs/github", - "integrations/secret-syncs/gcp-secret-manager" - ] - } - ] - }, { "group": "Infrastructure Integrations", "pages": [ @@ -406,6 +378,34 @@ "integrations/platforms/ansible" ] }, + { + "group": "App Connections", + "pages": [ + "integrations/app-connections/overview", + { + "group": "Connections", + "pages": [ + "integrations/app-connections/aws", + "integrations/app-connections/github", + "integrations/app-connections/gcp" + ] + } + ] + }, + { + "group": "Secret Syncs", + "pages": [ + "integrations/secret-syncs/overview", + { + "group": "Syncs", + "pages": [ + "integrations/secret-syncs/aws-parameter-store", + "integrations/secret-syncs/github", + "integrations/secret-syncs/gcp-secret-manager" + ] + } + ] + }, { "group": "Native Integrations", "pages": [ diff --git a/frontend/src/pages/secret-manager/integrations/AzureDevopsAuthorizePage/AzureDevopsAuthorizePage.tsx b/frontend/src/pages/secret-manager/integrations/AzureDevopsAuthorizePage/AzureDevopsAuthorizePage.tsx index 5581cca4a..e441b99b3 100644 --- a/frontend/src/pages/secret-manager/integrations/AzureDevopsAuthorizePage/AzureDevopsAuthorizePage.tsx +++ b/frontend/src/pages/secret-manager/integrations/AzureDevopsAuthorizePage/AzureDevopsAuthorizePage.tsx @@ -65,7 +65,7 @@ export const AzureDevopsAuthorizePage = () => {
Azure DevOps logo {
Azure DevOps logo
- Github logo + Azure Key Vault logo
Azure Key Vault Integration Date: Tue, 28 Jan 2025 23:31:00 -0500 Subject: [PATCH 12/12] add guide for how to wrote a design doc --- .../engineering/how-to-write-design-doc.mdx | 67 +++++++++++++++++++ company/mint.json | 3 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 company/documentation/engineering/how-to-write-design-doc.mdx diff --git a/company/documentation/engineering/how-to-write-design-doc.mdx b/company/documentation/engineering/how-to-write-design-doc.mdx new file mode 100644 index 000000000..7f6a49e47 --- /dev/null +++ b/company/documentation/engineering/how-to-write-design-doc.mdx @@ -0,0 +1,67 @@ +--- +title: "How to write a design document" +sidebarTitle: "Writing Design Docs" +description: "Learn how to write a design document at Infisical" +--- + +## **Why write a design document?** + +Writing a design document helps you efficiently solve broad, complex engineering problems at Infisical. While planning is important, we are a startup, so speed and urgency should be your top of mind. Keep the process lightweight and time boxed so that we can get the most out of it. + +**Writing a design will help you:** + +- **Understand the problem space:** Deeply understand the problem you’re solving to make sure it is well scoped. +- **Stay on the right path:** Without proper planning, you risk cycling between partial implementation and replanning, encountering roadblocks that force you back to square one. A solid plan minimizes wasted engineering hours. +- **An opportunity to collaborate:** Bring relevant engineers into the discussion to develop well-thought-out solutions and catch potential issues you might have overlooked. +- **Faster implementation:** A well-thought-out plan will help you catch roadblocks early and ship quickly because you know exactly what needs to get implemented. + +**When to write a design document:** + +- **Write a design doc**: If the feature is not well defined, high-security, or will take more than **1 full engineering week** to build. +- **Skip the design doc**: For small, straightforward features that can be built quickly with informal discussions. + +If you are unsure when to create a design doc, chat with @maidul. + +## **What to Include in your Design Document** + +Every feature/problem is unique, but your design docs should generally include the following sections. If you need to include additional sections, feel free to do so. + +1. **Title** + - A descriptive title. + - Name of document owner and name of reviewer(s). +2. **Overview** + - A high-level summary of the problem and proposed solution. Keep it brief (max 3 paragraphs). +3. **Context** + - Explain the problem’s background, why it’s important to solve now, and any constraints (e.g., technical, sales, or timeline-related). What do we get out of solving this problem? (needed to close a deal, scale, performance, etc.). +4. **Solution** + - Provide a big-picture explanation of the solution, followed by detailed technical architecture. + - Use diagrams/charts where needed. + - Write clearly so that another engineer could implement the solution in your absence. +5. **Milestones** + - Break the project into phases with clear start and end dates estimates. Use a table or bullet points. +6. **FAQ** + - Common questions or concerns someone might have while reading your document that can be quickly addressed. + + +## **How to Write a Design Doc** + +- **Keep it Simple**: Use clear, simple language. Opt for short sentences, bullet points, and concrete examples over fluff writing. +- **Use Visuals**: Add diagrams and charts for clarity to convey your ideas. +- **Make it Self-Explanatory**: Ensure that anyone reading the document can understand and implement the plan without needing additional context. + +Before sharing your design docs with others, review your design doc as if you were a teammate seeing it for the first time. Anticipate questions and address them. + + +## **Process from start to finish** + +1. **Research/Discuss** + - Before you start writing, take some time to research and get a solid understanding of the problem space. Look into how other well-established companies are tackling similar challenges, if they are. + Talk through the problem and your initial solution with other engineers on the team—bounce ideas around and get their feedback. If you have ideas on how the system could if implemented in Infisical, would it effect any downstream features/systems, etc? + + Once you’ve got a general direction, you might need to test a some theories. This is where quick proof of concepts (POCs) come in handy, but don’t get too caught up in the details. The goal of a POC is simply to validate a core idea or concept so you can get to the rest of your planning. +2. **Write the Doc** + - Based on your research/discussions, write the design doc and include all relevant sections. Your goal is to come up with a convincing plan on why this is the correct why to solve the problem at hand. +3. **Assign Reviewers** + - Ask a relevant engineer(s) to review your document. Their role is to identify blind spots, challenge assumptions, and ensure everything is clear. Once you and the reviewer are on the same page on the approach, update the document with any missing details they brought up. +4. **Team Review and Feedback** + - Invite the relevant engineers to a design doc review meeting and give them 10-15 minutes to read through the document. After everyone has had a chance to review it, open the floor up for discussion. Address any feedback or concerns raised during this meeting. If significant points were overlooked during your initial planning, you may need to revisit the drawing board. Your goal is to think about the feature holistically and minimize the need for drastic changes to your design doc later on. \ No newline at end of file diff --git a/company/mint.json b/company/mint.json index 11edf794e..247fb5a2b 100644 --- a/company/mint.json +++ b/company/mint.json @@ -66,7 +66,8 @@ { "group": "Engineering", "pages": [ - "documentation/engineering/oncall" + "documentation/engineering/oncall", + "documentation/engineering/how-to-write-design-doc" ] } ]