Merge branch 'main' into secret-rotations-v2-feature

This commit is contained in:
Scott Wilson
2025-04-03 21:21:06 -07:00
96 changed files with 4509 additions and 926 deletions
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn(TableName.SecretFolder, "lastSecretModified");
if (!hasCol) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.datetime("lastSecretModified");
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn(TableName.SecretFolder, "lastSecretModified");
if (hasCol) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.dropColumn("lastSecretModified");
});
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ export const SecretFoldersSchema = z.object({
envId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
isReserved: z.boolean().default(false).nullable().optional(),
description: z.string().nullable().optional()
description: z.string().nullable().optional(),
lastSecretModified: z.date().nullable().optional()
});
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;
@@ -9,13 +9,14 @@ import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type";
import { EventType } from "./audit-log-types";
import { EventType, filterableSecretEvents } from "./audit-log-types";
export type TAuditLogDALFactory = ReturnType<typeof auditLogDALFactory>;
type TFindQuery = {
actor?: string;
projectId?: string;
environment?: string;
orgId?: string;
eventType?: string;
startDate?: string;
@@ -32,6 +33,7 @@ export const auditLogDALFactory = (db: TDbClient) => {
{
orgId,
projectId,
environment,
userAgentType,
startDate,
endDate,
@@ -40,12 +42,14 @@ export const auditLogDALFactory = (db: TDbClient) => {
actorId,
actorType,
secretPath,
secretKey,
eventType,
eventMetadata
}: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string;
actorType?: ActorType;
secretPath?: string;
secretKey?: string;
eventType?: EventType[];
eventMetadata?: Record<string, string>;
},
@@ -90,8 +94,29 @@ export const auditLogDALFactory = (db: TDbClient) => {
});
}
if (projectId && secretPath) {
void sqlQuery.whereRaw(`"eventMetadata" @> jsonb_build_object('secretPath', ?::text)`, [secretPath]);
const eventIsSecretType = !eventType?.length || eventType.some((event) => filterableSecretEvents.includes(event));
// We only want to filter for environment/secretPath/secretKey if the user is either checking for all event types
// ? Note(daniel): use the `eventMetadata" @> ?::jsonb` approach to properly use our GIN index
if (projectId && eventIsSecretType) {
if (environment || secretPath) {
// Handle both environment and secret path together to only use the GIN index once
void sqlQuery.whereRaw(`"eventMetadata" @> ?::jsonb`, [
JSON.stringify({
...(environment && { environment }),
...(secretPath && { secretPath })
})
]);
}
// Handle secret key separately to include the OR condition
if (secretKey) {
void sqlQuery.whereRaw(
`("eventMetadata" @> ?::jsonb
OR "eventMetadata"->'secrets' @> ?::jsonb)`,
[JSON.stringify({ secretKey }), JSON.stringify([{ secretKey }])]
);
}
}
// Filter by actor type
@@ -63,6 +63,8 @@ export const auditLogServiceFactory = ({
actorType: filter.actorType,
eventMetadata: filter.eventMetadata,
secretPath: filter.secretPath,
secretKey: filter.secretKey,
environment: filter.environment,
...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId })
});
@@ -40,9 +40,11 @@ export type TListProjectAuditLogDTO = {
endDate?: string;
startDate?: string;
projectId?: string;
environment?: string;
auditLogActorId?: string;
actorType?: ActorType;
secretPath?: string;
secretKey?: string;
eventMetadata?: Record<string, string>;
};
} & Omit<TProjectPermission, "projectId">;
@@ -303,6 +305,16 @@ export enum EventType {
SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets"
}
export const filterableSecretEvents: EventType[] = [
EventType.GET_SECRET,
EventType.DELETE_SECRETS,
EventType.CREATE_SECRETS,
EventType.UPDATE_SECRETS,
EventType.CREATE_SECRET,
EventType.UPDATE_SECRET,
EventType.DELETE_SECRET
];
interface UserActorMetadata {
userId: string;
email?: string | null;
+6 -1
View File
@@ -637,7 +637,8 @@ export const FOLDERS = {
environment: "The slug of the environment to list folders from.",
path: "The path to list folders from.",
directory: "The directory to list folders from. (Deprecated in favor of path)",
recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories."
recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.",
lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)"
},
GET_BY_ID: {
folderId: "The ID of the folder to get details."
@@ -847,9 +848,13 @@ export const AUDIT_LOGS = {
EXPORT: {
projectId:
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
environment:
"The environment to filter logs by. If not provided, logs from all environments will be returned. Note that the projectId parameter must also be provided.",
eventType: "The type of the event to export.",
secretPath:
"The path of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
secretKey:
"The key of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
userAgentType: "Choose which consuming application to export audit logs for.",
eventMetadata:
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
@@ -6,6 +6,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
@@ -14,6 +15,7 @@ import {
validateAltNamesField,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerCaRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -649,6 +651,16 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: ca.id,
commonName: req.body.commonName,
...req.auditLogInfo
}
});
return {
certificate,
certificateChain,
@@ -707,7 +719,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } =
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } =
await server.services.certificateAuthority.signCertFromCa({
isInternal: false,
caId: req.params.caId,
@@ -731,6 +743,16 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignCert,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: ca.id,
commonName,
...req.auditLogInfo
}
});
return {
certificate: certificate.toString("pem"),
certificateChain,
@@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyUsage, CrlReason } from "@app/services/certificate/certificate-types";
@@ -12,6 +13,7 @@ import {
validateAltNamesField,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerCertRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -150,6 +152,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: req.body.caId,
certificateTemplateId: req.body.certificateTemplateId,
commonName: req.body.commonName,
...req.auditLogInfo
}
});
return {
certificate,
certificateChain,
@@ -228,7 +241,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } =
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } =
await server.services.certificateAuthority.signCertFromCa({
isInternal: false,
actor: req.permission.type,
@@ -251,6 +264,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignCert,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: req.body.caId,
certificateTemplateId: req.body.certificateTemplateId,
commonName,
...req.auditLogInfo
}
});
return {
certificate: certificate.toString("pem"),
certificateChain,
@@ -1106,6 +1106,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
projectId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
recursive: booleanSchema.default(false),
filterByAction: z
.enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue])
.default(ProjectPermissionSecretActions.ReadValue)
@@ -1124,7 +1125,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { projectId, environment, secretPath, filterByAction } = req.query;
const { projectId, environment, secretPath, filterByAction, recursive } = req.query;
const { secrets } = await server.services.secret.getAccessibleSecrets({
actorId: req.permission.id,
@@ -1134,7 +1135,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
environment,
secretPath,
projectId,
filterByAction
filterByAction,
recursive
});
return { secrets };
@@ -111,12 +111,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
description: "Get all audit logs for an organization",
querystring: z.object({
projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment),
actorType: z.nativeEnum(ActorType).optional(),
secretPath: z
.string()
.optional()
.transform((val) => (!val ? val : removeTrailingSlash(val)))
.describe(AUDIT_LOGS.EXPORT.secretPath),
secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey),
// eventType is split with , for multiple values, we need to transform it to array
eventType: z
@@ -335,6 +335,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
querystring: z.object({
workspaceId: z.string().trim().describe(FOLDERS.LIST.workspaceId),
environment: z.string().trim().describe(FOLDERS.LIST.environment),
lastSecretModified: z.string().datetime().trim().optional().describe(FOLDERS.LIST.lastSecretModified),
path: z
.string()
.trim()
@@ -1818,7 +1818,8 @@ export const certificateAuthorityServiceFactory = ({
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
issuingCaCertificate,
serialNumber,
ca
ca,
commonName: cn
};
};
@@ -423,7 +423,7 @@ export const projectMembershipServiceFactory = ({
const usernamesAndEmails = [...emails, ...usernames];
const projectMembers = await projectMembershipDAL.findMembershipsByUsername(projectId, [
...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))
...new Set(usernamesAndEmails.map((element) => element))
]);
if (projectMembers.length !== usernamesAndEmails.length) {
@@ -402,7 +402,8 @@ export const secretFolderServiceFactory = ({
orderDirection,
limit,
offset,
recursive
recursive,
lastSecretModified
}: TGetFolderDTO) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
@@ -425,7 +426,16 @@ export const secretFolderServiceFactory = ({
const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] });
// remove the parent folder
return recursiveFolders
.filter((folder) => folder.id !== parentFolder.id)
.filter((folder) => {
if (lastSecretModified) {
if (!folder.lastSecretModified) return false;
if (folder.lastSecretModified < new Date(lastSecretModified)) {
return false;
}
}
return folder.id !== parentFolder.id;
})
.map((folder) => ({
...folder,
relativePath: folder.path
@@ -445,6 +455,11 @@ export const secretFolderServiceFactory = ({
offset
}
);
if (lastSecretModified) {
return folders.filter((el) =>
el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false
);
}
return folders;
};
@@ -619,10 +634,29 @@ export const secretFolderServiceFactory = ({
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
const foldersWithPath = relevantFolders.map((folder) => ({
...folder,
path: buildFolderPath(folder, foldersMap)
}));
const foldersWithPath = relevantFolders
.map((folder) => {
try {
return {
...folder,
path: buildFolderPath(folder, foldersMap)
};
} catch (error) {
return null;
}
})
.filter(Boolean) as {
path: string;
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
envId: string;
version?: number | null | undefined;
parentId?: string | null | undefined;
isReserved?: boolean | undefined;
description?: string | undefined;
}[];
return [env.slug, { ...env, folders: foldersWithPath }];
})
@@ -46,6 +46,7 @@ export type TGetFolderDTO = {
limit?: number;
offset?: number;
recursive?: boolean;
lastSecretModified?: string;
} & TProjectPermission;
export type TGetFolderByIdDTO = {
@@ -356,7 +356,7 @@ export const fnSecretBulkDelete = async ({
interface FolderMap {
[parentId: string]: TSecretFolders[];
}
const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
export const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
const map: FolderMap = {};
map.null = []; // Initialize mapping for root directory
@@ -371,7 +371,7 @@ const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
return map;
};
const generatePaths = (
export const generatePaths = (
map: FolderMap,
parentId: string = "null",
basePath: string = "",
@@ -45,10 +45,12 @@ import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
import {
buildHierarchy,
expandSecretReferencesFactory,
fnSecretBulkDelete,
fnSecretBulkInsert,
fnSecretBulkUpdate,
generatePaths,
getAllSecretReferences,
recursivelyGetSecretPaths,
reshapeBridgeSecret
@@ -2663,7 +2665,8 @@ export const secretV2BridgeServiceFactory = ({
actorId,
actor,
actorAuthMethod,
actorOrgId
actorOrgId,
recursive
}: TGetAccessibleSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -2678,10 +2681,38 @@ export const secretV2BridgeServiceFactory = ({
secretPath
});
const folders = [];
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return { secrets: [] };
folders.push({ ...folder, parentId: null });
const secrets = await secretDAL.findByFolderIds([folder.id]);
const env = await projectEnvDAL.findOne({
projectId,
slug: environment
});
if (!env) {
throw new NotFoundError({
message: `Environment with slug '${environment}' in project with ID ${projectId} not found`
});
}
if (recursive) {
const subFolders = await folderDAL.find({
envId: env.id,
isReserved: false
});
folders.push(...subFolders);
}
if (folders.length === 0) return { secrets: [] };
const folderMap = buildHierarchy(folders);
const paths = Object.fromEntries(
generatePaths(folderMap).map(({ folderId, path }) => [folderId, path === "/" ? path : path.substring(1)])
);
const secrets = await secretDAL.findByFolderIds(folders.map((f) => f.id));
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
@@ -2693,7 +2724,7 @@ export const secretV2BridgeServiceFactory = ({
if (
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
environment,
secretPath,
secretPath: paths[el.folderId],
secretName: el.key,
secretTags: el.tags.map((i) => i.slug)
})
@@ -2704,7 +2735,7 @@ export const secretV2BridgeServiceFactory = ({
if (filterByAction === ProjectPermissionSecretActions.ReadValue) {
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
environment,
secretPath,
secretPath: paths[el.folderId],
secretName: el.key,
secretTags: el.tags.map((i) => i.slug)
});
@@ -2717,7 +2748,7 @@ export const secretV2BridgeServiceFactory = ({
filterByAction === ProjectPermissionSecretActions.DescribeSecret &&
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
environment,
secretPath,
secretPath: paths[secret.folderId],
secretName: secret.key,
secretTags: secret.tags.map((i) => i.slug)
});
@@ -2725,7 +2756,7 @@ export const secretV2BridgeServiceFactory = ({
return reshapeBridgeSecret(
projectId,
environment,
secretPath,
paths[secret.folderId],
{
...secret,
value: secret.encryptedValue
@@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = {
environment: string;
projectId: string;
secretPath: string;
recursive?: boolean;
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
} & TProjectPermission;
@@ -646,6 +646,10 @@ export const secretQueueFactory = ({
}
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return;
await folderDAL.updateById(folder.id, { lastSecretModified: new Date() });
await secretSyncQueue.queueSecretSyncsSyncSecretsByPath({ projectId, environmentSlug: environment, secretPath });
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
@@ -1321,7 +1321,8 @@ export const secretServiceFactory = ({
actorOrgId,
actorAuthMethod,
environment,
filterByAction
filterByAction,
recursive
}: TGetAccessibleSecretsDTO) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
@@ -1340,7 +1341,8 @@ export const secretServiceFactory = ({
actor,
actorId,
actorOrgId,
actorAuthMethod
actorAuthMethod,
recursive
});
return secrets;
@@ -184,6 +184,7 @@ export enum SecretsOrderBy {
export type TGetAccessibleSecretsDTO = {
secretPath: string;
environment: string;
recursive?: boolean;
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
} & TProjectPermission;
@@ -17,7 +17,9 @@ export enum PostHogEventTypes {
SecretRequestCreated = "Secret Request Created",
SecretRequestDeleted = "Secret Request Deleted",
SignSshKey = "Sign SSH Key",
IssueSshCreds = "Issue SSH Credentials"
IssueSshCreds = "Issue SSH Credentials",
SignCert = "Sign PKI Certificate",
IssueCert = "Issue PKI Certificate"
}
export type TSecretModifiedEvent = {
@@ -159,6 +161,26 @@ export type TIssueSshCredsEvent = {
};
};
export type TSignCertificateEvent = {
event: PostHogEventTypes.SignCert;
properties: {
caId?: string;
certificateTemplateId?: string;
commonName: string;
userAgent?: string;
};
};
export type TIssueCertificateEvent = {
event: PostHogEventTypes.IssueCert;
properties: {
caId?: string;
certificateTemplateId?: string;
commonName: string;
userAgent?: string;
};
};
export type TPostHogEvent = { distinctId: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
@@ -173,4 +195,6 @@ export type TPostHogEvent = { distinctId: string } & (
| TSecretRequestDeletedEvent
| TSignSshKeyEvent
| TIssueSshCredsEvent
| TSignCertificateEvent
| TIssueCertificateEvent
);