diff --git a/backend/src/db/migrations/20250502201443_invalidate-cache-status-superadmin.ts b/backend/src/db/migrations/20250502201443_invalidate-cache-status-superadmin.ts new file mode 100644 index 000000000..a3a5805c1 --- /dev/null +++ b/backend/src/db/migrations/20250502201443_invalidate-cache-status-superadmin.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "invalidatingCache"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.boolean("invalidatingCache").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "invalidatingCache"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("invalidatingCache"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index bc6f0b7af..8d8279802 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), - secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 2403d6cf4..297601fd0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(true).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index ec35042ad..18e45a5f0 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -29,7 +29,8 @@ export const SuperAdminSchema = z.object({ adminIdentityIds: z.string().array().nullable().optional(), encryptedMicrosoftTeamsAppId: zodBuffer.nullable().optional(), encryptedMicrosoftTeamsClientSecret: zodBuffer.nullable().optional(), - encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional() + encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional(), + invalidatingCache: z.boolean().default(false) }); export type TSuperAdmin = z.infer; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 5f5eb3f50..6da6c4fa4 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -103,6 +103,7 @@ export const keyStoreFactory = (redisUrl: string) => { // eslint-disable-next-line no-await-in-loop await pipeline.exec(); totalDeleted += batch.length; + console.log("BATCH DONE"); // eslint-disable-next-line no-await-in-loop await delayMs(Math.max(0, applyJitter(delay, jitter))); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 13d4e2728..8610a611b 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -587,4 +587,31 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "GET", + url: "/invalidating-cache-status", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + invalidating: z.boolean() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const invalidating = await server.services.superAdmin.checkIfInvalidatingCache(); + + return { + invalidating + }; + } + }); }; diff --git a/backend/src/services/super-admin/invalidate-cache-queue.ts b/backend/src/services/super-admin/invalidate-cache-queue.ts index 2bc897df6..d93df0052 100644 --- a/backend/src/services/super-admin/invalidate-cache-queue.ts +++ b/backend/src/services/super-admin/invalidate-cache-queue.ts @@ -1,4 +1,5 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { delay } from "@app/lib/delay"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -7,7 +8,7 @@ import { CacheType } from "./super-admin-types"; export type TInvalidateCacheQueueFactoryDep = { queueService: TQueueServiceFactory; - keyStore: Pick; + keyStore: Pick; }; export type TInvalidateCacheQueueFactory = ReturnType; @@ -18,9 +19,18 @@ export const invalidateCacheQueueFactory = ({ queueService, keyStore }: TInvalid type: CacheType; }; }) => { + // Cancel existing jobs if any + try { + console.log("stopping job"); + await queueService.clearQueue(QueueName.InvalidateCache); + } catch (err) { + logger.warn(err, "Failed to clear queue"); + } + await queueService.queue(QueueName.InvalidateCache, QueueJobs.InvalidateCache, dto, { removeOnComplete: true, - removeOnFail: true + removeOnFail: true, + jobId: "invalidate-cache" }); }; @@ -30,10 +40,19 @@ export const invalidateCacheQueueFactory = ({ queueService, keyStore }: TInvalid data: { type } } = job.data; + await keyStore.setItemWithExpiry("invalidating-cache", 3600, "true"); // 1 hour max (in case the job somehow silently fails) + + console.log("STARTING JOB"); + if (type === CacheType.ALL || type === CacheType.SECRETS) await keyStore.deleteItems({ pattern: "secret-manager:*" }); + + // await delay(12000); // TODO(andrey): Remove. It's for debug + + await keyStore.deleteItem("invalidating-cache"); } catch (err) { logger.error(err, "Failed to invalidate cache"); + await keyStore.deleteItem("invalidating-cache"); } }); diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 2e169f064..b21b97911 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -67,7 +67,7 @@ export let getServerCfg: () => Promise< const ADMIN_CONFIG_KEY = "infisical-admin-cfg"; const ADMIN_CONFIG_KEY_EXP = 60; // 60s -const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; +export const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; export const superAdminServiceFactory = ({ serverCfgDAL, @@ -641,6 +641,10 @@ export const superAdminServiceFactory = ({ }); }; + const checkIfInvalidatingCache = async () => { + return (await keyStore.getItem("invalidating-cache")) !== null; + }; + return { initServerCfg, updateServerCfg, @@ -655,6 +659,7 @@ export const superAdminServiceFactory = ({ grantServerAdminAccessToUser, deleteIdentitySuperAdminAccess, deleteUserSuperAdminAccess, - invalidateCache + invalidateCache, + checkIfInvalidatingCache }; }; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 75e826bce..b9bdf223a 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -8,6 +8,7 @@ import { AdminGetIdentitiesFilters, AdminGetUsersFilters, AdminIntegrationsConfig, + TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig } from "./types"; @@ -120,3 +121,16 @@ export const useGetServerRootKmsEncryptionDetails = () => { } }); }; + +export const useGetInvalidatingCacheStatus = () => { + return useQuery({ + queryKey: adminQueryKeys.getInvalidateCache(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/invalidating-cache-status" + ); + + return data.invalidating; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 7e808a101..8850b3375 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -24,6 +24,7 @@ export type TServerConfig = { enabledLoginMethods: LoginMethod[]; authConsentContent?: string; pageFrameContent?: string; + invalidatingCache: boolean; }; export type TUpdateServerConfigDTO = { @@ -93,3 +94,7 @@ export enum CacheType { export type TInvalidateCacheDTO = { type: CacheType; }; + +export type TGetInvalidatingCacheStatus = { + invalidating: boolean; +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx index 0d5e1c2ba..f8b8f43b8 100644 --- a/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx @@ -1,37 +1,66 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createNotification } from "@app/components/notifications"; -import { Button, DeleteActionModal } from "@app/components/v2"; +import { Badge, Button, DeleteActionModal } from "@app/components/v2"; import { useOrgPermission } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useInvalidateCache } from "@app/hooks/api"; import { CacheType } from "@app/hooks/api/admin/types"; +import { useGetInvalidatingCacheStatus } from "@app/hooks/api/admin/queries"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faRotate } from "@fortawesome/free-solid-svg-icons"; export const CachingPanel = () => { const { mutateAsync: invalidateCache } = useInvalidateCache(); + const { data: isInvalidating, refetch: refetchInvalidatingStatus } = + useGetInvalidatingCacheStatus(); const { membership } = useOrgPermission(); + const wasInvalidating = useRef(false); const [type, setType] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [buttonsDisabled, setButtonsDisabled] = useState(false); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "invalidateCache" ] as const); + const success = () => { + createNotification({ + text: `Successfully invalidated cache`, + type: "success" + }); + setButtonsDisabled(false); + }; + + const timeoutRef = useRef(null); + const disableButtons = () => { + // Enable buttons after 10 seconds, even if still invalidating + setButtonsDisabled(true); + timeoutRef.current = setTimeout(() => { + setButtonsDisabled(false); + }, 10000); + }; + const handleInvalidateCacheSubmit = async () => { if (!type) return; - setIsLoading(true); try { await invalidateCache({ type }); + wasInvalidating.current = true; + createNotification({ - text: `Successfully invalidated ${type} cache`, + text: `Began invalidating ${type} cache`, type: "success" }); - setType(null); + disableButtons(); handlePopUpClose("invalidateCache"); + + if (!(await refetchInvalidatingStatus()).data) { + success(); + return; + } } catch (err) { console.error(err); createNotification({ @@ -40,14 +69,61 @@ export const CachingPanel = () => { }); } - setIsLoading(false); + setType(null); }; + const pollingRef = useRef(null); + + // Update the "invalidating cache" status + useEffect(() => { + if (!isInvalidating) return; + + if (pollingRef.current) clearInterval(pollingRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + // Start polling every 3 seconds + pollingRef.current = setInterval(async () => { + try { + await refetchInvalidatingStatus(); + } catch (err) { + console.error("Polling error:", err); + } + }, 3000); + + disableButtons(); + + return () => { + if (pollingRef.current) clearInterval(pollingRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [isInvalidating]); + + useEffect(() => { + if (isInvalidating === false && wasInvalidating.current) { + success(); + wasInvalidating.current = false; + + if (pollingRef.current) clearInterval(pollingRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + } + }, [isInvalidating]); + return ( <>
- Secrets Cache +
+ Secrets Cache + {isInvalidating && ( + + + Invalidating Cache + + )} +
The encrypted secrets cache encompasses all secrets stored within the system and provides a temporary, secure storage location for frequently accessed credentials. @@ -56,12 +132,11 @@ export const CachingPanel = () => { @@ -93,7 +168,7 @@ export const CachingPanel = () => { handlePopUpToggle("invalidateCache", isOpen)} deleteKey="confirm" onDeleteApproved={handleInvalidateCacheSubmit} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b73752773..618dcb618 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,6 @@ import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react-swc"; -import { defineConfig, PluginOption } from "vite"; +import { defineConfig, loadEnv, PluginOption } from "vite"; import { nodePolyfills } from "vite-plugin-node-polyfills"; import topLevelAwait from "vite-plugin-top-level-await"; import wasm from "vite-plugin-wasm"; @@ -20,32 +20,38 @@ const virtualRouteFileChangeReloadPlugin: PluginOption = { }; // https://vite.dev/config/ -export default defineConfig({ - server: { - host: true, - port: 3000 - // proxy: { - // "/api": { - // target: "http://localhost:8080", - // changeOrigin: true, - // secure: false, - // ws: true - // } - // } - }, - plugins: [ - tsconfigPaths(), - nodePolyfills({ - globals: { - Buffer: true - } - }), - wasm(), - topLevelAwait(), - TanStackRouterVite({ - virtualRouteConfig: "./src/routes.ts" - }), - react(), - virtualRouteFileChangeReloadPlugin - ] +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()); + const allowedHosts = env.VITE_ALLOWED_HOSTS?.split(",") ?? []; + + return { + server: { + allowedHosts, + host: true, + port: 3000 + // proxy: { + // "/api": { + // target: "http://localhost:8080", + // changeOrigin: true, + // secure: false, + // ws: true + // } + // } + }, + plugins: [ + tsconfigPaths(), + nodePolyfills({ + globals: { + Buffer: true + } + }), + wasm(), + topLevelAwait(), + TanStackRouterVite({ + virtualRouteConfig: "./src/routes.ts" + }), + react(), + virtualRouteFileChangeReloadPlugin + ] + }; });