mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 17:28:26 +00:00
feat(server): added limit to leases creation and support for force delete when external system fails to comply
This commit is contained in:
@@ -18,6 +18,7 @@ const envSchema = z
|
|||||||
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default(
|
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default(
|
||||||
`postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`
|
`postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`
|
||||||
),
|
),
|
||||||
|
MAX_LEASE_LIMIT: z.coerce.number().default(10000),
|
||||||
DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()),
|
DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()),
|
||||||
DB_HOST: zpStr(z.string().describe("Postgres database host").optional()),
|
DB_HOST: zpStr(z.string().describe("Postgres database host").optional()),
|
||||||
DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"),
|
DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"),
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
|
|||||||
body: z.object({
|
body: z.object({
|
||||||
projectSlug: z.string().min(1),
|
projectSlug: z.string().min(1),
|
||||||
path: z.string().min(1).trim().default("/").transform(removeTrailingSlash),
|
path: z.string().min(1).trim().default("/").transform(removeTrailingSlash),
|
||||||
environment: z.string().min(1)
|
environment: z.string().min(1),
|
||||||
|
isForced: z.boolean().default(false)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
|||||||
body: z.object({
|
body: z.object({
|
||||||
projectSlug: z.string().min(1),
|
projectSlug: z.string().min(1),
|
||||||
path: z.string().trim().default("/").transform(removeTrailingSlash),
|
path: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
environment: z.string().min(1)
|
environment: z.string().min(1),
|
||||||
|
isForced: z.boolean().default(false)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export type TDynamicSecretLeaseDALFactory = ReturnType<typeof dynamicSecretLease
|
|||||||
export const dynamicSecretLeaseDALFactory = (db: TDbClient) => {
|
export const dynamicSecretLeaseDALFactory = (db: TDbClient) => {
|
||||||
const orm = ormify(db, TableName.DynamicSecretLease);
|
const orm = ormify(db, TableName.DynamicSecretLease);
|
||||||
|
|
||||||
|
const countLeasesForDynamicSecret = async (dynamicSecretId: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const doc = await (tx || db)(TableName.DynamicSecretLease).count("*").where({ dynamicSecretId }).first();
|
||||||
|
return parseInt(doc || "0", 10);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "DynamicSecretCountLeases" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const findById = async (id: string, tx?: Knex) => {
|
const findById = async (id: string, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const doc = await (tx || db)(TableName.DynamicSecretLease)
|
const doc = await (tx || db)(TableName.DynamicSecretLease)
|
||||||
@@ -67,5 +76,5 @@ export const dynamicSecretLeaseDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...orm, findById };
|
return { ...orm, findById, countLeasesForDynamicSecret };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
|||||||
})
|
})
|
||||||
) as object;
|
) as object;
|
||||||
|
|
||||||
await Promise.allSettled(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
|
await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
|
||||||
await Promise.allSettled(
|
await Promise.all(
|
||||||
dynamicSecretLeases.map(({ externalEntityId }) =>
|
dynamicSecretLeases.map(({ externalEntityId }) =>
|
||||||
selectedProvider.revoke(decryptedStoredInput, externalEntityId)
|
selectedProvider.revoke(decryptedStoredInput, externalEntityId)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ms from "ms";
|
|||||||
import { SecretKeyEncoding } from "@app/db/schemas";
|
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
|||||||
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
|
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
|
||||||
import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue";
|
import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue";
|
||||||
import {
|
import {
|
||||||
|
DynamicSecretLeaseStatus,
|
||||||
TCreateDynamicSecretLeaseDTO,
|
TCreateDynamicSecretLeaseDTO,
|
||||||
TDeleteDynamicSecretLeaseDTO,
|
TDeleteDynamicSecretLeaseDTO,
|
||||||
TDetailsDynamicSecretLeaseDTO,
|
TDetailsDynamicSecretLeaseDTO,
|
||||||
@@ -53,6 +55,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
|||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
ttl
|
ttl
|
||||||
}: TCreateDynamicSecretLeaseDTO) => {
|
}: TCreateDynamicSecretLeaseDTO) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
@@ -75,6 +78,10 @@ export const dynamicSecretLeaseServiceFactory = ({
|
|||||||
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id });
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id });
|
||||||
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
|
const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id);
|
||||||
|
if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT)
|
||||||
|
throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` });
|
||||||
|
|
||||||
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
const decryptedStoredInput = JSON.parse(
|
const decryptedStoredInput = JSON.parse(
|
||||||
infisicalSymmetricDecrypt({
|
infisicalSymmetricDecrypt({
|
||||||
@@ -179,7 +186,8 @@ export const dynamicSecretLeaseServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod
|
actorAuthMethod,
|
||||||
|
isForced
|
||||||
}: TDeleteDynamicSecretLeaseDTO) => {
|
}: TDeleteDynamicSecretLeaseDTO) => {
|
||||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
@@ -214,7 +222,21 @@ export const dynamicSecretLeaseServiceFactory = ({
|
|||||||
})
|
})
|
||||||
) as object;
|
) as object;
|
||||||
|
|
||||||
await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId);
|
const revokeResponse = await selectedProvider
|
||||||
|
.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId)
|
||||||
|
.catch(async (err) => {
|
||||||
|
// only propogate this error if forced is false
|
||||||
|
if (!isForced) return { error: err as Error };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((revokeResponse as { error?: Error })?.error) {
|
||||||
|
const { error } = revokeResponse as { error?: Error };
|
||||||
|
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||||
|
status: DynamicSecretLeaseStatus.FailedDeletion,
|
||||||
|
statusDetails: error?.message?.slice(0, 255)
|
||||||
|
});
|
||||||
|
return deletedDynamicSecretLease;
|
||||||
|
}
|
||||||
|
|
||||||
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
||||||
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id);
|
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type TDeleteDynamicSecretLeaseDTO = {
|
|||||||
path: string;
|
path: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
|
isForced?: boolean;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TRenewDynamicSecretLeaseDTO = {
|
export type TRenewDynamicSecretLeaseDTO = {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ type TDynamicSecretServiceFactoryDep = {
|
|||||||
dynamicSecretDAL: TDynamicSecretDALFactory;
|
dynamicSecretDAL: TDynamicSecretDALFactory;
|
||||||
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "find">;
|
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "find">;
|
||||||
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
||||||
dynamicSecretQueueService: Pick<TDynamicSecretLeaseQueueServiceFactory, "pruneDynamicSecret">;
|
dynamicSecretQueueService: Pick<
|
||||||
|
TDynamicSecretLeaseQueueServiceFactory,
|
||||||
|
"pruneDynamicSecret" | "unsetLeaseRevocation"
|
||||||
|
>;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
@@ -80,6 +83,10 @@ export const dynamicSecretServiceFactory = ({
|
|||||||
|
|
||||||
const selectedProvider = dynamicSecretProviders[provider.type];
|
const selectedProvider = dynamicSecretProviders[provider.type];
|
||||||
const inputs = await selectedProvider.validateProviderInputs(provider.inputs);
|
const inputs = await selectedProvider.validateProviderInputs(provider.inputs);
|
||||||
|
|
||||||
|
const isConnected = await selectedProvider.validateConnection(provider.inputs);
|
||||||
|
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
|
||||||
|
|
||||||
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs));
|
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs));
|
||||||
const dynamicSecretCfg = await dynamicSecretDAL.create({
|
const dynamicSecretCfg = await dynamicSecretDAL.create({
|
||||||
type: provider.type,
|
type: provider.type,
|
||||||
@@ -151,6 +158,10 @@ export const dynamicSecretServiceFactory = ({
|
|||||||
) as object;
|
) as object;
|
||||||
const newInput = { ...decryptedStoredInput, ...(inputs || {}) };
|
const newInput = { ...decryptedStoredInput, ...(inputs || {}) };
|
||||||
const updatedInput = await selectedProvider.validateProviderInputs(newInput);
|
const updatedInput = await selectedProvider.validateProviderInputs(newInput);
|
||||||
|
|
||||||
|
const isConnected = await selectedProvider.validateConnection(newInput);
|
||||||
|
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
|
||||||
|
|
||||||
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput));
|
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput));
|
||||||
const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
|
const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
|
||||||
inputIV: encryptedInput.iv,
|
inputIV: encryptedInput.iv,
|
||||||
@@ -176,7 +187,8 @@ export const dynamicSecretServiceFactory = ({
|
|||||||
projectSlug,
|
projectSlug,
|
||||||
slug,
|
slug,
|
||||||
path,
|
path,
|
||||||
environment
|
environment,
|
||||||
|
isForced
|
||||||
}: TDeleteDynamicSecretDTO) => {
|
}: TDeleteDynamicSecretDTO) => {
|
||||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
@@ -202,6 +214,16 @@ export const dynamicSecretServiceFactory = ({
|
|||||||
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
|
const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
|
||||||
|
// when not forced we check with the external system to first remove the things
|
||||||
|
// we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system
|
||||||
|
// this allows user to clean up it from infisical
|
||||||
|
if (isForced) {
|
||||||
|
// clear all queues for lease revocations
|
||||||
|
await Promise.all(leases.map(({ id: leaseId }) => dynamicSecretQueueService.unsetLeaseRevocation(leaseId)));
|
||||||
|
|
||||||
|
const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id);
|
||||||
|
return deletedDynamicSecretCfg;
|
||||||
|
}
|
||||||
// if leases exist we should flag it as deleting and then remove leases in background
|
// if leases exist we should flag it as deleting and then remove leases in background
|
||||||
// then delete the main one
|
// then delete the main one
|
||||||
if (leases.length) {
|
if (leases.length) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type TDeleteDynamicSecretDTO = {
|
|||||||
path: string;
|
path: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
|
isForced?: boolean;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TDetailsDynamicSecretDTO = {
|
export type TDetailsDynamicSecretDTO = {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
|||||||
|
|
||||||
export type TDynamicProviderFns = {
|
export type TDynamicProviderFns = {
|
||||||
create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>;
|
create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>;
|
||||||
|
validateConnection: (inputs: unknown) => Promise<boolean>;
|
||||||
validateProviderInputs: (inputs: object) => Promise<unknown>;
|
validateProviderInputs: (inputs: object) => Promise<unknown>;
|
||||||
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
|
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
|
||||||
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
|
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
|
|||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateConnection = async (inputs: unknown) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
const db = await getClient(providerInputs);
|
||||||
|
const isConnected = await db
|
||||||
|
.raw("SELECT NOW()")
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
await db.destroy();
|
||||||
|
return isConnected;
|
||||||
|
};
|
||||||
|
|
||||||
const create = async (inputs: unknown, expireAt: number) => {
|
const create = async (inputs: unknown, expireAt: number) => {
|
||||||
const providerInputs = await validateProviderInputs(inputs);
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
const db = await getClient(providerInputs);
|
const db = await getClient(providerInputs);
|
||||||
@@ -94,6 +105,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
validateProviderInputs,
|
validateProviderInputs,
|
||||||
|
validateConnection,
|
||||||
create,
|
create,
|
||||||
revoke,
|
revoke,
|
||||||
renew
|
renew
|
||||||
|
|||||||
Reference in New Issue
Block a user