Skip to content

Commit 59f2e3a

Browse files
authored
feat: switch tenants (#4107)
1 parent d9ce2ab commit 59f2e3a

File tree

13 files changed

+662
-80
lines changed

13 files changed

+662
-80
lines changed

docker/Dockerfile.ui

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ ENV NEXT_TELEMETRY_DISABLED 1
2424

2525
# If using npm comment out above and use below instead
2626
ENV API_URL http://localhost:8080
27-
RUN npm run build
27+
RUN NODE_OPTIONS=--max-old-space-size=8192 npm run build
2828

2929

3030
# Production image, copy all the files and run next

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def _verify_bearer_token(self, token) -> AuthenticatedEntity:
4242
with tracer.start_as_current_span("verify_bearer_token"):
4343
if not token:
4444
raise HTTPException(status_code=401, detail="No token provided 👈")
45+
46+
# more than one tenant support
47+
if token.startswith("keepActiveTenant"):
48+
active_tenant, token = token.split("&")
49+
active_tenant = active_tenant.split("=")[1]
50+
else:
51+
active_tenant = None
52+
4553
try:
4654
jwt_signing_key = jwks_client.get_signing_key_from_jwt(token).key
4755
payload = jwt.decode(
@@ -52,7 +60,24 @@ def _verify_bearer_token(self, token) -> AuthenticatedEntity:
5260
issuer=self.issuer,
5361
leeway=60,
5462
)
55-
tenant_id = payload.get("keep_tenant_id")
63+
# if active_tenant is set, we must verify its in the token
64+
if active_tenant:
65+
active_tenant_found = False
66+
for tenant in payload.get("keep_tenant_ids", []):
67+
if tenant.get("tenant_id") == active_tenant:
68+
active_tenant_found = True
69+
break
70+
if not active_tenant_found:
71+
self.logger.warning(
72+
"Someone tries to use a token with a tenant that is not in the token"
73+
)
74+
raise HTTPException(
75+
status_code=401,
76+
detail="Token does not contain the active tenant",
77+
)
78+
tenant_id = active_tenant
79+
else:
80+
tenant_id = payload.get("keep_tenant_id")
5681
role_name = payload.get(
5782
"keep_role", AdminRole.get_name()
5883
) # default to admin for backwards compatibility

keep-ui/auth.config.ts

+22
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ const baseProviderConfigs = {
172172
tenant_id: tenantId,
173173
user_id: "keep-user-for-no-auth-purposes",
174174
}),
175+
tenantIds: [
176+
{
177+
tenant_id: "keep",
178+
tenant_name: "Tenant of Keep (tenant_id: keep)",
179+
},
180+
{
181+
tenant_id: "keep2",
182+
tenant_name: "Tenant of another Keep (tenant_id: keep2)",
183+
},
184+
],
175185
tenantId: tenantId,
176186
role: "user",
177187
};
@@ -240,6 +250,14 @@ export const config = {
240250
let tenantId: string | undefined = user.tenantId;
241251
let role: string | undefined = user.role;
242252

253+
// if the account is from tenant-switch provider, return the token
254+
if (account.provider === "tenant-switch") {
255+
token.accessToken = user.accessToken;
256+
token.tenantId = user.tenantId;
257+
token.role = user.role;
258+
return token;
259+
}
260+
243261
if (authType === AuthType.AZUREAD) {
244262
accessToken = account.access_token;
245263
if (account.id_token) {
@@ -261,6 +279,10 @@ export const config = {
261279
if ((profile as any)?.keep_role) {
262280
role = (profile as any).keep_role;
263281
}
282+
// more than one tenants
283+
if ((profile as any)?.keep_tenant_ids) {
284+
user.tenantIds = (profile as any).keep_tenant_ids;
285+
}
264286
} else if (authType === AuthType.KEYCLOAK) {
265287
// TODO: remove this once we have a proper way to get the tenant id
266288
tenantId = (profile as any).keep_tenant_id || "keep";

keep-ui/auth.ts

+105-14
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,77 @@ import { config, authType, proxyUrl } from "@/auth.config";
44
import { ProxyAgent, fetch as undici } from "undici";
55
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
66
import { AuthType } from "@/utils/authenticationType";
7+
import Credentials from "next-auth/providers/credentials";
8+
import { User } from "next-auth";
9+
10+
// Implement the tenant switch provider directly in auth.ts
11+
const tenantSwitchProvider = Credentials({
12+
id: "tenant-switch",
13+
name: "Tenant Switch",
14+
credentials: {
15+
tenantId: { label: "Tenant ID", type: "text" },
16+
sessionAsJson: { label: "Session", type: "text" },
17+
},
18+
async authorize(credentials, req): Promise<User | null> {
19+
if (!credentials?.tenantId) {
20+
throw new Error("No tenant ID provided");
21+
}
22+
23+
let session = JSON.parse(credentials.sessionAsJson as string);
24+
25+
// Fallback to getting the user from cookies if session is not available
26+
let user: any;
27+
if (session?.user) {
28+
user = session.user;
29+
} else {
30+
// Try to get us er info from JWT token
31+
const token = (req as any)?.token;
32+
if (token) {
33+
user = {
34+
id: token.sub,
35+
name: token.name,
36+
email: token.email,
37+
tenantId: token.tenantId,
38+
tenantIds: token.tenantIds,
39+
};
40+
}
41+
}
42+
43+
if (!user || !user.tenantIds) {
44+
console.error("Cannot switch tenant: User information not available");
45+
throw new Error("User not authenticated or missing tenant information");
46+
}
47+
48+
// Verify the tenant ID is valid for this user
49+
const validTenant = user.tenantIds.find(
50+
(t: { tenant_id: string }) => t.tenant_id === credentials.tenantId
51+
);
52+
53+
if (!validTenant) {
54+
console.error(`Invalid tenant ID: ${credentials.tenantId}`);
55+
throw new Error("Invalid tenant ID for this user");
56+
}
57+
58+
console.log(`Switching to tenant: ${credentials.tenantId}`);
59+
60+
// if user aleady have keepActiveTenant as prefix - remove it
61+
if (user.accessToken.startsWith("keepActiveTenant=")) {
62+
user.accessToken = user.accessToken.replace(/keepActiveTenant=\w+&/, "");
63+
}
64+
// add keepActiveTenant= with the current tenant to user.accessToken
65+
user.accessToken = `keepActiveTenant=${credentials.tenantId}&${user.accessToken}`;
66+
// Return the user with the new tenant ID
67+
return {
68+
...user,
69+
tenantId: credentials.tenantId,
70+
};
71+
},
72+
});
73+
74+
// Add the tenant switch provider to the config
75+
// Use type assertion to add the tenant switch provider to the config
76+
// This bypasses TypeScript's type checking for this specific operation
77+
config.providers = [...config.providers, tenantSwitchProvider] as any;
778

879
function proxyFetch(
980
...args: Parameters<typeof fetch>
@@ -13,9 +84,7 @@ function proxyFetch(
1384
"Proxy called for URL:",
1485
args[0] instanceof Request ? args[0].url : args[0]
1586
);
16-
1787
const dispatcher = new ProxyAgent(proxyUrl!);
18-
1988
if (args[0] instanceof Request) {
2089
const request = args[0];
2190
// @ts-expect-error `undici` has a `duplex` option
@@ -29,13 +98,11 @@ function proxyFetch(
2998
if (isDebug) {
3099
// Clone the response to log it without consuming the body
31100
const clonedResponse = response.clone();
32-
33101
console.log("Proxy response status:", clonedResponse.status);
34102
console.log(
35103
"Proxy response headers:",
36104
Object.fromEntries(clonedResponse.headers)
37105
);
38-
39106
// Log response body only in debug mode
40107
try {
41108
const body = await clonedResponse.text();
@@ -47,20 +114,17 @@ function proxyFetch(
47114
return response;
48115
});
49116
}
50-
51117
// @ts-expect-error `undici` has a `duplex` option
52118
return undici(args[0], { ...(args[1] || {}), dispatcher }).then(
53119
async (response) => {
54120
if (isDebug) {
55121
// Clone the response to log it without consuming the body
56122
const clonedResponse = response.clone();
57-
58123
console.log("Proxy response status:", clonedResponse.status);
59124
console.log(
60125
"Proxy response headers:",
61126
Object.fromEntries(clonedResponse.headers)
62127
);
63-
64128
// Log response body only in debug mode
65129
try {
66130
const body = await clonedResponse.text();
@@ -77,18 +141,15 @@ function proxyFetch(
77141
// Modify the config if using Azure AD with proxy
78142
if (authType === AuthType.AZUREAD && proxyUrl) {
79143
const provider = config.providers[0] as ReturnType<typeof MicrosoftEntraID>;
80-
81144
if (!proxyUrl) {
82145
console.log("Proxy is not enabled for Azure AD");
83146
} else {
84147
console.log("Proxy is enabled for Azure AD:", proxyUrl);
85148
}
86-
87149
// Override the `customFetch` symbol in the provider
88150
provider[customFetch] = async (...args: Parameters<typeof fetch>) => {
89151
const url = new URL(args[0] instanceof Request ? args[0].url : args[0]);
90152
console.log("Custom Fetch Intercepted:", url.toString());
91-
92153
// Handle `.well-known/openid-configuration` logic
93154
if (url.pathname.endsWith(".well-known/openid-configuration")) {
94155
console.log("Intercepting .well-known/openid-configuration");
@@ -111,11 +172,9 @@ if (authType === AuthType.AZUREAD && proxyUrl) {
111172
console.log("Modified issuer:", issuer);
112173
return Response.json({ ...json, issuer });
113174
}
114-
115175
// Fallback for all other requests
116176
return proxyFetch(...args);
117177
};
118-
119178
// Override profile since it uses fetch without customFetch
120179
provider.profile = async (profile, tokens) => {
121180
// @tb: this causes 431 Request Header Fields Too Large
@@ -138,7 +197,6 @@ if (authType === AuthType.AZUREAD && proxyUrl) {
138197
// }
139198
// }
140199
// https://stackoverflow.com/questions/77686104/how-to-resolve-http-error-431-nextjs-next-auth
141-
142200
return {
143201
id: profile.sub,
144202
name: profile.name,
@@ -149,6 +207,39 @@ if (authType === AuthType.AZUREAD && proxyUrl) {
149207
};
150208
}
151209

152-
console.log("Starting Keep frontend with auth type:", authType);
210+
// Modify the session callback to ensure tenantIds are available
211+
const originalSessionCallback = config.callbacks.session;
212+
config.callbacks.session = async (params) => {
213+
const session = await originalSessionCallback(params);
214+
215+
// Make sure tenantIds from the token are added to the session
216+
if (params.token && "tenantIds" in params.token) {
217+
session.user.tenantIds = params.token.tenantIds as {
218+
tenant_id: string;
219+
tenant_name: string;
220+
}[];
221+
}
222+
223+
// Also copy tenantIds from user object if available
224+
if (params.user && "tenantIds" in params.user) {
225+
session.user.tenantIds = params.user.tenantIds;
226+
}
227+
228+
return session;
229+
};
230+
231+
// Modify the JWT callback to preserve tenantIds
232+
const originalJwtCallback = config.callbacks.jwt;
233+
config.callbacks.jwt = async (params) => {
234+
const token = await originalJwtCallback(params);
153235

236+
// Make sure tenantIds from the user are preserved in the token
237+
if (params.user && "tenantIds" in params.user) {
238+
token.tenantIds = params.user.tenantIds;
239+
}
240+
241+
return token;
242+
};
243+
244+
console.log("Starting Keep frontend with auth type:", authType);
154245
export const { handlers, auth, signIn, signOut } = NextAuth(config);

keep-ui/components/navbar/Menu.tsx

+9-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AiOutlineMenu, AiOutlineClose } from "react-icons/ai";
77
import { usePathname } from "next/navigation";
88
import { useLocalStorage } from "utils/hooks/useLocalStorage";
99
import { useHotkeys } from "react-hotkeys-hook";
10+
import { Session } from "next-auth";
1011

1112
type CloseMenuOnRouteChangeProps = {
1213
closeMenu: () => void;
@@ -24,9 +25,10 @@ const CloseMenuOnRouteChange = ({ closeMenu }: CloseMenuOnRouteChangeProps) => {
2425

2526
type MenuButtonProps = {
2627
children: ReactNode;
28+
session: Session | null;
2729
};
2830

29-
export const Menu = ({ children }: MenuButtonProps) => {
31+
export const Menu = ({ children, session }: MenuButtonProps) => {
3032
const [isMenuMinimized, setisMenuMinimized] = useLocalStorage<boolean>(
3133
"menu-minimized",
3234
false
@@ -57,7 +59,10 @@ export const Menu = ({ children }: MenuButtonProps) => {
5759
className='relative bg-gray-50 col-span-1 border-r border-gray-300 h-full hidden lg:block [&[data-minimized="true"]>nav]:invisible'
5860
data-minimized={isMenuMinimized}
5961
>
60-
<nav className="flex flex-col h-full">{children}</nav>
62+
<nav className="flex flex-col h-full">
63+
{/* No more TenantSwitcher - the logo and tenant switching is now in Search component */}
64+
{children}
65+
</nav>
6166
</aside>
6267

6368
<CloseMenuOnRouteChange closeMenu={closeMenu} />
@@ -71,7 +76,8 @@ export const Menu = ({ children }: MenuButtonProps) => {
7176
</Popover.Button>
7277
</div>
7378

74-
{children}
79+
{/* No more TenantSwitcher here either */}
80+
<div className="mt-12">{children}</div>
7581
</Popover.Panel>
7682
</>
7783
)}

keep-ui/components/navbar/Navbar.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import "./Navbar.css";
1212

1313
export default async function NavbarInner() {
1414
const session = await auth();
15+
1516
return (
1617
<>
17-
<Menu>
18-
<Search />
18+
<Menu session={session}>
19+
<Search session={session} />
1920
<div className="pt-4 space-y-4 flex-1 overflow-auto scrollable-menu-shadow">
2021
<IncidentsLinks session={session} />
2122
<AlertsLinks session={session} />

0 commit comments

Comments
 (0)