diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 48f52f9e7..ddf57bfd5 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -1,5 +1,6 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { Lock } from "@app/lib/red-lock"; +import RE2 from "re2"; export const mockKeyStore = (): TKeyStoreFactory => { const store: Record = {}; @@ -18,6 +19,17 @@ export const mockKeyStore = (): TKeyStoreFactory => { delete store[key]; return 1; }, + deleteItems: async (pattern) => { + const regex = new RE2(pattern.replace(/\*/g, ".*")); + let deletedCount = 0; + for (const key of Object.keys(store)) { + if (regex.test(key)) { + delete store[key]; + deletedCount += 1; + } + } + return deletedCount; + }, getItem: async (key) => { const value = store[key]; if (typeof value === "string") { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index ac28e9ade..9371cebb8 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -57,6 +57,8 @@ type TWaitTillReady = { jitter?: number; }; +const DELETION_BATCH_SIZE = 500; + export const keyStoreFactory = (redisUrl: string) => { const redis = new Redis(redisUrl); const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); @@ -75,6 +77,31 @@ export const keyStoreFactory = (redisUrl: string) => { const deleteItem = async (key: string) => redis.del(key); + const deleteItems = async (pattern: string) => { + let cursor = "0"; + let totalDeleted = 0; + + do { + // Await in loop is needed so that Redis is not overwhelmed + // eslint-disable-next-line no-await-in-loop + const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads + cursor = nextCursor; + + for (let i = 0; i < keys.length; i += DELETION_BATCH_SIZE) { + const batch = keys.slice(i, i + DELETION_BATCH_SIZE); + const pipeline = redis.pipeline(); + for (const key of batch) { + pipeline.unlink(key); + } + // eslint-disable-next-line no-await-in-loop + await pipeline.exec(); + totalDeleted += batch.length; + } + } while (cursor !== "0"); + + return totalDeleted; + }; + const incrementBy = async (key: string, value: number) => redis.incrby(key, value); const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); @@ -108,6 +135,7 @@ export const keyStoreFactory = (redisUrl: string) => { setExpiry, setItemWithExpiry, deleteItem, + deleteItems, incrementBy, acquireLock(resources: string[], duration: number, settings?: Partial) { return redisLock.acquire(resources, duration, settings); diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index 10b28ffec..55699207f 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { Lock } from "@app/lib/red-lock"; import { TKeyStoreFactory } from "./keystore"; @@ -19,6 +21,17 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { delete store[key]; return 1; }, + deleteItems: async (pattern) => { + const regex = new RE2(pattern.replace(/\*/g, ".*")); + let deletedCount = 0; + for (const key of Object.keys(store)) { + if (regex.test(key)) { + delete store[key]; + deletedCount += 1; + } + } + return deletedCount; + }, getItem: async (key) => { const value = store[key]; if (typeof value === "string") { diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 681442d1b..42bf37c71 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -100,3 +100,10 @@ export const publicSshCaLimit: RateLimitOptions = { max: 30, // conservative default keyGenerator: (req) => req.realIp }; + +export const invalidateCacheLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + hook: "preValidation", + max: 1, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6eb1804f1..c1dbe3805 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -4,13 +4,14 @@ import { z } from "zod"; import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; -import { LoginMethod } from "@app/services/super-admin/super-admin-types"; +import { CacheType, LoginMethod } from "@app/services/super-admin/super-admin-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerAdminRouter = async (server: FastifyZodProvider) => { @@ -535,4 +536,37 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/invalidate-cache", + config: { + rateLimit: invalidateCacheLimit + }, + schema: { + body: z.object({ + type: z.nativeEnum(CacheType) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.superAdmin.invalidateCache(req.body.type); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.InvalidateCache, + distinctId: getTelemetryDistinctId(req), + properties: { + ...req.auditLogInfo + } + }); + + return { + message: "Successfully purged cache" + }; + } + }); }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 6ab348520..cd2773172 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -7,6 +7,7 @@ import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecre import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { generateCacheKeyFromData } from "@app/lib/crypto/cache"; +import { applyJitter } from "@app/lib/dates"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { buildFindFilter, @@ -22,7 +23,6 @@ import type { TFindSecretsByFolderIdsFilter, TGetSecretsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; -import { applyJitter } from "@app/lib/dates"; export const SecretServiceCacheKeys = { get productKey() { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 317348cac..f993d2398 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -26,6 +26,7 @@ import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { + CacheType, LoginMethod, TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, @@ -45,7 +46,7 @@ type TSuperAdminServiceFactoryDep = { kmsService: Pick; kmsRootConfigDAL: TKmsRootConfigDALFactory; orgService: Pick; - keyStore: Pick; + keyStore: Pick; licenseService: Pick; }; @@ -570,6 +571,10 @@ export const superAdminServiceFactory = ({ await kmsService.updateEncryptionStrategy(strategy); }; + const invalidateCache = async (type: CacheType) => { + if (type === CacheType.ALL || type === CacheType.SECRETS) await keyStore.deleteItems("secret-manager:*"); + }; + return { initServerCfg, updateServerCfg, @@ -583,6 +588,7 @@ export const superAdminServiceFactory = ({ getConfiguredEncryptionStrategies, grantServerAdminAccessToUser, deleteIdentitySuperAdminAccess, - deleteUserSuperAdminAccess + deleteUserSuperAdminAccess, + invalidateCache }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 64ec92632..c804bed74 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -44,3 +44,8 @@ export enum LoginMethod { LDAP = "ldap", OIDC = "oidc" } + +export enum CacheType { + ALL = "all", + SECRETS = "secrets" +} diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index ab90a71d4..9e046cdbd 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -21,7 +21,8 @@ export enum PostHogEventTypes { IssueSshHostUserCert = "Issue SSH Host User Certificate", IssueSshHostHostCert = "Issue SSH Host Host Certificate", SignCert = "Sign PKI Certificate", - IssueCert = "Issue PKI Certificate" + IssueCert = "Issue PKI Certificate", + InvalidateCache = "Invalidate Cache" } export type TSecretModifiedEvent = { @@ -203,6 +204,13 @@ export type TIssueCertificateEvent = { }; }; +export type TInvalidateCacheEvent = { + event: PostHogEventTypes.InvalidateCache; + properties: { + userAgent?: string; + }; +}; + export type TPostHogEvent = { distinctId: string } & ( | TSecretModifiedEvent | TAdminInitEvent @@ -221,4 +229,5 @@ export type TPostHogEvent = { distinctId: string } & ( | TIssueSshHostHostCertEvent | TSignCertificateEvent | TIssueCertificateEvent + | TInvalidateCacheEvent ); diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index d43fbc080..bf2cd4345 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -3,6 +3,7 @@ export { useAdminGrantServerAdminAccess, useAdminRemoveIdentitySuperAdminAccess, useCreateAdminUser, + useInvalidateCache, useRemoveUserServerAdminAccess, useUpdateAdminSlackConfig, useUpdateServerConfig, diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b3e1e37b4..78d6edc9a 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -9,6 +9,7 @@ import { AdminSlackConfig, RootKeyEncryptionStrategy, TCreateAdminUserDTO, + TInvalidateCacheDTO, TServerConfig, TUpdateAdminSlackConfigDTO } from "./types"; @@ -147,3 +148,15 @@ export const useUpdateServerEncryptionStrategy = () => { } }); }; + +export const useInvalidateCache = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + await apiRequest.post("/api/v1/admin/invalidate-cache", dto); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getInvalidateCache() }); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index b24841dbd..a1a00ee6a 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -23,7 +23,8 @@ export const adminQueryKeys = { getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, - getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const + getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const, + getInvalidateCache: () => ["admin-invalidate-cache"] as const }; export const fetchServerConfig = async () => { diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 11f2cf44f..c3cf433fd 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -74,3 +74,12 @@ export enum RootKeyEncryptionStrategy { Software = "SOFTWARE", HSM = "HSM" } + +export enum CacheType { + ALL = "all", + SECRETS = "secrets" +} + +export type TInvalidateCacheDTO = { + type: CacheType; +}; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index a862c31a3..8533d7007 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -13,9 +13,9 @@ import { TBreadcrumbFormat } from "@app/components/v2"; import { - useProjectPermission, ProjectPermissionActions, ProjectPermissionSub, + useProjectPermission, useSubscription, useWorkspace } from "@app/context"; diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index a9242c252..1f1f0760c 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -31,6 +31,7 @@ import { import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel"; import { AuthPanel } from "./components/AuthPanel"; +import { CachingPanel } from "./components/CachingPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; import { UserPanel } from "./components/UserPanel"; @@ -42,7 +43,8 @@ enum TabSections { Integrations = "integrations", Users = "users", Identities = "identities", - Kmip = "kmip" + Kmip = "kmip", + Caching = "caching" } enum SignUpModes { @@ -164,6 +166,7 @@ export const OverviewPage = () => { Integrations User Identities Machine Identities + Caching @@ -408,6 +411,9 @@ export const OverviewPage = () => { + + + )} diff --git a/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx new file mode 100644 index 000000000..a30042999 --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/CachingPanel.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { 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"; + +export const CachingPanel = () => { + const { mutateAsync: invalidateCache } = useInvalidateCache(); + const { membership } = useOrgPermission(); + + const [type, setType] = useState(CacheType.ALL); + const [isLoading, setIsLoading] = useState(false); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "invalidateCache" + ] as const); + + const handleInvalidateCacheSubmit = async () => { + try { + setIsLoading(true); + + await invalidateCache({ type }); + + createNotification({ + text: `Successfully purged ${type} cache`, + type: "success" + }); + + setIsLoading(false); + handlePopUpClose("invalidateCache"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to purge ${type} cache`, + type: "error" + }); + } + }; + + return ( + <> +
+
+ Secrets Cache + + The secrets cache encompasses all secrets stored within the system and provides a + temporary, secure storage location for frequently accessed credentials. + +
+ + +
+ + {/* Uncomment this when we have more than one cache type */} + {/*
+
+ All Cache + + All cache refers to the entirety of cached data throughout the system, including secrets + and miscellaneous information. + +
+ + +
*/} + + handlePopUpToggle("invalidateCache", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleInvalidateCacheSubmit} + /> + + ); +};