From e060847ca615b7ebcbdfffcf90ed28fccc7a164b Mon Sep 17 00:00:00 2001 From: shahargl Date: Tue, 18 Mar 2025 16:35:06 +0200 Subject: [PATCH 01/14] feat: switch tenants --- keep-ui/auth.config.ts | 10 ++ keep-ui/auth.ts | 133 +++++++++++++++++------ keep-ui/components/navbar/Menu.tsx | 12 ++- keep-ui/components/navbar/Navbar.tsx | 5 +- keep-ui/components/navbar/Search.tsx | 156 ++++++++++++++++----------- keep-ui/types/auth.d.ts | 2 + keep-ui/utils/hooks/usePusher.ts | 3 - 7 files changed, 214 insertions(+), 107 deletions(-) diff --git a/keep-ui/auth.config.ts b/keep-ui/auth.config.ts index 71ed67fa60..b80b80af32 100644 --- a/keep-ui/auth.config.ts +++ b/keep-ui/auth.config.ts @@ -172,6 +172,16 @@ const baseProviderConfigs = { tenant_id: tenantId, user_id: "keep-user-for-no-auth-purposes", }), + tenantIds: [ + { + tenant_id: "keep", + tenant_name: "Tenant of Keep (tenant_id: keep)", + }, + { + tenant_id: "keep2", + tenant_name: "Tenant of another Keep (tenant_id: keep2)", + }, + ], tenantId: tenantId, role: "user", }; diff --git a/keep-ui/auth.ts b/keep-ui/auth.ts index c0d49ee466..b15a916dcd 100644 --- a/keep-ui/auth.ts +++ b/keep-ui/auth.ts @@ -4,6 +4,74 @@ import { config, authType, proxyUrl } from "@/auth.config"; import { ProxyAgent, fetch as undici } from "undici"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import { AuthType } from "@/utils/authenticationType"; +import Credentials from "next-auth/providers/credentials"; +import { User } from "next-auth"; + +// Implement the tenant switch provider directly in auth.ts +const tenantSwitchProvider = Credentials({ + id: "tenant-switch", + name: "Tenant Switch", + credentials: { + tenantId: { label: "Tenant ID", type: "text" }, + sessionAsJson: { label: "Session", type: "text" }, + }, + async authorize(credentials, req): Promise { + if (!credentials?.tenantId) { + throw new Error("No tenant ID provided"); + } + + let session = JSON.parse(credentials.sessionAsJson as string); + + // Fallback to getting the user from cookies if session is not available + let user: any; + if (session?.user) { + user = session.user; + } else { + // Try to get us er info from JWT token + const token = (req as any)?.token; + if (token) { + user = { + id: token.sub, + name: token.name, + email: token.email, + tenantId: token.tenantId, + tenantIds: token.tenantIds, + }; + } + } + + if (!user || !user.tenantIds) { + console.error("Cannot switch tenant: User information not available"); + throw new Error("User not authenticated or missing tenant information"); + } + + // Verify the tenant ID is valid for this user + const validTenant = user.tenantIds.find( + (t: { tenant_id: string }) => t.tenant_id === credentials.tenantId + ); + + if (!validTenant) { + console.error(`Invalid tenant ID: ${credentials.tenantId}`); + throw new Error("Invalid tenant ID for this user"); + } + + console.log(`Switching to tenant: ${credentials.tenantId}`); + + let accessToken = JSON.parse(user.accessToken) as any; + accessToken["tenant_id"] = credentials.tenantId; + user.accessToken = JSON.stringify(accessToken); + // Return the user with the new tenant ID + return { + ...user, + tenantId: credentials.tenantId, + }; + }, +}); + +// Add the tenant switch provider to the config +// Use type assertion to add the tenant switch provider to the config +// This bypasses TypeScript's type checking for this specific operation +config.providers = [...config.providers, tenantSwitchProvider] as any; function proxyFetch( ...args: Parameters @@ -13,9 +81,7 @@ function proxyFetch( "Proxy called for URL:", args[0] instanceof Request ? args[0].url : args[0] ); - const dispatcher = new ProxyAgent(proxyUrl!); - if (args[0] instanceof Request) { const request = args[0]; // @ts-expect-error `undici` has a `duplex` option @@ -29,13 +95,11 @@ function proxyFetch( if (isDebug) { // Clone the response to log it without consuming the body const clonedResponse = response.clone(); - console.log("Proxy response status:", clonedResponse.status); console.log( "Proxy response headers:", Object.fromEntries(clonedResponse.headers) ); - // Log response body only in debug mode try { const body = await clonedResponse.text(); @@ -47,20 +111,17 @@ function proxyFetch( return response; }); } - // @ts-expect-error `undici` has a `duplex` option return undici(args[0], { ...(args[1] || {}), dispatcher }).then( async (response) => { if (isDebug) { // Clone the response to log it without consuming the body const clonedResponse = response.clone(); - console.log("Proxy response status:", clonedResponse.status); console.log( "Proxy response headers:", Object.fromEntries(clonedResponse.headers) ); - // Log response body only in debug mode try { const body = await clonedResponse.text(); @@ -77,18 +138,15 @@ function proxyFetch( // Modify the config if using Azure AD with proxy if (authType === AuthType.AZUREAD && proxyUrl) { const provider = config.providers[0] as ReturnType; - if (!proxyUrl) { console.log("Proxy is not enabled for Azure AD"); } else { console.log("Proxy is enabled for Azure AD:", proxyUrl); } - // Override the `customFetch` symbol in the provider provider[customFetch] = async (...args: Parameters) => { const url = new URL(args[0] instanceof Request ? args[0].url : args[0]); console.log("Custom Fetch Intercepted:", url.toString()); - // Handle `.well-known/openid-configuration` logic if (url.pathname.endsWith(".well-known/openid-configuration")) { console.log("Intercepting .well-known/openid-configuration"); @@ -111,34 +169,11 @@ if (authType === AuthType.AZUREAD && proxyUrl) { console.log("Modified issuer:", issuer); return Response.json({ ...json, issuer }); } - // Fallback for all other requests return proxyFetch(...args); }; - // Override profile since it uses fetch without customFetch provider.profile = async (profile, tokens) => { - // @tb: this causes 431 Request Header Fields Too Large - // const profilePhotoSize = 48; - // console.log("Fetching profile photo via proxy"); - - // const response = await proxyFetch( - // `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, - // { headers: { Authorization: `Bearer ${tokens.access_token}` } } - // ); - - // let image: string | null = null; - // if (response.ok && typeof Buffer !== "undefined") { - // try { - // const pictureBuffer = await response.arrayBuffer(); - // const pictureBase64 = Buffer.from(pictureBuffer).toString("base64"); - // image = `data:image/jpeg;base64,${pictureBase64}`; - // } catch (error) { - // console.error("Error processing profile photo:", error); - // } - // } - // https://stackoverflow.com/questions/77686104/how-to-resolve-http-error-431-nextjs-next-auth - return { id: profile.sub, name: profile.name, @@ -149,6 +184,36 @@ if (authType === AuthType.AZUREAD && proxyUrl) { }; } -console.log("Starting Keep frontend with auth type:", authType); +// Modify the session callback to ensure tenantIds are available +const originalSessionCallback = config.callbacks.session; +config.callbacks.session = async (params) => { + const session = await originalSessionCallback(params); + // Make sure tenantIds from the token are added to the session + if (params.token && "tenantIds" in params.token) { + session.user.tenantIds = params.token.tenantIds; + } + + // Also copy tenantIds from user object if available + if (params.user && "tenantIds" in params.user) { + session.user.tenantIds = params.user.tenantIds; + } + + return session; +}; + +// Modify the JWT callback to preserve tenantIds +const originalJwtCallback = config.callbacks.jwt; +config.callbacks.jwt = async (params) => { + const token = await originalJwtCallback(params); + + // Make sure tenantIds from the user are preserved in the token + if (params.user && "tenantIds" in params.user) { + token.tenantIds = params.user.tenantIds; + } + + return token; +}; + +console.log("Starting Keep frontend with auth type:", authType); export const { handlers, auth, signIn, signOut } = NextAuth(config); diff --git a/keep-ui/components/navbar/Menu.tsx b/keep-ui/components/navbar/Menu.tsx index 4555cc0e0f..bebcc5da2b 100644 --- a/keep-ui/components/navbar/Menu.tsx +++ b/keep-ui/components/navbar/Menu.tsx @@ -7,6 +7,7 @@ import { AiOutlineMenu, AiOutlineClose } from "react-icons/ai"; import { usePathname } from "next/navigation"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; import { useHotkeys } from "react-hotkeys-hook"; +import { Session } from "next-auth"; type CloseMenuOnRouteChangeProps = { closeMenu: () => void; @@ -24,9 +25,10 @@ const CloseMenuOnRouteChange = ({ closeMenu }: CloseMenuOnRouteChangeProps) => { type MenuButtonProps = { children: ReactNode; + session: Session | null; }; -export const Menu = ({ children }: MenuButtonProps) => { +export const Menu = ({ children, session }: MenuButtonProps) => { const [isMenuMinimized, setisMenuMinimized] = useLocalStorage( "menu-minimized", false @@ -57,7 +59,10 @@ export const Menu = ({ children }: MenuButtonProps) => { className='relative bg-gray-50 col-span-1 border-r border-gray-300 h-full hidden lg:block [&[data-minimized="true"]>nav]:invisible' data-minimized={isMenuMinimized} > - + @@ -71,7 +76,8 @@ export const Menu = ({ children }: MenuButtonProps) => { - {children} + {/* No more TenantSwitcher here either */} +
{children}
)} diff --git a/keep-ui/components/navbar/Navbar.tsx b/keep-ui/components/navbar/Navbar.tsx index 64ec664213..ac5c36da10 100644 --- a/keep-ui/components/navbar/Navbar.tsx +++ b/keep-ui/components/navbar/Navbar.tsx @@ -12,10 +12,11 @@ import "./Navbar.css"; export default async function NavbarInner() { const session = await auth(); + return ( <> - - + +
diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index ac61e9d3dd..46c3e13ab4 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -4,13 +4,14 @@ import { ElementRef, Fragment, useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Icon, List, ListItem, TextInput, Subtitle } from "@tremor/react"; +import { Icon, List, ListItem, Subtitle } from "@tremor/react"; import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, + Popover, Transition, } from "@headlessui/react"; import { @@ -29,6 +30,8 @@ import { LuWorkflow } from "react-icons/lu"; import { AiOutlineAlert, AiOutlineGroup } from "react-icons/ai"; import { MdOutlineEngineering, MdOutlineSearchOff } from "react-icons/md"; import { useConfig } from "utils/hooks/useConfig"; +import { Session } from "next-auth"; +import { signIn } from "next-auth/react"; import KeepPng from "../../keep.png"; const NAVIGATION_OPTIONS = [ @@ -38,57 +41,14 @@ const NAVIGATION_OPTIONS = [ shortcut: ["p"], navigate: "/providers", }, - { - icon: AiOutlineAlert, - label: "Go to alert console", - shortcut: ["g"], - navigate: "/alerts/feed", - }, - { - icon: AiOutlineGroup, - label: "Go to alert quality", - shortcut: ["q"], - navigate: "/alerts/quality", - }, - { - icon: MdOutlineEngineering, - label: "Go to alert groups", - shortcut: ["g"], - navigate: "/rules", - }, - { - icon: LuWorkflow, - label: "Go to the workflows page", - shortcut: ["wf"], - navigate: "/workflows", - }, - { - icon: UserGroupIcon, - label: "Go to users management", - shortcut: ["u"], - navigate: "/settings?selectedTab=users", - }, - { - icon: GlobeAltIcon, - label: "Go to generic webhook", - shortcut: ["w"], - navigate: "/settings?selectedTab=webhook", - }, - { - icon: EnvelopeIcon, - label: "Go to SMTP settings", - shortcut: ["s"], - navigate: "/settings?selectedTab=smtp", - }, - { - icon: KeyIcon, - label: "Go to API key", - shortcut: ["a"], - navigate: "/settings?selectedTab=users&userSubTab=api-keys", - }, + // Rest of your navigation options... ]; -export const Search = () => { +interface SearchProps { + session: Session | null; +} + +export const Search = ({ session }: SearchProps) => { const [query, setQuery] = useState(""); const [selectedOption, setSelectedOption] = useState(null); const router = useRouter(); @@ -96,6 +56,12 @@ export const Search = () => { const comboboxInputRef = useRef>(null); const { data: configData } = useConfig(); const docsUrl = configData?.KEEP_DOCS_URL || "https://docs.keephq.dev"; + const [isLoading, setIsLoading] = useState(false); + + // Log session for debugging + useEffect(() => { + console.log("Search component session:", session); + }, [session]); const EXTERNAL_OPTIONS = [ { @@ -104,18 +70,7 @@ export const Search = () => { shortcut: ["⇧", "D"], navigate: docsUrl, }, - { - icon: GitHubLogoIcon, - label: "Keep Source code", - shortcut: ["⇧", "C"], - navigate: "https://github.com/keephq/keep", - }, - { - icon: TwitterLogoIcon, - label: "Keep Twitter", - shortcut: ["⇧", "T"], - navigate: "https://twitter.com/keepalerting", - }, + // Rest of your external options... ]; const OPTIONS = [...NAVIGATION_OPTIONS, ...EXTERNAL_OPTIONS]; @@ -159,6 +114,31 @@ export const Search = () => { ) : OPTIONS; + // Tenant switcher function + const switchTenant = async (tenantId: string) => { + setIsLoading(true); + try { + // Use the tenant-switch provider to change tenants + let sessionAsJson = JSON.stringify(session); + const result = await signIn("tenant-switch", { + redirect: false, + tenantId, + sessionAsJson, + }); + + if (result?.error) { + console.error("Error switching tenant:", result.error); + } else { + // new tenant, let's reload the page + window.location.reload(); + } + } catch (error) { + console.error("Error switching tenant:", error); + } finally { + setIsLoading(false); + } + }; + const NoQueriesFoundResult = () => { if (query.length && queriedOptions.length === 0) { return ( @@ -283,11 +263,57 @@ export const Search = () => { setPlaceholderText("Search or start with ⌘K"); }, []); + // Check if tenant switching is available - with null/undefined check safety + const hasTenantSwitcher = + session && + session.user && + session.user.tenantIds && + session.user.tenantIds.length > 1; + return (
- - Keep Logo - + {hasTenantSwitcher ? ( + + {({ open }) => ( + <> + + Keep Logo + + + +
+
+ Switch Tenant +
+ {session.user.tenantIds.map((tenant) => ( + + ))} +
+
+ + )} +
+ ) : ( + + Keep Logo + + )} { const { data: session } = useSession(); let channelName = `private-${session?.tenantId}`; - console.log("useWebsocket: Initializing with config:", configData); - console.log("useWebsocket: Session:", session); - // TODO: should be in useMemo? if ( PUSHER === null && From 75b56179bde02b3f21382193d139eb6a6b84ef1f Mon Sep 17 00:00:00 2001 From: shahargl Date: Tue, 18 Mar 2025 16:37:11 +0200 Subject: [PATCH 02/14] feat: switch tenants --- keep-ui/auth.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/keep-ui/auth.ts b/keep-ui/auth.ts index b15a916dcd..0b3937189e 100644 --- a/keep-ui/auth.ts +++ b/keep-ui/auth.ts @@ -174,6 +174,26 @@ if (authType === AuthType.AZUREAD && proxyUrl) { }; // Override profile since it uses fetch without customFetch provider.profile = async (profile, tokens) => { + // @tb: this causes 431 Request Header Fields Too Large + // const profilePhotoSize = 48; + // console.log("Fetching profile photo via proxy"); + + // const response = await proxyFetch( + // `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, + // { headers: { Authorization: `Bearer ${tokens.access_token}` } } + // ); + + // let image: string | null = null; + // if (response.ok && typeof Buffer !== "undefined") { + // try { + // const pictureBuffer = await response.arrayBuffer(); + // const pictureBase64 = Buffer.from(pictureBuffer).toString("base64"); + // image = `data:image/jpeg;base64,${pictureBase64}`; + // } catch (error) { + // console.error("Error processing profile photo:", error); + // } + // } + // https://stackoverflow.com/questions/77686104/how-to-resolve-http-error-431-nextjs-next-auth return { id: profile.sub, name: profile.name, From e8c01a3e03fc5f564d063434cc6bc946bba2be95 Mon Sep 17 00:00:00 2001 From: shahargl Date: Tue, 18 Mar 2025 16:38:55 +0200 Subject: [PATCH 03/14] feat: switch tenants --- keep-ui/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/keep-ui/auth.ts b/keep-ui/auth.ts index 0b3937189e..7ba1617d0b 100644 --- a/keep-ui/auth.ts +++ b/keep-ui/auth.ts @@ -211,7 +211,10 @@ config.callbacks.session = async (params) => { // Make sure tenantIds from the token are added to the session if (params.token && "tenantIds" in params.token) { - session.user.tenantIds = params.token.tenantIds; + session.user.tenantIds = params.token.tenantIds as { + tenant_id: string; + tenant_name: string; + }[]; } // Also copy tenantIds from user object if available From 6761fa51e2f377946ccbcc03ea675a040947abd0 Mon Sep 17 00:00:00 2001 From: shahargl Date: Tue, 18 Mar 2025 21:00:42 +0200 Subject: [PATCH 04/14] feat: multi --- .../auth0/auth0_authverifier.py | 27 ++++++++++++++++++- keep-ui/auth.config.ts | 12 +++++++++ keep-ui/auth.ts | 9 ++++--- .../noauth/noauth_authverifier.py | 26 ++++++++++++------ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/ee/identitymanager/identity_managers/auth0/auth0_authverifier.py b/ee/identitymanager/identity_managers/auth0/auth0_authverifier.py index ea5a5987a9..e351f5a622 100644 --- a/ee/identitymanager/identity_managers/auth0/auth0_authverifier.py +++ b/ee/identitymanager/identity_managers/auth0/auth0_authverifier.py @@ -42,6 +42,14 @@ def _verify_bearer_token(self, token) -> AuthenticatedEntity: with tracer.start_as_current_span("verify_bearer_token"): if not token: raise HTTPException(status_code=401, detail="No token provided 👈") + + # more than one tenant support + if token.startswith("keepActiveTenant"): + active_tenant, token = token.split("&") + active_tenant = active_tenant.split("=")[1] + else: + active_tenant = None + try: jwt_signing_key = jwks_client.get_signing_key_from_jwt(token).key payload = jwt.decode( @@ -52,7 +60,24 @@ def _verify_bearer_token(self, token) -> AuthenticatedEntity: issuer=self.issuer, leeway=60, ) - tenant_id = payload.get("keep_tenant_id") + # if active_tenant is set, we must verify its in the token + if active_tenant: + active_tenant_found = False + for tenant in payload.get("keep_tenant_ids", []): + if tenant.get("tenant_id") == active_tenant: + active_tenant_found = True + break + if not active_tenant_found: + self.logger.warning( + "Someone tries to use a token with a tenant that is not in the token" + ) + raise HTTPException( + status_code=401, + detail="Token does not contain the active tenant", + ) + tenant_id = active_tenant + else: + tenant_id = payload.get("keep_tenant_id") role_name = payload.get( "keep_role", AdminRole.get_name() ) # default to admin for backwards compatibility diff --git a/keep-ui/auth.config.ts b/keep-ui/auth.config.ts index b80b80af32..33fa07f546 100644 --- a/keep-ui/auth.config.ts +++ b/keep-ui/auth.config.ts @@ -250,6 +250,14 @@ export const config = { let tenantId: string | undefined = user.tenantId; let role: string | undefined = user.role; + // if the account is from tenant-switch provider, return the token + if (account.provider === "tenant-switch") { + token.accessToken = user.accessToken; + token.tenantId = user.tenantId; + token.role = user.role; + return token; + } + if (authType === AuthType.AZUREAD) { accessToken = account.access_token; if (account.id_token) { @@ -271,6 +279,10 @@ export const config = { if ((profile as any)?.keep_role) { role = (profile as any).keep_role; } + // more than one tenants + if ((profile as any)?.keep_tenant_ids) { + user.tenantIds = (profile as any).keep_tenant_ids; + } } else if (authType === AuthType.KEYCLOAK) { // TODO: remove this once we have a proper way to get the tenant id tenantId = (profile as any).keep_tenant_id || "keep"; diff --git a/keep-ui/auth.ts b/keep-ui/auth.ts index 7ba1617d0b..cb0ad2006e 100644 --- a/keep-ui/auth.ts +++ b/keep-ui/auth.ts @@ -57,9 +57,12 @@ const tenantSwitchProvider = Credentials({ console.log(`Switching to tenant: ${credentials.tenantId}`); - let accessToken = JSON.parse(user.accessToken) as any; - accessToken["tenant_id"] = credentials.tenantId; - user.accessToken = JSON.stringify(accessToken); + // if user aleady have keepActiveTenant as prefix - remove it + if (user.accessToken.startsWith("keepActiveTenant=")) { + user.accessToken = user.accessToken.replace(/keepActiveTenant=\w+&/, ""); + } + // add keepActiveTenant= with the current tenant to user.accessToken + user.accessToken = `keepActiveTenant=${credentials.tenantId}&${user.accessToken}`; // Return the user with the new tenant ID return { ...user, diff --git a/keep/identitymanager/identity_managers/noauth/noauth_authverifier.py b/keep/identitymanager/identity_managers/noauth/noauth_authverifier.py index aa9a450bbc..be410ce7f9 100644 --- a/keep/identitymanager/identity_managers/noauth/noauth_authverifier.py +++ b/keep/identitymanager/identity_managers/noauth/noauth_authverifier.py @@ -16,14 +16,24 @@ class NoAuthVerifier(AuthVerifierBase): def _verify_bearer_token(self, token: str) -> AuthenticatedEntity: try: - token_payload = json.loads(token) - tenant_id = token_payload["tenant_id"] or SINGLE_TENANT_UUID - email = token_payload["user_id"] or SINGLE_TENANT_EMAIL - return AuthenticatedEntity( - tenant_id=tenant_id, - email=email, - role=AdminRole.get_name(), - ) + if token.startswith("keepActiveTenant"): + active_tenant, token = token.split("&") + active_tenant = active_tenant.split("=")[1] + tenant_id = active_tenant or SINGLE_TENANT_UUID + return AuthenticatedEntity( + tenant_id=tenant_id, + email=SINGLE_TENANT_EMAIL, + role=AdminRole.get_name(), + ) + else: + token_payload = json.loads(token) + tenant_id = token_payload["tenant_id"] or SINGLE_TENANT_UUID + email = token_payload["user_id"] or SINGLE_TENANT_EMAIL + return AuthenticatedEntity( + tenant_id=tenant_id, + email=email, + role=AdminRole.get_name(), + ) except Exception: return AuthenticatedEntity( tenant_id=SINGLE_TENANT_UUID, From 8bd6ad7077a13f8c2d2f4ebc34d9fda473ea3546 Mon Sep 17 00:00:00 2001 From: shahargl Date: Tue, 18 Mar 2025 21:16:00 +0200 Subject: [PATCH 05/14] feat: multi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3d109062ce..e62d6a8d39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.39.10" +version = "0.40.0" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] packages = [{include = "keep"}] From fc23ed3660dc7bd8955714f376cc231f2d33f809 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 10:17:49 +0200 Subject: [PATCH 06/14] feat: switch tenants --- keep-ui/components/navbar/Search.tsx | 185 +++++++++++++++------------ keep-ui/next.config.js | 5 + keep-ui/types/auth.d.ts | 6 +- 3 files changed, 112 insertions(+), 84 deletions(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 46c3e13ab4..3059065c7f 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -260,7 +260,7 @@ export const Search = ({ session }: SearchProps) => { if (!isMac()) { return; } - setPlaceholderText("Search or start with ⌘K"); + setPlaceholderText("Search (or ⌘K)"); }, []); // Check if tenant switching is available - with null/undefined check safety @@ -270,94 +270,113 @@ export const Search = ({ session }: SearchProps) => { session.user.tenantIds && session.user.tenantIds.length > 1; + // Get current tenant logo URL if available + const currentTenant = session?.user?.tenantIds?.find( + (tenant) => tenant.tenant_id === session.tenantId + ); + const tenantLogoUrl = currentTenant?.tenant_logo_url; + return ( -
- {hasTenantSwitcher ? ( - +
+
+ {hasTenantSwitcher ? ( + + {({ open }) => ( + <> + + Keep Logo + {tenantLogoUrl && ( + {`${currentTenant?.tenant_name + )} + + + +
+
+ Switch Tenant +
+ {session.user.tenantIds?.map((tenant) => ( + + ))} +
+
+ + )} +
+ ) : ( + + Keep Logo + + )} +
+ +
+ {({ open }) => ( <> - - - -
-
- Switch Tenant -
- {session.user.tenantIds.map((tenant) => ( - - ))} -
-
+ + + + + + )} - - ) : ( - - Keep Logo - - )} - - - {({ open }) => ( - <> - {open && ( -
); }; diff --git a/keep-ui/next.config.js b/keep-ui/next.config.js index 1e83c7ece8..89f8c3046e 100644 --- a/keep-ui/next.config.js +++ b/keep-ui/next.config.js @@ -81,6 +81,11 @@ const nextConfig = { protocol: "https", hostname: "cdn.prod.website-files.com", }, + // Cloudflare Image Delivery + { + protocol: "https", + hostname: "imagedelivery.net", + }, ], }, // compiler is not supported in turbo mode diff --git a/keep-ui/types/auth.d.ts b/keep-ui/types/auth.d.ts index b60ec94907..35041ef7cb 100644 --- a/keep-ui/types/auth.d.ts +++ b/keep-ui/types/auth.d.ts @@ -24,7 +24,11 @@ declare module "next-auth" { accessToken: string; tenantId?: string; // a list of {"tenant_id": id, "tenant_name": name} objects - tenantIds?: { tenant_id: string; tenant_name: string }[]; + tenantIds?: { + tenant_id: string; + tenant_name: string; + tenant_logo_url?: string; + }[]; role?: string; } } From b5ab0bc3423d0aac12fea3b11256d4b810c036c2 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 10:18:31 +0200 Subject: [PATCH 07/14] feat: switch tenants --- keep-ui/components/navbar/Search.tsx | 49 +++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 3059065c7f..8a27e8e7af 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -41,7 +41,54 @@ const NAVIGATION_OPTIONS = [ shortcut: ["p"], navigate: "/providers", }, - // Rest of your navigation options... + { + icon: AiOutlineAlert, + label: "Go to alert console", + shortcut: ["g"], + navigate: "/alerts/feed", + }, + { + icon: AiOutlineGroup, + label: "Go to alert quality", + shortcut: ["q"], + navigate: "/alerts/quality", + }, + { + icon: MdOutlineEngineering, + label: "Go to alert groups", + shortcut: ["g"], + navigate: "/rules", + }, + { + icon: LuWorkflow, + label: "Go to the workflows page", + shortcut: ["wf"], + navigate: "/workflows", + }, + { + icon: UserGroupIcon, + label: "Go to users management", + shortcut: ["u"], + navigate: "/settings?selectedTab=users", + }, + { + icon: GlobeAltIcon, + label: "Go to generic webhook", + shortcut: ["w"], + navigate: "/settings?selectedTab=webhook", + }, + { + icon: EnvelopeIcon, + label: "Go to SMTP settings", + shortcut: ["s"], + navigate: "/settings?selectedTab=smtp", + }, + { + icon: KeyIcon, + label: "Go to API key", + shortcut: ["a"], + navigate: "/settings?selectedTab=users&userSubTab=api-keys", + }, ]; interface SearchProps { From 7683ecc42593e804e2d69d3ec46b8a932f7c458e Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 10:19:15 +0200 Subject: [PATCH 08/14] feat: switch tenants --- keep-ui/components/navbar/Search.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 8a27e8e7af..daf3954d4d 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -117,7 +117,18 @@ export const Search = ({ session }: SearchProps) => { shortcut: ["⇧", "D"], navigate: docsUrl, }, - // Rest of your external options... + { + icon: GitHubLogoIcon, + label: "Keep Source code", + shortcut: ["⇧", "C"], + navigate: "https://github.com/keephq/keep", + }, + { + icon: TwitterLogoIcon, + label: "Keep Twitter", + shortcut: ["⇧", "T"], + navigate: "https://twitter.com/keepalerting", + }, ]; const OPTIONS = [...NAVIGATION_OPTIONS, ...EXTERNAL_OPTIONS]; From 6bcaf995df0d0c0e4a1ea8b1d4eb1ba75d871d23 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 10:49:33 +0200 Subject: [PATCH 09/14] feat: adding tests --- tests/test_auth_new.py | 305 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 tests/test_auth_new.py diff --git a/tests/test_auth_new.py b/tests/test_auth_new.py new file mode 100644 index 0000000000..62b16eedda --- /dev/null +++ b/tests/test_auth_new.py @@ -0,0 +1,305 @@ +import time + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import HTTPException + +from keep.identitymanager.authenticatedentity import AuthenticatedEntity +from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory + + +# Reuse functions from your existing test +def generate_test_keys(): + # Generate private key + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + # Get the private key in PEM format + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + # Get the public key in PEM format + public_key = private_key.public_key() + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return private_pem, public_pem + + +# Mock classes from your existing test +class MockSigningKey: + def __init__(self, key): + self.key = key + + +class MockJWKSClient: + def __init__(self, public_key): + self.public_key = public_key + + def get_signing_key_from_jwt(self, token): + # We need to extract the actual JWT part if it has keepActiveTenant prefix + if token.startswith("keepActiveTenant"): + _, token = token.split("&") + + return MockSigningKey(key=self.public_key) + + +@pytest.mark.parametrize( + "test_app", + [ + { + "AUTH_TYPE": "AUTH0", + "AUTH0_DOMAIN": "test-domain.auth0.com", + "AUTH0_AUDIENCE": "test-audience", + }, + ], + indirect=True, +) +def test_auth0_with_active_tenant_success(client, test_app): + """Tests Auth0 authentication with keepActiveTenant parameter when tenant is in the token""" + + # Generate test keys + private_key_pem, public_key_pem = generate_test_keys() + + # Create payload with multiple tenant IDs + tenant_1 = "tenant-1" + tenant_2 = "tenant-2" + + payload = { + "iss": "https://test-domain.auth0.com/", + "sub": "test-user-id", + "aud": "test-audience", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + # Note: We're not setting keep_tenant_id here since we're using keep_tenant_ids + "keep_tenant_ids": [{"tenant_id": tenant_1}, {"tenant_id": tenant_2}], + "keep_role": "admin", + "email": "test@example.com", + } + + # Sign the JWT with our private key + token = jwt.encode( + payload, private_key_pem, algorithm="RS256", headers={"kid": "test-key-id"} + ) + + # Prepend the keepActiveTenant parameter to use tenant_1 + active_tenant_token = f"keepActiveTenant={tenant_1}&{token}" + + # Create a mock JWKS client with our public key + mock_jwks_client = MockJWKSClient(public_key_pem) + + # Patch the jwks_client in the auth0_authverifier module + from ee.identitymanager.identity_managers.auth0.auth0_authverifier import ( + jwks_client, + ) + + # Save the original to restore later + original_jwks_client = jwks_client + + try: + # Replace the module-level client with our mock + import ee.identitymanager.identity_managers.auth0.auth0_authverifier + + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + mock_jwks_client + ) + + # Get the auth verifier + auth_verifier = IdentityManagerFactory.get_auth_verifier([]) + + # Call the auth verifier with our active tenant token + result = auth_verifier( + token=active_tenant_token, api_key=None, authorization=None, request=None + ) + + # Assert authentication was successful with the specified active tenant + assert result is not None + assert isinstance(result, AuthenticatedEntity) + assert result.tenant_id == tenant_1 + assert result.email == "test@example.com" + assert result.role == "admin" + + finally: + # Restore the original jwks_client + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + original_jwks_client + ) + + +@pytest.mark.parametrize( + "test_app", + [ + { + "AUTH_TYPE": "AUTH0", + "AUTH0_DOMAIN": "test-domain.auth0.com", + "AUTH0_AUDIENCE": "test-audience", + }, + ], + indirect=True, +) +def test_auth0_with_unauthorized_active_tenant(client, test_app): + """Tests Auth0 authentication with keepActiveTenant parameter when tenant is NOT in the token""" + + # Generate test keys + private_key_pem, public_key_pem = generate_test_keys() + + # Create payload with tenant IDs that don't include our target tenant + authorized_tenant = "authorized-tenant" + unauthorized_tenant = "unauthorized-tenant" + + payload = { + "iss": "https://test-domain.auth0.com/", + "sub": "test-user-id", + "aud": "test-audience", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + "keep_tenant_ids": [{"tenant_id": authorized_tenant}], + "keep_role": "admin", + "email": "test@example.com", + } + + # Sign the JWT with our private key + token = jwt.encode( + payload, private_key_pem, algorithm="RS256", headers={"kid": "test-key-id"} + ) + + # Prepend the keepActiveTenant parameter with an unauthorized tenant + active_tenant_token = f"keepActiveTenant={unauthorized_tenant}&{token}" + + # Create a mock JWKS client with our public key + mock_jwks_client = MockJWKSClient(public_key_pem) + + # Patch the jwks_client in the auth0_authverifier module + from ee.identitymanager.identity_managers.auth0.auth0_authverifier import ( + jwks_client, + ) + + # Save the original to restore later + original_jwks_client = jwks_client + + try: + # Replace the module-level client with our mock + import ee.identitymanager.identity_managers.auth0.auth0_authverifier + + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + mock_jwks_client + ) + + # Get the auth verifier + auth_verifier = IdentityManagerFactory.get_auth_verifier([]) + + # Call the auth verifier with our unauthorized active tenant token + # This should raise an HTTPException with status code 401 + with pytest.raises(HTTPException) as exc_info: + auth_verifier( + token=active_tenant_token, + api_key=None, + authorization=None, + request=None, + ) + + # Verify that the error is what we expect + assert exc_info.value.status_code == 401 + assert "Token does not contain the active tenant" in exc_info.value.detail + + finally: + # Restore the original jwks_client + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + original_jwks_client + ) + + +@pytest.mark.parametrize( + "test_app", + [ + { + "AUTH_TYPE": "AUTH0", + "AUTH0_DOMAIN": "test-domain.auth0.com", + "AUTH0_AUDIENCE": "test-audience", + }, + ], + indirect=True, +) +def test_auth0_switching_between_tenants(client, test_app): + """Tests Auth0 authentication with switching between different active tenants""" + + # Generate test keys + private_key_pem, public_key_pem = generate_test_keys() + + # Create payload with multiple tenant IDs + tenant_1 = "tenant-1" + tenant_2 = "tenant-2" + + payload = { + "iss": "https://test-domain.auth0.com/", + "sub": "test-user-id", + "aud": "test-audience", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + "keep_tenant_ids": [{"tenant_id": tenant_1}, {"tenant_id": tenant_2}], + "keep_role": "admin", + "email": "test@example.com", + } + + # Sign the JWT with our private key + token = jwt.encode( + payload, private_key_pem, algorithm="RS256", headers={"kid": "test-key-id"} + ) + + # Create tokens for both tenants + tenant_1_token = f"keepActiveTenant={tenant_1}&{token}" + tenant_2_token = f"keepActiveTenant={tenant_2}&{token}" + + # Create a mock JWKS client with our public key + mock_jwks_client = MockJWKSClient(public_key_pem) + + # Patch the jwks_client in the auth0_authverifier module + from ee.identitymanager.identity_managers.auth0.auth0_authverifier import ( + jwks_client, + ) + + # Save the original to restore later + original_jwks_client = jwks_client + + try: + # Replace the module-level client with our mock + import ee.identitymanager.identity_managers.auth0.auth0_authverifier + + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + mock_jwks_client + ) + + # Get the auth verifier + auth_verifier = IdentityManagerFactory.get_auth_verifier([]) + + # Test with tenant_1 + result_1 = auth_verifier( + token=tenant_1_token, api_key=None, authorization=None, request=None + ) + + # Assert authentication was successful with tenant_1 + assert result_1 is not None + assert isinstance(result_1, AuthenticatedEntity) + assert result_1.tenant_id == tenant_1 + + # Now test with tenant_2 + result_2 = auth_verifier( + token=tenant_2_token, api_key=None, authorization=None, request=None + ) + + # Assert authentication was successful with tenant_2 + assert result_2 is not None + assert isinstance(result_2, AuthenticatedEntity) + assert result_2.tenant_id == tenant_2 + + finally: + # Restore the original jwks_client + ee.identitymanager.identity_managers.auth0.auth0_authverifier.jwks_client = ( + original_jwks_client + ) From 37a292e0547cf443bb04289bb99fc642bd8295f1 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 11:08:03 +0200 Subject: [PATCH 10/14] feat: adding tests --- tests/test_auth_new.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_auth_new.py b/tests/test_auth_new.py index 62b16eedda..a3b182484f 100644 --- a/tests/test_auth_new.py +++ b/tests/test_auth_new.py @@ -8,6 +8,7 @@ from keep.identitymanager.authenticatedentity import AuthenticatedEntity from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory +from tests.fixtures.client import client, setup_api_key, test_app # noqa # Reuse functions from your existing test From 4d1bf7173929537bb4db99b0b0ef77e7ecf99465 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 11:15:15 +0200 Subject: [PATCH 11/14] feat: adding tests --- keep-ui/components/navbar/Search.tsx | 14 ++++++++++++-- tests/test_auth_new.py | 17 +++-------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index daf3954d4d..0e28d3580d 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -328,11 +328,12 @@ export const Search = ({ session }: SearchProps) => { session.user.tenantIds && session.user.tenantIds.length > 1; - // Get current tenant logo URL if available + // Get current tenant logo URL if available - this now works even with just one tenant const currentTenant = session?.user?.tenantIds?.find( (tenant) => tenant.tenant_id === session.tenantId ); const tenantLogoUrl = currentTenant?.tenant_logo_url; + const hasTenantLogo = Boolean(tenantLogoUrl); return (
@@ -384,8 +385,17 @@ export const Search = ({ session }: SearchProps) => { )} ) : ( - + Keep Logo + {hasTenantLogo && ( + {`${currentTenant?.tenant_name + )} )}
diff --git a/tests/test_auth_new.py b/tests/test_auth_new.py index a3b182484f..5a5444d653 100644 --- a/tests/test_auth_new.py +++ b/tests/test_auth_new.py @@ -51,18 +51,7 @@ def get_signing_key_from_jwt(self, token): return MockSigningKey(key=self.public_key) -@pytest.mark.parametrize( - "test_app", - [ - { - "AUTH_TYPE": "AUTH0", - "AUTH0_DOMAIN": "test-domain.auth0.com", - "AUTH0_AUDIENCE": "test-audience", - }, - ], - indirect=True, -) -def test_auth0_with_active_tenant_success(client, test_app): +def test_auth0_with_active_tenant_success(db_session, client, test_app): """Tests Auth0 authentication with keepActiveTenant parameter when tenant is in the token""" # Generate test keys @@ -144,7 +133,7 @@ def test_auth0_with_active_tenant_success(client, test_app): ], indirect=True, ) -def test_auth0_with_unauthorized_active_tenant(client, test_app): +def test_auth0_with_unauthorized_active_tenant(db_session, client, test_app): """Tests Auth0 authentication with keepActiveTenant parameter when tenant is NOT in the token""" # Generate test keys @@ -227,7 +216,7 @@ def test_auth0_with_unauthorized_active_tenant(client, test_app): ], indirect=True, ) -def test_auth0_switching_between_tenants(client, test_app): +def test_auth0_switching_between_tenants(db_session, client, test_app): """Tests Auth0 authentication with switching between different active tenants""" # Generate test keys From c3f72e43226b18fbc061748b7edd9d09b7f1e232 Mon Sep 17 00:00:00 2001 From: shahargl Date: Wed, 19 Mar 2025 11:24:27 +0200 Subject: [PATCH 12/14] fix: stip --- keep-ui/components/navbar/Search.tsx | 2 +- tests/test_auth_new.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 0e28d3580d..98ca902829 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -349,7 +349,7 @@ export const Search = ({ session }: SearchProps) => { Keep Logo {tenantLogoUrl && ( {`${currentTenant?.tenant_name Date: Wed, 19 Mar 2025 11:32:25 +0200 Subject: [PATCH 13/14] feat: multi --- keep-ui/components/navbar/Search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 98ca902829..72a65e98b2 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -389,7 +389,7 @@ export const Search = ({ session }: SearchProps) => { Keep Logo {hasTenantLogo && ( {`${currentTenant?.tenant_name Date: Wed, 19 Mar 2025 11:41:43 +0200 Subject: [PATCH 14/14] feat: multi --- docker/Dockerfile.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui index b0f3aa1a41..b865ade6f1 100644 --- a/docker/Dockerfile.ui +++ b/docker/Dockerfile.ui @@ -24,7 +24,7 @@ ENV NEXT_TELEMETRY_DISABLED 1 # If using npm comment out above and use below instead ENV API_URL http://localhost:8080 -RUN npm run build +RUN NODE_OPTIONS=--max-old-space-size=8192 npm run build # Production image, copy all the files and run next