From b1131fcfba76dba277eed4c55f37a1fecaf5bcdc Mon Sep 17 00:00:00 2001 From: Stanislav Bychkov Date: Mon, 8 Dec 2025 04:29:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Role=20based=20Cadence-web.=20-=20UI=20?= =?UTF-8?q?RBAC=20aligned=20with=20Cadence=20JWT=20auth:=20tokens=20come?= =?UTF-8?q?=20from=20cookie=20(cadence-authorization)=20or=20env=20(CADENC?= =?UTF-8?q?E=5FWEB=5FJWT=5FTOKEN),=20are=20forwarded=20on=20all=20gRPC=20c?= =?UTF-8?q?alls,=20and=20claims/groups=20drive=20what=20the=20UI=20shows/e?= =?UTF-8?q?nables.=20-=20Auth=20endpoints:=20POST=20/api/auth/token=20to?= =?UTF-8?q?=20set=20the=20HttpOnly=20cookie,=20DELETE=20/api/auth/token=20?= =?UTF-8?q?to=20clear=20it,=20GET=20/api/auth/me=20to=20expose=20public=20?= =?UTF-8?q?auth=20context.=20-=20User=20context=20middleware=20populates?= =?UTF-8?q?=20gRPC=20metadata=20and=20user=20info=20for=20all=20route=20ha?= =?UTF-8?q?ndlers.=20-=20Domain=20visibility:=20getAllDomains=20filters=20?= =?UTF-8?q?by=20READ=5FGROUPS/WRITE=5FGROUPS.=20Redirects=20respect=20the?= =?UTF-8?q?=20filtered=20list.=20-=20Workflow/domain=20actions:=20start/si?= =?UTF-8?q?gnal/terminate/etc.=20are=20disabled=20with=20=E2=80=9CNot=20au?= =?UTF-8?q?thorized=E2=80=9D=20when=20the=20token=20lacks=20write=20access?= =?UTF-8?q?;=20-=20Login/logout=20UI:=20navbar=20shows=20JWT=20paste=20mod?= =?UTF-8?q?al=20when=20unauthenticated.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stanislav Bychkov --- README.md | 2 + src/app/api/auth/me/route.ts | 11 + src/app/api/auth/token/route.ts | 40 ++ src/components/app-nav-bar/app-nav-bar.tsx | 132 ++++++- .../auth-token-modal/auth-token-modal.tsx | 85 ++++ src/config/dynamic/dynamic.config.ts | 10 + .../use-domain-access/use-domain-access.ts | 55 +++ src/hooks/use-user-info/use-user-info.ts | 17 + .../__tests__/start-workflow.node.ts | 3 + src/utils/auth/__tests__/auth-context.test.ts | 370 ++++++++++++++++++ src/utils/auth/auth-context.ts | 134 +++++++ src/utils/auth/auth-shared.ts | 107 +++++ .../__fixtures__/resolved-config-values.ts | 2 + ...ute-handlers-default-middlewares.config.ts | 4 +- .../middlewares/__tests__/user-info.test.ts | 94 +++++ .../middlewares/user-info.ts | 24 +- .../middlewares/user-info.types.ts | 6 +- ...domain-page-start-workflow-button.test.tsx | 14 + .../domain-page-start-workflow-button.tsx | 16 +- .../__tests__/domain-workflows.test.tsx | 50 ++- .../domain-workflows/domain-workflows.tsx | 20 +- .../is-cluster-advanced-visibility-enabled.ts | 3 +- src/views/domains-page/domains-page.tsx | 10 +- .../domains-page/helpers/get-all-domains.ts | 21 +- .../__tests__/redirect-domain.node.ts | 23 +- src/views/redirect-domain/redirect-domain.tsx | 5 +- .../use-domain-description.ts | 11 + .../__tests__/workflow-actions.test.tsx | 21 + .../__tests__/workflow-actions-menu.test.tsx | 14 + .../workflow-actions-menu.tsx | 5 +- .../workflow-actions-menu.types.ts | 1 + .../workflow-actions/workflow-actions.tsx | 10 +- 32 files changed, 1258 insertions(+), 62 deletions(-) create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/token/route.ts create mode 100644 src/components/auth-token-modal/auth-token-modal.tsx create mode 100644 src/hooks/use-domain-access/use-domain-access.ts create mode 100644 src/hooks/use-user-info/use-user-info.ts create mode 100644 src/utils/auth/__tests__/auth-context.test.ts create mode 100644 src/utils/auth/auth-context.ts create mode 100644 src/utils/auth/auth-shared.ts create mode 100644 src/utils/route-handlers-middleware/middlewares/__tests__/user-info.test.ts create mode 100644 src/views/shared/hooks/use-domain-description/use-domain-description.ts diff --git a/README.md b/README.md index 970716ff2..c5e2d17dd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Set these environment variables if you need to change their defaults | CADENCE_WEB_PORT | HTTP port to serve on | 8088 | | CADENCE_WEB_HOSTNAME | Host name to serve on | 0.0.0.0 | | CADENCE_ADMIN_SECURITY_TOKEN | Admin token for accessing admin methods | '' | +| CADENCE_WEB_RBAC_ENABLED | Enables RBAC-aware UI (login/logout). | false | +| CADENCE_WEB_JWT_TOKEN | Static Cadence JWT forwarded when no request token exists | '' | | CADENCE_GRPC_TLS_CA_FILE | Path to root CA certificate file for enabling one-way TLS on gRPC connections | '' | | CADENCE_WEB_SERVICE_NAME | Name of the web service used as GRPC caller and OTEL resource name | cadence-web | diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 000000000..caba92529 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,11 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { + getPublicAuthContext, + resolveAuthContext, +} from '@/utils/auth/auth-context'; + +export async function GET(request: NextRequest) { + const authContext = await resolveAuthContext(request.cookies); + return NextResponse.json(getPublicAuthContext(authContext)); +} diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 000000000..d2a980bee --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { CADENCE_AUTH_COOKIE_NAME } from '@/utils/auth/auth-context'; + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV !== 'development', + sameSite: 'lax' as const, + path: '/', +}; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body?.token || typeof body.token !== 'string') { + return NextResponse.json( + { message: 'A valid token is required' }, + { status: 400 } + ); + } + + const response = NextResponse.json({ ok: true }); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, body.token, COOKIE_OPTIONS); + return response; + } catch { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } +} + +export async function DELETE() { + const response = NextResponse.json({ ok: true }); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, '', { + ...COOKIE_OPTIONS, + maxAge: 0, + }); + return response; +} diff --git a/src/components/app-nav-bar/app-nav-bar.tsx b/src/components/app-nav-bar/app-nav-bar.tsx index c30c221e3..72f846a93 100644 --- a/src/components/app-nav-bar/app-nav-bar.tsx +++ b/src/components/app-nav-bar/app-nav-bar.tsx @@ -1,29 +1,129 @@ 'use client'; +import React, { useMemo, useState } from 'react'; + import { AppNavBar as BaseAppNavBar } from 'baseui/app-nav-bar'; +import { useSnackbar } from 'baseui/snackbar'; import NextLink from 'next/link'; +import { useRouter } from 'next/navigation'; import useStyletronClasses from '@/hooks/use-styletron-classes'; +import useUserInfo from '@/hooks/use-user-info/use-user-info'; +import request from '@/utils/request'; + +import AuthTokenModal from '../auth-token-modal/auth-token-modal'; import { cssStyles } from './app-nav-bar.styles'; +const LOGIN_ITEM = 'login'; +const LOGOUT_ITEM = 'logout'; + export default function AppNavBar() { const { cls } = useStyletronClasses(cssStyles); + const router = useRouter(); + const { enqueue } = useSnackbar(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { data: authInfo, isLoading: isAuthLoading, refetch } = useUserInfo(); + const userItems = useMemo(() => { + if (!authInfo?.rbacEnabled) return undefined; + if (!authInfo.isAuthenticated) { + return [{ label: 'Login with JWT', info: LOGIN_ITEM }]; + } + return [ + { label: 'Switch token', info: LOGIN_ITEM }, + { label: 'Logout', info: LOGOUT_ITEM }, + ]; + }, [authInfo]); + + const username = useMemo(() => { + if (!authInfo?.rbacEnabled) { + return undefined; + } + if (isAuthLoading || !authInfo) { + return 'Checking access...'; + } + return authInfo.isAuthenticated + ? authInfo.userName || 'Authenticated user' + : 'Authenticate'; + }, [authInfo, isAuthLoading]); + + const usernameSubtitle = + authInfo?.rbacEnabled && authInfo.isAuthenticated + ? authInfo.isAdmin + ? 'Admin' + : 'Authenticated' + : authInfo?.rbacEnabled + ? 'Paste a Cadence JWT' + : undefined; + + const saveToken = async (token: string) => { + try { + await request('/api/auth/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); + await refetch(); + enqueue({ message: 'Token saved' }); + setIsModalOpen(false); + router.refresh(); + } catch (e) { + const message = + e instanceof Error ? e.message : 'Failed to save authentication token'; + enqueue({ message }); + throw e; + } + }; + + const logout = async () => { + try { + await request('/api/auth/token', { method: 'DELETE' }); + enqueue({ message: 'Signed out' }); + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to sign out'; + enqueue({ message }); + } finally { + setIsModalOpen(false); + await refetch(); + router.refresh(); + } + }; + return ( - - - - - - } - /> + <> + + + + + + } + username={userItems ? username : undefined} + usernameSubtitle={userItems ? usernameSubtitle : undefined} + userItems={userItems} + onUserItemSelect={(item) => { + if (item.info === LOGIN_ITEM) { + setIsModalOpen(true); + } else if (item.info === LOGOUT_ITEM) { + void logout(); + } + }} + /> + {authInfo?.rbacEnabled && ( + setIsModalOpen(false)} + onSubmit={saveToken} + /> + )} + ); } diff --git a/src/components/auth-token-modal/auth-token-modal.tsx b/src/components/auth-token-modal/auth-token-modal.tsx new file mode 100644 index 000000000..de3e65519 --- /dev/null +++ b/src/components/auth-token-modal/auth-token-modal.tsx @@ -0,0 +1,85 @@ +'use client'; +import React, { useState } from 'react'; + +import { FormControl } from 'baseui/form-control'; +import { + Modal, + ModalBody, + ModalButton, + ModalFooter, + ModalHeader, +} from 'baseui/modal'; +import { Textarea } from 'baseui/textarea'; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSubmit: (token: string) => Promise | void; +}; + +export default function AuthTokenModal({ isOpen, onClose, onSubmit }: Props) { + const [token, setToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!token.trim()) { + setError('Please paste a JWT token first'); + return; + } + + setIsSubmitting(true); + setError(null); + try { + await onSubmit(token.trim()); + setToken(''); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to save authentication token' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + Authenticate with JWT + + +