diff --git a/backend/src/ee/services/dynamic-secret/providers/github.ts b/backend/src/ee/services/dynamic-secret/providers/github.ts new file mode 100644 index 000000000..9b5f02c78 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/github.ts @@ -0,0 +1,130 @@ +import axios from "axios"; +import * as jwt from "jsonwebtoken"; + +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { DynamicSecretGithubSchema, TDynamicProviderFns } from "./models"; + +interface GitHubInstallationTokenResponse { + token: string; + expires_at: string; // ISO 8601 timestamp e.g., "2024-01-15T12:00:00Z" + permissions?: Record; + repository_selection?: string; +} + +interface TGithubProviderInputs { + appId: number; + installationId: number; + privateKey: string; +} + +export const GithubProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretGithubSchema.parseAsync(inputs); + return providerInputs; + }; + + const $generateGitHubInstallationAccessToken = async ( + credentials: TGithubProviderInputs + ): Promise => { + const { appId, installationId, privateKey } = credentials; + + const nowInSeconds = Math.floor(Date.now() / 1000); + const jwtPayload = { + iat: nowInSeconds - 60, + exp: nowInSeconds + 10 * 60 - 60, + iss: String(appId) + }; + + let appJwt: string; + try { + appJwt = jwt.sign(jwtPayload, privateKey, { algorithm: "RS256" }); + } catch (error) { + let message = "Failed to sign JWT."; + if (error instanceof jwt.JsonWebTokenError) { + message += ` JsonWebTokenError: ${error.message}`; + } + throw new InternalServerError({ + message + }); + } + + const tokenUrl = `${IntegrationUrls.GITHUB_API_URL}/app/installations/${String(installationId)}/access_tokens`; + + try { + const response = await axios.post(tokenUrl, undefined, { + headers: { + Authorization: `Bearer ${appJwt}`, + Accept: "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + if (response.status === 201 && response.data.token) { + return response.data; // Includes token, expires_at, permissions, repository_selection + } + + throw new InternalServerError({ + message: `GitHub API responded with unexpected status ${response.status}: ${JSON.stringify(response.data)}` + }); + } catch (error) { + let message = "Failed to fetch GitHub installation access token."; + if (axios.isAxiosError(error) && error.response) { + const githubErrorMsg = + (error.response.data as { message?: string })?.message || JSON.stringify(error.response.data); + message += ` GitHub API Error: ${error.response.status} - ${githubErrorMsg}`; + + // Classify as BadRequestError for auth-related issues (401, 403, 404) which might be due to user input + if ([401, 403, 404].includes(error.response.status)) { + throw new BadRequestError({ message }); + } + } + + throw new InternalServerError({ message }); + } + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + await $generateGitHubInstallationAccessToken(providerInputs); + return true; + }; + + const create = async (data: { inputs: unknown }) => { + const { inputs } = data; + const providerInputs = await validateProviderInputs(inputs); + + const ghTokenData = await $generateGitHubInstallationAccessToken(providerInputs); + const entityId = alphaNumericNanoId(32); + + return { + entityId, + data: { + TOKEN: ghTokenData.token, + EXPIRES_AT: ghTokenData.expires_at, + PERMISSIONS: ghTokenData.permissions, + REPOSITORY_SELECTION: ghTokenData.repository_selection + } + }; + }; + + const revoke = async (_inputs: unknown, entityId: string) => { + // GitHub installation access tokens cannot be revoked. + return { entityId }; + }; + + const renew = async () => { + // No renewal + throw new BadRequestError({ message: "Github dynamic secret does not support renewal" }); + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 7e14cf1ab..7fd65f98d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -7,6 +7,7 @@ import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; import { GcpIamProvider } from "./gcp-iam"; +import { GithubProvider } from "./github"; import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; @@ -44,5 +45,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.SapAse]: SapAseProvider(), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), - [DynamicSecretProviders.GcpIam]: GcpIamProvider() + [DynamicSecretProviders.GcpIam]: GcpIamProvider(), + [DynamicSecretProviders.Github]: GithubProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 8f361e166..972c53543 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -474,6 +474,23 @@ export const DynamicSecretGcpIamSchema = z.object({ serviceAccountEmail: z.string().email().trim().min(1, "Service account email required").max(128) }); +export const DynamicSecretGithubSchema = z.object({ + appId: z.number().min(1).describe("The ID of your GitHub App."), + installationId: z.number().min(1).describe("The ID of the GitHub App installation."), + privateKey: z + .string() + .trim() + .min(1) + .refine( + (val) => + new RE2( + /^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/ + ).test(val), + "Invalid PEM format for private key" + ) + .describe("The private key generated for your GitHub App.") +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -492,7 +509,8 @@ export enum DynamicSecretProviders { SapAse = "sap-ase", Kubernetes = "kubernetes", Vertica = "vertica", - GcpIam = "gcp-iam" + GcpIam = "gcp-iam", + Github = "github" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -513,7 +531,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), - z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }) + z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }) ]); export type TDynamicProviderFns = { diff --git a/docs/documentation/platform/dynamic-secrets/github.mdx b/docs/documentation/platform/dynamic-secrets/github.mdx new file mode 100644 index 000000000..9eced246d --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/github.mdx @@ -0,0 +1,120 @@ +--- +title: "Github" +description: "Learn how to dynamically generate Github app tokens." +--- + +The Infisical Github dynamic secret allows you to generate short-lived tokens for a Github app on demand based on service account permissions. + + + Github app tokens cannot be revoked. As such, revoking a token on Infisical does not invalidate the Github token; it remains active until it expires. + + + + Github app tokens are fixed to a TTL of 1 hour. + + +## Setup Github App + + + + Navigate to [GitHub app settings](https://github.com/settings/apps) and click **New GitHub App**. + + ![integrations github app create](/images/integrations/github/app/self-hosted-github-app-create.png) + + Give the application a name and a homepage URL. These values do not need to be anything specific. + + Disable webhook by unchecking the Active checkbox. + ![integrations github app webhook](/images/integrations/github/app/self-hosted-github-app-webhook.png) + + Configure the app's permissions to grant the necessary access for the dynamic secret's short-lived tokens. + + Create the Github application. + ![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png) + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + + Copy the **App ID** and generate a new **Private Key** for your Github application. + ![integrations github app create private key](/images/integrations/github/app/self-hosted-github-app-private-key.png) + + Save these for later steps. + + + Install your application to whichever repositories and organizations that you want the dynamic secret to access. + ![Install App](/images/platform/dynamic-secrets/github/install-app.png) + + ![Install App](/images/platform/dynamic-secrets/github/install-app-modal.png) + + Once you've installed the app, **copy the installation ID** from the URL and save it for later steps. + ![Install App](/images/platform/dynamic-secrets/github/installation.png) + + + +## Set up Dynamic Secrets with Github + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/github/modal.png) + + + + Name by which you want the secret to be referenced + + + The ID of the app created in earlier steps. + + + The Private Key of the app created in earlier steps. + + + The ID of the installation from earlier steps. + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, the TTL will be fixed to 1 hour. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Dynamic Secret Lease](/images/platform/dynamic-secrets/github/lease.png) + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. + +This will allow you to see the expiration time of the lease or delete a lease before its set time to live. + +![Lease Data](/images/platform/dynamic-secrets/lease-data.png) + + + Github app tokens cannot be revoked. As such, revoking a token on Infisical does not invalidate the Github token; it remains active until it expires. + + +## Renew Leases + + + Github app tokens cannot be renewed because they are fixed to a lifetime of 1 hour. + diff --git a/docs/images/platform/dynamic-secrets/github/install-app-modal.png b/docs/images/platform/dynamic-secrets/github/install-app-modal.png new file mode 100644 index 000000000..f3aa1c51d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/install-app-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/github/install-app.png b/docs/images/platform/dynamic-secrets/github/install-app.png new file mode 100644 index 000000000..7be3d6c1b Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/install-app.png differ diff --git a/docs/images/platform/dynamic-secrets/github/installation.png b/docs/images/platform/dynamic-secrets/github/installation.png new file mode 100644 index 000000000..61a06ec04 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/installation.png differ diff --git a/docs/images/platform/dynamic-secrets/github/lease.png b/docs/images/platform/dynamic-secrets/github/lease.png new file mode 100644 index 000000000..4ce602898 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/lease.png differ diff --git a/docs/images/platform/dynamic-secrets/github/modal.png b/docs/images/platform/dynamic-secrets/github/modal.png new file mode 100644 index 000000000..9ac7743a8 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/modal.png differ diff --git a/docs/mint.json b/docs/mint.json index c6d657d66..3a5eda635 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -214,6 +214,7 @@ "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/gcp-iam", + "documentation/platform/dynamic-secrets/github", "documentation/platform/dynamic-secrets/ldap", "documentation/platform/dynamic-secrets/mongo-atlas", "documentation/platform/dynamic-secrets/mongo-db", diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index 02f162200..fdb9aea5e 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -26,7 +26,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( } diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index e8d80e632..932f4f1a6 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -12,7 +12,7 @@ export type TDynamicSecret = { defaultTTL: string; status?: DynamicSecretStatus; statusDetails?: string; - maxTTL: string; + maxTTL?: string; usernameTemplate?: string | null; metadata?: { key: string; value: string }[]; }; @@ -35,7 +35,8 @@ export enum DynamicSecretProviders { SapAse = "sap-ase", Kubernetes = "kubernetes", Vertica = "vertica", - GcpIam = "gcp-iam" + GcpIam = "gcp-iam", + Github = "github" } export enum KubernetesDynamicSecretCredentialType { @@ -333,6 +334,14 @@ export type TDynamicSecretProvider = inputs: { serviceAccountEmail: string; }; + } + | { + type: DynamicSecretProviders.Github; + inputs: { + appId: number; + installationId: number; + privateKey: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index f67342190..1d3b77d00 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -11,7 +11,7 @@ import { SiSnowflake } from "react-icons/si"; import { VscAzure } from "react-icons/vsc"; -import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons"; +import { faAws, faGithub, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -38,6 +38,7 @@ import { SnowflakeInputForm } from "./SnowflakeInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; import { TotpInputForm } from "./TotpInputForm"; import { VerticaInputForm } from "./VerticaInputForm"; +import { GithubInputForm } from "./GithubInputForm"; type Props = { isOpen?: boolean; @@ -143,6 +144,11 @@ const DYNAMIC_SECRET_LIST = [ icon: , provider: DynamicSecretProviders.GcpIam, title: "GCP IAM" + }, + { + icon: , + provider: DynamicSecretProviders.Github, + title: "Github" } ]; @@ -548,6 +554,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Github && ( + + + + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx new file mode 100644 index 000000000..34620280f --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx @@ -0,0 +1,234 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + FormLabel, + Input, + SecretInput, + Tooltip +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; + +const formSchema = z.object({ + provider: z.object({ + appId: z.coerce.number().min(1, "Required"), + installationId: z.coerce.number().min(1, "Required"), + privateKey: z + .string() + .trim() + .min(1, "Required") + .refine( + (val) => + /^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test( + val + ), + "Invalid PEM format for private key" + ) + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), + environment: z.object({ name: z.string(), slug: z.string() }) +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const GithubInputForm = ({ + onCompleted, + onCancel, + environments, + secretPath, + projectSlug, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + environment: isSingleEnvironmentMode && environments.length > 0 ? environments[0] : undefined + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, provider, environment }: TForm) => { + if (createDynamicSecret.isPending) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { + type: DynamicSecretProviders.Github, + inputs: { + ...provider + } + }, + defaultTTL: "1h", // Github is limited to 1 hour tokens + name, + path: secretPath, + projectSlug, + environmentSlug: environment.slug + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ + + + } + /> + } + > + + +
+
+
+
+ Configuration +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + {!isSingleEnvironmentMode && ( + ( + + option.name} + getOptionValue={(option) => option.slug} + menuPlacement="top" + /> + + )} + /> + )} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 1bdf94665..4f41add4e 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -368,6 +368,22 @@ const renderOutputForm = ( ); } + if (provider === DynamicSecretProviders.Github) { + const { TOKEN } = data as { + TOKEN: string; + }; + + return ( +
+ +
+ ); + } + return null; }; @@ -608,6 +624,8 @@ export const CreateDynamicSecretLease = ({ return ; } + const fixedTtl = provider === DynamicSecretProviders.Github; + return (
@@ -629,8 +647,11 @@ export const CreateDynamicSecretLease = ({ label={} isError={Boolean(error?.message)} errorText={error?.message} + helperText={ + fixedTtl ? `This provider has a fixed TTL of ${field.value}` : undefined + } > - + )} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx index 49c38b2eb..0e19ecbb5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx @@ -29,7 +29,7 @@ import { import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api"; -import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { DynamicSecretProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types"; import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease"; @@ -44,6 +44,8 @@ type Props = { onClose: () => void; }; +const DYNAMIC_SECRETS_WITHOUT_RENEWAL = [DynamicSecretProviders.Github]; + export const DynamicSecretLease = ({ projectSlug, dynamicSecretName, @@ -94,6 +96,8 @@ export const DynamicSecretLease = ({ } }; + const canRenew = !DYNAMIC_SECRETS_WITHOUT_RENEWAL.includes(dynamicSecret.type); + return (
@@ -141,29 +145,31 @@ export const DynamicSecretLease = ({
- - {(isAllowed) => ( - handlePopUpOpen("renewSecret", { leaseId: id })} - > - - - )} - + {canRenew && ( + + {(isAllowed) => ( + handlePopUpOpen("renewSecret", { leaseId: id })} + > + + + )} + + )} void; @@ -366,6 +367,23 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.Github && ( + + + + )} ); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx new file mode 100644 index 000000000..f29566625 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx @@ -0,0 +1,199 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, FormLabel, Input, SecretInput, Tooltip } from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; + +const formSchema = z.object({ + inputs: z.object({ + appId: z.coerce.number().min(1, "Required"), + installationId: z.coerce.number().min(1, "Required"), + privateKey: z + .string() + .trim() + .min(1, "Required") + .refine( + (val) => + /^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test( + val + ), + "Invalid PEM format for private key" + ) + }), + newName: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; +export const EditDynamicSecretGithubForm = ({ + onClose, + dynamicSecret, + secretPath, + environment, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => { + if (updateDynamicSecret.isPending) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ + + + } + /> + } + > + + +
+
+
+
+ Configuration +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/RenewDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/RenewDynamicSecretLease.tsx index 9cee0a671..f9e91ffd0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/RenewDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/RenewDynamicSecretLease.tsx @@ -7,7 +7,7 @@ import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input } from "@app/components/v2"; import { useRenewDynamicSecretLease } from "@app/hooks/api"; -import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { DynamicSecretProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; type Props = { onClose: () => void; @@ -28,7 +28,7 @@ export const RenewDynamicSecretLease = ({ environment, dynamicSecret }: Props) => { - const maxTtlMs = ms(dynamicSecret.maxTTL); + const maxTtlMs = dynamicSecret.maxTTL ? ms(dynamicSecret.maxTTL) : undefined; const formSchema = z.object({ ttl: z.string().superRefine((val, ctx) => { @@ -39,7 +39,7 @@ export const RenewDynamicSecretLease = ({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1 second" }); - if (valMs > maxTtlMs) + if (maxTtlMs && valMs > maxTtlMs) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `TTL must be less than ${dynamicSecret.maxTTL}`