Merge remote-tracking branch 'origin/main' into feat/add-key-usages-for-template-and-cert
@@ -17,7 +17,7 @@ const generateUsername = () => {
|
||||
return alphaNumericNanoId(32);
|
||||
};
|
||||
|
||||
export const ElasticSearchDatabaseProvider = (): TDynamicProviderFns => {
|
||||
export const ElasticSearchProvider = (): TDynamicProviderFns => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const appCfg = getConfig();
|
||||
const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
|
||||
import { AwsIamProvider } from "./aws-iam";
|
||||
import { CassandraProvider } from "./cassandra";
|
||||
import { ElasticSearchDatabaseProvider } from "./elastic-search";
|
||||
import { ElasticSearchProvider } from "./elastic-search";
|
||||
import { DynamicSecretProviders } from "./models";
|
||||
import { MongoAtlasProvider } from "./mongo-atlas";
|
||||
import { MongoDBProvider } from "./mongo-db";
|
||||
import { RabbitMqProvider } from "./rabbit-mq";
|
||||
import { RedisDatabaseProvider } from "./redis";
|
||||
import { SqlDatabaseProvider } from "./sql-database";
|
||||
|
||||
@@ -15,6 +16,7 @@ export const buildDynamicSecretProviders = () => ({
|
||||
[DynamicSecretProviders.Redis]: RedisDatabaseProvider(),
|
||||
[DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider(),
|
||||
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(),
|
||||
[DynamicSecretProviders.ElasticSearch]: ElasticSearchDatabaseProvider(),
|
||||
[DynamicSecretProviders.MongoDB]: MongoDBProvider()
|
||||
[DynamicSecretProviders.MongoDB]: MongoDBProvider(),
|
||||
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
|
||||
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider()
|
||||
});
|
||||
|
||||
@@ -56,6 +56,26 @@ export const DynamicSecretElasticSearchSchema = z.object({
|
||||
ca: z.string().optional()
|
||||
});
|
||||
|
||||
export const DynamicSecretRabbitMqSchema = z.object({
|
||||
host: z.string().trim().min(1),
|
||||
port: z.number(),
|
||||
tags: z.array(z.string().trim()).default([]),
|
||||
|
||||
username: z.string().trim().min(1),
|
||||
password: z.string().trim().min(1),
|
||||
|
||||
ca: z.string().optional(),
|
||||
|
||||
virtualHost: z.object({
|
||||
name: z.string().trim().min(1),
|
||||
permissions: z.object({
|
||||
read: z.string().trim().min(1),
|
||||
write: z.string().trim().min(1),
|
||||
configure: z.string().trim().min(1)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const DynamicSecretSqlDBSchema = z.object({
|
||||
client: z.nativeEnum(SqlProviders),
|
||||
host: z.string().trim().toLowerCase(),
|
||||
@@ -154,7 +174,8 @@ export enum DynamicSecretProviders {
|
||||
AwsElastiCache = "aws-elasticache",
|
||||
MongoAtlas = "mongo-db-atlas",
|
||||
ElasticSearch = "elastic-search",
|
||||
MongoDB = "mongo-db"
|
||||
MongoDB = "mongo-db",
|
||||
RabbitMq = "rabbit-mq"
|
||||
}
|
||||
|
||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
@@ -165,7 +186,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema })
|
||||
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema })
|
||||
]);
|
||||
|
||||
export type TDynamicProviderFns = {
|
||||
|
||||
172
backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import axios, { Axios } from "axios";
|
||||
import https from "https";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
const generateUsername = () => {
|
||||
return alphaNumericNanoId(32);
|
||||
};
|
||||
|
||||
type TCreateRabbitMQUser = {
|
||||
axiosInstance: Axios;
|
||||
createUser: {
|
||||
username: string;
|
||||
password: string;
|
||||
tags: string[];
|
||||
};
|
||||
virtualHost: {
|
||||
name: string;
|
||||
permissions: {
|
||||
read: string;
|
||||
write: string;
|
||||
configure: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type TDeleteRabbitMqUser = {
|
||||
axiosInstance: Axios;
|
||||
usernameToDelete: string;
|
||||
};
|
||||
|
||||
async function createRabbitMqUser({ axiosInstance, createUser, virtualHost }: TCreateRabbitMQUser): Promise<void> {
|
||||
try {
|
||||
// Create user
|
||||
const userUrl = `/users/${createUser.username}`;
|
||||
const userData = {
|
||||
password: createUser.password,
|
||||
tags: createUser.tags.join(",")
|
||||
};
|
||||
|
||||
await axiosInstance.put(userUrl, userData);
|
||||
|
||||
// Set permissions for the virtual host
|
||||
if (virtualHost) {
|
||||
const permissionData = {
|
||||
configure: virtualHost.permissions.configure,
|
||||
write: virtualHost.permissions.write,
|
||||
read: virtualHost.permissions.read
|
||||
};
|
||||
|
||||
await axiosInstance.put(
|
||||
`/permissions/${encodeURIComponent(virtualHost.name)}/${createUser.username}`,
|
||||
permissionData
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error creating RabbitMQ user");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRabbitMqUser) {
|
||||
await axiosInstance.delete(`users/${usernameToDelete}`);
|
||||
return { username: usernameToDelete };
|
||||
}
|
||||
|
||||
export const RabbitMqProvider = (): TDynamicProviderFns => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const appCfg = getConfig();
|
||||
const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not
|
||||
|
||||
const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs);
|
||||
if (
|
||||
isCloud &&
|
||||
// localhost
|
||||
// internal ips
|
||||
(providerInputs.host === "host.docker.internal" ||
|
||||
providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) ||
|
||||
providerInputs.host.match(/^192\.168\.\d+\.\d+/))
|
||||
) {
|
||||
throw new BadRequestError({ message: "Invalid db host" });
|
||||
}
|
||||
if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") {
|
||||
throw new BadRequestError({ message: "Invalid db host" });
|
||||
}
|
||||
|
||||
return providerInputs;
|
||||
};
|
||||
|
||||
const getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema>) => {
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: `${removeTrailingSlash(providerInputs.host)}:${providerInputs.port}/api`,
|
||||
auth: {
|
||||
username: providerInputs.username,
|
||||
password: providerInputs.password
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
|
||||
...(providerInputs.ca && {
|
||||
httpsAgent: new https.Agent({ ca: providerInputs.ca, rejectUnauthorized: false })
|
||||
})
|
||||
});
|
||||
|
||||
return axiosInstance;
|
||||
};
|
||||
|
||||
const validateConnection = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const connection = await getClient(providerInputs);
|
||||
|
||||
const infoResponse = await connection.get("/whoami").then(() => true);
|
||||
|
||||
return infoResponse;
|
||||
};
|
||||
|
||||
const create = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const connection = await getClient(providerInputs);
|
||||
|
||||
const username = generateUsername();
|
||||
const password = generatePassword();
|
||||
|
||||
await createRabbitMqUser({
|
||||
axiosInstance: connection,
|
||||
virtualHost: providerInputs.virtualHost,
|
||||
createUser: {
|
||||
password,
|
||||
username,
|
||||
tags: [...(providerInputs.tags ?? []), "infisical-user"]
|
||||
}
|
||||
});
|
||||
|
||||
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
|
||||
};
|
||||
|
||||
const revoke = async (inputs: unknown, entityId: string) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const connection = await getClient(providerInputs);
|
||||
|
||||
await deleteRabbitMqUser({ axiosInstance: connection, usernameToDelete: entityId });
|
||||
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
// Do nothing
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
return {
|
||||
validateProviderInputs,
|
||||
validateConnection,
|
||||
create,
|
||||
revoke,
|
||||
renew
|
||||
};
|
||||
};
|
||||
@@ -468,7 +468,7 @@ export const registerRoutes = async (
|
||||
projectMembershipDAL
|
||||
});
|
||||
|
||||
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL });
|
||||
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL });
|
||||
const passwordService = authPaswordServiceFactory({
|
||||
tokenService,
|
||||
smtpService,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
.describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm),
|
||||
requireTemplateForIssuance: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.default(false)
|
||||
.describe(CERTIFICATE_AUTHORITIES.CREATE.requireTemplateForIssuance)
|
||||
})
|
||||
.refine(
|
||||
|
||||
@@ -101,7 +101,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.caId !== undefined && data.certificateTemplateId === undefined) ||
|
||||
(data.caId === undefined && data.pkiCollectionId === undefined && data.certificateTemplateId !== undefined),
|
||||
(data.caId === undefined && data.certificateTemplateId !== undefined),
|
||||
{
|
||||
message: "Either CA ID or Certificate Template ID must be present, but not both",
|
||||
path: ["caId", "certificateTemplateId"]
|
||||
@@ -192,7 +192,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.caId !== undefined && data.certificateTemplateId === undefined) ||
|
||||
(data.caId === undefined && data.pkiCollectionId === undefined && data.certificateTemplateId !== undefined),
|
||||
(data.caId === undefined && data.certificateTemplateId !== undefined),
|
||||
{
|
||||
message: "Either CA ID or Certificate Template ID must be present, but not both",
|
||||
path: ["caId", "certificateTemplateId"]
|
||||
|
||||
@@ -42,7 +42,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
organizationId: z.string().trim()
|
||||
organizationId: z.string().trim(),
|
||||
userAgent: z.enum(["cli"]).optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -53,7 +54,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
|
||||
handler: async (req, res) => {
|
||||
const cfg = getConfig();
|
||||
const tokens = await server.services.login.selectOrganization({
|
||||
userAgent: req.headers["user-agent"],
|
||||
userAgent: req.body.userAgent ?? req.headers["user-agent"],
|
||||
authJwtToken: req.headers.authorization,
|
||||
organizationId: req.body.organizationId,
|
||||
ipAddress: req.realIp
|
||||
|
||||
@@ -12,7 +12,6 @@ import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/erro
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
|
||||
import { TTokenDALFactory } from "../auth-token/auth-token-dal";
|
||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
@@ -34,7 +33,6 @@ type TAuthLoginServiceFactoryDep = {
|
||||
orgDAL: TOrgDALFactory;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
smtpService: TSmtpService;
|
||||
tokenDAL: TTokenDALFactory;
|
||||
};
|
||||
|
||||
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
|
||||
@@ -42,8 +40,7 @@ export const authLoginServiceFactory = ({
|
||||
userDAL,
|
||||
tokenService,
|
||||
smtpService,
|
||||
orgDAL,
|
||||
tokenDAL
|
||||
orgDAL
|
||||
}: TAuthLoginServiceFactoryDep) => {
|
||||
/*
|
||||
* Private
|
||||
@@ -376,8 +373,6 @@ export const authLoginServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
await tokenDAL.incrementTokenSessionVersion(user.id, decodedToken.tokenVersionId);
|
||||
|
||||
const tokens = await generateUserTokens({
|
||||
authMethod: decodedToken.authMethod,
|
||||
user,
|
||||
|
||||
@@ -7,7 +7,7 @@ const isValidDate = (dateString: string) => {
|
||||
|
||||
export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" });
|
||||
|
||||
export const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/;
|
||||
export const hostnameRegex = /^(?!:\/\/)(\*\.)?([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/;
|
||||
export const validateAltNamesField = z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import picomatch from "picomatch";
|
||||
|
||||
export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) =>
|
||||
policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
||||
@@ -28,6 +28,7 @@ import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identit
|
||||
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
|
||||
import { TOrgBotDALFactory } from "../org/org-bot-dal";
|
||||
import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal";
|
||||
import { doesFieldValueMatchOidcPolicy } from "./identity-oidc-auth-fns";
|
||||
import {
|
||||
TAttachOidcAuthDTO,
|
||||
TGetOidcAuthDTO,
|
||||
@@ -123,7 +124,7 @@ export const identityOidcAuthServiceFactory = ({
|
||||
}) as Record<string, string>;
|
||||
|
||||
if (identityOidcAuth.boundSubject) {
|
||||
if (tokenData.sub !== identityOidcAuth.boundSubject) {
|
||||
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: OIDC subject not allowed."
|
||||
});
|
||||
@@ -131,7 +132,11 @@ export const identityOidcAuthServiceFactory = ({
|
||||
}
|
||||
|
||||
if (identityOidcAuth.boundAudiences) {
|
||||
if (!identityOidcAuth.boundAudiences.split(", ").includes(tokenData.aud)) {
|
||||
if (
|
||||
!identityOidcAuth.boundAudiences
|
||||
.split(", ")
|
||||
.some((policyValue) => doesFieldValueMatchOidcPolicy(tokenData.aud, policyValue))
|
||||
) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: OIDC audience not allowed."
|
||||
});
|
||||
@@ -142,7 +147,9 @@ export const identityOidcAuthServiceFactory = ({
|
||||
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
|
||||
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
|
||||
// handle both single and multi-valued claims
|
||||
if (!claimValue.split(", ").some((claimEntry) => tokenData[claimKey] === claimEntry)) {
|
||||
if (
|
||||
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(tokenData[claimKey], claimEntry))
|
||||
) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: OIDC claim not allowed."
|
||||
});
|
||||
|
||||
@@ -567,8 +567,8 @@ const syncSecretsAWSParameterStore = async ({
|
||||
});
|
||||
ssm.config.update(config);
|
||||
|
||||
const metadata = z.record(z.any()).parse(integration.metadata || {});
|
||||
const awsParameterStoreSecretsObj: Record<string, AWS.SSM.Parameter> = {};
|
||||
const metadata = IntegrationMetadataSchema.parse(integration.metadata);
|
||||
const awsParameterStoreSecretsObj: Record<string, AWS.SSM.Parameter & { KeyId?: string }> = {};
|
||||
logger.info(
|
||||
`getIntegrationSecrets: integration sync triggered for ssm with [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [shouldDisableDelete=${metadata.shouldDisableDelete}]`
|
||||
);
|
||||
@@ -598,18 +598,57 @@ const syncSecretsAWSParameterStore = async ({
|
||||
nextToken = parameters.NextToken;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`getIntegrationSecrets: all fetched keys from AWS SSM [projectId=${projectId}] [environment=${
|
||||
integration.environment.slug
|
||||
}] [secretPath=${integration.secretPath}] [awsParameterStoreSecretsObj=${Object.keys(
|
||||
awsParameterStoreSecretsObj
|
||||
).join(",")}]`
|
||||
);
|
||||
logger.info(
|
||||
`getIntegrationSecrets: all secrets from Infisical to send to AWS SSM [projectId=${projectId}] [environment=${
|
||||
integration.environment.slug
|
||||
}] [secretPath=${integration.secretPath}] [secrets=${Object.keys(secrets).join(",")}]`
|
||||
);
|
||||
let areParametersKmsKeysFetched = false;
|
||||
|
||||
if (metadata.kmsKeyId) {
|
||||
// we put this inside a try catch so that existing integrations without the ssm:DescribeParameters
|
||||
// AWS permission will not break
|
||||
try {
|
||||
let hasNextDescribePage = true;
|
||||
let describeNextToken: string | undefined;
|
||||
|
||||
while (hasNextDescribePage) {
|
||||
const parameters = await ssm
|
||||
.describeParameters({
|
||||
MaxResults: 10,
|
||||
NextToken: describeNextToken,
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "OneLevel",
|
||||
Values: [integration.path as string]
|
||||
}
|
||||
]
|
||||
})
|
||||
.promise();
|
||||
|
||||
if (parameters.Parameters) {
|
||||
parameters.Parameters.forEach((parameter) => {
|
||||
if (parameter.Name) {
|
||||
const secKey = parameter.Name.substring((integration.path as string).length);
|
||||
awsParameterStoreSecretsObj[secKey].KeyId = parameter.KeyId;
|
||||
}
|
||||
});
|
||||
}
|
||||
areParametersKmsKeysFetched = true;
|
||||
hasNextDescribePage = Boolean(parameters.NextToken);
|
||||
describeNextToken = parameters.NextToken;
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).code === "AccessDeniedException") {
|
||||
logger.error(
|
||||
`AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)`
|
||||
);
|
||||
}
|
||||
|
||||
response = {
|
||||
isSynced: false,
|
||||
syncMessage: (error as AWSError)?.message || "Error syncing with AWS Parameter Store"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Identify secrets to create
|
||||
// don't use Promise.all() and promise map here
|
||||
// it will cause rate limit
|
||||
@@ -620,7 +659,7 @@ const syncSecretsAWSParameterStore = async ({
|
||||
// -> create secret
|
||||
if (secrets[key].value) {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: create secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]`
|
||||
`getIntegrationSecrets: create secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}]`
|
||||
);
|
||||
await ssm
|
||||
.putParameter({
|
||||
@@ -648,7 +687,7 @@ const syncSecretsAWSParameterStore = async ({
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`getIntegrationSecrets: create secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]`
|
||||
`getIntegrationSecrets: create secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}]`
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((err as any).code === "AccessDeniedException") {
|
||||
@@ -667,16 +706,23 @@ const syncSecretsAWSParameterStore = async ({
|
||||
// case: secret exists in AWS parameter store
|
||||
} else {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: update secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]`
|
||||
`getIntegrationSecrets: update secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}]`
|
||||
);
|
||||
// -> update secret
|
||||
if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) {
|
||||
|
||||
const shouldUpdateKms =
|
||||
areParametersKmsKeysFetched &&
|
||||
Boolean(metadata.kmsKeyId) &&
|
||||
awsParameterStoreSecretsObj[key].KeyId !== metadata.kmsKeyId;
|
||||
|
||||
// we ensure that the KMS key configured in the integration is applied for ALL parameters on AWS
|
||||
if (shouldUpdateKms || awsParameterStoreSecretsObj[key].Value !== secrets[key].value) {
|
||||
await ssm
|
||||
.putParameter({
|
||||
Name: `${integration.path}${key}`,
|
||||
Type: "SecureString",
|
||||
Value: secrets[key].value,
|
||||
Overwrite: true
|
||||
Overwrite: true,
|
||||
...(metadata.kmsKeyId && { KeyId: metadata.kmsKeyId })
|
||||
})
|
||||
.promise();
|
||||
}
|
||||
@@ -698,7 +744,7 @@ const syncSecretsAWSParameterStore = async ({
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`getIntegrationSecrets: update secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]`
|
||||
`getIntegrationSecrets: update secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}]`
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((err as any).code === "AccessDeniedException") {
|
||||
@@ -728,11 +774,11 @@ const syncSecretsAWSParameterStore = async ({
|
||||
for (const key in awsParameterStoreSecretsObj) {
|
||||
if (Object.hasOwn(awsParameterStoreSecretsObj, key)) {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=2]`
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [step=2]`
|
||||
);
|
||||
if (!(key in secrets)) {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=3]`
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [step=3]`
|
||||
);
|
||||
// case:
|
||||
// -> delete secret
|
||||
@@ -742,7 +788,7 @@ const syncSecretsAWSParameterStore = async ({
|
||||
})
|
||||
.promise();
|
||||
logger.info(
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=4]`
|
||||
`getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [step=4]`
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@@ -4,22 +4,27 @@ Copyright (c) 2023 Infisical Inc.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/models"
|
||||
"github.com/Infisical/infisical-merge/packages/util"
|
||||
"github.com/fatih/color"
|
||||
"github.com/posthog/posthog-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var ErrManualSignalInterrupt = errors.New("signal: interrupt")
|
||||
var watcherWaitGroup = new(sync.WaitGroup)
|
||||
|
||||
// runCmd represents the run command
|
||||
var runCmd = &cobra.Command{
|
||||
Example: `
|
||||
@@ -77,11 +82,35 @@ var runCmd = &cobra.Command{
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
command, err := cmd.Flags().GetString("command")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
secretOverriding, err := cmd.Flags().GetBool("secret-overriding")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
watchMode, err := cmd.Flags().GetBool("watch")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
watchModeInterval, err := cmd.Flags().GetInt("watch-interval")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
// If the --watch flag has been set, the --watch-interval flag should also be set
|
||||
if watchMode && watchModeInterval < 5 {
|
||||
util.HandleError(fmt.Errorf("watch interval must be at least 5 seconds, you passed %d seconds", watchModeInterval))
|
||||
}
|
||||
|
||||
shouldExpandSecrets, err := cmd.Flags().GetBool("expand")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
@@ -116,108 +145,50 @@ var runCmd = &cobra.Command{
|
||||
Recursive: recursive,
|
||||
}
|
||||
|
||||
if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
|
||||
request.InfisicalToken = token.Token
|
||||
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
|
||||
request.UniversalAuthAccessToken = token.Token
|
||||
}
|
||||
|
||||
secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir)
|
||||
|
||||
injectableEnvironment, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, shouldExpandSecrets, token)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid")
|
||||
}
|
||||
|
||||
if secretOverriding {
|
||||
secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL)
|
||||
log.Debug().Msgf("injecting the following environment variables into shell: %v", injectableEnvironment.Variables)
|
||||
|
||||
if watchMode {
|
||||
executeCommandWithWatchMode(command, args, watchModeInterval, request, projectConfigDir, shouldExpandSecrets, secretOverriding, token)
|
||||
} else {
|
||||
secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED)
|
||||
}
|
||||
if cmd.Flags().Changed("command") {
|
||||
command := cmd.Flag("command").Value.String()
|
||||
err = executeMultipleCommandWithEnvs(command, injectableEnvironment.SecretsCount, injectableEnvironment.Variables)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if shouldExpandSecrets {
|
||||
|
||||
authParams := models.ExpandSecretsAuthentication{}
|
||||
|
||||
if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
|
||||
authParams.InfisicalToken = token.Token
|
||||
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
|
||||
authParams.UniversalAuthAccessToken = token.Token
|
||||
}
|
||||
|
||||
secrets = util.ExpandSecrets(secrets, authParams, projectConfigDir)
|
||||
}
|
||||
|
||||
secretsByKey := getSecretsByKeys(secrets)
|
||||
environmentVariables := make(map[string]string)
|
||||
|
||||
// add all existing environment vars
|
||||
for _, s := range os.Environ() {
|
||||
kv := strings.SplitN(s, "=", 2)
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
environmentVariables[key] = value
|
||||
}
|
||||
|
||||
// check to see if there are any reserved key words in secrets to inject
|
||||
filterReservedEnvVars(secretsByKey)
|
||||
|
||||
// now add infisical secrets
|
||||
for k, v := range secretsByKey {
|
||||
environmentVariables[k] = v.Value
|
||||
}
|
||||
|
||||
// turn it back into a list of envs
|
||||
var env []string
|
||||
for key, value := range environmentVariables {
|
||||
s := key + "=" + value
|
||||
env = append(env, s)
|
||||
}
|
||||
|
||||
log.Debug().Msgf("injecting the following environment variables into shell: %v", env)
|
||||
|
||||
Telemetry.CaptureEvent("cli-command:run",
|
||||
posthog.NewProperties().
|
||||
Set("secretsCount", len(secrets)).
|
||||
Set("environment", environmentName).
|
||||
Set("isUsingServiceToken", token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER).
|
||||
Set("isUsingUniversalAuthToken", token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER).
|
||||
Set("single-command", strings.Join(args, " ")).
|
||||
Set("multi-command", cmd.Flag("command").Value.String()).
|
||||
Set("version", util.CLI_VERSION))
|
||||
|
||||
if cmd.Flags().Changed("command") {
|
||||
command := cmd.Flag("command").Value.String()
|
||||
|
||||
err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
} else {
|
||||
err = executeSingleCommandWithEnvs(args, len(secretsByKey), env)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
err = executeSingleCommandWithEnvs(args, injectableEnvironment.SecretsCount, injectableEnvironment.Variables)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
reservedEnvVars = []string{
|
||||
"HOME", "PATH", "PS1", "PS2",
|
||||
"PWD", "EDITOR", "XAUTHORITY", "USER",
|
||||
"TERM", "TERMINFO", "SHELL", "MAIL",
|
||||
}
|
||||
|
||||
reservedEnvVarPrefixes = []string{
|
||||
"XDG_",
|
||||
"LC_",
|
||||
}
|
||||
)
|
||||
|
||||
func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) {
|
||||
var (
|
||||
reservedEnvVars = []string{
|
||||
"HOME", "PATH", "PS1", "PS2",
|
||||
"PWD", "EDITOR", "XAUTHORITY", "USER",
|
||||
"TERM", "TERMINFO", "SHELL", "MAIL",
|
||||
}
|
||||
|
||||
reservedEnvVarPrefixes = []string{
|
||||
"XDG_",
|
||||
"LC_",
|
||||
}
|
||||
)
|
||||
|
||||
for _, reservedEnvName := range reservedEnvVars {
|
||||
if _, ok := env[reservedEnvName]; ok {
|
||||
delete(env, reservedEnvName)
|
||||
@@ -237,13 +208,15 @@ func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) {
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(runCmd)
|
||||
runCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token")
|
||||
runCmd.Flags().String("token", "", "fetch secrets using service token or machine identity access token")
|
||||
runCmd.Flags().String("projectId", "", "manually set the project ID to fetch secrets from when using machine identity based auth")
|
||||
runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from")
|
||||
runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets")
|
||||
runCmd.Flags().Bool("include-imports", true, "Import linked secrets ")
|
||||
runCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders")
|
||||
runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets")
|
||||
runCmd.Flags().StringP("env", "e", "dev", "set the environment (dev, prod, etc.) from which your secrets should be pulled from")
|
||||
runCmd.Flags().Bool("expand", true, "parse shell parameter expansions in your secrets")
|
||||
runCmd.Flags().Bool("include-imports", true, "import linked secrets ")
|
||||
runCmd.Flags().Bool("recursive", false, "fetch secrets from all sub-folders")
|
||||
runCmd.Flags().Bool("secret-overriding", true, "prioritizes personal secrets, if any, with the same name over shared secrets")
|
||||
runCmd.Flags().Bool("watch", false, "enable reload of application when secrets change")
|
||||
runCmd.Flags().Int("watch-interval", 10, "interval in seconds to check for secret changes")
|
||||
runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")")
|
||||
runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ")
|
||||
runCmd.Flags().String("path", "/", "get secrets within a folder path")
|
||||
@@ -263,7 +236,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = env
|
||||
|
||||
return execCmd(cmd)
|
||||
return execBasicCmd(cmd)
|
||||
}
|
||||
|
||||
func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string) error {
|
||||
@@ -286,11 +259,10 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []
|
||||
log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount))
|
||||
log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand)
|
||||
|
||||
return execCmd(cmd)
|
||||
return execBasicCmd(cmd)
|
||||
}
|
||||
|
||||
// Credit: inspired by AWS Valut
|
||||
func execCmd(cmd *exec.Cmd) error {
|
||||
func execBasicCmd(cmd *exec.Cmd) error {
|
||||
sigChannel := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChannel)
|
||||
|
||||
@@ -314,3 +286,217 @@ func execCmd(cmd *exec.Cmd) error {
|
||||
os.Exit(waitStatus.ExitStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForExitCommand(cmd *exec.Cmd) (int, error) {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
// ignore errors
|
||||
cmd.Process.Signal(os.Kill) // #nosec G104
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
return exitError.ExitCode(), exitError
|
||||
}
|
||||
|
||||
return 2, err
|
||||
}
|
||||
|
||||
waitStatus, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return 2, fmt.Errorf("unexpected ProcessState type, expected syscall.WaitStatus, got %T", waitStatus)
|
||||
}
|
||||
return waitStatus.ExitStatus(), nil
|
||||
}
|
||||
|
||||
func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInterval int, request models.GetAllSecretsParameters, projectConfigDir string, expandSecrets bool, secretOverriding bool, token *models.TokenDetails) {
|
||||
|
||||
var cmd *exec.Cmd
|
||||
var err error
|
||||
var lastSecretsFetch time.Time
|
||||
var lastUpdateEvent time.Time
|
||||
var watchMutex sync.Mutex
|
||||
var processMutex sync.Mutex
|
||||
var beingTerminated = false
|
||||
var currentETag string
|
||||
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to fetch secrets")
|
||||
}
|
||||
|
||||
runCommandWithWatcher := func(environmentVariables models.InjectableEnvironmentResult) {
|
||||
currentETag = environmentVariables.ETag
|
||||
secretsFetchedAt := time.Now()
|
||||
if secretsFetchedAt.After(lastSecretsFetch) {
|
||||
lastSecretsFetch = secretsFetchedAt
|
||||
}
|
||||
|
||||
shouldRestartProcess := cmd != nil
|
||||
// terminate the old process before starting a new one
|
||||
if shouldRestartProcess {
|
||||
log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Environment changes detected. Reloading process..."))
|
||||
beingTerminated = true
|
||||
|
||||
log.Debug().Msgf(color.HiMagentaString("[HOT RELOAD] Sending SIGTERM to PID %d", cmd.Process.Pid))
|
||||
if e := cmd.Process.Signal(syscall.SIGTERM); e != nil {
|
||||
log.Error().Err(e).Msg(color.HiMagentaString("[HOT RELOAD] Failed to send SIGTERM"))
|
||||
}
|
||||
// wait up to 10 sec for the process to exit
|
||||
for i := 0; i < 10; i++ {
|
||||
if !util.IsProcessRunning(cmd.Process) {
|
||||
// process has been killed so we break out
|
||||
break
|
||||
}
|
||||
if i == 5 {
|
||||
log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Still waiting for process exit status"))
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
// SIGTERM may not work on Windows so we try SIGKILL
|
||||
if util.IsProcessRunning(cmd.Process) {
|
||||
log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process still hasn't fully exited, attempting SIGKILL"))
|
||||
if e := cmd.Process.Kill(); e != nil {
|
||||
log.Error().Err(e).Msg(color.HiMagentaString("[HOT RELOAD] Failed to send SIGKILL"))
|
||||
}
|
||||
}
|
||||
|
||||
cmd = nil
|
||||
} else {
|
||||
// If `cmd` is nil, we know this is the first time we are starting the process
|
||||
log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Watching for secret changes..."))
|
||||
}
|
||||
|
||||
processMutex.Lock()
|
||||
|
||||
if lastUpdateEvent.After(secretsFetchedAt) {
|
||||
processMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
beingTerminated = false
|
||||
watcherWaitGroup.Add(1)
|
||||
|
||||
// start the process
|
||||
log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", environmentVariables.SecretsCount))
|
||||
|
||||
cmd, err = util.RunCommand(commandFlag, args, environmentVariables.Variables, false)
|
||||
if err != nil {
|
||||
defer watcherWaitGroup.Done()
|
||||
util.HandleError(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer processMutex.Unlock()
|
||||
defer watcherWaitGroup.Done()
|
||||
|
||||
exitCode, err := waitForExitCommand(cmd)
|
||||
|
||||
// ignore errors if we are being terminated
|
||||
if !beingTerminated {
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "exec") || strings.HasPrefix(err.Error(), "fork/exec") {
|
||||
log.Error().Err(err).Msg("Failed to execute command")
|
||||
}
|
||||
if err.Error() != ErrManualSignalInterrupt.Error() {
|
||||
log.Error().Err(err).Msg("Process exited with error")
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
recheckSecretsChannel := make(chan bool, 1)
|
||||
recheckSecretsChannel <- true
|
||||
|
||||
// a simple goroutine that triggers the recheckSecretsChan every watch interval (defaults to 10 seconds)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Duration(watchModeInterval) * time.Second)
|
||||
recheckSecretsChannel <- true
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
<-recheckSecretsChannel
|
||||
watchMutex.Lock()
|
||||
|
||||
newEnvironmentVariables, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, expandSecrets, token)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[HOT RELOAD] Failed to fetch secrets")
|
||||
continue
|
||||
}
|
||||
|
||||
if newEnvironmentVariables.ETag != currentETag {
|
||||
runCommandWithWatcher(newEnvironmentVariables)
|
||||
} else {
|
||||
log.Debug().Msg("[HOT RELOAD] No changes detected in secrets, not reloading process")
|
||||
}
|
||||
|
||||
watchMutex.Unlock()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAndFormatSecretsForShell(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, shouldExpandSecrets bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) {
|
||||
|
||||
if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
|
||||
request.InfisicalToken = token.Token
|
||||
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
|
||||
request.UniversalAuthAccessToken = token.Token
|
||||
}
|
||||
|
||||
secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir)
|
||||
|
||||
if err != nil {
|
||||
return models.InjectableEnvironmentResult{}, err
|
||||
}
|
||||
|
||||
if secretOverriding {
|
||||
secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL)
|
||||
} else {
|
||||
secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED)
|
||||
}
|
||||
|
||||
if shouldExpandSecrets {
|
||||
|
||||
authParams := models.ExpandSecretsAuthentication{}
|
||||
|
||||
if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
|
||||
authParams.InfisicalToken = token.Token
|
||||
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
|
||||
authParams.UniversalAuthAccessToken = token.Token
|
||||
}
|
||||
|
||||
secrets = util.ExpandSecrets(secrets, authParams, projectConfigDir)
|
||||
}
|
||||
|
||||
secretsByKey := getSecretsByKeys(secrets)
|
||||
environmentVariables := make(map[string]string)
|
||||
|
||||
// add all existing environment vars
|
||||
for _, s := range os.Environ() {
|
||||
kv := strings.SplitN(s, "=", 2)
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
environmentVariables[key] = value
|
||||
}
|
||||
|
||||
// check to see if there are any reserved key words in secrets to inject
|
||||
filterReservedEnvVars(secretsByKey)
|
||||
|
||||
// now add infisical secrets
|
||||
for k, v := range secretsByKey {
|
||||
environmentVariables[k] = v.Value
|
||||
}
|
||||
|
||||
env := make([]string, 0, len(environmentVariables))
|
||||
for key, value := range environmentVariables {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
|
||||
return models.InjectableEnvironmentResult{
|
||||
Variables: env,
|
||||
ETag: util.GenerateETagFromSecrets(secrets),
|
||||
SecretsCount: len(secretsByKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -104,6 +104,12 @@ type GetAllSecretsParameters struct {
|
||||
Recursive bool
|
||||
}
|
||||
|
||||
type InjectableEnvironmentResult struct {
|
||||
Variables []string
|
||||
ETag string
|
||||
SecretsCount int
|
||||
}
|
||||
|
||||
type GetAllFoldersParameters struct {
|
||||
WorkspaceId string
|
||||
Environment string
|
||||
|
||||
92
cli/packages/util/exec.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func RunCommand(singleCommand string, args []string, env []string, waitForExit bool) (*exec.Cmd, error) {
|
||||
var c *exec.Cmd
|
||||
var err error
|
||||
|
||||
if singleCommand != "" {
|
||||
c, err = RunCommandFromString(singleCommand, env, waitForExit)
|
||||
} else {
|
||||
c, err = RunCommandFromArgs(args, env, waitForExit)
|
||||
}
|
||||
|
||||
return c, err
|
||||
}
|
||||
|
||||
func IsProcessRunning(p *os.Process) bool {
|
||||
err := p.Signal(syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// For "infisical run -- COMMAND"
|
||||
func RunCommandFromArgs(args []string, env []string, waitForExit bool) (*exec.Cmd, error) {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = env
|
||||
|
||||
err := execCommand(cmd, waitForExit)
|
||||
|
||||
return cmd, err
|
||||
}
|
||||
|
||||
func execCommand(cmd *exec.Cmd, waitForExit bool) error {
|
||||
sigChannel := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChannel)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
sig := <-sigChannel
|
||||
_ = cmd.Process.Signal(sig) // process all sigs
|
||||
}
|
||||
}()
|
||||
|
||||
if !waitForExit {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
_ = cmd.Process.Signal(os.Kill)
|
||||
return fmt.Errorf("failed to wait for command termination: %v", err)
|
||||
}
|
||||
|
||||
waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus)
|
||||
os.Exit(waitStatus.ExitStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
// For "infisical run --command=COMMAND"
|
||||
func RunCommandFromString(command string, env []string, waitForExit bool) (*exec.Cmd, error) {
|
||||
shell := [2]string{"sh", "-c"}
|
||||
if runtime.GOOS == "windows" {
|
||||
shell = [2]string{"cmd", "/C"}
|
||||
} else {
|
||||
currentShell := os.Getenv("SHELL")
|
||||
if currentShell != "" {
|
||||
shell[0] = currentShell
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell[0], shell[1], command) // #nosec G204 nosemgrep: semgrep_configs.prohibit-exec-command
|
||||
cmd.Env = env
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err := execCommand(cmd, waitForExit)
|
||||
return cmd, err
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
@@ -298,3 +299,16 @@ func GenerateRandomString(length int) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func GenerateETagFromSecrets(secrets []models.SingleEnvironmentVariable) string {
|
||||
sortedSecrets := SortSecretsByKeys(secrets)
|
||||
content := []byte{}
|
||||
|
||||
for _, secret := range sortedSecrets {
|
||||
content = append(content, []byte(secret.Key)...)
|
||||
content = append(content, []byte(secret.Value)...)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(content)
|
||||
return fmt.Sprintf(`"%s"`, hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
@@ -47,20 +47,20 @@ $ infisical run -- npm run dev
|
||||
Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=<identity-client-id> --client-secret=<identity-client-secret> --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages.
|
||||
# Example
|
||||
export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=<identity-client-id> --client-secret=<identity-client-secret> --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages.
|
||||
```
|
||||
|
||||
<Info>
|
||||
Alternatively, you may use service tokens.
|
||||
|
||||
Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys).
|
||||
|
||||
```bash
|
||||
# Example
|
||||
export INFISICAL_TOKEN=<service-token>
|
||||
# Example
|
||||
export INFISICAL_TOKEN=<service-token>
|
||||
```
|
||||
|
||||
</Info>
|
||||
</Info>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="INFISICAL_DISABLE_UPDATE_CHECK">
|
||||
@@ -69,22 +69,30 @@ $ infisical run -- npm run dev
|
||||
To use, simply export this variable in the terminal before running this command.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
export INFISICAL_DISABLE_UPDATE_CHECK=true
|
||||
# Example
|
||||
export INFISICAL_DISABLE_UPDATE_CHECK=true
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Flags
|
||||
|
||||
<Accordion title="--project-config-dir">
|
||||
<Accordion title="--watch">
|
||||
By passing the `watch` flag, you are telling the CLI to watch for changes that happen in your Infisical project.
|
||||
If secret changes happen, the command you provided will automatically be restarted with the new environment variables attached.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
infisical run --watch -- printenv
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--project-config-dir">
|
||||
Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
infisical run --project-config-dir=/some-dir -- printenv
|
||||
# Example
|
||||
infisical run --project-config-dir=/some-dir -- printenv
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--command">
|
||||
@@ -172,3 +180,19 @@ $ infisical run -- npm run dev
|
||||
</Accordion>
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
## Automatically reload command when secrets change
|
||||
|
||||
To automatically reload your command when secrets change, use the `--watch` flag.
|
||||
|
||||
```bash
|
||||
infisical run --watch -- npm run dev
|
||||
```
|
||||
|
||||
This will watch for changes in your secrets and automatically restart your command with the new secrets.
|
||||
When your command restarts, it will have the new environment variables injeceted into it.
|
||||
|
||||
<Note>
|
||||
Please note that this feature is intended for development purposes. It is not recommended to use this in production environments. Generally it's not recommended to automatically reload your application in production when remote changes are made.
|
||||
</Note>
|
||||
@@ -58,7 +58,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa
|
||||
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">
|
||||

|
||||

|
||||
</Step>
|
||||
<Step title="Select 'AWS ElastiCache'">
|
||||

|
||||
@@ -116,7 +116,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa
|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
@@ -125,7 +125,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -133,11 +133,11 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa
|
||||
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 see the expiration time of the lease or delete a lease before it's set time to live.
|
||||
|
||||

|
||||

|
||||
|
||||
## Renew Leases
|
||||
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
|
||||

|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
|
||||
@@ -23,7 +23,7 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch
|
||||
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">
|
||||

|
||||

|
||||
</Step>
|
||||
<Step title="Select 'Elasticsearch'">
|
||||

|
||||
@@ -99,7 +99,7 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch
|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
@@ -108,7 +108,7 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -116,11 +116,11 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch
|
||||
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 see the expiration time of the lease or delete a lease before it's set time to live.
|
||||
|
||||

|
||||

|
||||
|
||||
## Renew Leases
|
||||
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
|
||||

|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
|
||||
116
docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: "RabbitMQ"
|
||||
description: "Learn how to dynamically generate RabbitMQ user credentials."
|
||||
---
|
||||
|
||||
The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credentials on demand based on configured role.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Ensure that the `management` plugin is enabled on your RabbitMQ instance. This is required for the dynamic secret to work.
|
||||
|
||||
|
||||
## Set up Dynamic Secrets with RabbitMQ
|
||||
|
||||
<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">
|
||||

|
||||
</Step>
|
||||
<Step title="Select 'RabbitMQ'">
|
||||

|
||||
</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="Default TTL" type="string" required>
|
||||
Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Max TTL" type="string" required>
|
||||
Maximum time-to-live for a generated secret.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Host" type="string" required>
|
||||
Your RabbitMQ host. This must be in HTTP format. _(Example: http://your-cluster-ip)_
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Port" type="string" required>
|
||||
The port that the RabbitMQ management plugin is listening on. This is `15672` by default.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Virtual host name" type="string" required>
|
||||
The name of the virtual host that the user will be assigned to. This defaults to `/`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Virtual host permissions (Read/Write/Configure)" type="string" required>
|
||||
The permissions that the user will have on the virtual host. This defaults to `.*`.
|
||||
|
||||
The three permission fields all take a regular expression _(regex)_, that should match resource names for which the user is granted read / write / configuration permissions
|
||||
</ParamField>
|
||||
|
||||
|
||||
<ParamField path="Username" type="string" required>
|
||||
The username of the user that will be used to provision new dynamic secret leases.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Password" type="string" required>
|
||||
The password of the user that will be used to provision new dynamic secret leases.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="CA(SSL)" type="string">
|
||||
A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service.
|
||||
</ParamField>
|
||||
|
||||

|
||||
|
||||
|
||||
</Step>
|
||||
<Step title="Click `Submit`">
|
||||
After submitting the form, you will see a dynamic secret created in the dashboard.
|
||||
|
||||
<Note>
|
||||
If this step fails, you may have to add the CA certificate.
|
||||
</Note>
|
||||
|
||||
</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.
|
||||
|
||||

|
||||

|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
</Tip>
|
||||
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
|
||||
|
||||

|
||||
</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 see the expiration time of the lease or delete a lease before it's set time to live.
|
||||
|
||||

|
||||
|
||||
## Renew Leases
|
||||
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
</Warning>
|
||||
@@ -16,7 +16,7 @@ Create a user with the required permission in your Redis instance. This user wil
|
||||
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">
|
||||

|
||||

|
||||
</Step>
|
||||
<Step title="Select 'Redis'">
|
||||

|
||||
@@ -78,7 +78,7 @@ Create a user with the required permission in your Redis instance. This user wil
|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
@@ -87,7 +87,7 @@ Create a user with the required permission in your Redis instance. This user wil
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -95,11 +95,11 @@ Create a user with the required permission in your Redis instance. This user wil
|
||||
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 see the expiration time of the lease or delete a lease before it's set time to live.
|
||||
|
||||

|
||||

|
||||
|
||||
## Renew Leases
|
||||
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
|
||||

|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
|
||||
@@ -93,7 +93,12 @@ In the following steps, we explore how to create and use identities to access th
|
||||
- Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time.
|
||||
- Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses.
|
||||
- Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address.
|
||||
<Info>
|
||||
The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible.
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Adding an identity to a project">
|
||||
To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project.
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ In the following steps, we explore how to create and use identities to access th
|
||||
- Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time.
|
||||
- Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses.
|
||||
- Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address.
|
||||
|
||||
<Tip>If you are unsure about what to configure for the subject, audience, and claims fields you can use [github/actions-oidc-debugger](https://github.com/github/actions-oidc-debugger) to get the appropriate values. Alternatively, you can fetch the JWT from the workflow and inspect the fields manually.</Tip>
|
||||
<Info>The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible.</Info>
|
||||
</Step>
|
||||
<Step title="Adding an identity to a project">
|
||||
To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project.
|
||||
|
||||
@@ -62,7 +62,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO."
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Retrieve Identity Provider (IdP) Information from Okta">
|
||||
<Step title="Retrieve Identity Provider (IdP) Information from Azure">
|
||||
In the **Set up Single Sign-On with SAML** screen, copy the **Login URL** and **SAML Certificate** to use when finishing configuring Azure SAML in Infisical.
|
||||
|
||||

|
||||
@@ -115,4 +115,4 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO."
|
||||
|
||||
- `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`.
|
||||
- `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com)
|
||||
</Note>
|
||||
</Note>
|
||||
|
||||
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 388 KiB |
@@ -30,6 +30,7 @@ Prerequisites:
|
||||
"ssm:DeleteParameter",
|
||||
"ssm:GetParameters",
|
||||
"ssm:GetParametersByPath",
|
||||
"ssm:DescribeParameters",
|
||||
"ssm:DeleteParameters",
|
||||
"ssm:AddTagsToResource", // if you need to add tags to secrets
|
||||
"kms:ListKeys", // if you need to specify the KMS key
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
"documentation/platform/dynamic-secrets/redis",
|
||||
"documentation/platform/dynamic-secrets/aws-elasticache",
|
||||
"documentation/platform/dynamic-secrets/elastic-search",
|
||||
"documentation/platform/dynamic-secrets/rabbit-mq",
|
||||
"documentation/platform/dynamic-secrets/aws-iam",
|
||||
"documentation/platform/dynamic-secrets/mongo-atlas",
|
||||
"documentation/platform/dynamic-secrets/mongo-db"
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SRP1DTO,
|
||||
SRPR1Res,
|
||||
TOauthTokenExchangeDTO,
|
||||
UserAgentType,
|
||||
VerifyMfaTokenDTO,
|
||||
VerifyMfaTokenRes,
|
||||
VerifySignupInviteDTO
|
||||
@@ -60,7 +61,10 @@ export const useLogin1 = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const selectOrganization = async (data: { organizationId: string }) => {
|
||||
export const selectOrganization = async (data: {
|
||||
organizationId: string;
|
||||
userAgent?: UserAgentType;
|
||||
}) => {
|
||||
const { data: res } = await apiRequest.post<{ token: string }>(
|
||||
"/api/v3/auth/select-organization",
|
||||
data
|
||||
@@ -71,11 +75,14 @@ export const selectOrganization = async (data: { organizationId: string }) => {
|
||||
export const useSelectOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (details: { organizationId: string }) => {
|
||||
mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => {
|
||||
const data = await selectOrganization(details);
|
||||
|
||||
SecurityClient.setToken(data.token);
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
// If a custom user agent is set, then this session is meant for another consuming application, not the web application.
|
||||
if (!details.userAgent) {
|
||||
SecurityClient.setToken(data.token);
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -145,3 +145,7 @@ export type IssueBackupPrivateKeyDTO = {
|
||||
export type GetBackupEncryptedPrivateKeyDTO = {
|
||||
verificationToken: string;
|
||||
};
|
||||
|
||||
export enum UserAgentType {
|
||||
CLI = "cli"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ export enum DynamicSecretProviders {
|
||||
AwsElastiCache = "aws-elasticache",
|
||||
MongoAtlas = "mongo-db-atlas",
|
||||
ElasticSearch = "elastic-search",
|
||||
MongoDB = "mongo-db"
|
||||
MongoDB = "mongo-db",
|
||||
RabbitMq = "rabbit-mq"
|
||||
}
|
||||
|
||||
export enum SqlProviders {
|
||||
@@ -155,6 +156,27 @@ export type TDynamicSecretProvider =
|
||||
apiKeyId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DynamicSecretProviders.RabbitMq;
|
||||
inputs: {
|
||||
host: string;
|
||||
port: number;
|
||||
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
tags: string[];
|
||||
virtualHost: {
|
||||
name: string;
|
||||
permissions: {
|
||||
configure: string;
|
||||
write: string;
|
||||
read: string;
|
||||
};
|
||||
};
|
||||
ca?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCreateDynamicSecretDTO = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { useUser } from "@app/context";
|
||||
import { useGetOrganizations, useLogoutUser, useSelectOrganization } from "@app/hooks/api";
|
||||
import { UserAgentType } from "@app/hooks/api/auth/types";
|
||||
import { Organization } from "@app/hooks/api/types";
|
||||
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
|
||||
import { navigateUserToOrg } from "@app/views/Login/Login.utils";
|
||||
@@ -68,7 +69,10 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { token } = await selectOrg.mutateAsync({ organizationId: organization.id });
|
||||
const { token } = await selectOrg.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
userAgent: callbackPort ? UserAgentType.CLI : undefined
|
||||
});
|
||||
|
||||
if (callbackPort) {
|
||||
const privateKey = localStorage.getItem("PRIVATE_KEY");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus, faXmark } 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, IconButton, Input, TextArea } from "@app/components/v2";
|
||||
import { Button, FormControl, IconButton, Input, TextArea, Tooltip } from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { useAddIdentityOidcAuth, useUpdateIdentityOidcAuth } from "@app/hooks/api";
|
||||
import { IdentityAuthMethod } from "@app/hooks/api/identities";
|
||||
@@ -258,7 +259,19 @@ export const IdentityOidcAuthForm = ({
|
||||
control={control}
|
||||
name="boundSubject"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Subject" isError={Boolean(error)} errorText={error?.message}>
|
||||
<FormControl
|
||||
label="Subject"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input {...field} type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -267,7 +280,19 @@ export const IdentityOidcAuthForm = ({
|
||||
control={control}
|
||||
name="boundAudiences"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Audiences" isError={Boolean(error)} errorText={error?.message}>
|
||||
<FormControl
|
||||
label="Audiences"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input {...field} type="text" placeholder="service1, service2" />
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -282,6 +307,16 @@ export const IdentityOidcAuthForm = ({
|
||||
<FormControl
|
||||
className="mb-0 flex-grow"
|
||||
label={index === 0 ? "Claims" : undefined}
|
||||
icon={
|
||||
index === 0 ? (
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Edit Identity">
|
||||
<Tooltip content="Edit CA">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="copy icon"
|
||||
|
||||
@@ -446,26 +446,24 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* {!ca && ( */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.ca?.data ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("ca", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/* )} */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.ca?.data ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("ca", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -59,25 +59,6 @@ export const CertificateTemplatesSection = ({ caId }: Props) => {
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
{/* <div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Certificate Templates</p>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.CertificateTemplates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("certificateTemplate")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div> */}
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Certificate Templates</h3>
|
||||
<ProjectPermissionCan
|
||||
|
||||
@@ -47,10 +47,6 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data, isLoading } = useGetCaCertTemplates(caId);
|
||||
|
||||
// const { data, isLoading } = useListWorkspaceCertificateTemplates({
|
||||
// workspaceId: currentWorkspace?.id ?? ""
|
||||
// });
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -59,7 +55,6 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{/* <Th>Certificate Authority</Th> */}
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
@@ -70,7 +65,6 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`certificate-${certificateTemplate.id}`}>
|
||||
<Td>{certificateTemplate.name}</Td>
|
||||
{/* <Td>{certificateTemplate.caName}</Td> */}
|
||||
<Td className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { DiRedis } from "react-icons/di";
|
||||
import { SiApachecassandra, SiElasticsearch, SiMongodb } from "react-icons/si";
|
||||
import { SiApachecassandra, SiElasticsearch, SiMongodb, SiRabbitmq } from "react-icons/si";
|
||||
import { faAws } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -15,6 +15,7 @@ import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
|
||||
import { MongoDBDatabaseInputForm } from "./MongoDBInputForm";
|
||||
import { RabbitMqInputForm } from "./RabbitMqInputForm";
|
||||
import { RedisInputForm } from "./RedisInputForm";
|
||||
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
|
||||
|
||||
@@ -71,6 +72,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <SiElasticsearch size="2rem" />,
|
||||
provider: DynamicSecretProviders.ElasticSearch,
|
||||
title: "Elastic Search"
|
||||
},
|
||||
{
|
||||
icon: <SiRabbitmq size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.RabbitMq,
|
||||
title: "RabbitMQ"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -276,6 +282,24 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.RabbitMq && (
|
||||
<motion.div
|
||||
key="dynamic-rabbit-mq-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<RabbitMqInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Link from "next/link";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } from "@app/components/v2";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
host: z.string().trim().min(1),
|
||||
port: z.coerce.number(), // important: this is the management plugin port
|
||||
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
|
||||
tags: z.array(z.string().trim()),
|
||||
virtualHost: z.object({
|
||||
name: z.string().trim().min(1),
|
||||
permissions: z.object({
|
||||
read: z.string().trim().min(1),
|
||||
write: z.string().trim().min(1),
|
||||
configure: z.string().trim().min(1)
|
||||
})
|
||||
}),
|
||||
ca: z.string().optional()
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const RabbitMqInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
environment,
|
||||
secretPath,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
provider: {
|
||||
port: 15672,
|
||||
virtualHost: {
|
||||
name: "/",
|
||||
permissions: {
|
||||
read: ".*",
|
||||
write: ".*",
|
||||
configure: ".*"
|
||||
}
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (createDynamicSecret.isLoading) return;
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: { type: DynamicSecretProviders.RabbitMq, inputs: provider },
|
||||
maxTTL,
|
||||
name,
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: environment
|
||||
});
|
||||
onCompleted();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTags = watch("provider.tags");
|
||||
|
||||
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">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</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">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.host"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Host"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="https://your-rabbitmq-host.com" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.port"
|
||||
defaultValue={443}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The port on which the RabbitMQ management plugin is running. Default is 15672."
|
||||
label="Management Port"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.username"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel label="Virtual Host" className="mb-2" />
|
||||
<div className="mt-2 flex items-center justify-evenly gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.virtualHost.name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name"
|
||||
tooltipText="The virtual host to which the user will be assigned. Default is /."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="/virtual-host" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.virtualHost.permissions.read"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Read Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted read permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.virtualHost.permissions.write"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Write Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted write permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.virtualHost.permissions.configure"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Configure Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted configure permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<FormLabel
|
||||
isOptional
|
||||
className="mb-2"
|
||||
label="Tags"
|
||||
tooltipText={
|
||||
<div className="space-y-4">
|
||||
<p>Select which tag(s) to assign the users provisioned by Infisical.</p>
|
||||
<p>
|
||||
There is a wide range of in-built roles in RabbitMQ. Some include,
|
||||
management, policymaker, monitoring, administrator. <br />
|
||||
<Link passHref href="https://www.rabbitmq.com/docs/management#permissions">
|
||||
<a target="_blank" rel="noopener noreferrer">
|
||||
<span className="cursor-pointer text-primary-400">
|
||||
Read more about management tags here
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
You can also assign custom roles by providing the name of the custom role in
|
||||
the input field.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col -space-y-2">
|
||||
{selectedTags.map((_, i) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`provider.tags.${i}`}
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`role-${i}`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Input
|
||||
placeholder="Insert tag name, (management, policymaker, etc.)"
|
||||
className="mb-0 flex-grow"
|
||||
{...field}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={selectedTags.length === 1}
|
||||
ariaLabel="delete key"
|
||||
className="h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (selectedTags && selectedTags?.length > 1) {
|
||||
setValue(
|
||||
"provider.tags",
|
||||
selectedTags.filter((__, idx) => idx !== i)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
className="mb-4"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setValue("provider.tags", [...selectedTags, ""]);
|
||||
}}
|
||||
>
|
||||
Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isOptional
|
||||
label="CA (SSL)"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -140,6 +140,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.RabbitMq) {
|
||||
const { DB_USERNAME, DB_PASSWORD } = data as {
|
||||
DB_USERNAME: string;
|
||||
DB_PASSWORD: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay label="Username" value={DB_USERNAME} />
|
||||
<OutputDisplay
|
||||
label="Password"
|
||||
value={DB_PASSWORD}
|
||||
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.ElasticSearch) {
|
||||
const { DB_USERNAME, DB_PASSWORD } = data as {
|
||||
DB_USERNAME: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
||||
import { EditDynamicSecretMongoDBForm } from "./EditDynamicSecretMongoDBForm";
|
||||
import { EditDynamicSecretRabbitMqForm } from "./EditDynamicSecretRabbitMqForm";
|
||||
import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm";
|
||||
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
|
||||
|
||||
@@ -183,6 +184,24 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.RabbitMq && (
|
||||
<motion.div
|
||||
key="rabbit-mq-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretRabbitMqForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Link from "next/link";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } 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({
|
||||
host: z.string().trim().min(1),
|
||||
port: z.coerce.number(), // important: this is the management plugin port
|
||||
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
|
||||
tags: z.array(z.string().trim()),
|
||||
virtualHost: z.object({
|
||||
name: z.string().trim().min(1),
|
||||
permissions: z.object({
|
||||
read: z.string().trim().min(1),
|
||||
write: z.string().trim().min(1),
|
||||
configure: z.string().trim().min(1)
|
||||
})
|
||||
}),
|
||||
ca: z.string().optional()
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
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 EditDynamicSecretRabbitMqForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
secretPath,
|
||||
environment,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
newName: dynamicSecret.name,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"])
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isLoading) return;
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
maxTTL: maxTTL || undefined,
|
||||
defaultTTL,
|
||||
inputs,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTags = watch("inputs.tags");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
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">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</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">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.host"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Host"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="https://your-rabbitmq-host.com" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.port"
|
||||
defaultValue={443}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The port on which the RabbitMQ management plugin is running. Default is 15672."
|
||||
label="Management Port"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.username"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel label="Virtual Host" className="mb-2" />
|
||||
<div className="mt-2 flex items-center justify-evenly gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.virtualHost.name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name"
|
||||
tooltipText="The virtual host to which the user will be assigned. Default is /."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="/virtual-host" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.virtualHost.permissions.read"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Read Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted read permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.virtualHost.permissions.write"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Write Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted write permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.virtualHost.permissions.configure"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Configure Permissions"
|
||||
tooltipText="A regular expression matching resource names for which the user is granted configure permissions."
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder=".*" {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<FormLabel
|
||||
isOptional
|
||||
className="mb-2"
|
||||
label="Tags"
|
||||
tooltipText={
|
||||
<div className="space-y-4">
|
||||
<p>Select which tag(s) to assign the users provisioned by Infisical.</p>
|
||||
<p>
|
||||
There is a wide range of in-built roles in RabbitMQ. Some include,
|
||||
management, policymaker, monitoring, administrator. <br />
|
||||
<Link passHref href="https://www.rabbitmq.com/docs/management#permissions">
|
||||
<a target="_blank" rel="noopener noreferrer">
|
||||
<span className="cursor-pointer text-primary-400">
|
||||
Read more about management tags here
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
You can also assign custom roles by providing the name of the custom role in
|
||||
the input field.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col -space-y-2">
|
||||
{selectedTags.map((_, i) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`inputs.tags.${i}`}
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`role-${i}`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Input
|
||||
placeholder="Insert tag name, (management, policymaker, etc.)"
|
||||
className="mb-0 flex-grow"
|
||||
{...field}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={selectedTags.length === 1}
|
||||
ariaLabel="delete key"
|
||||
className="h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (selectedTags && selectedTags?.length > 1) {
|
||||
setValue(
|
||||
"inputs.tags",
|
||||
selectedTags.filter((__, idx) => idx !== i)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
className="mb-4"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setValue("inputs.tags", [...selectedTags, ""]);
|
||||
}}
|
||||
>
|
||||
Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isOptional
|
||||
label="CA (SSL)"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||