diff --git a/.github/workflows/release_docker_k8_operator.yaml b/.github/workflows/release_docker_k8_operator.yaml index 4af62df1c..1f894df47 100644 --- a/.github/workflows/release_docker_k8_operator.yaml +++ b/.github/workflows/release_docker_k8_operator.yaml @@ -4,6 +4,10 @@ on: tags: - "infisical-k8-operator/v*.*.*" +permissions: + contents: write + pull-requests: write + jobs: release-image: name: Generate Helm Chart PR diff --git a/.infisicalignore b/.infisicalignore index 8072078d4..b80ecaad5 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -12,3 +12,5 @@ docs/cli/commands/bootstrap.mdx:jwt:86 docs/documentation/platform/audit-log-streams/audit-log-streams.mdx:generic-api-key:102 docs/self-hosting/guides/automated-bootstrapping.mdx:jwt:74 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx:generic-api-key:72 +k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml:private-key:11 +k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml:private-key:52 diff --git a/backend/src/db/migrations/20250326171707_folder-last-secret-modified.ts b/backend/src/db/migrations/20250326171707_folder-last-secret-modified.ts new file mode 100644 index 000000000..227fe6ef7 --- /dev/null +++ b/backend/src/db/migrations/20250326171707_folder-last-secret-modified.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + const hasCol = await knex.schema.hasColumn(TableName.SecretFolder, "lastSecretModified"); + if (hasCol) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.dropColumn("lastSecretModified"); + }); + } +} diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 33b13b673..09e2fe8c1 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -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; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 21f785835..ad6b72e33 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -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; 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 & { actorId?: string; actorType?: ActorType; secretPath?: string; + secretKey?: string; eventType?: EventType[]; eventMetadata?: Record; }, @@ -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 diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 295923ef5..ce6689fe9 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -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 }) }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 1214cc5a0..5e7df9444 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -33,9 +33,11 @@ export type TListProjectAuditLogDTO = { endDate?: string; startDate?: string; projectId?: string; + environment?: string; auditLogActorId?: string; actorType?: ActorType; secretPath?: string; + secretKey?: string; eventMetadata?: Record; }; } & Omit; @@ -286,6 +288,16 @@ export enum EventType { KMIP_OPERATION_REGISTER = "kmip-operation-register" } +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; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 78e569044..d7e3df02a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -632,7 +632,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." @@ -840,9 +841,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.", diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 992a16253..b108215ca 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -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, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index f61b1ea1e..3101393bd 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -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, diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 901723647..fc33bcafe 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -897,6 +897,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) @@ -915,7 +916,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, @@ -925,7 +926,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { environment, secretPath, projectId, - filterByAction + filterByAction, + recursive }); return { secrets }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 67b938ae4..93376d261 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -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 diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 5f8b553f5..b55564d80 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -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() diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index f03b5db4c..499a25741 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1818,7 +1818,8 @@ export const certificateAuthorityServiceFactory = ({ certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), issuingCaCertificate, serialNumber, - ca + ca, + commonName: cn }; }; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index e5c9d4eb0..6be7397f3 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -3595,7 +3595,10 @@ const syncSecretsTeamCity = async ({ `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, { name: `env.${key}`, - value: secrets[key].value + value: secrets[key].value, + type: { + rawValue: "password display='hidden'" + } }, { headers: { @@ -3652,7 +3655,10 @@ const syncSecretsTeamCity = async ({ `${integrationAuth.url}/app/rest/projects/id:${integration.appId}/parameters`, { name: `env.${key}`, - value: secrets[key].value + value: secrets[key].value, + type: { + rawValue: "password display='hidden'" + } }, { headers: { diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index f452e381f..1d6ba969a 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -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) { diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index d4f58a1d4..842eb2bb7 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -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 }]; }) diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 2a5681b5b..4008676db 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -46,6 +46,7 @@ export type TGetFolderDTO = { limit?: number; offset?: number; recursive?: boolean; + lastSecretModified?: string; } & TProjectPermission; export type TGetFolderByIdDTO = { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index b2fe923aa..1544e963c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -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 = "", diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index dc975854f..6fdfb10f1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -44,10 +44,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 @@ -2620,7 +2622,8 @@ export const secretV2BridgeServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + recursive }: TGetAccessibleSecretsDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -2635,10 +2638,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, @@ -2650,7 +2681,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) }) @@ -2661,7 +2692,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) }); @@ -2674,7 +2705,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) }); @@ -2682,7 +2713,7 @@ export const secretV2BridgeServiceFactory = ({ return reshapeBridgeSecret( projectId, environment, - secretPath, + paths[secret.folderId], { ...secret, value: secret.encryptedValue diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 7f415bff8..5c5b26ae4 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = { environment: string; projectId: string; secretPath: string; + recursive?: boolean; filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; } & TProjectPermission; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 76a486b46..60eec9cb1 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -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 }); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 13a5c9736..7aeb59f01 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -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; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 6a5c097ed..bea59a439 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -184,6 +184,7 @@ export enum SecretsOrderBy { export type TGetAccessibleSecretsDTO = { secretPath: string; environment: string; + recursive?: boolean; filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; } & TProjectPermission; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 2f92978b2..45510899a 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -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 ); diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 53f97f4c9..0693db509 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -694,7 +694,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin if err != nil { return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) } - + httpClient.SetAuthToken(tokenDetails.Token) httpClient.SetHeader("Accept", "application/json") // pull current secrets diff --git a/docs/documentation/platform/admin-panel/server-admin.mdx b/docs/documentation/platform/admin-panel/server-admin.mdx index 137cdde59..aa604e2a5 100644 --- a/docs/documentation/platform/admin-panel/server-admin.mdx +++ b/docs/documentation/platform/admin-panel/server-admin.mdx @@ -40,7 +40,7 @@ If you're using SAML/LDAP/OIDC for only one organization on your instance, you c By default, users signing up through SAML/LDAP/OIDC will still need to verify their email address to prevent email spoofing. This requirement can be skipped by enabling the switch to trust logins through the respective method. -### Notices +### Broadcast Messages Auth consent content is displayed to users on the login page. They can be used to display important information to users, such as a maintenance message or a new feature announcement. Both HTML and Markdown formatting are supported, allowing for customized styling like below: diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 119a8cefa..545ed6b3b 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -11,10 +11,11 @@ This means that updating the value of a base secret propagates directly to other ![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) -Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, fetching back secrets -must be permissioned access to all base and dependent secrets. +Since secret referencing reconstructs values on the client side, any client (user, service token, or machine identity) fetching secrets must have proper permissions to access all base and dependent secrets. Without sufficient permissions, secret references will not resolve to their appropriate values. -For example, to access some secret `A` whose values depend on secrets `B` and `C` from different scopes, a client must have `read` access to the scopes of secrets `A`, `B`, and `C`. +For example, if secret A references values from secrets B and C located in different scopes, the client must have read access to all three scopes containing secrets A, B, and C. If permission to any referenced secret is missing, the reference will remain unresolved, potentially causing application errors or unexpected behavior. + +This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references. ### Syntax @@ -28,11 +29,11 @@ Then consider the following scenarios: Here are a few more helpful examples for how to reference secrets in different contexts: -| Reference syntax | Environment | Folder | Secret Key | -| --------------------- | ----------- | ------------ | ---------- | -| `${KEY1}` | same env | same folder | KEY1 | -| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 | -| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 | +| Reference syntax | Environment | Folder | Secret Key | +| ----------------------- | ----------- | ----------------------------- | ---------- | +| `${KEY1}` | same env | same folder | KEY1 | +| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 | +| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 | ## Secret Imports @@ -59,4 +60,12 @@ To reorder a secret import, hover over it and drag the arrows handle to the posi ![reorder secret import](../../images/platform/secret-references-imports/secret-import-reorder.png) - + diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index 67fc7f2a8..0664f0cd8 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -401,6 +401,59 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y +## Using templating to push secrets + +Pushing secrets to Infisical from the operator may not always be enough. +Templating is a useful utility of the Infisical secrets operator that allows you to use Go Templating to template the secrets you want to push to Infisical. +Using Go templates, you can format, combine, and create new key-value pairs of secrets that you want to push to Infisical. + + + + This property controls what secrets are included in your push to Infisica. + When set to `true`, all secrets included in the `push.secret.secretName` Kubernetes secret will be pushed to Infisical. + **Use this option when you would like to push all secrets to Infisical from the secrets operator, but want to template a subset of them.** + + When set to `false`, only secrets defined in the `push.secret.template.data` field of the template will be pushed to Infisical. + Use this option when you would like to push **only** a subset of secrets from the Kubernetes secret to Infisical. + + + Define secret keys and their corresponding templates. + Each data value uses a Golang template with access to all secrets defined in the `push.secret.secretName` Kubernetes secret. + + Secrets are structured as follows: + + ```go + type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` + } + ``` + + #### Example template configuration: + + ```yaml + # This example assumes that the `push-secret-demo` Kubernetes secret contains the following secrets: + # SITE_URL = "https://example.com" + # REGION = "us-east-1" + # OTHER_SECRET = "other-secret" + + push: + secret: + secretName: push-secret-demo + secretNamespace: default + template: + includeAllSecrets: true # Includes all secrets from the `push-secret-demo` Kubernetes secret + data: + SITE_URL: "{{ .SITE_URL.Value }}" + API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com" # Will create a new secret in Infisical with the key `API_URL` with the value of the `SITE_URL` and `REGION` secrets + ``` + + To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. + + ### Available templating functions + Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. + + ## Applying the InfisicalPushSecret CRD to your cluster Once you have configured the `InfisicalPushSecret` CRD with the required fields, you can apply it to your cluster. diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index 3266fd2b0..f23eb010d 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -654,30 +654,7 @@ To help transform your secrets further, the operator provides a set of built-in ### Available templating functions - - **Function name**: decodeBase64ToBytes - -**Description**: -Given a base64 encoded string, this function will decodes the base64-encoded string. -This function is useful when your secrets are already stored as base64 encoded value in Infisical. - -**Returns**: The decoded base64 string as bytes. - -**Example**: -The example below assumes that the `BINARY_KEY_BASE64` secret is stored as a base64 encoded value in Infisical. -The resulting managed secret will contain the decoded value of `BINARY_KEY_BASE64`. - -```yaml -managedKubeSecretReferences: -secretName: managed-secret -secretNamespace: default -template: - includeAllSecrets: true - data: - BINARY_KEY: "{{ decodeBase64ToBytes .BINARY_KEY_BASE64.Value }}" -``` - - +Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. @@ -783,31 +760,8 @@ Using Go templates, you can format, combine, and create new key-value pairs from To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. ### Available templating functions - - - **Function name**: decodeBase64ToBytes - - **Description**: - Given a base64 encoded string, this function will decodes the base64-encoded string. - This function is useful when your Infisical secrets are already stored as base64 encoded value in Infisical. - - **Returns**: The decoded base64 string as bytes. - - **Example**: - The example below assumes that the `BINARY_KEY_BASE64` secret is stored as a base64 encoded value in Infisical. - The resulting managed config map will contain the decoded value of `BINARY_KEY_BASE64`. - - ```yaml - managedKubeConfigMapReferences: - - configMapName: managed-configmap - configMapNamespace: default - template: - includeAllSecrets: true - data: - BINARY_KEY: "{{ decodeBase64ToBytes .BINARY_KEY_BASE64.Value }}" - ``` - - + + Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. ## Applying CRD @@ -854,39 +808,39 @@ Here, we will highlight three of the most common ways to utilize it. Learn more This will take all the secrets from your managed secret and expose them to your container -````yaml - envFrom: - - secretRef: - name: managed-secret # managed secret name - ``` + ````yaml + envFrom: + - secretRef: + name: managed-secret # managed secret name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - secretRef: - name: managed-secret # <- name of managed secret - ports: - - containerPort: 80 -```` + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - secretRef: + name: managed-secret # <- name of managed secret + ports: + - containerPort: 80 + ```` @@ -902,91 +856,90 @@ spec: key: SOME_SECRET_KEY # The name of the key which exists in the managed secret ``` -Example usage in a deployment + Example usage in a deployment -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - secretKeyRef: - name: managed-secret # <- name of managed secret - key: STRIPE_API_SECRET - ports: - - containerPort: 80 -``` - + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + secretKeyRef: + name: managed-secret # <- name of managed secret + key: STRIPE_API_SECRET + ports: + - containerPort: 80 + ``` -This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret -```yaml -volumes: - - name: secrets-volume-name # The name of the volume under which secrets will be stored - secret: - secretName: managed-secret # managed secret name -```` + This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret + ```yaml + volumes: + - name: secrets-volume-name # The name of the volume under which secrets will be stored + secret: + secretName: managed-secret # managed secret name + ```` -You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets + You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets -```yaml -volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true -``` + ```yaml + volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true + ``` -Example usage in a deployment + Example usage in a deployment -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: secrets-volume-name - secret: - secretName: managed-secret # <- managed secrets -``` + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: secrets-volume-name + secret: + secretName: managed-secret # <- managed secrets + ``` @@ -1021,34 +974,34 @@ secrets.infisical.com/auto-reload: "true" ``` -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx - annotations: - secrets.infisical.com/auto-reload: "true" # <- redeployment annotation -spec: - replicas: 1 - selector: - matchLabels: + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + annotations: + secrets.infisical.com/auto-reload: "true" # <- redeployment annotation + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - secretRef: - name: managed-secret - ports: - - containerPort: 80 -``` + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - secretRef: + name: managed-secret + ports: + - containerPort: 80 + ``` #### How it works @@ -1069,39 +1022,39 @@ Here, we will highlight three of the most common ways to utilize it. Learn more This will take all the secrets from your managed ConfigMap and expose them to your container -````yaml - envFrom: - - configMapRef: - name: managed-configmap # managed configmap name - ``` + ````yaml + envFrom: + - configMapRef: + name: managed-configmap # managed configmap name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - configMapRef: - name: managed-configmap # <- name of managed configmap - ports: - - containerPort: 80 -```` + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - configMapRef: + name: managed-configmap # <- name of managed configmap + ports: + - containerPort: 80 + ```` @@ -1117,92 +1070,91 @@ spec: key: SOME_CONFIG_KEY # The name of the key which exists in the managed configmap ``` -Example usage in a deployment + Example usage in a deployment -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - configMapKeyRef: - name: managed-configmap # <- name of managed configmap - key: STRIPE_API_SECRET - ports: - - containerPort: 80 -``` + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + configMapKeyRef: + name: managed-configmap # <- name of managed configmap + key: STRIPE_API_SECRET + ports: + - containerPort: 80 + ``` -This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret -```yaml -volumes: - - name: configmaps-volume-name # The name of the volume under which configmaps will be stored - configMap: - name: managed-configmap # managed configmap name -```` + This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret + ```yaml + volumes: + - name: configmaps-volume-name # The name of the volume under which configmaps will be stored + configMap: + name: managed-configmap # managed configmap name + ```` -You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets + You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets -```yaml -volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true -``` + ```yaml + volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true + ``` -Example usage in a deployment + Example usage in a deployment -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment - labels: - app: nginx -spec: - replicas: 1 - selector: - matchLabels: + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: app: nginx - template: - metadata: - labels: + spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: configmaps-volume-name - configMap: - name: managed-configmap # <- managed configmap -``` - + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: configmaps-volume-name + configMap: + name: managed-configmap # <- managed configmap + ``` The definition file of the Kubernetes secret for the CA certificate can be structured like the following: @@ -1228,37 +1180,37 @@ The operator will transfer all labels & annotations present on the `InfisicalSec Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example: -```yaml -apiVersion: secrets.infisical.com/v1alpha1 -kind: InfisicalSecret -metadata: - name: infisicalsecret-sample - labels: - label-to-be-passed-to-managed-secret: sample-value - annotations: - example.com/annotation-to-be-passed-to-managed-secret: "sample-value" -spec: - .. - authentication: - ... - managedKubeSecretReferences: - ... -``` + ```yaml + apiVersion: secrets.infisical.com/v1alpha1 + kind: InfisicalSecret + metadata: + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" + spec: + .. + authentication: + ... + managedKubeSecretReferences: + ... + ``` -This would result in the following managed secret to be created: + This would result in the following managed secret to be created: -```yaml -apiVersion: v1 -data: ... -kind: Secret -metadata: - annotations: - example.com/annotation-to-be-passed-to-managed-secret: sample-value - secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" - labels: - label-to-be-passed-to-managed-secret: sample-value - name: managed-token - namespace: default -type: Opaque -``` + ```yaml + apiVersion: v1 + data: ... + kind: Secret + metadata: + annotations: + example.com/annotation-to-be-passed-to-managed-secret: sample-value + secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" + labels: + label-to-be-passed-to-managed-secret: sample-value + name: managed-token + namespace: default + type: Opaque + ``` diff --git a/docs/integrations/platforms/kubernetes/overview.mdx b/docs/integrations/platforms/kubernetes/overview.mdx index c4e3f7c64..ca700d777 100644 --- a/docs/integrations/platforms/kubernetes/overview.mdx +++ b/docs/integrations/platforms/kubernetes/overview.mdx @@ -114,6 +114,48 @@ spec: ``` +## Advanced Templating + +With the Infisical Secrets Operator, you can use templating to dynamically generate secrets in Kubernetes. The templating is built on top of [Go templates](https://pkg.go.dev/text/template), which is a powerful and flexible template engine built into Go. + +Please be aware that trying to reference non-existing keys will result in an error. Additionally, each template field is processed individually, which means one template field cannot reference another template field. + + + Please note that templating is currently only supported for the `InfisicalPushSecret` and `InfisicalSecret` CRDs. + + +### Available helper functions + +The Infisical Secrets Operator exposes a wide range of helper functions to make it easier to work with secrets in Kubernetes. + +| Function | Description | Signature | +| -------- | ----------- | --------- | +| `decodeBase64ToBytes` | Given a base64 encoded string, this function will decode the base64-encoded string. | `decodeBase64ToBytes(encodedString string) string` | +| `encodeBase64` | Given a string, this function will encode the string to a base64 encoded string. | `encodeBase64(plainString string) string` | +| `pkcs12key`| Extracts all private keys from a PKCS#12 archive and encodes them in PKCS#8 PEM format. | `pkcs12key(input string) string` | +| `pkcs12keyPass`|Same as pkcs12key. Uses the provided password to decrypt the PKCS#12 archive. | `pkcs12keyPass(pass string, input string) string` | +| `pkcs12cert` | Extracts all certificates from a PKCS#12 archive and orders them if possible. If disjunct or multiple leaf certs are provided they are returned as-is. Sort order: `leaf / intermediate(s) / root`. | `pkcs12cert(input string) string` | +| `pkcs12certPass` | Same as `pkcs12cert`. Uses the provided password to decrypt the PKCS#12 archive. | `pkcs12certPass(pass string, input string) string` | +| `pemToPkcs12` | Takes a PEM encoded certificate and key and creates a base64 encoded PKCS#12 archive. | `pemToPkcs12(cert string, key string) string` | +| `pemToPkcs12Pass` | Same as `pemToPkcs12`. Uses the provided password to encrypt the PKCS#12 archive. | `pemToPkcs12Pass(cert string, key string, pass string) string` | +| `fullPemToPkcs12` | Takes a PEM encoded certificates chain and key and creates a base64 encoded PKCS#12 archive. | `fullPemToPkcs12(cert string, key string) string` | +| `fullPemToPkcs12Pass` | Same as `fullPemToPkcs12`. Uses the provided password to encrypt the PKCS#12 archive. | `fullPemToPkcs12Pass(cert string, key string, pass string) string` | +| `filterPEM` | Filters PEM blocks with a specific type from a list of PEM blocks.. | `filterPEM(pemType string, input string) string` | +| `filterCertChain` | Filters PEM block(s) with a specific certificate type (`leaf`, `intermediate` or `root`) from a certificate chain of PEM blocks (PEM blocks with type `CERTIFICATE`). | `filterCertChain(certType string, input string) string` | +| `jwkPublicKeyPem` | Takes an json-serialized JWK and returns an PEM block of type `PUBLIC KEY` that contains the public key. [See here](https://golang.org/pkg/crypto/x509/#MarshalPKIXPublicKey) for details. | `jwkPublicKeyPem(jwkjson string) string` | +| `jwkPrivateKeyPem` | Takes an json-serialized JWK and returns an PEM block of type `PRIVATE KEY` that contains the private key. [See here](https://pkg.go.dev/crypto/x509#MarshalPKCS8PrivateKey) for details. | `jwkPrivateKeyPem(jwkjson string) string` | +| `toYaml` | Takes an interface, marshals it to yaml. It returns a string, even on marshal error (empty string). | `toYaml(v any) string` | +| `fromYaml` | Function converts a YAML document into a `map[string]any`. | `fromYaml(str string) map[string]any` | + +### Sprig functions + +The Infisical Secrets Operator integrates with the [Sprig library](https://github.com/Masterminds/sprig) to provide additional helper functions. + + + We've removed `expandEnv` and `env` from the supported functions for security reasons. + + + ## Global configuration To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 609a39c4b..76e80d8b6 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { + faAnglesUp, faArrowUpRightFromSquare, + faDownLeftAndUpRightToCenter, faUpRightAndDownLeftFromCenter, faWindowRestore } from "@fortawesome/free-solid-svg-icons"; @@ -10,6 +12,7 @@ import { Background, BackgroundVariant, ConnectionLineType, + ControlButton, Controls, Node, NodeMouseHandler, @@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge"; import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; -import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; +import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput"; +import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; +import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components"; import { BasePermissionEdge } from "./edges"; import { useAccessTree } from "./hooks"; import { FolderNode, RoleNode } from "./nodes"; @@ -35,13 +40,30 @@ export type AccessTreeProps = { const EdgeTypes = { base: BasePermissionEdge }; -const NodeTypes = { role: RoleNode, folder: FolderNode }; +const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode }; const AccessTreeContent = ({ permissions }: AccessTreeProps) => { - const accessTreeData = useAccessTree(permissions); - const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; + const [selectedPath, setSelectedPath] = useState("/"); + const accessTreeData = useAccessTree(permissions, selectedPath); + const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData; + const [initialRender, setInitialRender] = useState(true); - const { fitView, getViewport, setCenter } = useReactFlow(); + useEffect(() => { + setSelectedPath("/"); + }, [environment]); + + const { getViewport, setCenter, fitView } = useReactFlow(); + + const goToRootNode = useCallback(() => { + const roleNode = nodes.find((node) => node.type === "role"); + if (roleNode) { + setCenter( + roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0), + roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0), + { duration: 800, zoom: 1 } + ); + } + }, [nodes, setCenter]); const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { @@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { ); useEffect(() => { - setTimeout(() => { - fitView({ - padding: 0.2, - duration: 1000, - maxZoom: 1 - }); - }, 1); - }, [fitView, nodes, edges, getViewport()]); + setInitialRender(true); + }, [selectedPath, environment]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (initialRender) { + timer = setTimeout(() => { + goToRootNode(); + setInitialRender(false); + }, 500); + } + return () => clearTimeout(timer); + }, [nodes, edges, getViewport(), initialRender, goToRootNode]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); @@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { edges={edges} edgeTypes={EdgeTypes} nodeTypes={NodeTypes} - fitView onNodeClick={onNodeClick} colorMode="dark" nodesDraggable={false} edgesReconnectable={false} nodesConnectable={false} connectionLineType={ConnectionLineType.SmoothStep} + minZoom={0.001} proOptions={{ hideAttribution: false // we need pro license if we want to hide }} @@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} {viewMode !== ViewMode.Docked && ( + {viewMode !== ViewMode.Undocked && ( + + )} { { @@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} - + {viewMode === ViewMode.Docked && ( + + + + )} - + fitView({ duration: 800 })} + > + + + + + + diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx index 476f2d27c..6e3790b3c 100644 --- a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -46,6 +46,12 @@ export const PermissionSimulation = ({ className="mr-1 rounded" colorSchema="secondary" onClick={handlePermissionSimulation} + rightIcon={ + + } > Permission Simulation diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 5672230c8..a03f8fa0f 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; +import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; import { useAccessTreeContext } from "../components"; import { PermissionAccess } from "../types"; @@ -15,8 +16,24 @@ import { getSubjectActionRuleMap, positionElements } from "../utils"; +import { createShowMoreNode } from "../utils/createShowMoreNode"; -export const useAccessTree = (permissions: MongoAbility) => { +const INITIAL_FOLDERS_PER_LEVEL = 10; +const FOLDERS_INCREMENT = 10; + +type LevelFolderMap = Record< + string, + { + folders: TSecretFolderWithPath[]; + visibleCount: number; + hasMore: boolean; + } +>; + +export const useAccessTree = ( + permissions: MongoAbility, + searchPath: string +) => { const { currentWorkspace } = useWorkspace(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const [nodes, setNodes] = useNodesState([]); @@ -27,19 +44,124 @@ export const useAccessTree = (permissions: MongoAbility({}); + const [totalFolderCount, setTotalFolderCount] = useState(0); + + const showMoreFolders = (parentId: string) => { + setLevelFolderMap((prevMap) => { + const level = prevMap[parentId]; + if (!level) return prevMap; + + const newVisibleCount = Math.min( + level.visibleCount + FOLDERS_INCREMENT, + level.folders.length + ); + + return { + ...prevMap, + [parentId]: { + ...level, + visibleCount: newVisibleCount, + hasMore: newVisibleCount < level.folders.length + } + }; + }); + }; + + const levelsWithMoreFolders = Object.entries(levelFolderMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, level]) => level.hasMore) + .map(([parentId]) => parentId); + + const getLevelCounts = (parentId: string) => { + const level = levelFolderMap[parentId]; + if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false }; + + return { + visibleCount: level.visibleCount, + totalCount: level.folders.length, + hasMore: level.hasMore + }; + }; + useEffect(() => { if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; - const { folders, name } = environmentsFolders[environment]; + const { folders } = environmentsFolders[environment]; + setTotalFolderCount(folders.length); + const groupedFolders: Record = {}; + + const filteredFolders = folders.filter((folder) => { + if (folder.path.startsWith(searchPath)) { + return true; + } + + if ( + searchPath.startsWith(folder.path) && + (folder.path === "/" || + searchPath === folder.path || + searchPath.indexOf("/", folder.path.length) === folder.path.length) + ) { + return true; + } + + return false; + }); + + filteredFolders.forEach((folder) => { + const parentId = folder.parentId || ""; + if (!groupedFolders[parentId]) { + groupedFolders[parentId] = []; + } + groupedFolders[parentId].push(folder); + }); + + const newLevelFolderMap: LevelFolderMap = {}; + + Object.entries(groupedFolders).forEach(([parentId, folderList]) => { + const key = parentId; + newLevelFolderMap[key] = { + folders: folderList, + visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length), + hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL + }; + }); + + setLevelFolderMap(newLevelFolderMap); + }, [permissions, environmentsFolders, environment, subject, secretName, searchPath]); + + useEffect(() => { + if ( + !environmentsFolders || + !permissions || + !environmentsFolders[environment] || + Object.keys(levelFolderMap).length === 0 + ) + return; + + const { slug } = environmentsFolders[environment]; const roleNode = createRoleNode({ subject, - environment: name + environment: slug, + environments: environmentsFolders, + onSubjectChange: setSubject, + onEnvironmentChange: setEnvironment }); const actionRuleMap = getSubjectActionRuleMap(subject, permissions); - const folderNodes = folders.map((folder) => + const visibleFolders: TSecretFolderWithPath[] = []; + Object.entries(levelFolderMap).forEach(([key, levelData]) => { + if (key !== "__rootFolderId") { + visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount)); + } + }); + + // eslint-disable-next-line no-underscore-dangle + const rootFolder = levelFolderMap.__rootFolderId?.folders[0]; + + const folderNodes = visibleFolders.map((folder) => createFolderNode({ folder, permissions, @@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility { - const actions = Object.values(folder.actions); + const folderEdges: Edge[] = []; + if (rootFolder) { + const rootFolderNode = folderNodes.find( + (node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path + ); + + if (rootFolderNode) { + const rootActions = Object.values(rootFolderNode.data.actions); + let rootAccess: PermissionAccess; + + if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) { + rootAccess = PermissionAccess.Full; + } else if ( + Object.values(rootActions).some((action) => action === PermissionAccess.Partial) + ) { + rootAccess = PermissionAccess.Partial; + } else { + rootAccess = PermissionAccess.None; + } + + folderEdges.push( + createBaseEdge({ + source: roleNode.id, + target: rootFolderNode.id, + access: rootAccess + }) + ); + } + } + + folderNodes.forEach(({ data: folder }) => { + if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) { + return; + } + + const actions = Object.values(folder.actions); let access: PermissionAccess; + if (Object.values(actions).some((action) => action === PermissionAccess.Full)) { access = PermissionAccess.Full; } else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) { @@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility { + if (parentId === "__rootFolderId") return; + + const key = parentId === "null" ? null : parentId; + + if (key && levelData.hasMore) { + const showMoreButtonNode = createShowMoreNode({ + parentId: key, + onClick: () => showMoreFolders(key), + remaining: levelData.folders.length - levelData.visibleCount, + subject + }); + + addMoreButtons.push(showMoreButtonNode); + + folderEdges.push( + createBaseEdge({ + source: key, + target: showMoreButtonNode.id, + access: PermissionAccess.Partial + }) + ); + } + }); + + const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]); setNodes(init.nodes); setEdges(init.edges); - }, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); + }, [ + levelFolderMap, + permissions, + environmentsFolders, + environment, + subject, + secretName, + setNodes, + setEdges + ]); return { nodes, @@ -86,6 +281,11 @@ export const useAccessTree = (permissions: MongoAbility void; +}; + +export const AccessTreeSecretPathInput = ({ + placeholder, + environment, + value, + onChange +}: AccessTreeSecretPathInputProps) => { + const [isFocused, setIsFocused] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + const timeout: NodeJS.Timeout = setTimeout(() => { + setIsFocused(false); + }, 200); + return () => clearTimeout(timeout); + }; + + useEffect(() => { + if (!isFocused) { + setIsExpanded(false); + } + }, [isFocused]); + + const focusInput = () => { + const inputElement = inputRef.current?.querySelector("input"); + if (inputElement) { + inputElement.focus(); + } + }; + + const toggleSearch = () => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + const timeout: NodeJS.Timeout = setTimeout(focusInput, 300); + return () => clearTimeout(timeout); + } + return () => {}; + }; + + return ( +
+
+ {isExpanded ? ( +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+ ) : ( + +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+
+ )} + +
+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx index d38d896f2..f6ffe212e 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -1,10 +1,42 @@ +import { Dispatch, SetStateAction } from "react"; +import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; +import { Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { createRoleNode } from "../utils"; +const getSubjectIcon = (subject: ProjectPermissionSub) => { + switch (subject) { + case ProjectPermissionSub.Secrets: + return ; + case ProjectPermissionSub.SecretFolders: + return ; + case ProjectPermissionSub.DynamicSecrets: + return ; + case ProjectPermissionSub.SecretImports: + return ; + default: + return ; + } +}; + +const formatLabel = (text: string) => { + return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +}; + export const RoleNode = ({ - data: { subject, environment } -}: NodeProps & { data: ReturnType["data"] }) => { + data: { subject, environment, onSubjectChange, onEnvironmentChange, environments } +}: NodeProps & { + data: ReturnType["data"] & { + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; + environments: TProjectEnvironmentsFolders; + }; +}) => { return ( <> -
-
- {subject.replace("-", " ")} Access -
-

{environment}

+
+
+
+
Subject
+ +
+ +
+
Environment
+
diff --git a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx new file mode 100644 index 000000000..dc412d8ca --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx @@ -0,0 +1,37 @@ +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { Button, Tooltip } from "@app/components/v2"; + +import { createShowMoreNode } from "../utils/createShowMoreNode"; + +export const ShowMoreButtonNode = ({ + data: { onClick, remaining } +}: NodeProps & { data: ReturnType["data"] }) => { + const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`; + + return ( +
+ + +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/types/index.ts b/frontend/src/components/permissions/AccessTree/types/index.ts index 9536d7f86..c5da34915 100644 --- a/frontend/src/components/permissions/AccessTree/types/index.ts +++ b/frontend/src/components/permissions/AccessTree/types/index.ts @@ -7,7 +7,8 @@ export enum PermissionAccess { export enum PermissionNode { Role = "role", Folder = "folder", - Environment = "environment" + Environment = "environment", + ShowMoreButton = "showMoreButton" } export enum PermissionEdge { diff --git a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts index cc53360c8..0e4aa0c9d 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts @@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types"; export const createBaseEdge = ({ source, target, - access + access, + hideEdge = false }: { source: string; target: string; access: PermissionAccess; + hideEdge?: boolean; }) => { const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; return { @@ -17,10 +19,12 @@ export const createBaseEdge = ({ source, target, type: PermissionEdge.Base, - markerEnd: { - type: MarkerType.ArrowClosed, - color - }, - style: { stroke: color } + markerEnd: hideEdge + ? undefined + : { + type: MarkerType.ArrowClosed, + color + }, + style: { stroke: hideEdge ? "transparent" : color } }; }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts index 354a69482..69ec60896 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts @@ -1,17 +1,31 @@ +import { Dispatch, SetStateAction } from "react"; + +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { PermissionNode } from "../types"; export const createRoleNode = ({ subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }: { subject: string; environment: string; + environments: TProjectEnvironmentsFolders; + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; }) => ({ id: `role-${subject}-${environment}`, position: { x: 0, y: 0 }, data: { subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }, type: PermissionNode.Role, height: 48, diff --git a/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts new file mode 100644 index 000000000..f12cd598e --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts @@ -0,0 +1,45 @@ +import { ProjectPermissionSub } from "@app/context"; + +import { PermissionNode } from "../types"; + +export const createShowMoreNode = ({ + parentId, + onClick, + remaining, + subject +}: { + parentId: string | null; + onClick: () => void; + remaining: number; + subject: ProjectPermissionSub; +}) => { + let height: number; + + switch (subject) { + case ProjectPermissionSub.DynamicSecrets: + height = 130; + break; + case ProjectPermissionSub.Secrets: + height = 85; + break; + default: + height = 64; + } + const id = `show-more-${parentId || "root"}`; + return { + id, + type: PermissionNode.ShowMoreButton, + position: { x: 0, y: 0 }, + data: { + parentId, + onClick, + remaining + }, + width: 150, + height, + style: { + background: "transparent", + border: "none" + } + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 523b402d1..c4daf6317 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre"; import { Edge, Node } from "@xyflow/react"; export const positionElements = (nodes: Node[], edges: Edge[]) => { + const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton"); + const showMoreParentIds = new Set( + showMoreNodes.map((node) => node.data.parentId).filter(Boolean) + ); + + const nodeMap: Record = {}; + const childrenMap: Record = {}; + + edges.forEach((edge) => { + if (!childrenMap[edge.source]) { + childrenMap[edge.source] = []; + } + childrenMap[edge.source].push(edge.target); + }); + const dagre = new Dagre.graphlib.Graph({ directed: true }) .setDefaultEdgeLabel(() => ({})) - .setGraph({ rankdir: "TB" }); + .setGraph({ + rankdir: "TB", + nodesep: 50, + ranksep: 70 + }); + + nodes.forEach((node) => { + dagre.setNode(node.id, { + width: node.width || 150, + height: node.height || 40 + }); + }); edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); - nodes.forEach((node) => dagre.setNode(node.id, node)); Dagre.layout(dagre, {}); - return { - nodes: nodes.map((node) => { - const { x, y } = dagre.node(node.id); + const positionedNodes = nodes.map((node) => { + const { x, y } = dagre.node(node.id); + if (node.type === "role") { return { ...node, position: { x: x - (node.width ? node.width / 2 : 0), - y: y - (node.height ? node.height / 2 : 0) + y: y - 150 } }; - }), + } + + return { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + }, + style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style + }; + }); + + positionedNodes.forEach((node) => { + nodeMap[node.id] = node; + }); + + Array.from(showMoreParentIds).forEach((parentId) => { + const showMoreNodeIndex = positionedNodes.findIndex( + (node) => node.type === "showMoreButton" && node.data.parentId === parentId + ); + + if (showMoreNodeIndex !== -1) { + const siblings = positionedNodes.filter( + (node) => node.data?.parentId === parentId && node.type !== "showMoreButton" + ); + + if (siblings.length > 0) { + const rightmostSibling = siblings.reduce( + (rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost), + siblings[0] + ); + + positionedNodes[showMoreNodeIndex] = { + ...positionedNodes[showMoreNodeIndex], + position: { + x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30, + y: rightmostSibling.position.y + } + }; + } + } + }); + + return { + nodes: positionedNodes, edges }; }; diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 8e82c2915..c82e492e8 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -48,7 +48,6 @@ export const SecretPathInput = ({ }, [propValue]); useEffect(() => { - // update secret path if input is valid if ( (debouncedInputValue.length > 0 && debouncedInputValue[debouncedInputValue.length - 1] === "/") || @@ -59,7 +58,6 @@ export const SecretPathInput = ({ }, [debouncedInputValue]); useEffect(() => { - // filter suggestions based on matching const searchFragment = debouncedInputValue.split("/").pop() || ""; const filteredSuggestions = folders .filter((suggestionEntry) => @@ -78,7 +76,6 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - // removed trailing slash const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; onChange?.(newValue); setInputValue(newValue); @@ -102,7 +99,6 @@ export const SecretPathInput = ({ }; const handleInputChange = (e: any) => { - // propagate event to react-hook-form onChange if (onChange) { onChange(e.target.value); } @@ -141,7 +137,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
+
{suggestions.map((suggestion, i) => (
; actorType?: ActorType; projectId?: string; + environment?: string; actor?: string; // user ID format secretPath?: string; + secretKey?: string; startDate?: Date; endDate?: Date; limit: number; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index ba417430a..061baae0c 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -319,12 +319,13 @@ const fetchAccessibleSecrets = async ({ projectId, secretPath, environment, - filterByAction + filterByAction, + recursive = false }: TGetAccessibleSecretsDTO) => { const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>( "/api/v1/dashboard/accessible-secrets", { - params: { projectId, secretPath, environment, filterByAction } + params: { projectId, secretPath, environment, filterByAction, recursive } } ); @@ -399,7 +400,8 @@ export const useGetAccessibleSecrets = ({ secretPath, environment, filterByAction, - options + options, + recursive = false }: TGetAccessibleSecretsDTO & { options?: Omit< UseQueryOptions< @@ -417,8 +419,10 @@ export const useGetAccessibleSecrets = ({ projectId, secretPath, environment, - filterByAction + filterByAction, + recursive }), - queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction }) + queryFn: () => + fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive }) }); }; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index bdff878cd..556f76eaf 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -111,6 +111,7 @@ export type TGetAccessibleSecretsDTO = { projectId: string; secretPath: string; environment: string; + recursive?: boolean; filterByAction: | ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 5c4d4ffc6..58383e1ff 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = { includeImports?: boolean; viewSecretValue?: boolean; expandSecretReferences?: boolean; + recursive?: boolean; }; export type TGetProjectSecretsDTO = TGetProjectSecretsKey; diff --git a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx index e0d2fba73..bec3bfd77 100644 --- a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx @@ -18,7 +18,7 @@ export const AuditLogsPage = () => { title="Audit logs" description="Audit logs for security and compliance teams to monitor information access." /> - +
diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx new file mode 100644 index 000000000..38df23aad --- /dev/null +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx @@ -0,0 +1,32 @@ +import { twMerge } from "tailwind-merge"; + +import { Button, Tooltip } from "@app/components/v2"; + +type Props = { + hoverTooltip?: string; + className?: string; + label: string; + onClear: () => void; + children: React.ReactNode; +}; + +export const LogFilterItem = ({ label, onClear, hoverTooltip, children, className }: Props) => { + return ( + +
+
+

{label}

+ +
+ {children} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index c32819cde..59440c814 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -1,11 +1,26 @@ /* eslint-disable no-nested-ternary */ -import { useState } from "react"; -import { Control, Controller, UseFormReset, UseFormSetValue, UseFormWatch } from "react-hook-form"; -import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { useMemo, useState } from "react"; +import { + Control, + Controller, + UseFormGetFieldState, + UseFormReset, + UseFormResetField, + UseFormSetValue, + UseFormWatch +} from "react-hook-form"; +import { + faArrowRight, + faCaretDown, + faCheckCircle, + faFilterCircleXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { + Badge, Button, DatePicker, DropdownMenu, @@ -19,13 +34,17 @@ import { SelectItem } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/api"; -import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; -import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums"; -import { Actor } from "@app/hooks/api/auditLogs/types"; -import { ProjectType } from "@app/hooks/api/workspace/types"; +import { useGetUserWorkspaces } from "@app/hooks/api"; +import { + eventToNameMap, + secretEvents, + userAgentTTypeoNameMap +} from "@app/hooks/api/auditLogs/constants"; +import { EventType } from "@app/hooks/api/auditLogs/enums"; +import { UserAgentType } from "@app/hooks/api/auth/types"; -import { AuditLogFilterFormData } from "./types"; +import { LogFilterItem } from "./LogFilterItem"; +import { AuditLogFilterFormData, Presets } from "./types"; const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({ @@ -34,26 +53,70 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label })); type Props = { - presets?: { - actorId?: string; - eventType?: EventType[]; - }; - className?: string; - isOrgAuditLogs?: boolean; - setValue: UseFormSetValue; + presets?: Presets; control: Control; reset: UseFormReset; + resetField: UseFormResetField; watch: UseFormWatch; + getFieldState: UseFormGetFieldState; + setValue: UseFormSetValue; +}; + +const getActiveFilterCount = ( + getFieldState: UseFormGetFieldState, + watch: UseFormWatch +) => { + const fields = [ + "actor", + "project", + "eventType", + "startDate", + "endDate", + "environment", + "secretPath", + "userAgentType", + "secretKey" + ] as Partial[]; + + let filterCount = 0; + + // either start or end date should only be counted as one filter + let dateProcessed = false; + + fields.forEach((field) => { + const fieldState = getFieldState(field); + + if ( + field === "userAgentType" || + field === "environment" || + field === "secretKey" || + field === "secretPath" + ) { + const value = watch(field); + + if (value !== undefined && value !== "") { + filterCount += 1; + } + } else if (fieldState.isDirty && !dateProcessed) { + filterCount += 1; + + if (field === "startDate" || field === "endDate") { + dateProcessed = true; + } + } + }); + + return filterCount; }; export const LogsFilter = ({ presets, - isOrgAuditLogs, - className, control, reset, - setValue, - watch + resetField, + watch, + getFieldState, + setValue }: Props) => { const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); @@ -63,288 +126,423 @@ export const LogsFilter = ({ const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id); - const { data, isPending } = useGetAuditLogActorFilterOpts(workspaces?.[0]?.id ?? ""); - - const renderActorSelectItem = (actor: Actor) => { - switch (actor.type) { - case ActorType.USER: - return ( - - {actor.metadata.email} - - ); - case ActorType.SERVICE: - return ( - - {actor.metadata.name} - - ); - case ActorType.IDENTITY: - return ( - - {actor.metadata.name} - - ); - case ActorType.KMIP_CLIENT: - return ( - - {actor.metadata.name} - - ); - default: - return ( - - N/A - - ); - } - }; - const selectedEventTypes = watch("eventType") as EventType[] | undefined; const selectedProject = watch("project"); + const showSecretsSection = + selectedEventTypes?.some( + (eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS + ) || selectedEventTypes?.length === 0; + + const availableEnvironments = useMemo(() => { + if (!selectedProject) return []; + + return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? []; + }, [selectedProject, workspacesInOrg]); + + const activeFilterCount = getActiveFilterCount(getFieldState, watch); + return ( -
-
- {isOrgAuditLogs && workspacesInOrg.length > 0 && ( - ( - - { - if (e === null) { - setValue("secretPath", ""); - } - onChange(e); - }} - placeholder="Select a project..." - options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - - )} - /> - )} - {selectedProject?.type === ProjectType.SecretManager && ( - ( - - onChange(e.target.value)} /> - - )} - /> - )} -
-
- ( - - - -
- {selectedEventTypes?.length === 1 - ? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0]) - ?.label - : selectedEventTypes?.length === 0 - ? "All events" - : `${selectedEventTypes?.length} events selected`} - -
-
- -
- {eventTypes && eventTypes.length > 0 ? ( - eventTypes.map((eventType) => { - const isSelected = selectedEventTypes?.includes( - eventType.value as EventType - ); - - return ( - eventTypes.length > 1 && event.preventDefault()} - onClick={() => { - if (selectedEventTypes?.includes(eventType.value as EventType)) { - field.onChange( - selectedEventTypes?.filter((e: string) => e !== eventType.value) - ); - } else { - field.onChange([...(selectedEventTypes || []), eventType.value]); - } - }} - key={`event-type-${eventType.value}`} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {eventType.label} - - ); - }) - ) : ( -
- )} -
- - - + + + -
-
+ + +
+
+
+
+ Filters + + {activeFilterCount} + +
+ +
+
+ +
+ { + resetField("eventType"); + }} + > + ( + + + +
+ {selectedEventTypes?.length === 1 + ? eventTypes.find( + (eventType) => eventType.value === selectedEventTypes[0] + )?.label + : selectedEventTypes?.length === 0 + ? "All events" + : `${selectedEventTypes?.length} events selected`} + +
+
+ +
+ {eventTypes && eventTypes.length > 0 ? ( + eventTypes.map((eventType) => { + const isSelected = selectedEventTypes?.includes( + eventType.value as EventType + ); + + return ( + + eventTypes.length > 1 && event.preventDefault() + } + onClick={() => { + if ( + selectedEventTypes?.includes(eventType.value as EventType) + ) { + field.onChange( + selectedEventTypes?.filter( + (e: string) => e !== eventType.value + ) + ); + } else { + field.onChange([ + ...(selectedEventTypes || []), + eventType.value + ]); + } + }} + key={`event-type-${eventType.value}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {eventType.label} + + ); + }) + ) : ( +
+ )} +
+ + + + )} + /> + + { + resetField("userAgentType"); + }} + > + ( + + + + )} + /> + + + { + resetField("startDate"); + resetField("endDate"); + }} + > +
+ { + return ( + + + + ); + }} + /> + +
+
+ +
+ + { + return ( + + + + ); + }} + /> +
+ + + {showSecretsSection && ( + +
+

Secrets

+
+
+ + { + resetField("project"); + resetField("environment"); + setValue("secretPath", ""); + setValue("secretKey", ""); + }} + > + ( + + { + if (e === null) { + setValue("secretPath", ""); + setValue("secretKey", ""); + } + resetField("environment"); + onChange(e); + }} + placeholder="All projects" + options={workspacesInOrg.map(({ name, id, type }) => ({ + name, + id, + type + }))} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + + { + resetField("environment"); + }} + > + ( + + onChange(e)} + placeholder="All environments" + options={availableEnvironments.map(({ name, slug }) => ({ + name, + slug + }))} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + { + setValue("secretPath", ""); + }} + > + ( + + onChange(e.target.value)} + /> + + )} + /> + + + { + setValue("secretKey", ""); + }} + > + ( + + + setValue("secretKey", e.target.value, { shouldDirty: true }) + } + /> + + )} + /> + + + )} + +
+
+ + ); }; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx index 6ee1e5093..1878a49e3 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx @@ -6,46 +6,40 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; import { useDebounce } from "@app/hooks"; -import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; +import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { usePopUp } from "@app/hooks/usePopUp"; import { LogsFilter } from "./LogsFilter"; import { LogsTable } from "./LogsTable"; -import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types"; +import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types"; type Props = { - presets?: { - actorId?: string; - eventType?: EventType[]; - actorType?: ActorType; - startDate?: Date; - endDate?: Date; - eventMetadata?: Record; - }; - - showFilters?: boolean; - filterClassName?: string; + presets?: Presets; refetchInterval?: number; + showFilters?: boolean; }; export const LogsSection = withPermission( - ({ presets, filterClassName, refetchInterval, showFilters }: Props) => { + ({ presets, refetchInterval, showFilters = true }: Props) => { const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); - const { control, reset, watch, setValue } = useForm({ - resolver: zodResolver(auditLogFilterFormSchema), - defaultValues: { - project: null, - actor: presets?.actorId, - eventType: presets?.eventType || [], - page: 1, - perPage: 10, - startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today - endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today - } - }); + const { control, reset, watch, getFieldState, resetField, setValue } = + useForm({ + resolver: zodResolver(auditLogFilterFormSchema), + defaultValues: { + project: null, + environment: undefined, + secretKey: "", + secretPath: "", + actor: presets?.actorId, + eventType: presets?.eventType || [], + userAgentType: undefined, + startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), + endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) + } + }); useEffect(() => { if (subscription && !subscription.auditLogs) { @@ -57,30 +51,37 @@ export const LogsSection = withPermission( const userAgentType = watch("userAgentType") as UserAgentType | undefined; const actor = watch("actor"); const projectId = watch("project")?.id; + const environment = watch("environment")?.slug; const secretPath = watch("secretPath"); + const secretKey = watch("secretKey"); const startDate = watch("startDate"); const endDate = watch("endDate"); const [debouncedSecretPath] = useDebounce(secretPath!, 500); + const [debouncedSecretKey] = useDebounce(secretKey!, 500); return ( -
- {showFilters && ( - - )} +
+
+ {showFilters && ( + + )} +
+ diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx index 69b550144..1c1baccf0 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx @@ -1,10 +1,12 @@ import { Fragment } from "react"; import { faFile, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { Button, EmptyState, + Spinner, Table, TableContainer, TableSkeleton, @@ -52,7 +54,9 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => { -
+ + + Timestamp { )} + + {(isAllowed) => ( + + )} + @@ -679,6 +1011,15 @@ export const ActionBar = ({ handlePopUpToggle={handlePopUpToggle} onMoveApproved={handleSecretsMove} /> + handlePopUpToggle("replicateFolder", isOpen)} + onParsedEnv={handleParsedEnvMultiFolder} + environment={environment} + environments={currentWorkspace.environments} + workspaceId={workspaceId} + secretPath={secretPath} + /> {subscription && ( )} + handlePopUpToggle("confirmUpload", open)} + > + + {isNonConflictingUpload ? "Upload" : "Overwrite"} + , + + ]} + > + {isNonConflictingUpload ? ( +
+ Are you sure you want to import {createSecretCount} secret + {createSecretCount > 1 ? "s" : ""} to this environment? +
+ ) : ( +
+
Your project already contains the following {updateSecretCount} secrets:
+
+ {Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {}) + ?.map((key) => key) + .join(", ")} +
+
+ Are you sure you want to overwrite these secrets + {createSecretCount > 0 + ? ` and import ${createSecretCount} new + one${createSecretCount > 1 ? "s" : ""}` + : ""} + ? +
+
+ )} +
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx new file mode 100644 index 000000000..339612a89 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faClone } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalContent, + Switch +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; +import { useDebounce } from "@app/hooks"; +import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard"; +import { SecretV3Raw } from "@app/hooks/api/types"; + +import { SecretTreeView } from "./SecretTreeView"; + +const formSchema = z.object({ + environment: z.object({ name: z.string(), slug: z.string() }), + secretPath: z + .string() + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ), + secrets: z + .object({ + secretKey: z.string(), + secretValue: z.string().optional(), + secretPath: z.string() + }) + .array() + .min(1, "Select one or more secrets to copy") +}); + +type TFormSchema = z.infer; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + onParsedEnv: ( + env: Record> + ) => void; + environments?: { name: string; slug: string }[]; + workspaceId: string; + environment: string; + secretPath: string; +}; + +type SecretFolder = { + items: Partial[]; + subFolders: Record; +}; + +type SecretStructure = { + [rootPath: string]: SecretFolder; +}; + +export const ReplicateFolderFromBoard = ({ + environments = [], + workspaceId, + isOpen, + onToggle, + onParsedEnv +}: Props) => { + const [shouldIncludeValues, setShouldIncludeValues] = useState(true); + + const { handleSubmit, control, watch, reset, setValue } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0], secrets: [] } + }); + + const envCopySecPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const selectedSecrets = watch("secrets"); + const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath); + + const { data: accessibleSecrets } = useGetAccessibleSecrets({ + projectId: workspaceId, + secretPath: "/", + environment: selectedEnvSlug.slug, + recursive: true, + filterByAction: shouldIncludeValues + ? ProjectPermissionSecretActions.ReadValue + : ProjectPermissionSecretActions.DescribeSecret, + options: { enabled: Boolean(workspaceId) && Boolean(selectedEnvSlug) && isOpen } + }); + + const restructureSecrets = useMemo(() => { + if (!accessibleSecrets) return {}; + + const result: SecretStructure = {}; + result["/"] = { + items: [], + subFolders: {} + }; + + accessibleSecrets.forEach((secret) => { + const path = secret.secretPath || "/"; + + if (path === "/") { + result["/"]?.items.push(secret); + return; + } + + const normalizedPath = path.startsWith("/") ? path.substring(1) : path; + const pathParts = normalizedPath.split("/"); + + let currentFolder = result["/"]; + + for (let i = 0; i < pathParts.length; i += 1) { + const part = pathParts[i]; + + // eslint-disable-next-line no-continue + if (!part) continue; + + if (i === pathParts.length - 1) { + if (!currentFolder.subFolders[part]) { + currentFolder.subFolders[part] = { + items: [], + subFolders: {} + }; + } + currentFolder.subFolders[part].items.push(secret); + } else { + if (!currentFolder.subFolders[part]) { + currentFolder.subFolders[part] = { + items: [], + subFolders: {} + }; + } + currentFolder = currentFolder.subFolders[part]; + } + } + }); + + return result; + }, [accessibleSecrets, selectedEnvSlug]); + + const secretsFilteredByPath = useMemo(() => { + let normalizedPath = debouncedEnvCopySecretPath; + normalizedPath = debouncedEnvCopySecretPath.startsWith("/") + ? debouncedEnvCopySecretPath + : `/${debouncedEnvCopySecretPath}`; + if (normalizedPath.length > 1 && normalizedPath.endsWith("/")) { + normalizedPath = debouncedEnvCopySecretPath.slice(0, -1); + } + + if (normalizedPath === "/") { + return restructureSecrets["/"]; + } + + const segments = normalizedPath.split("/").filter((segment) => segment !== ""); + + let currentLevel = restructureSecrets["/"]; + let result = null; + let currentPath = ""; + + if (!currentLevel) { + setValue("secretPath", "/"); + return null; + } + + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]; + currentPath += `/${segment}`; + + if (currentLevel?.subFolders?.[segment]) { + currentLevel = currentLevel.subFolders[segment]; + + if (currentPath === normalizedPath) { + result = currentLevel; + break; + } + } else { + return null; + } + } + + return result; + }, [restructureSecrets, debouncedEnvCopySecretPath]); + + useEffect(() => { + setValue("secrets", []); + }, [debouncedEnvCopySecretPath, selectedEnvSlug]); + + const handleFormSubmit = async (data: TFormSchema) => { + const secretsToBePulled: Record< + string, + Record + > = {}; + data.secrets.forEach(({ secretKey, secretValue, secretPath: secretPathToRecreate }) => { + const normalizedPath = secretPathToRecreate.startsWith(envCopySecPath) + ? secretPathToRecreate.slice(envCopySecPath.length) + : secretPathToRecreate; + + if (!secretsToBePulled[normalizedPath]) { + secretsToBePulled[normalizedPath] = {}; + } + + secretsToBePulled[normalizedPath][secretKey] = { + value: (shouldIncludeValues && secretValue) || "", + comments: [""], + secretPath: normalizedPath + }; + }); + onParsedEnv(secretsToBePulled); + onToggle(false); + reset(); + }; + + return ( + { + onToggle(state); + reset(); + }} + > + +
+
+ ( + + option.name} + getOptionValue={(option) => option.slug} + /> + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ { + setValue("secrets", []); + setShouldIncludeValues(isChecked as boolean); + }} + > + Include secret values + +
+
+ + +
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx new file mode 100644 index 000000000..4119f54e3 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + faChevronDown, + faChevronRight, + faFolder, + faFolderOpen, + faFolderTree, + faKey +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +import { Checkbox } from "@app/components/v2"; + +interface SecretItem { + id?: string; + secretKey?: string; + secretValue?: string; + secretPath?: string; + [key: string]: any; +} + +interface FolderStructure { + items: SecretItem[]; + subFolders: { + [key: string]: FolderStructure; + }; +} + +interface TreeData { + [key: string]: FolderStructure | null; +} + +interface FolderProps { + name: string; + structure: FolderStructure; + path: string; + selectedItems: SecretItem[]; + onItemSelect: (item: SecretItem, isChecked: boolean) => void; + onFolderSelect: (folderPath: string, isChecked: boolean) => void; + isExpanded?: boolean; + level: number; + basePath?: string; +} + +interface TreeViewProps { + data: FolderStructure | null; + basePath?: string; + className?: string; + onChange: (items: SecretItem[]) => void; +} + +const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => { + let items: SecretItem[] = []; + + items = items.concat(folder.items); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Object.entries(folder.subFolders).forEach(([_, subFolder]) => { + items = items.concat(getAllItemsInFolder(subFolder)); + }); + + return items; +}; + +const getDisplayName = (name: string): string => { + const parts = name.split("/"); + return parts[parts.length - 1]; +}; + +const Collapsible = CollapsiblePrimitive.Root; +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; +const CollapsibleContent = CollapsiblePrimitive.Content; + +const Folder: React.FC = ({ + name, + structure, + path, + selectedItems, + onItemSelect, + onFolderSelect, + isExpanded = false, + level, + basePath +}) => { + const [open, setOpen] = useState(isExpanded); + const displayName = useMemo(() => getDisplayName(name), [name]); + + const allItems = useMemo(() => getAllItemsInFolder(structure), [structure]); + const allItemIds = useMemo(() => allItems.map((item) => item.id), [allItems]); + const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]); + const allSelected = useMemo( + () => allItemIds.length > 0 && allItemIds.every((id) => selectedItemIds.includes(id)), + [allItemIds, selectedItemIds] + ); + const someSelected = useMemo( + () => allItemIds.some((id) => selectedItemIds.includes(id)) && !allSelected, + [allItemIds, selectedItemIds, allSelected] + ); + const hasContents = structure.items.length > 0 || Object.keys(structure.subFolders).length > 0; + + const handleFolderSelect = (checked: boolean) => { + onFolderSelect(path, checked); + }; + + return ( +
0 ? "4" : 0}`}> + +
+ + + + +
+ 0 ? (open ? faFolderOpen : faFolder) : faFolderTree} + className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`} + /> +
+ + + + + {allItemIds.length > 0 && ( + + {allItemIds.length} {allItemIds.length === 1 ? "item" : "items"} + + )} +
+ + +
+
+ {structure.items.map((item) => ( +
+
+ +
+ onItemSelect(item, !!checked)} + /> + +
+ ))} + + {Object.entries(structure.subFolders).map(([subName, subStructure]) => ( + + ))} +
+ + +
+ ); +}; + +export const SecretTreeView: React.FC = ({ + data, + basePath = "/", + className = "", + onChange +}) => { + const [selectedItems, setSelectedItems] = useState([]); + const rootPath = "/"; + const treeData: TreeData = data ? { [rootPath]: data as FolderStructure } : { [rootPath]: null }; + + const rootFolders = useMemo(() => { + return Object.entries(treeData); + }, [treeData]); + + const isEmptyData = useMemo(() => { + return ( + !data || (typeof data === "object" && Object.keys(data).length === 0) || !rootFolders.length + ); + }, [data, rootFolders]); + + const handleItemSelect = (item: SecretItem, isChecked: boolean) => { + if (isChecked) { + setSelectedItems((prev) => [...prev, item]); + } else { + setSelectedItems((prev) => prev.filter((i) => i.id !== item.id)); + } + }; + + const handleFolderSelect = (folderPath: string, isChecked: boolean) => { + const getFolderFromPath = (tree: TreeData, path: string): FolderStructure | null => { + if (rootFolders.length === 1 && rootFolders[0][0] === path) { + return rootFolders[0][1]; + } + + let adjustedPath = path; + if (!path.startsWith(rootPath)) { + adjustedPath = rootPath === path ? rootPath : `${rootPath}/${path}`; + } + + if (adjustedPath === "/") return tree["/"]; + + const parts = adjustedPath.split("/").filter((p) => p !== ""); + + let current: any; + current = tree["/"]; + + const targetExists = parts.every((part) => { + if (current?.subFolders?.[part]) { + current = current.subFolders[part]; + return true; + } + return false; + }); + + if (!targetExists) { + return null; + } + + return current; + }; + + const folder = getFolderFromPath(treeData, folderPath); + if (!folder) return; + + const folderItems = getAllItemsInFolder(folder); + const folderItemIds = folderItems.map((item) => item.id); + + if (isChecked) { + setSelectedItems((prev) => { + const prevIds = prev.map((item) => item.id); + const newItems = [...prev]; + folderItems.forEach((item) => { + if (!prevIds.includes(item.id)) { + newItems.push(item); + } + }); + return newItems; + }); + } else { + setSelectedItems((prev) => prev.filter((item) => !folderItemIds.includes(item.id))); + } + }; + + useEffect(() => { + setSelectedItems([]); + }, [data]); + + useEffect(() => { + onChange(selectedItems); + }, [selectedItems]); + + return ( +
+
+
+ {isEmptyData ? ( +
+

No secrets or folders available

+
+ ) : ( + rootFolders.map(([folderName, folderStructure]) => ( + + )) + )} +
+ +
+

+ {selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected +

+
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index e289c1894..de4043975 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -64,7 +64,8 @@ export const SecretDropzone = ({ const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({ options: { onSuccess: undefined } }); - + // hide copy secrets from board due to import folders feature + const shouldRenderCopySecrets = false; const isSubmitting = isCreatingSecrets || isUpdatingSecrets; const handleDrag = (e: DragEvent) => { @@ -308,16 +309,18 @@ export const SecretDropzone = ({ secretPath={secretPath} isSmaller={isSmaller} /> - handlePopUpToggle("importSecEnv", isOpen)} - onParsedEnv={handleParsedEnv} - environment={environment} - environments={environments} - workspaceId={workspaceId} - secretPath={secretPath} - isSmaller={isSmaller} - /> + {shouldRenderCopySecrets && ( + handlePopUpToggle("importSecEnv", isOpen)} + onParsedEnv={handleParsedEnv} + environment={environment} + environments={environments} + workspaceId={workspaceId} + secretPath={secretPath} + isSmaller={isSmaller} + /> + )} {!isSmaller && ( { startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), eventType: INTEGRATION_EVENTS }} - filterClassName="bg-mineshaft-900 static" /> ) : (
diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 087bcd4b6..fea5a1006 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.8.15 +version: v0.9.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.8.15" +appVersion: "v0.9.0" diff --git a/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml index 8491df01c..0c73101a3 100644 --- a/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml @@ -137,6 +137,19 @@ spec: secretNamespace: description: The name space where the Kubernetes Secret is located type: string + template: + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the + top level of your template. Secrets defined in the template + will take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 897670c46..80ed74356 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: + tag: v0.9.0 resources: limits: cpu: 500m diff --git a/k8-operator/api/v1alpha1/common.go b/k8-operator/api/v1alpha1/common.go index 5631984d1..2489b0b95 100644 --- a/k8-operator/api/v1alpha1/common.go +++ b/k8-operator/api/v1alpha1/common.go @@ -105,7 +105,7 @@ type ManagedKubeSecretConfig struct { // The template to transform the secret data // +kubebuilder:validation:Optional - Template *InfisicalSecretTemplate `json:"template,omitempty"` + Template *SecretTemplate `json:"template,omitempty"` } type ManagedKubeConfigMapConfig struct { @@ -127,5 +127,15 @@ type ManagedKubeConfigMapConfig struct { // The template to transform the secret data // +kubebuilder:validation:Optional - Template *InfisicalSecretTemplate `json:"template,omitempty"` + Template *SecretTemplate `json:"template,omitempty"` +} + +type SecretTemplate struct { + // This injects all retrieved secrets into the top level of your template. + // Secrets defined in the template will take precedence over the injected ones. + // +kubebuilder:validation:Optional + IncludeAllSecrets bool `json:"includeAllSecrets"` + // The template key values + // +kubebuilder:validation:Optional + Data map[string]string `json:"data,omitempty"` } diff --git a/k8-operator/api/v1alpha1/infisicalpushsecret_types.go b/k8-operator/api/v1alpha1/infisicalpushsecret_types.go index 9a1040868..5a7438881 100644 --- a/k8-operator/api/v1alpha1/infisicalpushsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalpushsecret_types.go @@ -16,9 +16,22 @@ type InfisicalPushSecretDestination struct { ProjectID string `json:"projectId"` } +type InfisicalPushSecretSecretSource struct { + // The name of the Kubernetes Secret + // +kubebuilder:validation:Required + SecretName string `json:"secretName"` + + // The name space where the Kubernetes Secret is located + // +kubebuilder:validation:Required + SecretNamespace string `json:"secretNamespace"` + + // +kubebuilder:validation:Optional + Template *SecretTemplate `json:"template,omitempty"` +} + type SecretPush struct { // +kubebuilder:validation:Required - Secret KubeSecretReference `json:"secret"` + Secret InfisicalPushSecretSecretSource `json:"secret"` } // InfisicalPushSecretSpec defines the desired state of InfisicalPushSecret diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index 8f871dfa6..e90c06938 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -116,16 +116,6 @@ type MachineIdentityScopeInWorkspace struct { Recursive bool `json:"recursive"` } -type InfisicalSecretTemplate struct { - // This injects all retrieved secrets into the top level of your template. - // Secrets defined in the template will take precedence over the injected ones. - // +kubebuilder:validation:Optional - IncludeAllSecrets bool `json:"includeAllSecrets"` - // The template key values - // +kubebuilder:validation:Optional - Data map[string]string `json:"data,omitempty"` -} - // InfisicalSecretSpec defines the desired state of InfisicalSecret type InfisicalSecretSpec struct { // +kubebuilder:validation:Optional diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index 11d864ad4..2ad97108d 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -383,7 +383,7 @@ func (in *InfisicalPushSecret) DeepCopyInto(out *InfisicalPushSecret) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -452,12 +452,32 @@ func (in *InfisicalPushSecretList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InfisicalPushSecretSecretSource) DeepCopyInto(out *InfisicalPushSecretSecretSource) { + *out = *in + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(SecretTemplate) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalPushSecretSecretSource. +func (in *InfisicalPushSecretSecretSource) DeepCopy() *InfisicalPushSecretSecretSource { + if in == nil { + return nil + } + out := new(InfisicalPushSecretSecretSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InfisicalPushSecretSpec) DeepCopyInto(out *InfisicalPushSecretSpec) { *out = *in out.Destination = in.Destination out.Authentication = in.Authentication - out.Push = in.Push + in.Push.DeepCopyInto(&out.Push) out.TLS = in.TLS } @@ -614,28 +634,6 @@ func (in *InfisicalSecretStatus) DeepCopy() *InfisicalSecretStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InfisicalSecretTemplate) DeepCopyInto(out *InfisicalSecretTemplate) { - *out = *in - if in.Data != nil { - in, out := &in.Data, &out.Data - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalSecretTemplate. -func (in *InfisicalSecretTemplate) DeepCopy() *InfisicalSecretTemplate { - if in == nil { - return nil - } - out := new(InfisicalSecretTemplate) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeSecretReference) DeepCopyInto(out *KubeSecretReference) { *out = *in @@ -703,7 +701,7 @@ func (in *ManagedKubeConfigMapConfig) DeepCopyInto(out *ManagedKubeConfigMapConf *out = *in if in.Template != nil { in, out := &in.Template, &out.Template - *out = new(InfisicalSecretTemplate) + *out = new(SecretTemplate) (*in).DeepCopyInto(*out) } } @@ -723,7 +721,7 @@ func (in *ManagedKubeSecretConfig) DeepCopyInto(out *ManagedKubeSecretConfig) { *out = *in if in.Template != nil { in, out := &in.Template, &out.Template - *out = new(InfisicalSecretTemplate) + *out = new(SecretTemplate) (*in).DeepCopyInto(*out) } } @@ -741,7 +739,7 @@ func (in *ManagedKubeSecretConfig) DeepCopy() *ManagedKubeSecretConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretPush) DeepCopyInto(out *SecretPush) { *out = *in - out.Secret = in.Secret + in.Secret.DeepCopyInto(&out.Secret) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretPush. @@ -769,6 +767,28 @@ func (in *SecretScopeInWorkspace) DeepCopy() *SecretScopeInWorkspace { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretTemplate) DeepCopyInto(out *SecretTemplate) { + *out = *in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretTemplate. +func (in *SecretTemplate) DeepCopy() *SecretTemplate { + if in == nil { + return nil + } + out := new(SecretTemplate) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceAccountDetails) DeepCopyInto(out *ServiceAccountDetails) { *out = *in diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml index a12ec9dbe..25fafd98a 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml @@ -137,6 +137,19 @@ spec: description: The name space where the Kubernetes Secret is located type: string + template: + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the + top level of your template. Secrets defined in the template + will take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/k8-operator/config/samples/crd/infisicalsecret/infisical-secret-crd-with-template.yml b/k8-operator/config/samples/crd/infisicalsecret/infisical-secret-crd-with-template.yml index c1e0c86b9..06086e93f 100644 --- a/k8-operator/config/samples/crd/infisicalsecret/infisical-secret-crd-with-template.yml +++ b/k8-operator/config/samples/crd/infisicalsecret/infisical-secret-crd-with-template.yml @@ -41,6 +41,9 @@ spec: # Native Kubernetes Auth kubernetesAuth: + serviceAccountRef: + name: + namespace: identityId: serviceAccountTokenPath: "/path/to/your/service-account/token" # Optional, defaults to /var/run/secrets/kubernetes.io/serviceaccount/token @@ -97,18 +100,13 @@ spec: secretsPath: "/path" recursive: true - managedSecretReference: - secretName: managed-secret - secretNamespace: default - template: - includeAllSecrets: true - data: - SSH_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" - BINARY_KEY: "{{ decodeBase64ToBytes .BINARY_KEY_BASE64.Value }}" - creationPolicy: "Orphan" ## Owner | Orphan - # secretType: kubernetes.io/dockerconfigjson - - # # To be depreciated soon - # tokenSecretReference: - # secretName: service-token - # secretNamespace: default + managedKubeSecretReferences: + - secretName: managed-secret + secretNamespace: default + creationPolicy: "Orphan" ## Owner | Orphan + # secretType: kubernetes.io/dockerconfigjson + template: + includeAllSecrets: true + data: + SSH_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" + BINARY_KEY: "{{ decodeBase64ToBytes .BINARY_KEY_BASE64.Value }}" diff --git a/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml b/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml index 108b86e78..82bb8039c 100644 --- a/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml +++ b/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml @@ -41,6 +41,9 @@ spec: # Native Kubernetes Auth kubernetesAuth: + serviceAccountRef: + name: + namespace: identityId: serviceAccountTokenPath: "/path/to/your/service-account/token" # Optional, defaults to /var/run/secrets/kubernetes.io/serviceaccount/token @@ -97,7 +100,7 @@ spec: secretsPath: "/path" recursive: true - managedSecretReferences: + managedKubeSecretReferences: - secretName: managed-secret secretNamespace: default creationPolicy: "Orphan" ## Owner | Orphan diff --git a/k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml b/k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml new file mode 100644 index 000000000..a81c2189d --- /dev/null +++ b/k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml @@ -0,0 +1,91 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalPushSecret +metadata: + name: infisical-api-secret-sample-push +spec: + resyncInterval: 1m + hostAPI: https://app.infisical.com/api # This is the default hostAPI for the Infisical API + + # Optional, defaults to replacement. + updatePolicy: Replace # If set to replace, existing secrets inside Infisical will be replaced by the value of the PushSecret on sync. + + # Optional, defaults to no deletion. + deletionPolicy: Delete # If set to delete, the secret(s) inside Infisical managed by the operator, will be deleted if the InfisicalPushSecret CRD is deleted. + + destination: + projectId: + environmentSlug: + secretsPath: + + push: + secret: + secretName: push-secret-demo-with-templating + secretNamespace: default + template: + includeAllSecrets: false + data: + PKCS12_CERT_NO_PASSWORD: "{{ .PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12cert }}" + PKCS12_KEY_NO_PASSWORD: "{{ .PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12key }}" + + PKCS12_CERT_WITH_PASSWORD: '{{ .PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12certPass "123456" }}' + PKCS12_KEY_WITH_PASSWORD: '{{ .PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12keyPass "123456" }}' + + PEM_TO_PKCS12_PASS: '{{ pemToPkcs12Pass + (.PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12certPass "123456") + (.PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12keyPass "123456") + "123456" }}' + PEM_TO_PKCS12_NO_PASSWORD: "{{ pemToPkcs12 + (.PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12cert) + (.PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12key) + }}" + + FULL_PEM_TO_PKCS12_PASS: '{{ fullPemToPkcs12Pass + (.PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12certPass "123456") + (.PKCS12_CONTENT_WITH_PASSWORD.Value | decodeBase64ToBytes | pkcs12keyPass "123456") + "123456" }}' + FULL_PEM_TO_PKCS12_NO_PASSWORD: "{{ fullPemToPkcs12 + (.PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12cert) + (.PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12key) + }}" + + FILTERED_PEM_CERT: '{{ filterPEM "CERTIFICATE" (printf "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----" .JWK_PRIVATE_RSA_PKCS8.Value .PKCS12_CONTENT_NO_PASSWORD.Value) }}' + FILTERED_PEM_KEY: '{{ filterPEM "PRIVATE KEY" (printf "-----BEGIN PRIVATE KEY-----\n%s\n-----END PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\n%s\n-----END PRIVATE KEY-----" .JWK_PRIVATE_RSA_PKCS8.Value .PKCS12_CONTENT_NO_PASSWORD.Value) }}' + + # Will be empty with our current test data as there is no chain + CERT_CHAIN: '{{ filterCertChain "CERTIFICATE" (.PKCS12_CONTENT_NO_PASSWORD.Value | decodeBase64ToBytes | pkcs12cert) }}' + + JWK_RSA_PUBLIC_PEM: "{{ jwkPublicKeyPem .JWK_PUB_RSA.Value }}" + JWK_ECDSA_PUBLIC_PEM: "{{ jwkPublicKeyPem .JWK_PUB_ECDSA.Value }}" + + JWK_ECDSA_PRIVATE_PEM: "{{ jwkPrivateKeyPem .JWK_PRIV_ECDSA.Value }}" + JWK_RSA_PRIVATE_PEM: "{{ jwkPrivateKeyPem .JWK_PRIV_RSA.Value }}" + + JSON_STR_TO_YAML: "{{ .TEST_JSON_DATA.Value | fromJsonStringToJson | toYaml }}" + + FROM_YAML_TO_JSON: "{{ .TEST_YAML_STRING.Value | fromYaml | toJson }}" + YAML_ROUNDTRIP: "{{ .TEST_YAML_STRING.Value | fromYaml | toYaml }}" + + TEST_LOWERCASE_STRING: "{{ .TEST_LOWERCASE_STRING.Value | upper }}" + + # Only have one authentication method defined or you are likely to run into authentication issues. + # Remove all except one authentication method. + authentication: + awsIamAuth: + identityId: + azureAuth: + identityId: + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: + gcpIdTokenAuth: + identityId: + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + universalAuth: + credentialsRef: + secretName: # universal-auth-credentials + secretNamespace: # default + diff --git a/k8-operator/config/samples/crd/pushsecret/pushSecret.yaml b/k8-operator/config/samples/crd/pushsecret/push-secret.yaml similarity index 96% rename from k8-operator/config/samples/crd/pushsecret/pushSecret.yaml rename to k8-operator/config/samples/crd/pushsecret/push-secret.yaml index f1738fc47..b6a132987 100644 --- a/k8-operator/config/samples/crd/pushsecret/pushSecret.yaml +++ b/k8-operator/config/samples/crd/pushsecret/push-secret.yaml @@ -19,7 +19,7 @@ spec: push: secret: - secretName: push-secret-demo # Secret CRD + secretName: push-secret-source-secret # Secret CRD secretNamespace: default # Only have one authentication method defined or you are likely to run into authentication issues. diff --git a/k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml b/k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml new file mode 100644 index 000000000..79a12a4d4 --- /dev/null +++ b/k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml @@ -0,0 +1,91 @@ +# This is the source secret that you can use to demo the advanced templating functionality seen in `push-secret-with-template.yaml` + +apiVersion: v1 +kind: Secret +metadata: + name: push-secret-demo-with-templating + namespace: default +stringData: + PKCS12_CONTENT_NO_PASSWORD: MIIJYQIBAzCCCScGCSqGSIb3DQEHAaCCCRgEggkUMIIJEDCCA8cGCSqGSIb3DQEHBqCCA7gwggO0AgEAMIIDrQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQInZmyWpNTPS4CAggAgIIDgPzZTmogBRiLP0NJZEUghZ3Oh1aqHJJ32HKgXUpD5BJ/5AvpUL9FC7m6a3GD++P1On/35J9N50bDjfBJjJrl2zpA143bzltPQBOK30cBJjNsCeN2Dq1dcsvJZfEy20z75NduXjMF6/qs4BbE+1E6nYFYVNHUybFnaQwSx7+2/2OMbXbcFpt4bv3HTw0YLw2pZeW/4/4A9d+tC9UdVQTTyNbI8l9nf1aeaaPsw1keVLmHurmTihfwh469FvjgwiHUP/P3ZCn1tOpWDR8ck0j+ru6imVP2hn+Kvk6svllmYqo3A5DnDRoF/Cl9R0DAPyS0lw7BeGskgTm7B79mzVitTbzRnIUP+sGJjc1AVghnitfcX4ffv8gq5xWaKGucO/IZXbPBoe7tMhKZmsirKzD4RBhC3nMyrwaHJB6PqUwxMQGMLbuHe7GlWhJAyFlcOTt5dgNl+axIkWdisoKNinYYeOuxudqyX6yPfsyaRCV5MEez3Wu+59MENGlGDRWbw61QuwsZkr1bAT2SJrQ/zHn5aGAluQZ1csJhKQ34iy1Ml9K9F4Zh3/2OWPs0u6+JCb1PC1vChBkguqcqQtEcikRwR9dNF9cdMB1T1Xk5GqlmOPaigkYzGWLgtl8cV5/Zl0m2j77mX9x4HVCTercAABGf9JcCLzSCo04c5OwIYtWUXBkux5n2VI2ZIuS1KF+r6JNyL3lg/D8LColzDUP/6tQCBVVgMar3iLblM17wPMTDMR5Bn+NvenwJj6FWaGGMtdjygtN+oSHpNDbVygfGQy+jEgUtK7yw0uh/WKBMWVw1E6iNuhb8HIyCFtQon8sDkuZ81czOpR3Ta1SWUWrZD+pjpL2Z4y8Nc2wt9pVPvLFOTn+GDFVqGpde3kovh3GfJjYCG/HI5rXZyziflDOoSy0SyG6aVCG4ZqW2LTymoVN/kxf+skqAweX1vxvvJniiv8HgYfEASFUWear4uT641d1YwcEIawNv4n+GKBilK/7ODl2QL86svwqIcbyiJrneyU2tHymKzGcU2VxmSgf8EnjqGuIEo7WXOpk0oUMcvYrM73cgzZ3BchUDIN0KWSDI+vDcVY82dbI39KM6dtOJFAx3kEdms/gdSqZtmHUIeArGp+8caCCAK/W+4wTOvtisK+6MtzdMz6P93N78N4Vo6cs3dkj6t/6tgNog5SCfwlOEyUpmMIIFQQYJKoZIhvcNAQcBoIIFMgSCBS4wggUqMIIFJgYLKoZIhvcNAQwKAQKgggTuMIIE6jAcBgoqhkiG9w0BDAEDMA4ECHVnarQ94cqlAgIIAASCBMgUvEVKsUcqEvYJEJ9JixgB0W3uhSi/Espt931a/mwx5Ja2K7vjlttaOct3Zc8umVrP5C322tmHz9QDVPj3Bln8CGfofC/8Nb6+SDeofmYaQYReOZpZGksEBs4P3yURl8wQpIkG31Oyf3urDTJdplfDrzu6XpEpIf7RicIR+Zh4Q1+F75XwPo52/yNs8q/kVV8H97gSRqQ2GixIdyNu+JLtNjdwAERHy4DeQjwgiMCdL+xMfN+WJyIvkLZDoy9bacXeG4IcQM+n84272C6j1a0BPaOm0K5A7I0H1zpXOJiWfn3MrT4LHDudrQoIWUOvcJjWaIM/KyghotDN50THKN9qCEE9SmtfWXGGFaJmyxbUDFizBIAsFshNtMs/47PoInTSNwzxNvUUQ3ap93iquGZ9EaZAMY2HQHW/QJIQ70IbtcHU28Bus/hrMcV0X9D1p4UeHuk37W7aCrL6hS+ac9pmzwmcDBwZUliyInxRmqCCerjg2ojAM9SVg8FrpQUErP+BOaoCBwQqLLiz9BM+3tUQc/8MyaBHq+c2dUoPfvipDIQXYiq66CkjmPHxPFEL1l9d9oBFoIGkt6SIHDjWnTPc5q5SvJ9tz8Dp1k/1HQSA8OUS6j+XySYuGe8xTvN/oUpVRswef2Qd/kxZlc1FJ4lVAXvbW7C7772l14BJv/WULcFH4Sn83rlL3YwHr4vJMf6wLahn7oQPI0VFSQiiOOb/+gkiTrwO3Gz+HXOkUwaKnW85PeoIt3/q1u0CRl64mUjqCegi7RMY9Q9tRMlD5yx0RsH7mc4b6Eg/3IwGu8VQmZCO5W2unCpfzzyrOx7OaGGaW4RJ2Mx7bJ8uV9HU8MbbNntmc9oxebPdDnBmbt8p8t4ZZxC+zcqcXi3TxACXmwnasogQEi0d0ttXkB5cnDCG00Y8WPdNIWfJdIQh8Hj16LAMYWUacz/J0kLP99ENQntZibVw/Q3zZtHSF5tmsYp7o1HglBpRwLTcd026YTrxB+VCEiUYy4hH6a38oEEpY7wTIiRmEBQPIRM0HUOqVh4z6TNzRx6iIhrQEvg06B8U6iVPqy8FGDkhf3P55Ed95/Rw6uSdlMTHng+Q4aG00k4qKdKOyv55IXPcvEzAeVNBuesknaS8x7Eb/I5mHSoZU3RYAEFGbehUkvkhNr3Xq7/W/400AKiliravJq8j/qKIZ9hAVUWOps09F/4peYfLXM1AhxWWGa5QqvwFkClM+uRyqIRGJwl2Z7asl4sWVXbwtb+Axio+mYGdzxIki5iwJvRCwKapoZplndXKTrn2nYBuhxW2+fRHa8WYdsm/wn0K+jYMlZhquVjNXyL70/Sym6DkzCtJvveQs2CfcEWQuedjRSGFVFT2jV/s5F8L2TV7nQNVj6dEJSNM5JCdZ//OpiMHMCbPNeSxY9koGplUqFhP54F1WU9x+8xiFjEp8WKxQYKHUtj+ace0lLF4CDGXhFR/0k7Icarpax3hYnvagd2OpZyRJdavKBSs5U7/NPuO6sNhZ2NpzsOiul9Iu8bu3UHCECNKkwN4wF4alTlG9sAAbS4ns4wb9XTajG+OPYoDQZmuJfc71McN6m8KBHEnXU8r4epdR7xREe/w+h2MwtPhLvbxwO592tUxJTAjBgkqhkiG9w0BCRUxFgQUOEXV6IFYGpCSHi0MPHz4b3W0KOQwMTAhMAkGBSsOAwIaBQAEFAjyBCA+mr+5UkKuQ1jGw90ASfbVBAjbvqJJZikDPgICCAA= + PKCS12_CONTENT_WITH_PASSWORD: MIIJYQIBAzCCCScGCSqGSIb3DQEHAaCCCRgEggkUMIIJEDCCA8cGCSqGSIb3DQEHBqCCA7gwggO0AgEAMIIDrQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQI2eZRJ7Ar+JQCAggAgIIDgFTbOtkFPjqxAoYRHoq1SbyXKf/NRbBA5AqxQlv9aFVT4VcxUSrMGaSWifX2UjsVWQzn134yoLLbdJ0jTorVD+EuBUmtb3xXbBwLqtFZxwcWodYA5WhPQdDcQo0cD3o1vrsXPQARQR6ISSFnhFjPYdH9cO2LqUKV5pjFhIs2/1VPDS2eY7SWZN52DK3QknSj23S3ZW2s4TFEj/5C4ssbO7cWNWBjjaORnd17FMNgVtcRw8ITmLdGBOpFUwP8wIdiLGrXiyjfMLns74nztRelV30/v0DPlz0pZtOPygi/dy0qpbil3wtOFrtQBLEdvLNmt9ikQgGs3pJBS68eMJLu3jAU6rCIKycq0+E0eMXeHcseyMwgguTj2h4t+E4S7nU11lViBFqkSBKxE28+9fNlPvCsZ4WhQZ6TAW3E/jDy/ZSqmak5V7/khMlRPvtrxz71ivksH0iipPdJJkGi7SDEvETySBETiqIslUmsF0ZYeHR5wIBkB5V8zmi8RRZtpvDGbzuQ22V6sNk2mTDh+BRus7gNCoSGWYXWqNNp1PnznuYCJp9T+0mObcAijE7IQuhpYMeQPF+MUIlG5lmpNouzuygTf++xrKIjzP36DcthnMPeD/8LYWfzkuAeRodjl7Z1G6XLvBD5h+Xlq23WPjMcXUiiWYXxTREAQ1EWUf4A9twGcxHJ5AatbvQY3QUoS4a7LNuy17lF7G+O1SFDtGeHZXHHecaVpuAtqZEYeUpqy6ZzMJXtXE1JNl/UR9TtTordb1V5Pf45JTYKLI+SwxVQbRiTgfhulNc+E3tV1AEELZt4CKmh1OFJoJRtyREMfdVuP4rx7ywIoMYuWw8CRqJ3qSmdwz2kmL2pfJNn6vDfN6rNa+sXWErOJ7naSAKQH2CJfnkCOFxkKfwjbOcNRbnGKah8NyWu6xqlv7Y4xEJYqmPahGHmrSfdAt3mpc5zD74DvetLIczcadKaLH7mp6h98xHayvXh0UE7ERHeskfSPrLxL9A3V1RZXDOtcZFWSKHzokuXMDF9OnrcMYDzYgtzof4ReY2t1ldGF7phYINhDlUNyhzyjwyWQbdkxr/+FtWq8Sbm7o2zMTby48c25gnqD9U8RTAO+bY3oV3dQ4bpAOzWdzVmFjESUFx0xVwbTSJGXdkH4YmD5He+xwxTa0Je0HE5+ui5dbP1gxUY+pCGLOhGMIIFQQYJKoZIhvcNAQcBoIIFMgSCBS4wggUqMIIFJgYLKoZIhvcNAQwKAQKgggTuMIIE6jAcBgoqhkiG9w0BDAEDMA4ECGYAccNFWW6kAgIIAASCBMgbXw69iyx73RGWS3FYeuvF5L1VWXQGrDLdUGwa3SdovXxmP1pKBAb82UuiydXDpKSVCmefeEQTs6ucEFRmXrDIldNanqUwbydy3GSJ+4iNsVQHbFgNppH4e90L7BlLcZ3MzSrVEwxWVMHq+XLqTKf3X3QyrmA3mmF96+Nd+icpDIktd+/h2BHnSXHNP0QfVH27H4DwbMDijttXY0JB+8qP9m25Wn4zkmOPEUhrY4Ptv2I08eHFAuNI0jWUwfRhC4FDbUdwFb0aZjA3Te6uYTsu2zAlmg9HuqsD/Ef/wkBEKZLBkjiXa/niFVrwELXhWZDPBAuo+/1UbzXglsW4QDU4LbUutcs6DLag1vLe40a2LO1ODQm7Zw0bxLkb3f/ry6ZFYvO78XmHo4c/oQf4KPUtM2bLz5q7uOxAx07vHYaU2BVt3NjgiIO5VVKjw0075GdgFxwPvYncv1fsC5jSIkX43GuzEtoBTpJKDYb2nhKbN9XWixwGOhUBTK3WYBhn+uaMJs4l3EgkDtK9tsUs5VQQHawj0WrGS1mQhaBfcyZzv4wSn0d3JUO2CN0e9EReJcQvsEnwUvohilOvjDHHhTq8Kp4XU4jbq7TAKqxs3TOmdoskRykn9oKUPExJVhJQonFT3ietV5BHrnN/QoDCSeOR80ZxvWHrQDz3Hm1ygiHd8LYmN4IjiD8b28ZrCALifWxh0WmIYtLZrUjMZavPh+caWH9IG32fTxV9b1bgJD8vWqscj9jCjeMJvkKQo8PFg1kMAxt1u+bIyktTq42O9qxwGrdqEMeBzXxDJMMaRIH3m9LNZ/P5Nk4/hMURhCZJtRtNfOVTK+Q6kKgsdK2EHcuEnp/qBefZjve+xmitbF1W7C4+B7b2JNBacdIm1nE56DwglT/IUk65JrNFP3rf4c5ic76LCQrvyfLiKCGaqcihM9siLVFPYdrnr8TlGbCFnGbpBqMQA5MtZQaDUug50PJtdxlgfwWH4qliimgchCaZbSTcgN5YTguSe16uUSusHD+r6XdtI0939uDILXJjQMczhIKNw8w0Tn4Z3/g2KlB6cwbtaglnnO4a/USh0cPC1a581byNqeFoMi+mAhqfKkwdDuti4GX7OrhkUOkiRjEUXdcckpmmIsyamH/g1dq3CNFXFNIgRRrzIDo4Opr3Ip2VE/4BDQoo/+Rybzxh8bsHgCEujQf8urGxjGyd2ulHoXzHWhz7pPPuY5UN6dC9WZmOQDVous/1nhYThoLVVc61Rk6d83+Ac7iRg4bY5q/73J4HvPMmrTOOOqqn3wc9Pe5ibEy4tFaYnim4p1ZRm8YcwosZmuFPdsP6G5l5qt6uOyr2+qNpXIBkDpG7I6Ls10O7L3PQAX9zRGfcz6Ds0KtuDrLpaVvhuXpewsBwpo1lmhv9bAa4ppBuWznmKigX+vYojSxd/eCRAtMs+Lx6ppZsYNVhbdEIGKXSGwG98sSTZkoLHBMkUW7S8jpeSCHZWEFBUOPJQzAr5cW1w+RAs33cGUygZ5XEEx4DeW8MnO4lCuP+VDOwu3TAKhzAD+qCyXbLEzWiyL5fq3XL+YJtoAc8Mra9lK6jDqzq4u+PLNoYY+kWTBhCyRZ+PfzcXLry8pxuP5E6VtRgfYcxJTAjBgkqhkiG9w0BCRUxFgQUOEXV6IFYGpCSHi0MPHz4b3W0KOQwMTAhMAkGBSsOAwIaBQAEFBa+SV9FU2UObo+nYKdyt/kZVw6FBAgey4GonFtJ2gICCAA= + JWK_PRIVATE_RSA_PKCS8: "-----BEGIN PRIVATE KEY----- + MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQCmN2yzxloN8Qfo + rpTsZ5bafEOpHgg/Tj1+TV8rSWd2KZswxUF0+/+FKmbxPwS0EPGtR2LU4dl8yFSL + EZq637edDgYb2czbj2jGEK3Gqo28ReuZBEapzPIvG6H58qf0WD76FL1SlrMel9UA + WcHloJ9eg2E+4jygHLIUowpo5WAc2o/k0ESppuIt+1kPdb+WwUI8a7OvhWnRhLvN + LaENhJwLag4y7isZTUtwxl/f2nfXncKrttLZeHpj6/DmnDMVhl2NDEOfzHwEbd8n + qPxMYtdCxsofXbXz8dxQlG8zB2ltRAbme8DYZdWoup3CnTngvOT38H9/WVWuY4q4 + eNM0erjzAgMBAAECggEBAJLA5rnHTCV5BRmcYqJjR566DmcXvAJgywxjtb4bPjzm + uT2TO5rVD6J8cI1ZrYZqW2c5WvpIOeThXzu2HF4YPh5tjlkysJu9/6y4dyWr2h47 + warFSrqK191d0WJEq6Oh8mCMxSdRJO7C8W4w0XAzo+Inr0l9KDfZfiWYWg2JT5XI + ubibKKq6P2KxND0UVlYbRsp3fv2loEL9WM5H2bjA/oSbQ4tSJtobpjlsQOHmaxbP + XhvsIV3Dr2ksDuLEhm0vfXnEGRzNk3HV3gLNT741YEP3Sp2ZRjd5U1qFn0D+eWe0 + 4LfDX9auGQCnfjZTHvu4qghX7JxcF40omjmtgkRmZ/kCgYEA4A5nU4ahEww7B65y + uzmGeCUUi8ikWzv1C81pSyUKvKzu8CX41hp9J6oRaLGesKImYiuVQK47FhZ++wwf + pRwHvSxtNU9qXb8ewo+BvadyO1eVrIk4tNV543QlSe7pQAoJGkxCia5rfznAE3In + KF4JvIlchyqs0RQ8wx7lULqwnn0CgYEAven83GM6SfrmO+TBHbjTk6JhP/3CMsIv + mSdo4KrbQNvp4vHO3w1/0zJ3URkmkYGhz2tgPlfd7v1l2I6QkIh4Bumdj6FyFZEB + pxjE4MpfdNVcNINvVj87cLyTRmIcaGxmfylY7QErP8GFA+k4UoH/eQmGKGK44TRz + Yj5hZYGWIC8CgYEAlmmU/AG5SGxBhJqb8wxfNXDPJjf//i92BgJT2Vp4pskBbr5P + GoyV0HbfUQVMnw977RONEurkR6O6gxZUeCclGt4kQlGZ+m0/XSWx13v9t9DIbheA + tgVJ2mQyVDvK4m7aRYlEceFh0PsX8vYDS5o1txgPwb3oXkPTtrmbAGMUBpECgYEA + mxRTU3QDyR2EnCv0Nl0TCF90oliJGAHR9HJmBe//EjuCBbwHfcT8OG3hWOv8vpzo + kQPRl5cQt3NckzX3fs6xlJN4Ai2Hh2zduKFVQ2p+AF2p6Yfahscjtq+GY9cB85Nx + Ly2IXCC0PF++Sq9LOrTE9QV988SJy/yUrAjcZ5MmECkCgYEAldHXIrEmMZVaNwGz + DF9WG8sHj2mOZmQpw9yrjLK9hAsmsNr5LTyqWAqJIYZSwPTYWhY4nu2O0EY9G9uY + iqewXfCKw/UngrJt8Xwfq1Zruz0YY869zPN4GiE9+9rzdZB33RBw8kIOquY3MK74 + FMwCihYx/LiU2YTHkaoJ3ncvtvg= + -----END PRIVATE KEY-----" + JWK_PUB_RSA: '{"kid":"ex","kty":"RSA","key_ops":["sign","verify","wrapKey","unwrapKey","encrypt","decrypt"],"n":"p2VQo8qCfWAZmdWBVaYuYb-a-tWWm78K6Sr9poCvNcmv8rUPSLACxitQWR8gZaSH1DklVkqz-Ed8Cdlf8lkDg4Ex5tkB64jRdC1Uvn4CDpOH6cp-N2s8hTFLqy9_YaDmyQS7HiqthOi9oVjil1VMeWfaAbClGtFt6UnKD0Vb_DvLoWYQSqlhgBArFJi966b4E1pOq5Ad02K8pHBDThlIIx7unibLehhDU6q3DCwNH_OOLx6bgNtmvGYJDd1cywpkLQ3YzNCUPWnfMBJRP3iQP_WI21uP6cvo0DqBPBM4wvVzHbCT0vnIflwkbgEWkq1FprqAitZlop9KjLqzjp9vyQ","e":"AQAB"}' + JWK_PUB_ECDSA: '{"kid":"https://kv-test-mj.vault.azure.net/keys/ec-p-521/e3d0e9c179b54988860c69c6ae172c65","kty":"EC","key_ops":["sign","verify"],"crv":"P-521","x":"AedOAtb7H7Oz1C_cPKI_R4CN_eai5nteY6KFW07FOoaqgQfVCSkQDK22fCOiMT_28c8LZYJRsiIFz_IIbQUW7bXj","y":"AOnchHnmBphIWXvanmMAmcCDkaED6ycW8GsAl9fQ43BMVZTqcTkJYn6vGnhn7MObizmkNSmgZYTwG-vZkIg03HHs"}' + + JWK_PRIV_RSA: '{"kty" : "RSA","kid" : "cc34c0a0-bd5a-4a3c-a50d-a2a7db7643df","use" : "sig","n" : "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w","e" : "AQAB","d" : "ksDmucdMJXkFGZxiomNHnroOZxe8AmDLDGO1vhs-POa5PZM7mtUPonxwjVmthmpbZzla-kg55OFfO7YcXhg-Hm2OWTKwm73_rLh3JavaHjvBqsVKuorX3V3RYkSro6HyYIzFJ1Ek7sLxbjDRcDOj4ievSX0oN9l-JZhaDYlPlci5uJsoqro_YrE0PRRWVhtGynd-_aWgQv1YzkfZuMD-hJtDi1Im2humOWxA4eZrFs9eG-whXcOvaSwO4sSGbS99ecQZHM2TcdXeAs1PvjVgQ_dKnZlGN3lTWoWfQP55Z7Tgt8Nf1q4ZAKd-NlMe-7iqCFfsnFwXjSiaOa2CRGZn-Q","p" : "4A5nU4ahEww7B65yuzmGeCUUi8ikWzv1C81pSyUKvKzu8CX41hp9J6oRaLGesKImYiuVQK47FhZ--wwfpRwHvSxtNU9qXb8ewo-BvadyO1eVrIk4tNV543QlSe7pQAoJGkxCia5rfznAE3InKF4JvIlchyqs0RQ8wx7lULqwnn0","q" : "ven83GM6SfrmO-TBHbjTk6JhP_3CMsIvmSdo4KrbQNvp4vHO3w1_0zJ3URkmkYGhz2tgPlfd7v1l2I6QkIh4Bumdj6FyFZEBpxjE4MpfdNVcNINvVj87cLyTRmIcaGxmfylY7QErP8GFA-k4UoH_eQmGKGK44TRzYj5hZYGWIC8","dp" : "lmmU_AG5SGxBhJqb8wxfNXDPJjf__i92BgJT2Vp4pskBbr5PGoyV0HbfUQVMnw977RONEurkR6O6gxZUeCclGt4kQlGZ-m0_XSWx13v9t9DIbheAtgVJ2mQyVDvK4m7aRYlEceFh0PsX8vYDS5o1txgPwb3oXkPTtrmbAGMUBpE","dq" : "mxRTU3QDyR2EnCv0Nl0TCF90oliJGAHR9HJmBe__EjuCBbwHfcT8OG3hWOv8vpzokQPRl5cQt3NckzX3fs6xlJN4Ai2Hh2zduKFVQ2p-AF2p6Yfahscjtq-GY9cB85NxLy2IXCC0PF--Sq9LOrTE9QV988SJy_yUrAjcZ5MmECk","qi" : "ldHXIrEmMZVaNwGzDF9WG8sHj2mOZmQpw9yrjLK9hAsmsNr5LTyqWAqJIYZSwPTYWhY4nu2O0EY9G9uYiqewXfCKw_UngrJt8Xwfq1Zruz0YY869zPN4GiE9-9rzdZB33RBw8kIOquY3MK74FMwCihYx_LiU2YTHkaoJ3ncvtvg"}' + JWK_PRIV_ECDSA: '{"kty": "EC","kid": "rie3pHe8u8gjSa0IaJfqk7_iEfHeYfDYx-Bqi7vQc0s","crv": "P-256","x": "fDjg3Nq4jPf8IOZ0277aPVal_8iXySnzLUJAZghUzZM","y": "d863PeyBOK_Q4duiSmWwgIRzi1RPlFZTR-vACMlPg-Q","d": "jJs5xsoHUetdMabtt8H2KyX5T92nGul1chFeMT5hlr0"}' + + TEST_LOWERCASE_STRING: i am a lowercase string + + TEST_YAML_STRING: | + name: test-object + type: example + properties: + key1: value1 + key2: value2 + numbers: + - 1 + - 2 + - 3 + + TEST_JSON_DATA: '{ + "id": "f1bc87fc-99cf-48d2-b289-7cf578152f9a", + "name": "test", + "description": "", + "type": "secret-manager", + "slug": "test-o-bto", + "autoCapitalization": false, + "orgId": "601815be-6884-4ee4-86c7-bfc6415f2123", + "createdAt": "2025-03-26T01:32:39.890Z", + "updatedAt": "2025-03-26T01:32:41.688Z", + "version": 3, + "upgradeStatus": null, + "pitVersionLimit": 10, + "kmsCertificateKeyId": null, + "auditLogsRetentionDays": null, + "_id": "f1bc87fc-99cf-48d2-b289-7cf578152f9a", + "environments": [ + { + "name": "Development", + "slug": "dev", + "id": "cbb62f88-44cb-4c29-975a-871f8d7d303b" + }, + { + "name": "Staging", + "slug": "staging", + "id": "c933a63d-418a-4d5c-a7d1-91b74d3ee2eb" + }, + { + "name": "Production", + "slug": "prod", + "id": "0b70125e-47d5-46e8-a03e-a3105df05d37" + } + ] + }' diff --git a/k8-operator/config/samples/crd/pushsecret/source-secret.yaml b/k8-operator/config/samples/crd/pushsecret/source-secret.yaml new file mode 100644 index 000000000..88fd3e0d2 --- /dev/null +++ b/k8-operator/config/samples/crd/pushsecret/source-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: push-secret-source-secret + namespace: default +stringData: + ENCRYPTION_KEY: secret-encryption-key + API_URL: https://example.com/api + REGION: us-east-1 diff --git a/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml b/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml deleted file mode 100644 index 6a3703a95..000000000 --- a/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: push-secret-demo - namespace: default -stringData: # can also be "data", but needs to be base64 encoded - API_KEY: some-api-key - DATABASE_URL: postgres://127.0.0.1:5432 - ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go index b87c5bcc2..a5526bd6c 100644 --- a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go @@ -65,6 +65,8 @@ func (r *InfisicalPushSecretReconciler) Reconcile(ctx context.Context, req ctrl. if err != nil { if errors.IsNotFound(err) { logger.Info("Infisical Push Secret CRD not found") + r.DeleteManagedSecrets(ctx, logger, infisicalPushSecretCRD) + return ctrl.Result{ Requeue: false, }, nil diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go index 47fc8e69b..848df3c82 100644 --- a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go @@ -1,16 +1,21 @@ package controllers import ( + "bytes" "context" "errors" "fmt" "strings" + tpl "text/template" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" "github.com/Infisical/infisical/k8-operator/packages/constants" + "github.com/Infisical/infisical/k8-operator/packages/model" + "github.com/Infisical/infisical/k8-operator/packages/template" "github.com/Infisical/infisical/k8-operator/packages/util" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -101,6 +106,48 @@ func (r *InfisicalPushSecretReconciler) updateResourceVariables(infisicalPushSec infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)] = resourceVariables } +func (r *InfisicalPushSecretReconciler) processTemplatedSecrets(infisicalPushSecret v1alpha1.InfisicalPushSecret, kubePushSecret *corev1.Secret, destination v1alpha1.InfisicalPushSecretDestination) (map[string]string, error) { + + processedSecrets := make(map[string]string) + + sourceSecrets := make(map[string]model.SecretTemplateOptions) + for key, value := range kubePushSecret.Data { + + sourceSecrets[key] = model.SecretTemplateOptions{ + Value: string(value), + SecretPath: destination.SecretsPath, + } + } + + if infisicalPushSecret.Spec.Push.Secret.Template == nil || (infisicalPushSecret.Spec.Push.Secret.Template != nil && infisicalPushSecret.Spec.Push.Secret.Template.IncludeAllSecrets) { + for key, value := range kubePushSecret.Data { + processedSecrets[key] = string(value) + } + } + + if infisicalPushSecret.Spec.Push.Secret.Template != nil && + len(infisicalPushSecret.Spec.Push.Secret.Template.Data) > 0 { + + for templateKey, userTemplate := range infisicalPushSecret.Spec.Push.Secret.Template.Data { + + tmpl, err := tpl.New("push-secret-templates").Funcs(template.GetTemplateFunctions()).Parse(userTemplate) + if err != nil { + return nil, fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, sourceSecrets) + if err != nil { + return nil, fmt.Errorf("unable to execute template: %s [err=%v]", templateKey, err) + } + + processedSecrets[templateKey] = buf.String() + } + } + + return processedSecrets, nil +} + func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context.Context, logger logr.Logger, infisicalPushSecret v1alpha1.InfisicalPushSecret) error { resourceVariables := r.getResourceVariables(infisicalPushSecret) @@ -134,10 +181,9 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context return fmt.Errorf("unable to fetch kube secret [err=%s]", err) } - var kubeSecrets = make(map[string]string) - - for key, value := range kubePushSecret.Data { - kubeSecrets[key] = string(value) + processedSecrets, err := r.processTemplatedSecrets(infisicalPushSecret, kubePushSecret, infisicalPushSecret.Spec.Destination) + if err != nil { + return fmt.Errorf("unable to process templated secrets [err=%s]", err) } destination := infisicalPushSecret.Spec.Destination @@ -191,7 +237,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context infisicalPushSecret.Status.ManagedSecrets = make(map[string]string) // (string[id], string[key] ) - for secretKey, secretValue := range kubeSecrets { + for secretKey, secretValue := range processedSecrets { if exists := getExistingSecretByKey(secretKey); exists != nil { if updatePolicy == string(constants.PUSH_SECRET_REPLACE_POLICY_ENABLED) { @@ -280,7 +326,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context // We need to check if any of the secrets have been removed in the new kube secret for _, managedSecretKey := range infisicalPushSecret.Status.ManagedSecrets { - if _, ok := kubeSecrets[managedSecretKey]; !ok { + if _, ok := processedSecrets[managedSecretKey]; !ok { // Secret has been removed, verify that the secret is managed by the operator if getExistingSecretByKey(managedSecretKey) != nil { @@ -305,7 +351,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context } // We need to check if any new secrets have been added in the kube secret - for currentSecretKey := range kubeSecrets { + for currentSecretKey := range processedSecrets { if exists := getExistingSecretByKey(currentSecretKey); exists == nil { @@ -317,7 +363,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context createdSecret, err := infisicalClient.Secrets().Create(infisicalSdk.CreateSecretOptions{ SecretKey: currentSecretKey, - SecretValue: kubeSecrets[currentSecretKey], + SecretValue: processedSecrets[currentSecretKey], ProjectID: destination.ProjectID, Environment: destination.EnvironmentSlug, SecretPath: destination.SecretsPath, @@ -336,12 +382,12 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context existingSecret := getExistingSecretByKey(currentSecretKey) - if existingSecret != nil && existingSecret.SecretValue != kubeSecrets[currentSecretKey] { + if existingSecret != nil && existingSecret.SecretValue != processedSecrets[currentSecretKey] { logger.Info(fmt.Sprintf("Secret with key [key=%s] has changed value. Updating secret in Infisical", currentSecretKey)) updatedSecret, err := infisicalClient.Secrets().Update(infisicalSdk.UpdateSecretOptions{ SecretKey: currentSecretKey, - NewSecretValue: kubeSecrets[currentSecretKey], + NewSecretValue: processedSecrets[currentSecretKey], ProjectID: destination.ProjectID, Environment: destination.EnvironmentSlug, SecretPath: destination.SecretsPath, @@ -353,7 +399,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context continue } - updateExistingSecretByKey(currentSecretKey, kubeSecrets[currentSecretKey]) + updateExistingSecretByKey(currentSecretKey, processedSecrets[currentSecretKey]) infisicalPushSecret.Status.ManagedSecrets[updatedSecret.ID] = currentSecretKey } } @@ -361,7 +407,7 @@ func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context } // Check if any of the existing secrets values have changed - for secretKey, secretValue := range kubeSecrets { + for secretKey, secretValue := range processedSecrets { existingSecret := getExistingSecretByKey(secretKey) @@ -440,9 +486,28 @@ func (r *InfisicalPushSecretReconciler) DeleteManagedSecrets(ctx context.Context resourceVariables := r.getResourceVariables(infisicalPushSecret) infisicalClient := resourceVariables.InfisicalClient + cancelCtx := resourceVariables.CancelCtx + authDetails := resourceVariables.AuthDetails + var err error + + if authDetails.AuthStrategy == "" { + logger.Info("No authentication strategy found. Attempting to authenticate") + authDetails, err = r.handleAuthentication(ctx, infisicalPushSecret, infisicalClient) + r.SetAuthenticatedStatusCondition(ctx, &infisicalPushSecret, err) + + if err != nil { + return fmt.Errorf("unable to authenticate [err=%s]", err) + } + + r.updateResourceVariables(infisicalPushSecret, util.ResourceVariables{ + InfisicalClient: infisicalClient, + CancelCtx: cancelCtx, + AuthDetails: authDetails, + }) + } destination := infisicalPushSecret.Spec.Destination - existingSecrets, err := infisicalClient.Secrets().List(infisicalSdk.ListSecretsOptions{ + existingSecrets, err := resourceVariables.InfisicalClient.Secrets().List(infisicalSdk.ListSecretsOptions{ ProjectID: destination.ProjectID, Environment: destination.EnvironmentSlug, SecretPath: destination.SecretsPath, diff --git a/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go index eb41fe6e9..f2197b0ed 100644 --- a/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go @@ -3,17 +3,17 @@ package controllers import ( "bytes" "context" - "encoding/base64" "errors" "fmt" "strings" - "text/template" + tpl "text/template" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" "github.com/Infisical/infisical/k8-operator/packages/constants" "github.com/Infisical/infisical/k8-operator/packages/crypto" "github.com/Infisical/infisical/k8-operator/packages/model" + "github.com/Infisical/infisical/k8-operator/packages/template" "github.com/Infisical/infisical/k8-operator/packages/util" "github.com/go-logr/logr" @@ -156,16 +156,6 @@ func (r *InfisicalSecretReconciler) getInfisicalServiceAccountCredentialsFromKub return model.ServiceAccountDetails{AccessKey: string(accessKeyFromSecret), PrivateKey: string(privateKeyFromSecret), PublicKey: string(publicKeyFromSecret)}, nil } -var infisicalSecretTemplateFunctions = template.FuncMap{ - "decodeBase64ToBytes": func(encodedString string) string { - decoded, err := base64.StdEncoding.DecodeString(encodedString) - if err != nil { - panic(fmt.Sprintf("Error: %v", err)) - } - return string(decoded) - }, -} - func convertBinaryToStringMap(binaryMap map[string][]byte) map[string]string { stringMap := make(map[string]string) for k, v := range binaryMap { @@ -177,7 +167,7 @@ func convertBinaryToStringMap(binaryMap map[string][]byte) map[string]string { func (r *InfisicalSecretReconciler) createInfisicalManagedKubeResource(ctx context.Context, logger logr.Logger, infisicalSecret v1alpha1.InfisicalSecret, managedSecretReferenceInterface interface{}, secretsFromAPI []model.SingleEnvironmentVariable, ETag string, resourceType constants.ManagedKubeResourceType) error { plainProcessedSecrets := make(map[string][]byte) - var managedTemplateData *v1alpha1.InfisicalSecretTemplate + var managedTemplateData *v1alpha1.SecretTemplate if resourceType == constants.MANAGED_KUBE_RESOURCE_TYPE_SECRET { managedTemplateData = managedSecretReferenceInterface.(v1alpha1.ManagedKubeSecretConfig).Template @@ -201,7 +191,7 @@ func (r *InfisicalSecretReconciler) createInfisicalManagedKubeResource(ctx conte } for templateKey, userTemplate := range managedTemplateData.Data { - tmpl, err := template.New("secret-templates").Funcs(infisicalSecretTemplateFunctions).Parse(userTemplate) + tmpl, err := tpl.New("secret-templates").Funcs(template.GetTemplateFunctions()).Parse(userTemplate) if err != nil { return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) } @@ -322,7 +312,7 @@ func (r *InfisicalSecretReconciler) updateInfisicalManagedKubeSecret(ctx context } for templateKey, userTemplate := range managedTemplateData.Data { - tmpl, err := template.New("secret-templates").Funcs(infisicalSecretTemplateFunctions).Parse(userTemplate) + tmpl, err := tpl.New("secret-templates").Funcs(template.GetTemplateFunctions()).Parse(userTemplate) if err != nil { return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) } @@ -373,7 +363,7 @@ func (r *InfisicalSecretReconciler) updateInfisicalManagedConfigMap(ctx context. } for templateKey, userTemplate := range managedTemplateData.Data { - tmpl, err := template.New("secret-templates").Funcs(infisicalSecretTemplateFunctions).Parse(userTemplate) + tmpl, err := tpl.New("secret-templates").Funcs(template.GetTemplateFunctions()).Parse(userTemplate) if err != nil { return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) } diff --git a/k8-operator/go.mod b/k8-operator/go.mod index 0731666fa..6ce68ed7f 100644 --- a/k8-operator/go.mod +++ b/k8-operator/go.mod @@ -3,12 +3,15 @@ module github.com/Infisical/infisical/k8-operator go 1.21 require ( + github.com/Masterminds/sprig/v3 v3.3.0 github.com/infisical/go-sdk v0.4.4 + github.com/lestrrat-go/jwx/v2 v2.1.4 github.com/onsi/ginkgo/v2 v2.6.0 github.com/onsi/gomega v1.24.1 k8s.io/apimachinery v0.26.1 k8s.io/client-go v0.26.1 sigs.k8s.io/controller-runtime v0.14.4 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( @@ -16,6 +19,9 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/compute/metadata v0.4.0 // indirect cloud.google.com/go/iam v1.1.11 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/aws/aws-sdk-go-v2 v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.24 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.24 // indirect @@ -29,18 +35,31 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.7.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/sync v0.7.0 // indirect + golang.org/x/sync v0.10.0 // indirect google.golang.org/api v0.188.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240708141625-4ad9e859172b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect @@ -84,18 +103,18 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect - golang.org/x/crypto v0.25.0 + golang.org/x/crypto v0.32.0 golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.26.1 k8s.io/apiextensions-apiserver v0.26.1 // indirect k8s.io/component-base v0.26.1 // indirect diff --git a/k8-operator/go.sum b/k8-operator/go.sum index 9037bc4a0..bcecfadc0 100644 --- a/k8-operator/go.sum +++ b/k8-operator/go.sum @@ -38,9 +38,17 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -92,6 +100,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= @@ -106,6 +116,8 @@ github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJ github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -138,6 +150,8 @@ github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/ github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -214,6 +228,8 @@ github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBY github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -239,10 +255,24 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc= +github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= @@ -250,6 +280,10 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM= github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -300,9 +334,17 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= @@ -319,8 +361,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -362,8 +404,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -458,8 +500,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -506,16 +548,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -527,8 +569,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -728,3 +770,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kF sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/k8-operator/main.go b/k8-operator/main.go index 79df0d4d8..d2f190595 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -22,6 +22,7 @@ import ( infisicalDynamicSecretController "github.com/Infisical/infisical/k8-operator/controllers/infisicaldynamicsecret" infisicalPushSecretController "github.com/Infisical/infisical/k8-operator/controllers/infisicalpushsecret" infisicalSecretController "github.com/Infisical/infisical/k8-operator/controllers/infisicalsecret" + "github.com/Infisical/infisical/k8-operator/packages/template" //+kubebuilder:scaffold:imports ) @@ -86,6 +87,8 @@ func main() { os.Exit(1) } + template.InitializeTemplateFunctions() + if err = (&infisicalSecretController.InfisicalSecretReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/k8-operator/packages/template/base64.go b/k8-operator/packages/template/base64.go new file mode 100644 index 000000000..3fff06c86 --- /dev/null +++ b/k8-operator/packages/template/base64.go @@ -0,0 +1,18 @@ +package template + +import ( + "encoding/base64" + "fmt" +) + +func decodeBase64ToBytes(encodedString string) string { + decoded, err := base64.StdEncoding.DecodeString(encodedString) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + return string(decoded) +} + +func encodeBase64(plainString string) string { + return base64.StdEncoding.EncodeToString([]byte(plainString)) +} diff --git a/k8-operator/packages/template/jwk.go b/k8-operator/packages/template/jwk.go new file mode 100644 index 000000000..8dbc8f379 --- /dev/null +++ b/k8-operator/packages/template/jwk.go @@ -0,0 +1,43 @@ +package template + +import ( + "crypto/x509" + "fmt" + + "github.com/lestrrat-go/jwx/v2/jwk" +) + +func jwkPublicKeyPem(jwkjson string) string { + k, err := jwk.ParseKey([]byte(jwkjson)) + if err != nil { + panic(fmt.Sprintf("[jwkPublicKeyPem] Error: %v", err)) + } + var rawkey any + err = k.Raw(&rawkey) + if err != nil { + panic(fmt.Sprintf("[jwkPublicKeyPem] Error: %v", err)) + } + mpk, err := x509.MarshalPKIXPublicKey(rawkey) + if err != nil { + panic(fmt.Sprintf("[jwkPublicKeyPem] Error: %v", err)) + } + return pemEncode(mpk, "PUBLIC KEY") +} + +func jwkPrivateKeyPem(jwkjson string) string { + k, err := jwk.ParseKey([]byte(jwkjson)) + if err != nil { + panic(fmt.Sprintf("[jwkPrivateKeyPem] Error: %v", err)) + } + var mpk []byte + var pk any + err = k.Raw(&pk) + if err != nil { + panic(fmt.Sprintf("[jwkPrivateKeyPem] Error: %v", err)) + } + mpk, err = x509.MarshalPKCS8PrivateKey(pk) + if err != nil { + panic(fmt.Sprintf("[jwkPrivateKeyPem] Error: %v", err)) + } + return pemEncode(mpk, "PRIVATE KEY") +} diff --git a/k8-operator/packages/template/pem.go b/k8-operator/packages/template/pem.go new file mode 100644 index 000000000..f37a9d576 --- /dev/null +++ b/k8-operator/packages/template/pem.go @@ -0,0 +1,98 @@ +package template + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" +) + +const ( + errJunk = "error filtering pem: found junk" + + certTypeLeaf = "leaf" + certTypeIntermediate = "intermediate" + certTypeRoot = "root" +) + +func filterPEM(pemType, input string) string { + data := []byte(input) + var blocks []byte + var block *pem.Block + var rest []byte + for { + block, rest = pem.Decode(data) + data = rest + + if block == nil { + break + } + if !strings.EqualFold(block.Type, pemType) { + continue + } + + var buf bytes.Buffer + err := pem.Encode(&buf, block) + if err != nil { + panic(fmt.Sprintf("[filterPEM] Error: %v", err)) + } + blocks = append(blocks, buf.Bytes()...) + } + + if len(blocks) == 0 && len(rest) != 0 { + panic(fmt.Sprintf("[filterPEM] Error: %v", errJunk)) + } + + return string(blocks) +} + +func filterCertChain(certType, input string) string { + ordered := fetchX509CertChains([]byte(input)) + + switch certType { + case certTypeLeaf: + cert := ordered[0] + if cert.AuthorityKeyId != nil && !bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId) { + return pemEncode(ordered[0].Raw, pemTypeCertificate) + } + case certTypeIntermediate: + if len(ordered) < 2 { + return "" + } + var pemData []byte + for _, cert := range ordered[1:] { + if isRootCertificate(cert) { + break + } + b := &pem.Block{ + Type: pemTypeCertificate, + Bytes: cert.Raw, + } + pemData = append(pemData, pem.EncodeToMemory(b)...) + } + return string(pemData) + case certTypeRoot: + cert := ordered[len(ordered)-1] + if isRootCertificate(cert) { + return pemEncode(cert.Raw, pemTypeCertificate) + } + } + + return "" +} + +func isRootCertificate(cert *x509.Certificate) bool { + return cert.AuthorityKeyId == nil || bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId) +} + +func pemEncode(thing []byte, kind string) string { + buf := bytes.NewBuffer(nil) + err := pem.Encode(buf, &pem.Block{Type: kind, Bytes: thing}) + + if err != nil { + panic(fmt.Sprintf("[pemEncode] Error: %v", err)) + } + + return buf.String() +} diff --git a/k8-operator/packages/template/pem_chain.go b/k8-operator/packages/template/pem_chain.go new file mode 100644 index 000000000..00c4f5d3f --- /dev/null +++ b/k8-operator/packages/template/pem_chain.go @@ -0,0 +1,117 @@ +package template + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" +) + +const ( + errNilCert = "certificate is nil" + errFoundDisjunctCert = "found multiple leaf or disjunct certificates" + errNoLeafFound = "no leaf certificate found" + errChainCycle = "constructing chain resulted in cycle" +) + +type node struct { + cert *x509.Certificate + parent *node + isParent bool +} + +func fetchX509CertChains(data []byte) []*x509.Certificate { + var newCertChain []*x509.Certificate + nodes := pemToNodes(data) + + // at the end of this computation, the output will be a single linked list + // the tail of the list will be the root node (which has no parents) + // the head of the list will be the leaf node (whose parent will be intermediate certs) + // (head) leaf -> intermediates -> root (tail) + for i := range nodes { + for j := range nodes { + // ignore same node to prevent generating a cycle + if i == j { + continue + } + // if ith node AuthorityKeyId is same as jth node SubjectKeyId, jth node was used + // to sign the ith certificate + if bytes.Equal(nodes[i].cert.AuthorityKeyId, nodes[j].cert.SubjectKeyId) { + nodes[j].isParent = true + nodes[i].parent = nodes[j] + break + } + } + } + + var foundLeaf bool + var leaf *node + for i := range nodes { + if !nodes[i].isParent { + if foundLeaf { + panic(fmt.Sprintf("[fetchX509CertChains] Error: %v", errFoundDisjunctCert)) + } + // this is the leaf node as it's not a parent for any other node + leaf = nodes[i] + foundLeaf = true + } + } + + if leaf == nil { + panic(fmt.Sprintf("[fetchX509CertChains] Error: %v", errNoLeafFound)) + } + + processedNodes := 0 + // iterate through the directed list and append the nodes to new cert chain + for leaf != nil { + processedNodes++ + // ensure we aren't stuck in a cyclic loop + if processedNodes > len(nodes) { + panic(fmt.Sprintf("[fetchX509CertChains] Error: %v", errChainCycle)) + } + newCertChain = append(newCertChain, leaf.cert) + leaf = leaf.parent + } + return newCertChain +} + +func fetchCertChains(data []byte) []byte { + var pemData []byte + newCertChain := fetchX509CertChains(data) + + for _, cert := range newCertChain { + b := &pem.Block{ + Type: pemTypeCertificate, + Bytes: cert.Raw, + } + pemData = append(pemData, pem.EncodeToMemory(b)...) + } + return pemData +} + +func pemToNodes(data []byte) []*node { + nodes := make([]*node, 0) + for { + // decode pem to der first + block, rest := pem.Decode(data) + data = rest + + if block == nil { + break + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + panic(fmt.Sprintf("[pemToNodes] Error: %v", err)) + } + + if cert == nil { + panic(fmt.Sprintf("[pemToNodes] Error: %v", errNilCert)) + } + nodes = append(nodes, &node{ + cert: cert, + parent: nil, + isParent: false, + }) + } + return nodes +} diff --git a/k8-operator/packages/template/pkcs12.go b/k8-operator/packages/template/pkcs12.go new file mode 100644 index 000000000..e6763fc46 --- /dev/null +++ b/k8-operator/packages/template/pkcs12.go @@ -0,0 +1,144 @@ +package template + +import ( + "bytes" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + + gopkcs12 "software.sslmate.com/src/go-pkcs12" +) + +func pkcs12keyPass(pass, input string) string { + privateKey, _, _, err := gopkcs12.DecodeChain([]byte(input), pass) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + marshalPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: pemTypeKey, + Bytes: marshalPrivateKey, + }); err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + return buf.String() +} + +func parsePrivateKey(block []byte) any { + if k, err := x509.ParsePKCS1PrivateKey(block); err == nil { + return k + } + if k, err := x509.ParsePKCS8PrivateKey(block); err == nil { + return k + } + if k, err := x509.ParseECPrivateKey(block); err == nil { + return k + } + panic("Error: unable to parse private key") +} + +func pkcs12key(input string) string { + return pkcs12keyPass("", input) +} + +func pkcs12certPass(pass, input string) string { + _, certificate, caCerts, err := gopkcs12.DecodeChain([]byte(input), pass) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + var pemData []byte + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: pemTypeCertificate, + Bytes: certificate.Raw, + }); err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + pemData = append(pemData, buf.Bytes()...) + + for _, ca := range caCerts { + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: pemTypeCertificate, + Bytes: ca.Raw, + }); err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + pemData = append(pemData, buf.Bytes()...) + } + + // try to order certificate chain. If it fails we return + // the unordered raw pem data. + // This fails if multiple leaf or disjunct certs are provided. + ordered := fetchCertChains(pemData) + + return string(ordered) +} + +func pkcs12cert(input string) string { + return pkcs12certPass("", input) +} + +func pemToPkcs12(cert, key string) string { + return pemToPkcs12Pass(cert, key, "") +} + +func pemToPkcs12Pass(cert, key, pass string) string { + certPem, _ := pem.Decode([]byte(cert)) + + parsedCert, err := x509.ParseCertificate(certPem.Bytes) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + return certsToPkcs12(parsedCert, key, nil, pass) +} + +func fullPemToPkcs12(cert, key string) string { + return fullPemToPkcs12Pass(cert, key, "") +} + +func fullPemToPkcs12Pass(cert, key, pass string) string { + certPem, rest := pem.Decode([]byte(cert)) + + parsedCert, err := x509.ParseCertificate(certPem.Bytes) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + caCerts := make([]*x509.Certificate, 0) + for len(rest) > 0 { + caPem, restBytes := pem.Decode(rest) + rest = restBytes + + caCert, err := x509.ParseCertificate(caPem.Bytes) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + caCerts = append(caCerts, caCert) + } + + return certsToPkcs12(parsedCert, key, caCerts, pass) +} + +func certsToPkcs12(cert *x509.Certificate, key string, caCerts []*x509.Certificate, password string) string { + keyPem, _ := pem.Decode([]byte(key)) + parsedKey := parsePrivateKey(keyPem.Bytes) + + pfx, err := gopkcs12.Modern.Encode(parsedKey, cert, caCerts, password) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + + return base64.StdEncoding.EncodeToString(pfx) +} diff --git a/k8-operator/packages/template/template.go b/k8-operator/packages/template/template.go new file mode 100644 index 000000000..d56b3da5d --- /dev/null +++ b/k8-operator/packages/template/template.go @@ -0,0 +1,67 @@ +package template + +import ( + tpl "text/template" + + "github.com/Masterminds/sprig/v3" +) + +var customInfisicalSecretTemplateFunctions = tpl.FuncMap{ + "pkcs12key": pkcs12key, + "pkcs12keyPass": pkcs12keyPass, + "pkcs12cert": pkcs12cert, + "pkcs12certPass": pkcs12certPass, + + "pemToPkcs12": pemToPkcs12, + "pemToPkcs12Pass": pemToPkcs12Pass, + "fullPemToPkcs12": fullPemToPkcs12, + "fullPemToPkcs12Pass": fullPemToPkcs12Pass, + + "filterPEM": filterPEM, + "filterCertChain": filterCertChain, + + "jwkPublicKeyPem": jwkPublicKeyPem, + "jwkPrivateKeyPem": jwkPrivateKeyPem, + + "toYaml": toYAML, + "fromYaml": fromYAML, + + "decodeBase64ToBytes": decodeBase64ToBytes, + "encodeBase64": encodeBase64, +} + +const ( + errParse = "unable to parse template at key %s: %s" + errExecute = "unable to execute template at key %s: %s" + errDecodePKCS12WithPass = "unable to decode pkcs12 with password: %s" + errDecodeCertWithPass = "unable to decode pkcs12 certificate with password: %s" + errParsePrivKey = "unable to parse private key type" + errUnmarshalJSON = "unable to unmarshal json: %s" + errMarshalJSON = "unable to marshal json: %s" + + pemTypeCertificate = "CERTIFICATE" + pemTypeKey = "PRIVATE KEY" +) + +func InitializeTemplateFunctions() { + templates := customInfisicalSecretTemplateFunctions + + sprigFuncs := sprig.TxtFuncMap() + // removed for security reasons + delete(sprigFuncs, "env") + delete(sprigFuncs, "expandenv") + + for k, v := range sprigFuncs { + // make sure we aren't overwriting any of our own functions + _, exists := templates[k] + if !exists { + templates[k] = v + } + } + + customInfisicalSecretTemplateFunctions = templates +} + +func GetTemplateFunctions() tpl.FuncMap { + return customInfisicalSecretTemplateFunctions +} diff --git a/k8-operator/packages/template/yaml.go b/k8-operator/packages/template/yaml.go new file mode 100644 index 000000000..5352d5a02 --- /dev/null +++ b/k8-operator/packages/template/yaml.go @@ -0,0 +1,30 @@ +package template + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +func toYAML(v any) string { + data, err := yaml.Marshal(v) + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + + } + return strings.TrimSuffix(string(data), "\n") +} + +// fromYAML converts a YAML document into a map[string]any. +// +// This is not a general-purpose YAML parser, and will not parse all valid +// YAML documents. +func fromYAML(str string) map[string]any { + mapData := map[string]any{} + + if err := yaml.Unmarshal([]byte(str), &mapData); err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + return mapData +}