Skip to content

Commit b1131fc

Browse files
committed
feat: Role based Cadence-web.
- UI RBAC aligned with Cadence JWT auth: tokens come from cookie (cadence-authorization) or env (CADENCE_WEB_JWT_TOKEN), are forwarded on all gRPC calls, and claims/groups drive what the UI shows/enables. - Auth endpoints: POST /api/auth/token to set the HttpOnly cookie, DELETE /api/auth/token to clear it, GET /api/auth/me to expose public auth context. - User context middleware populates gRPC metadata and user info for all route handlers. - Domain visibility: getAllDomains filters by READ_GROUPS/WRITE_GROUPS. Redirects respect the filtered list. - Workflow/domain actions: start/signal/terminate/etc. are disabled with “Not authorized” when the token lacks write access; - Login/logout UI: navbar shows JWT paste modal when unauthenticated. Signed-off-by: Stanislav Bychkov <[email protected]>
1 parent 299bdea commit b1131fc

File tree

32 files changed

+1258
-62
lines changed

32 files changed

+1258
-62
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Set these environment variables if you need to change their defaults
2121
| CADENCE_WEB_PORT | HTTP port to serve on | 8088 |
2222
| CADENCE_WEB_HOSTNAME | Host name to serve on | 0.0.0.0 |
2323
| CADENCE_ADMIN_SECURITY_TOKEN | Admin token for accessing admin methods | '' |
24+
| CADENCE_WEB_RBAC_ENABLED | Enables RBAC-aware UI (login/logout). | false |
25+
| CADENCE_WEB_JWT_TOKEN | Static Cadence JWT forwarded when no request token exists | '' |
2426
| CADENCE_GRPC_TLS_CA_FILE | Path to root CA certificate file for enabling one-way TLS on gRPC connections | '' |
2527
| CADENCE_WEB_SERVICE_NAME | Name of the web service used as GRPC caller and OTEL resource name | cadence-web |
2628

src/app/api/auth/me/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse, type NextRequest } from 'next/server';
2+
3+
import {
4+
getPublicAuthContext,
5+
resolveAuthContext,
6+
} from '@/utils/auth/auth-context';
7+
8+
export async function GET(request: NextRequest) {
9+
const authContext = await resolveAuthContext(request.cookies);
10+
return NextResponse.json(getPublicAuthContext(authContext));
11+
}

src/app/api/auth/token/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextResponse, type NextRequest } from 'next/server';
2+
3+
import { CADENCE_AUTH_COOKIE_NAME } from '@/utils/auth/auth-context';
4+
5+
const COOKIE_OPTIONS = {
6+
httpOnly: true,
7+
secure: process.env.NODE_ENV !== 'development',
8+
sameSite: 'lax' as const,
9+
path: '/',
10+
};
11+
12+
export async function POST(request: NextRequest) {
13+
try {
14+
const body = await request.json();
15+
if (!body?.token || typeof body.token !== 'string') {
16+
return NextResponse.json(
17+
{ message: 'A valid token is required' },
18+
{ status: 400 }
19+
);
20+
}
21+
22+
const response = NextResponse.json({ ok: true });
23+
response.cookies.set(CADENCE_AUTH_COOKIE_NAME, body.token, COOKIE_OPTIONS);
24+
return response;
25+
} catch {
26+
return NextResponse.json(
27+
{ message: 'Invalid request body' },
28+
{ status: 400 }
29+
);
30+
}
31+
}
32+
33+
export async function DELETE() {
34+
const response = NextResponse.json({ ok: true });
35+
response.cookies.set(CADENCE_AUTH_COOKIE_NAME, '', {
36+
...COOKIE_OPTIONS,
37+
maxAge: 0,
38+
});
39+
return response;
40+
}

src/components/app-nav-bar/app-nav-bar.tsx

Lines changed: 116 additions & 16 deletions
Large diffs are not rendered by default.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
import React, { useState } from 'react';
3+
4+
import { FormControl } from 'baseui/form-control';
5+
import {
6+
Modal,
7+
ModalBody,
8+
ModalButton,
9+
ModalFooter,
10+
ModalHeader,
11+
} from 'baseui/modal';
12+
import { Textarea } from 'baseui/textarea';
13+
14+
type Props = {
15+
isOpen: boolean;
16+
onClose: () => void;
17+
onSubmit: (token: string) => Promise<void> | void;
18+
};
19+
20+
export default function AuthTokenModal({ isOpen, onClose, onSubmit }: Props) {
21+
const [token, setToken] = useState('');
22+
const [isSubmitting, setIsSubmitting] = useState(false);
23+
const [error, setError] = useState<string | null>(null);
24+
25+
const handleSubmit = async () => {
26+
if (!token.trim()) {
27+
setError('Please paste a JWT token first');
28+
return;
29+
}
30+
31+
setIsSubmitting(true);
32+
setError(null);
33+
try {
34+
await onSubmit(token.trim());
35+
setToken('');
36+
} catch (e) {
37+
setError(
38+
e instanceof Error ? e.message : 'Failed to save authentication token'
39+
);
40+
} finally {
41+
setIsSubmitting(false);
42+
}
43+
};
44+
45+
return (
46+
<Modal
47+
size="default"
48+
onClose={onClose}
49+
isOpen={isOpen}
50+
closeable={!isSubmitting}
51+
autoFocus
52+
>
53+
<ModalHeader>Authenticate with JWT</ModalHeader>
54+
<ModalBody>
55+
<FormControl
56+
label="Cadence JWT"
57+
caption="Paste a Cadence-compatible JWT issued by your identity provider."
58+
error={error || null}
59+
>
60+
<Textarea
61+
value={token}
62+
onChange={(event) =>
63+
setToken((event?.target as HTMLTextAreaElement)?.value || '')
64+
}
65+
clearOnEscape
66+
disabled={isSubmitting}
67+
rows={6}
68+
/>
69+
</FormControl>
70+
</ModalBody>
71+
<ModalFooter>
72+
<ModalButton kind="tertiary" onClick={onClose} disabled={isSubmitting}>
73+
Cancel
74+
</ModalButton>
75+
<ModalButton
76+
onClick={handleSubmit}
77+
isLoading={isSubmitting}
78+
data-testid="auth-token-submit"
79+
>
80+
Save token
81+
</ModalButton>
82+
</ModalFooter>
83+
</Modal>
84+
);
85+
}

src/config/dynamic/dynamic.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import workflowDiagnosticsEnabled from './resolvers/workflow-diagnostics-enabled
2626
const dynamicConfigs: {
2727
CADENCE_WEB_PORT: ConfigEnvDefinition;
2828
ADMIN_SECURITY_TOKEN: ConfigEnvDefinition;
29+
CADENCE_WEB_RBAC_ENABLED: ConfigEnvDefinition;
30+
CADENCE_WEB_JWT_TOKEN: ConfigEnvDefinition;
2931
CLUSTERS: ConfigSyncResolverDefinition<
3032
undefined,
3133
ClustersConfigs,
@@ -89,6 +91,14 @@ const dynamicConfigs: {
8991
env: 'CADENCE_ADMIN_SECURITY_TOKEN',
9092
default: '',
9193
},
94+
CADENCE_WEB_RBAC_ENABLED: {
95+
env: 'CADENCE_WEB_RBAC_ENABLED',
96+
default: 'false',
97+
},
98+
CADENCE_WEB_JWT_TOKEN: {
99+
env: 'CADENCE_WEB_JWT_TOKEN',
100+
default: process.env.CADENCE_WEB_RBAC_TOKEN || '',
101+
},
92102
CLUSTERS: {
93103
resolver: clusters,
94104
evaluateOn: 'serverStart',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client';
2+
import { useMemo } from 'react';
3+
4+
import { useQuery } from '@tanstack/react-query';
5+
6+
import { getDomainAccessForUser } from '@/utils/auth/auth-shared';
7+
import getDomainDescriptionQueryOptions from '@/views/shared/hooks/use-domain-description/get-domain-description-query-options';
8+
import { type UseDomainDescriptionParams } from '@/views/shared/hooks/use-domain-description/use-domain-description.types';
9+
10+
import useUserInfo from '../use-user-info/use-user-info';
11+
12+
export default function useDomainAccess(params: UseDomainDescriptionParams) {
13+
const userInfoQuery = useUserInfo();
14+
const shouldLoadDomain = Boolean(userInfoQuery.data);
15+
16+
const domainQuery = useQuery({
17+
...getDomainDescriptionQueryOptions(params),
18+
enabled: shouldLoadDomain,
19+
});
20+
21+
const access = useMemo(() => {
22+
if (userInfoQuery.isError) {
23+
return { canRead: false, canWrite: false };
24+
}
25+
26+
if (!userInfoQuery.data) {
27+
return undefined;
28+
}
29+
30+
if (domainQuery.data) {
31+
return getDomainAccessForUser(domainQuery.data, userInfoQuery.data);
32+
}
33+
34+
if (domainQuery.isError) {
35+
return { canRead: false, canWrite: false };
36+
}
37+
38+
return undefined;
39+
}, [
40+
domainQuery.data,
41+
domainQuery.isError,
42+
userInfoQuery.data,
43+
userInfoQuery.isError,
44+
]);
45+
46+
const isLoading =
47+
userInfoQuery.isLoading || (shouldLoadDomain && domainQuery.isLoading);
48+
49+
return {
50+
access,
51+
isLoading,
52+
isError: userInfoQuery.isError || domainQuery.isError,
53+
userInfoQuery,
54+
};
55+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
import { type PublicAuthContext } from '@/utils/auth/auth-shared';
5+
import request from '@/utils/request';
6+
import { type RequestError } from '@/utils/request/request-error';
7+
8+
export default function useUserInfo() {
9+
return useQuery<PublicAuthContext, RequestError>({
10+
queryKey: ['auth', 'me'],
11+
queryFn: async () => {
12+
const res = await request('/api/auth/me', { method: 'GET' });
13+
return res.json();
14+
},
15+
staleTime: 30_000,
16+
});
17+
}

src/route-handlers/start-workflow/__tests__/start-workflow.node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ async function setup({
308308
},
309309
userInfo: {
310310
id: 'test-user-id',
311+
rbacEnabled: false,
312+
isAdmin: true,
313+
groups: [],
311314
},
312315
};
313316

0 commit comments

Comments
 (0)