feat(server): added some more validation and feedback for deletion etc

This commit is contained in:
Akhil Mohan
2024-03-21 20:21:58 +05:30
parent e201e80a06
commit 70fe80414d
11 changed files with 255 additions and 68 deletions

View File

@@ -19,9 +19,9 @@ export async function up(knex: Knex): Promise<void> {
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8);
t.uuid("folderId").notNullable();
t.boolean("isDeleting").defaultTo(false);
// used for flag why delete is failing
// for background process communication
t.string("status");
t.string("statusDetails");
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.unique(["slug", "folderId"]);
t.timestamps(true, true, true);
@@ -36,8 +36,10 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.integer("version").notNullable();
t.string("externalEntityId").notNullable();
t.string("maxTTL");
t.datetime("expireAt").notNullable();
// for background process communication
t.string("status");
t.string("statusDetails");
t.uuid("dynamicSecretId").notNullable();
t.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE");
t.timestamps(true, true, true);

View File

@@ -11,8 +11,9 @@ export const DynamicSecretLeasesSchema = z.object({
id: z.string().uuid(),
version: z.number(),
externalEntityId: z.string(),
maxTTL: z.string().nullable().optional(),
expireAt: z.date(),
status: z.string().nullable().optional(),
statusDetails: z.string().nullable().optional(),
dynamicSecretId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()

View File

@@ -20,8 +20,8 @@ export const DynamicSecretsSchema = z.object({
algorithm: z.string().default("aes-256-gcm"),
keyEncoding: z.string().default("utf8"),
folderId: z.string().uuid(),
isDeleting: z.boolean().default(false).nullable().optional(),
status: z.string().nullable().optional(),
statusDetails: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});

View File

@@ -1,6 +1,11 @@
import { z } from "zod";
import { IntegrationAuthsSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas";
import {
DynamicSecretsSchema,
IntegrationAuthsSchema,
SecretApprovalPoliciesSchema,
UsersSchema
} from "@app/db/schemas";
// sometimes the return data must be santizied to avoid leaking important values
// always prefer pick over omit in zod
@@ -56,3 +61,11 @@ export const secretRawSchema = z.object({
secretValue: z.string(),
secretComment: z.string().optional()
});
export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
inputIV: true,
inputTag: true,
inputCiphertext: true,
keyEncoding: true,
algorithm: true
});

View File

@@ -2,6 +2,8 @@ import ms from "ms";
import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -16,8 +18,15 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
ttl: z
.string()
.optional()
.refine((val) => typeof val === "undefined" || ms(val) > 0, "TTL must be a positive number"),
path: z.string().default("/"),
.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" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
@@ -49,7 +58,7 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
}),
body: z.object({
projectId: z.string(),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
@@ -80,8 +89,19 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
leaseId: z.string()
}),
body: z.object({
ttl: 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" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
projectId: z.string(),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
@@ -113,7 +133,7 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
}),
querystring: z.object({
projectId: z.string(),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {

View File

@@ -1,11 +1,14 @@
import ms from "ms";
import { z } from "zod";
import { DynamicSecretsSchema } from "@app/db/schemas";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { DynamicSecretProviderSchema } from "@app/services/dynamic-secret/providers/models";
import { SanitizedDynamicSecretSchema } from "../sanitizedSchemas";
export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
@@ -14,18 +17,32 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
body: z.object({
projectId: z.string(),
provider: DynamicSecretProviderSchema,
defaultTTL: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
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" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.refine((val) => typeof val === "undefined" || ms(val) > 0, "Max TTL must be a positive number"),
path: z.string().default("/"),
.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" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
.nullable(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string(),
slug: z.string().toLowerCase()
}),
response: {
200: z.object({
dynamicSecret: DynamicSecretsSchema
dynamicSecret: SanitizedDynamicSecretSchema
})
}
},
@@ -51,22 +68,39 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
projectId: z.string(),
inputs: z.any().optional(),
defaultTTL: z
.string()
.optional()
.refine((val) => typeof val === "undefined" || ms(val) > 0, "TTL must be a positive number"),
maxTTL: z
.string()
.optional()
.refine((val) => typeof val === "undefined" || ms(val) > 0, "Max TTL must be a positive number"),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string(),
newSlug: z.string()
data: z.object({
inputs: z.any().optional(),
defaultTTL: 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" });
if (valMs > daysToMillisecond(1))
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" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
.nullable(),
newSlug: z.string().optional()
})
}),
response: {
200: z.object({
dynamicSecret: DynamicSecretsSchema
dynamicSecret: SanitizedDynamicSecretSchema
})
}
},
@@ -78,7 +112,10 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
slug: req.params.slug,
...req.body
path: req.body.path,
projectId: req.body.projectId,
environment: req.body.environment,
...req.body.data
});
return { dynamicSecret: dynamicSecretCfg };
}
@@ -93,12 +130,12 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
projectId: z.string(),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
200: z.object({
dynamicSecret: DynamicSecretsSchema
dynamicSecret: SanitizedDynamicSecretSchema
})
}
},
@@ -116,18 +153,52 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
url: "/:slug",
method: "GET",
schema: {
params: z.object({
slug: z.string()
}),
querystring: z.object({
projectId: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
200: z.object({
dynamicSecret: SanitizedDynamicSecretSchema.extend({
inputs: z.unknown()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const dynamicSecretCfg = await server.services.dynamicSecret.getDetails({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
slug: req.params.slug,
...req.query
});
return { dynamicSecret: dynamicSecretCfg };
}
});
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
projectId: z.string(),
path: z.string(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
environment: z.string()
}),
response: {
200: z.object({
dynamicSecrets: DynamicSecretsSchema.array()
dynamicSecrets: SanitizedDynamicSecretSchema.array()
})
}
},

View File

@@ -5,13 +5,14 @@ import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types";
import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
type TDynamicSecretLeaseQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "findById" | "deleteById" | "find">;
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById">;
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "findById" | "deleteById" | "find" | "updateById">;
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById">;
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
};
@@ -89,44 +90,60 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id);
const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId);
if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" });
if (!dynamicSecretCfg.isDeleting) throw new DisableRotationErrors({ message: "Document not deleted" });
if ((dynamicSecretCfg.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting)
throw new DisableRotationErrors({ message: "Document not deleted" });
const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfgId });
if (dynamicSecretLeases.length) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
if (dynamicSecretLeases.length) {
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse(
infisicalSymmetricDecrypt({
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
ciphertext: dynamicSecretCfg.inputCiphertext,
tag: dynamicSecretCfg.inputTag,
iv: dynamicSecretCfg.inputIV
})
) as object;
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse(
infisicalSymmetricDecrypt({
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
ciphertext: dynamicSecretCfg.inputCiphertext,
tag: dynamicSecretCfg.inputTag,
iv: dynamicSecretCfg.inputIV
})
) as object;
await Promise.allSettled(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
await Promise.allSettled(
dynamicSecretLeases.map(({ externalEntityId }) =>
selectedProvider.revoke(decryptedStoredInput, externalEntityId)
)
);
await Promise.allSettled(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
await Promise.allSettled(
dynamicSecretLeases.map(({ externalEntityId }) =>
selectedProvider.revoke(decryptedStoredInput, externalEntityId)
)
);
}
await dynamicSecretDAL.deleteById(dynamicSecretCfgId);
}
logger.info("Finished dynamic secret job", job.id);
} catch (error) {
logger.error(error);
if (job?.name === QueueJobs.DynamicSecretPruning) {
const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string };
await dynamicSecretDAL.updateById(dynamicSecretCfgId, {
status: DynamicSecretStatus.FailedDeletion,
statusDetails: (error as Error)?.message?.slice(0, 255)
});
}
if (job?.name === QueueJobs.DynamicSecretRevocation) {
const { leaseId } = job.data as { leaseId: string };
await dynamicSecretLeaseDAL.updateById(leaseId, {
status: DynamicSecretStatus.FailedDeletion,
statusDetails: (error as Error)?.message?.slice(0, 255)
});
}
if (error instanceof DisableRotationErrors) {
if (job.id) {
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id);
}
}
// propogate to next part
throw error;
}
});
queueService.listen(QueueName.AuditLogPrune, "failed", (err) => {
logger.error(err, `${QueueName.AuditLogPrune}: log pruning failed`);
});
return {
pruneDynamicSecret,
setLeaseRevocation,

View File

@@ -156,7 +156,7 @@ export const dynamicSecretLeaseServiceFactory = ({
);
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(new Date()) - Number(expireAt));
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
expireAt,
externalEntityId: entityId

View File

@@ -1,5 +1,9 @@
import { TProjectPermission } from "@app/lib/types";
export enum DynamicSecretLeaseStatus {
FailedDeletion = "Failed to delete"
}
export type TCreateDynamicSecretLeaseDTO = {
slug: string;
path: string;

View File

@@ -11,9 +11,11 @@ import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
import {
DynamicSecretStatus,
TCreateDynamicSecretDTO,
TDeleteDyanmicSecretDTO,
TListDyanmicSecretsDTO,
TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO,
TListDynamicSecretsDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
@@ -146,7 +148,9 @@ export const dynamicSecretServiceFactory = ({
keyEncoding: encryptedInput.encoding,
maxTTL,
defaultTTL,
slug: newSlug ?? slug
slug: newSlug ?? slug,
status: null,
statusDetails: null
});
return updatedDynamicCfg;
@@ -161,7 +165,7 @@ export const dynamicSecretServiceFactory = ({
slug,
path,
environment
}: TDeleteDyanmicSecretDTO) => {
}: TDeleteDynamicSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
@@ -184,7 +188,9 @@ export const dynamicSecretServiceFactory = ({
// if leases exist we should flag it as deleting and then remove leases in background
// then delete the main one
if (leases.length) {
const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { isDeleting: true });
const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
status: DynamicSecretStatus.Deleting
});
await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id);
return updatedDynamicSecretCfg;
}
@@ -193,6 +199,46 @@ export const dynamicSecretServiceFactory = ({
return deletedDynamicSecretCfg;
};
const getDetails = async ({
slug,
projectId,
path,
environment,
actorAuthMethod,
actorOrgId,
actorId,
actor
}: TDetailsDynamicSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id });
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
const decryptedStoredInput = JSON.parse(
infisicalSymmetricDecrypt({
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
ciphertext: dynamicSecretCfg.inputCiphertext,
tag: dynamicSecretCfg.inputTag,
iv: dynamicSecretCfg.inputIV
})
) as object;
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object;
return { ...dynamicSecretCfg, inputs: providerInputs };
};
const list = async ({
actorAuthMethod,
actorOrgId,
@@ -201,7 +247,7 @@ export const dynamicSecretServiceFactory = ({
projectId,
path,
environment
}: TListDyanmicSecretsDTO) => {
}: TListDynamicSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
@@ -225,6 +271,7 @@ export const dynamicSecretServiceFactory = ({
create,
updateBySlug,
deleteBySlug,
getDetails,
list
};
};

View File

@@ -4,11 +4,17 @@ import { TProjectPermission } from "@app/lib/types";
import { DynamicSecretProviderSchema } from "./providers/models";
// various status for dynamic secret that happens in background
export enum DynamicSecretStatus {
Deleting = "Revocation in process",
FailedDeletion = "Failed to delete"
}
type TProvider = z.infer<typeof DynamicSecretProviderSchema>;
export type TCreateDynamicSecretDTO = {
provider: TProvider;
defaultTTL: string;
maxTTL?: string;
maxTTL?: string | null;
path: string;
environment: string;
slug: string;
@@ -18,19 +24,25 @@ export type TUpdateDynamicSecretDTO = {
slug: string;
newSlug?: string;
defaultTTL?: string;
maxTTL?: string;
maxTTL?: string | null;
path: string;
environment: string;
inputs?: TProvider["inputs"];
} & TProjectPermission;
export type TDeleteDyanmicSecretDTO = {
export type TDeleteDynamicSecretDTO = {
slug: string;
path: string;
environment: string;
} & TProjectPermission;
export type TListDyanmicSecretsDTO = {
export type TDetailsDynamicSecretDTO = {
slug: string;
path: string;
environment: string;
} & TProjectPermission;
export type TListDynamicSecretsDTO = {
path: string;
environment: string;
} & TProjectPermission;