Merge pull request #3821 from Infisical/ENG-2832

feat(dynamic-secret): Github App Tokens
This commit is contained in:
x032205
2025-06-19 21:03:46 -04:00
committed by GitHub
19 changed files with 814 additions and 34 deletions

View File

@@ -0,0 +1,133 @@
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<string, string>;
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<GitHubInstallationTokenResponse> => {
const { appId, installationId, privateKey } = credentials;
const nowInSeconds = Math.floor(Date.now() / 1000);
const jwtPayload = {
iat: nowInSeconds - 5,
exp: nowInSeconds + 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<GitHubInstallationTokenResponse>(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 () => {
// GitHub installation tokens cannot be revoked.
throw new BadRequestError({
message:
"Github dynamic secret does not support revocation because GitHub itself cannot revoke installation tokens"
});
};
const renew = async () => {
// No renewal
throw new BadRequestError({ message: "Github dynamic secret does not support renewal" });
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};

View File

@@ -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()
});

View File

@@ -477,6 +477,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",
@@ -495,7 +512,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", [
@@ -516,7 +534,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 = {

View File

@@ -0,0 +1,112 @@
---
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.
## Setup GitHub App
<Steps>
<Step title="Create an application on GitHub">
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 based on your use case.
Create the GitHub Application.
![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png)
<Note>
If you have a GitHub organization, you can create an application under it
in your organization Settings > Developer settings > GitHub Apps > New GitHub App.
</Note>
</Step>
<Step title="Save app credentials">
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.
</Step>
<Step title="Install app">
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)
</Step>
</Steps>
## Set up Dynamic Secrets with GitHub
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select 'GitHub'">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/github/modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="App ID" type="string" required>
The ID of the app created in earlier steps.
</ParamField>
<ParamField path="App Private Key PEM" type="string" required>
The Private Key of the app created in earlier steps.
</ParamField>
<ParamField path="Installation ID" type="string" required>
The ID of the installation from earlier steps.
</ParamField>
</Step>
<Step title="Click `Submit`">
After submitting the form, you will see a dynamic secret created in the dashboard.
</Step>
<Step title="Generate dynamic secrets">
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)
</Step>
</Steps>
## 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)
<Warning>
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.
</Warning>
## Renew Leases
<Note>
GitHub App tokens cannot be renewed because they are fixed to a lifetime of 1 hour.
</Note>

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@@ -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",

View File

@@ -26,7 +26,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
className="relative bottom-px right-1"
/>
</Tooltip>
}

View File

@@ -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 }[];
tags?: { key: string; value: string }[];
@@ -36,7 +36,8 @@ export enum DynamicSecretProviders {
SapAse = "sap-ase",
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam"
GcpIam = "gcp-iam",
Github = "github"
}
export enum KubernetesDynamicSecretCredentialType {
@@ -335,6 +336,14 @@ export type TDynamicSecretProvider =
inputs: {
serviceAccountEmail: string;
};
}
| {
type: DynamicSecretProviders.Github;
inputs: {
appId: number;
installationId: number;
privateKey: string;
};
};
export type TCreateDynamicSecretDTO = {

View File

@@ -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";
@@ -26,6 +26,7 @@ import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { GcpIamInputForm } from "./GcpIamInputForm";
import { GithubInputForm } from "./GithubInputForm";
import { KubernetesInputForm } from "./KubernetesInputForm";
import { LdapInputForm } from "./LdapInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -143,6 +144,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <FontAwesomeIcon icon={faGoogle} size="lg" />,
provider: DynamicSecretProviders.GcpIam,
title: "GCP IAM"
},
{
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
provider: DynamicSecretProviders.Github,
title: "GitHub"
}
];
@@ -548,6 +554,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Github && (
<motion.div
key="dynamic-github-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<GithubInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>

View File

@@ -0,0 +1,234 @@
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
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<typeof formSchema>;
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<TForm>({
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 (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<FormControl
label={
<FormLabel
label="Default TTL"
icon={
<Tooltip content="Github token TTL is fixed to 1 hour">
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-px right-1"
/>
</Tooltip>
}
/>
}
>
<Input disabled value="1h" className="pointer-events-none opacity-50" />
</FormControl>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<Controller
control={control}
name="provider.appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="0000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.installationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Installation ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="00000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Private Key PEM"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -368,6 +368,22 @@ const renderOutputForm = (
);
}
if (provider === DynamicSecretProviders.Github) {
const { TOKEN } = data as {
TOKEN: string;
};
return (
<div>
<OutputDisplay
label="Token"
value={TOKEN}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};
@@ -608,6 +624,9 @@ export const CreateDynamicSecretLease = ({
return <Spinner className="mx-auto h-40 text-mineshaft-700" />;
}
// Github tokens are fixed to 1 hour
const fixedTtl = provider === DynamicSecretProviders.Github;
return (
<div>
<AnimatePresence>
@@ -629,8 +648,11 @@ export const CreateDynamicSecretLease = ({
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
fixedTtl ? `This provider has a fixed TTL of ${field.value}` : undefined
}
>
<Input {...field} />
<Input {...field} isDisabled={fixedTtl} />
</FormControl>
)}
/>

View File

@@ -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 (
<div>
<TableContainer>
@@ -141,29 +145,31 @@ export const DynamicSecretLease = ({
</Td>
<Td>
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faRepeat} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
{canRenew && (
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
{(isAllowed) => (
<IconButton
ariaLabel="renew-lease"
variant="plain"
size="sm"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faRepeat} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {

View File

@@ -10,6 +10,7 @@ import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntra
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm";
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
@@ -366,6 +367,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Github && (
<motion.div
key="github-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretGithubForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,199 @@
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
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<typeof formSchema>;
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<TForm>({
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 (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<FormControl
label={
<FormLabel
label="Default TTL"
icon={
<Tooltip content="Github token TTL is fixed to 1 hour">
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-px right-1"
/>
</Tooltip>
}
/>
}
>
<Input disabled value="1h" className="pointer-events-none opacity-50" />
</FormControl>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<Controller
control={control}
name="inputs.appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="0000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.installationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Installation ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="00000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Private Key PEM"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -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}`