Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: switch tenants #4107

Merged
merged 16 commits into from
Mar 19, 2025
2 changes: 1 addition & 1 deletion docker/Dockerfile.ui
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion ee/identitymanager/identity_managers/auth0/auth0_authverifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@
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]

Check warning on line 49 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L47-L49

Added lines #L47 - L49 were not covered by tests
else:
active_tenant = None

Check warning on line 51 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L51

Added line #L51 was not covered by tests

try:
jwt_signing_key = jwks_client.get_signing_key_from_jwt(token).key
payload = jwt.decode(
Expand All @@ -52,7 +60,24 @@
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(

Check warning on line 71 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L64-L71

Added lines #L64 - L71 were not covered by tests
"Someone tries to use a token with a tenant that is not in the token"
)
raise HTTPException(

Check warning on line 74 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L74

Added line #L74 was not covered by tests
status_code=401,
detail="Token does not contain the active tenant",
)
tenant_id = active_tenant

Check warning on line 78 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L78

Added line #L78 was not covered by tests
else:
tenant_id = payload.get("keep_tenant_id")

Check warning on line 80 in ee/identitymanager/identity_managers/auth0/auth0_authverifier.py

View check run for this annotation

Codecov / codecov/patch

ee/identitymanager/identity_managers/auth0/auth0_authverifier.py#L80

Added line #L80 was not covered by tests
role_name = payload.get(
"keep_role", AdminRole.get_name()
) # default to admin for backwards compatibility
Expand Down
22 changes: 22 additions & 0 deletions keep-ui/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down Expand Up @@ -240,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) {
Expand All @@ -261,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";
Expand Down
119 changes: 105 additions & 14 deletions keep-ui/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,77 @@ 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<User | null> {
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}`);

// 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,
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<typeof fetch>
Expand All @@ -13,9 +84,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
Expand All @@ -29,13 +98,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();
Expand All @@ -47,20 +114,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();
Expand All @@ -77,18 +141,15 @@ function proxyFetch(
// Modify the config if using Azure AD with proxy
if (authType === AuthType.AZUREAD && proxyUrl) {
const provider = config.providers[0] as ReturnType<typeof MicrosoftEntraID>;

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<typeof fetch>) => {
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");
Expand All @@ -111,11 +172,9 @@ 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
Expand All @@ -138,7 +197,6 @@ if (authType === AuthType.AZUREAD && proxyUrl) {
// }
// }
// https://stackoverflow.com/questions/77686104/how-to-resolve-http-error-431-nextjs-next-auth

return {
id: profile.sub,
name: profile.name,
Expand All @@ -149,6 +207,39 @@ 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 as {
tenant_id: string;
tenant_name: string;
}[];
}

// 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);
12 changes: 9 additions & 3 deletions keep-ui/components/navbar/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<boolean>(
"menu-minimized",
false
Expand Down Expand Up @@ -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}
>
<nav className="flex flex-col h-full">{children}</nav>
<nav className="flex flex-col h-full">
{/* No more TenantSwitcher - the logo and tenant switching is now in Search component */}
{children}
</nav>
</aside>

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

{children}
{/* No more TenantSwitcher here either */}
<div className="mt-12">{children}</div>
</Popover.Panel>
</>
)}
Expand Down
5 changes: 3 additions & 2 deletions keep-ui/components/navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import "./Navbar.css";

export default async function NavbarInner() {
const session = await auth();

return (
<>
<Menu>
<Search />
<Menu session={session}>
<Search session={session} />
<div className="pt-4 space-y-4 flex-1 overflow-auto scrollable-menu-shadow">
<IncidentsLinks session={session} />
<AlertsLinks session={session} />
Expand Down
Loading