diff --git a/frontend/packages/configure-connections/src/api/useDeleteConnection.ts b/frontend/packages/configure-connections/src/api/useDeleteConnection.ts index 1fa5c67967..e1953c1a38 100644 --- a/frontend/packages/configure-connections/src/api/useDeleteConnection.ts +++ b/frontend/packages/configure-connections/src/api/useDeleteConnection.ts @@ -32,8 +32,7 @@ export default function useDeleteConnection(type: ConnectionType): UseMutationRe }, } as unknown as Parameters[0]); }, - onSuccess: (_data, id) => { - queryClient.removeQueries({queryKey: [ConnectionQueryKeys.CONNECTION, type, id]}); + onSuccess: () => { queryClient.invalidateQueries({queryKey: [ConnectionQueryKeys.CONNECTIONS]}).catch(() => { // Ignore invalidation errors }); diff --git a/frontend/packages/configure-connections/src/api/useGetConnectionUsages.ts b/frontend/packages/configure-connections/src/api/useGetConnectionUsages.ts index c2fb03d290..ea31f0826a 100644 --- a/frontend/packages/configure-connections/src/api/useGetConnectionUsages.ts +++ b/frontend/packages/configure-connections/src/api/useGetConnectionUsages.ts @@ -26,6 +26,7 @@ export default function useGetConnectionUsages( return useQuery({ queryKey: [ConnectionQueryKeys.CONNECTION_USAGES, type, id], enabled: Boolean(id) && enabled, + retry: false, queryFn: async (): Promise => { const serverUrl: string = getServerUrl(); const response: { diff --git a/frontend/packages/configure-connections/src/components/ConnectionDeleteDialog.tsx b/frontend/packages/configure-connections/src/components/ConnectionDeleteDialog.tsx index e8efc2eb08..780efba3a3 100644 --- a/frontend/packages/configure-connections/src/components/ConnectionDeleteDialog.tsx +++ b/frontend/packages/configure-connections/src/components/ConnectionDeleteDialog.tsx @@ -1,6 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {getErrorMessage} from '@thunderid/utils'; import { Alert, Button, @@ -46,17 +47,22 @@ export default function ConnectionDeleteDialog({ }: ConnectionDeleteDialogProps): JSX.Element { const {t} = useTranslation('connections'); - const {data: usagesData, isLoading: isLoadingUsages} = useGetConnectionUsages( - connectionType, - connectionId || undefined, - open, - ); + const usagesQuery = useGetConnectionUsages(connectionType, connectionId || undefined, open); + const {data: usagesData, isLoading: isLoadingUsages, isError: isUsagesError, error: usagesError} = usagesQuery; const usagesKnown = usagesData !== undefined && usagesData.totalResults !== null; const blockingUsages = usagesData?.usages.filter((usage) => usage.behaviorOnDelete === 'restrict') ?? []; const hasBlockingUsages = usagesKnown && blockingUsages.length > 0; const visibleBlocking = blockingUsages.slice(0, MAX_VISIBLE_USAGES); const hiddenBlockingCount = blockingUsages.length - visibleBlocking.length; + const usagesErrorMessage = isUsagesError + ? getErrorMessage( + usagesError, + t, + 'delete.usages.error', + 'Failed to check connection usage. Please try again.', + ) + : null; return ( @@ -68,6 +74,8 @@ export default function ConnectionDeleteDialog({ }> {t('delete.usages.loading')} + ) : isUsagesError ? ( + {usagesErrorMessage} ) : hasBlockingUsages ? ( @@ -108,7 +116,7 @@ export default function ConnectionDeleteDialog({ onClick={onConfirm} color="error" variant="contained" - disabled={isPending || isLoadingUsages || hasBlockingUsages} + disabled={isPending || isLoadingUsages || isUsagesError || hasBlockingUsages} data-testid="connection-delete-confirm" > {t('common:actions.delete')} diff --git a/frontend/packages/configure-connections/src/components/SubjectMappingSection.tsx b/frontend/packages/configure-connections/src/components/SubjectMappingSection.tsx new file mode 100644 index 0000000000..607115f89d --- /dev/null +++ b/frontend/packages/configure-connections/src/components/SubjectMappingSection.tsx @@ -0,0 +1,592 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {SettingsCard} from '@thunderid/components'; +import {useGetUserType, useGetUserTypes} from '@thunderid/configure-user-types'; +import { + Autocomplete, + Box, + Button, + FormControl, + FormLabel, + IconButton, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import {Plus, Route, Trash2} from '@wso2/oxygen-ui-icons-react'; +import {type JSX, useMemo, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {flattenUserTypeAttributes} from '../utils/attributeConfiguration'; +import {parseKeyValuePairs, sanitizeKeyValuePart} from '../utils/keyValuePairs'; + +export interface SubjectMappingValues { + subjectProperties?: string; + subjectPropertyMappings?: string; + subjectAttributeMappings?: SubjectAttributeMappingGroupValue[]; +} + +interface SubjectMappingSectionProps { + values: SubjectMappingValues; + onChange: (field: K, value: NonNullable) => void; +} + +export interface SubjectAttributeMappingGroupValue { + userType: string; + attributes: SubjectAttributeMappingValue[]; +} + +export interface SubjectAttributeMappingValue { + attribute: string; + pdpAttribute?: string; +} + +interface SubjectAttributeRow { + key: number; + attribute: string; + pdpAttribute: string; +} + +interface SubjectMappingGroup { + key: number; + userType: string; + rows: SubjectAttributeRow[]; +} + +interface SubjectRowsState { + groups: SubjectMappingGroup[]; + seq: number; + syncedProperties: string; + syncedMappings: string; + syncedAttributeMappings: string; +} + +const BUILT_IN_OPTIONAL_SUBJECT_ATTRIBUTES = ['groups', 'ouId']; +const DEFAULT_SUBJECT_FIELDS = ['Subject ID']; + +function parseSubjectProperties(value: string | undefined): string[] { + return (value ?? '') + .split(/\s+/) + .map((attribute) => attribute.trim()) + .filter((attribute) => attribute !== ''); +} + +function uniqueValues(values: string[]): string[] { + return values.filter((value, index, all) => value.trim() !== '' && all.indexOf(value) === index); +} + +function buildRowsFromValues( + subjectProperties: string | undefined, + subjectPropertyMappings: string | undefined, + fromSeq: number, +): {rows: SubjectAttributeRow[]; seq: number} { + const mappings = parseKeyValuePairs(subjectPropertyMappings ?? ''); + const mappedByAttribute = new Map(mappings.map((mapping) => [mapping.name, mapping.value])); + const attributes = uniqueValues([ + ...parseSubjectProperties(subjectProperties), + ...mappings.map((mapping) => mapping.name).filter((name) => name.trim() !== ''), + ]); + const rows = + attributes.length > 0 + ? attributes.map((attribute, index) => ({ + key: fromSeq + index + 1, + attribute, + pdpAttribute: mappedByAttribute.get(attribute) ?? '', + })) + : [{key: fromSeq + 1, attribute: '', pdpAttribute: ''}]; + return {rows, seq: fromSeq + rows.length}; +} + +function buildSubjectRows( + subjectProperties: string | undefined, + subjectPropertyMappings: string | undefined, + subjectAttributeMappings: SubjectAttributeMappingGroupValue[] | undefined, + defaultUserType: string, + fromSeq: number, +): SubjectRowsState { + if (subjectAttributeMappings && subjectAttributeMappings.length > 0) { + let seq = fromSeq; + const groups = subjectAttributeMappings.map((group) => { + const rows = + group.attributes.length > 0 + ? group.attributes.map((attribute) => { + seq += 1; + return { + key: seq, + attribute: attribute.attribute, + pdpAttribute: attribute.pdpAttribute ?? '', + }; + }) + : [{key: seq + 1, attribute: '', pdpAttribute: ''}]; + seq += group.attributes.length > 0 ? 0 : 1; + seq += 1; + return {key: seq, userType: group.userType, rows}; + }); + return { + groups, + seq, + syncedProperties: subjectProperties ?? '', + syncedMappings: subjectPropertyMappings ?? '', + syncedAttributeMappings: canonicalSubjectAttributeMappings(subjectAttributeMappings), + }; + } + const {rows, seq} = buildRowsFromValues(subjectProperties, subjectPropertyMappings, fromSeq); + return { + groups: [{key: seq + 1, userType: defaultUserType, rows}], + seq: seq + 1, + syncedProperties: subjectProperties ?? '', + syncedMappings: subjectPropertyMappings ?? '', + syncedAttributeMappings: canonicalSubjectAttributeMappings(subjectAttributeMappings), + }; +} + +function serializeSubjectProperties(groups: SubjectMappingGroup[]): string { + return uniqueValues(groups.flatMap((group) => group.rows.map((row) => row.attribute.trim()))).join(' '); +} + +function serializeSubjectMappings(groups: SubjectMappingGroup[]): string { + const mappings = new Map(); + for (const group of groups) { + for (const row of group.rows) { + const attribute = row.attribute.trim(); + const pdpAttribute = row.pdpAttribute.trim(); + if (attribute !== '' && pdpAttribute !== '') { + mappings.set(attribute, pdpAttribute); + } + } + } + return [...mappings.entries()].map(([attribute, pdpAttribute]) => `${attribute}: ${pdpAttribute}`).join(', '); +} + +function serializeSubjectAttributeMappings(groups: SubjectMappingGroup[]): SubjectAttributeMappingGroupValue[] { + return groups + .map((group) => ({ + userType: group.userType, + attributes: group.rows + .map((row) => ({ + attribute: row.attribute.trim(), + pdpAttribute: row.pdpAttribute.trim(), + })) + .filter((row) => row.attribute !== '') + .map((row) => ({ + attribute: row.attribute, + ...(row.pdpAttribute !== '' ? {pdpAttribute: row.pdpAttribute} : {}), + })), + })) + .filter((group) => group.userType.trim() !== '' || group.attributes.length > 0); +} + +function canonicalSubjectAttributeMappings( + subjectAttributeMappings: SubjectAttributeMappingGroupValue[] | undefined, +): string { + return JSON.stringify(subjectAttributeMappings ?? []); +} + +function withStoredOption(options: string[], stored: string): string[] { + return stored.trim() !== '' && !options.includes(stored) ? [...options, stored] : options; +} + +function hasUnusedUserType(groups: SubjectMappingGroup[], userTypeNames: string[]): boolean { + const used = new Set(groups.map((group) => group.userType).filter((userType) => userType.trim() !== '')); + return userTypeNames.some((name) => !used.has(name)); +} + +function SubjectMappingGroupEditor({ + group, + userTypeNames, + otherUsedUserTypes, + userTypeIdByName, + canRemove, + onUserTypeChange, + onAddRow, + onRemoveRow, + onUpdateRow, + onRemoveGroup, +}: { + group: SubjectMappingGroup; + userTypeNames: string[]; + otherUsedUserTypes: string[]; + userTypeIdByName: Map; + canRemove: boolean; + onUserTypeChange: (userType: string) => void; + onAddRow: () => void; + onRemoveRow: (rowKey: number) => void; + onUpdateRow: (rowKey: number, part: 'attribute' | 'pdpAttribute', value: string) => void; + onRemoveGroup: () => void; +}): JSX.Element { + const {t} = useTranslation('connections'); + const userTypeDetail = useGetUserType(userTypeIdByName.get(group.userType)); + const userAttributes = useMemo( + () => flattenUserTypeAttributes(userTypeDetail.data?.schema), + [userTypeDetail.data?.schema], + ); + const subjectAttributeOptions = useMemo( + () => [...new Set([...BUILT_IN_OPTIONAL_SUBJECT_ATTRIBUTES, ...userAttributes])].sort(), + [userAttributes], + ); + const userTypeOptions = useMemo(() => { + const usedElsewhere = new Set(otherUsedUserTypes); + return withStoredOption( + userTypeNames.filter((name) => !usedElsewhere.has(name)), + group.userType, + ); + }, [group.userType, otherUsedUserTypes, userTypeNames]); + const singleUserType = userTypeNames.length === 1; + const lastRow = group.rows[group.rows.length - 1]; + const lastRowIsEmpty = lastRow?.attribute.trim() === '' && lastRow?.pdpAttribute.trim() === ''; + + return ( + + {(!singleUserType || canRemove) && ( + + {!singleUserType && ( + + + {t('subjectMapping.attributes.userType.label', 'User type')} + + + + )} + + {canRemove && ( + + )} + + )} + + + + + {t('subjectMapping.mappings.thunderIdAttribute', 'ThunderID Attribute')} + + + {t('subjectMapping.mappings.pdpAttributeOptional', 'PDP Attribute (optional)')} + + + + + {group.rows.map((row, index) => { + const isOnlyEmptyRow = + group.rows.length === 1 && row.attribute.trim() === '' && row.pdpAttribute.trim() === ''; + return ( + + onUpdateRow(row.key, 'attribute', nextValue)} + renderInput={(params) => ( + + )} + /> + onUpdateRow(row.key, 'pdpAttribute', event.target.value)} + slotProps={{ + input: { + 'aria-label': t('subjectMapping.mappings.pdpAttribute', 'PDP Attribute'), + }, + }} + /> + {isOnlyEmptyRow ? ( + + ) : ( + onRemoveRow(row.key)} + aria-label={t('form.keyValue.remove', 'Remove')} + data-testid={`subject-mapping-remove-${group.key}-${index + 1}`} + > + + + )} + + ); + })} + + + + + + + ); +} + +export default function SubjectMappingSection({values, onChange}: SubjectMappingSectionProps): JSX.Element { + const {t} = useTranslation('connections'); + const userTypesQuery = useGetUserTypes(); + const userTypeList = useMemo(() => userTypesQuery.data?.types ?? [], [userTypesQuery.data]); + const userTypeNames = useMemo(() => userTypeList.map((type) => type.name), [userTypeList]); + const userTypeIdByName = useMemo(() => new Map(userTypeList.map((type) => [type.name, type.id])), [userTypeList]); + const defaultUserType = userTypeList[0]?.name ?? ''; + const [state, setState] = useState(() => + buildSubjectRows( + values.subjectProperties, + values.subjectPropertyMappings, + values.subjectAttributeMappings, + defaultUserType, + 0, + ), + ); + + if ( + values.subjectProperties !== state.syncedProperties || + (values.subjectPropertyMappings ?? '') !== state.syncedMappings || + canonicalSubjectAttributeMappings(values.subjectAttributeMappings) !== state.syncedAttributeMappings + ) { + setState( + buildSubjectRows( + values.subjectProperties, + values.subjectPropertyMappings, + values.subjectAttributeMappings, + defaultUserType, + state.seq, + ), + ); + } + + if ( + defaultUserType !== '' && + state.groups.length === 1 && + state.groups[0].userType === '' && + state.syncedProperties === (values.subjectProperties ?? '') && + state.syncedMappings === (values.subjectPropertyMappings ?? '') && + state.syncedAttributeMappings === canonicalSubjectAttributeMappings(values.subjectAttributeMappings) + ) { + setState((prev) => ({ + ...prev, + groups: prev.groups.map((group, index) => (index === 0 ? {...group, userType: defaultUserType} : group)), + })); + } + + const iconBox = (icon: JSX.Element): JSX.Element => ( + + {icon} + + ); + + const commit = (groups: SubjectMappingGroup[], seq: number): void => { + const subjectProperties = serializeSubjectProperties(groups); + const subjectPropertyMappings = serializeSubjectMappings(groups); + const subjectAttributeMappings = serializeSubjectAttributeMappings(groups); + setState({ + groups, + seq, + syncedProperties: subjectProperties, + syncedMappings: subjectPropertyMappings, + syncedAttributeMappings: canonicalSubjectAttributeMappings(subjectAttributeMappings), + }); + onChange('subjectProperties', subjectProperties); + onChange('subjectPropertyMappings', subjectPropertyMappings); + onChange('subjectAttributeMappings', subjectAttributeMappings); + }; + + const updateGroupType = (groupKey: number, userType: string): void => { + const groups = state.groups.map((group) => (group.key === groupKey ? {...group, userType} : group)); + commit(groups, state.seq); + }; + + const addGroup = (): void => + setState((prev) => ({ + ...prev, + groups: [ + ...prev.groups, + {key: prev.seq + 1, userType: '', rows: [{key: prev.seq + 2, attribute: '', pdpAttribute: ''}]}, + ], + seq: prev.seq + 2, + })); + + const removeGroup = (groupKey: number): void => { + const groups = state.groups.filter((group) => group.key !== groupKey); + commit(groups.length > 0 ? groups : [{key: state.seq + 1, userType: '', rows: []}], state.seq + 1); + }; + + const addRow = (groupKey: number): void => + setState((prev) => ({ + ...prev, + groups: prev.groups.map((group) => + group.key === groupKey + ? {...group, rows: [...group.rows, {key: prev.seq + 1, attribute: '', pdpAttribute: ''}]} + : group, + ), + seq: prev.seq + 1, + })); + + const removeRow = (groupKey: number, rowKey: number): void => { + const groups = state.groups.map((group) => { + if (group.key !== groupKey) { + return group; + } + const rows = group.rows.filter((row) => row.key !== rowKey); + return { + ...group, + rows: rows.length > 0 ? rows : [{key: state.seq + 1, attribute: '', pdpAttribute: ''}], + }; + }); + commit(groups, state.seq + 1); + }; + + const updateRow = (groupKey: number, rowKey: number, part: 'attribute' | 'pdpAttribute', value: string): void => { + const groups = state.groups.map((group) => + group.key === groupKey + ? { + ...group, + rows: group.rows.map((row) => + row.key === rowKey + ? {...row, [part]: sanitizeKeyValuePart(value, part === 'attribute' ? 'name' : 'value')} + : row, + ), + } + : group, + ); + commit(groups, state.seq); + }; + + const selectedAttributes = uniqueValues([ + ...DEFAULT_SUBJECT_FIELDS, + ...state.groups.flatMap((group) => group.rows.map((row) => row.attribute.trim())), + ]); + const showAddUserType = hasUnusedUserType(state.groups, userTypeNames); + + return ( + + )} + > + + + + {t('subjectMapping.selected.title', 'Selected attributes')} + + + {selectedAttributes.map((field) => ( + + {field} + + ))} + + + + + {t('subjectMapping.mappings.label', 'Additional attributes')} + + {state.groups.map((group) => ( + other.key !== group.key) + .map((other) => other.userType) + .filter((userType) => userType.trim() !== '')} + userTypeIdByName={userTypeIdByName} + canRemove={state.groups.length > 1} + onUserTypeChange={(userType) => updateGroupType(group.key, userType)} + onAddRow={() => addRow(group.key)} + onRemoveRow={(rowKey) => removeRow(group.key, rowKey)} + onUpdateRow={(rowKey, part, value) => updateRow(group.key, rowKey, part, value)} + onRemoveGroup={() => removeGroup(group.key)} + /> + ))} + + {showAddUserType && ( + + + + )} + + + {t( + 'subjectMapping.mappings.hint', + 'Select extra user attributes to include in the PDP request. The PDP attribute is optional and is only needed when the PDP expects a different name.', + )} + + + + + + + ); +} diff --git a/frontend/packages/configure-connections/src/components/__tests__/ConnectionForm.test.tsx b/frontend/packages/configure-connections/src/components/__tests__/ConnectionForm.test.tsx index 0ff8877fdf..26f8d1a694 100644 --- a/frontend/packages/configure-connections/src/components/__tests__/ConnectionForm.test.tsx +++ b/frontend/packages/configure-connections/src/components/__tests__/ConnectionForm.test.tsx @@ -365,4 +365,5 @@ describe('ConnectionForm', () => { expect(onFieldChange).toHaveBeenLastCalledWith('httpHeaders', 'X-ABC: text/html application/json'); }); }); + }); diff --git a/frontend/packages/configure-connections/src/components/create-connection/SelectConnectionType.tsx b/frontend/packages/configure-connections/src/components/create-connection/SelectConnectionType.tsx index 65a98a2e99..5e3164a8f8 100644 --- a/frontend/packages/configure-connections/src/components/create-connection/SelectConnectionType.tsx +++ b/frontend/packages/configure-connections/src/components/create-connection/SelectConnectionType.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {Box, Card, CardContent, Chip, Stack, Typography} from '@wso2/oxygen-ui'; -import {CircleCheck, KeyRound, MessagesSquare, ShieldCheck} from '@wso2/oxygen-ui-icons-react'; +import {CircleCheck, KeyRound, MessagesSquare, ServerCog, ShieldCheck} from '@wso2/oxygen-ui-icons-react'; import type {JSX} from 'react'; import {useTranslation} from 'react-i18next'; import {type ConnectionType, ConnectionTypes} from '../../models/connection'; @@ -69,6 +69,17 @@ export default function SelectConnectionType({selectedType, onSelect}: SelectCon icon: , comingSoon: false, }, + { + type: ConnectionTypes.EXTERNAL_AUTHZEN_PDP, + labelKey: 'wizard.type.externalAuthzenPdp.label', + labelDefault: 'External AuthZEN PDP', + descriptionKey: 'wizard.type.externalAuthzenPdp.description', + descriptionDefault: 'Call an external AuthZEN-compatible policy decision point for authorization evaluation.', + tagKey: 'wizard.type.externalAuthzenPdp.tag', + tagDefault: 'Authorization · PDP', + icon: , + comingSoon: false, + }, { type: ConnectionTypes.SMS_GATEWAY, labelKey: 'wizard.type.sms.label', diff --git a/frontend/packages/configure-connections/src/components/create-connection/__tests__/SelectConnectionType.test.tsx b/frontend/packages/configure-connections/src/components/create-connection/__tests__/SelectConnectionType.test.tsx index c45b2bf721..30cd74cab4 100644 --- a/frontend/packages/configure-connections/src/components/create-connection/__tests__/SelectConnectionType.test.tsx +++ b/frontend/packages/configure-connections/src/components/create-connection/__tests__/SelectConnectionType.test.tsx @@ -16,6 +16,7 @@ describe('SelectConnectionType', () => { expect(screen.getByTestId('connection-type-option-oidc')).toBeInTheDocument(); expect(screen.getByTestId('connection-type-option-oauth')).toBeInTheDocument(); expect(screen.getByTestId('connection-type-option-trusted-idp')).toBeInTheDocument(); + expect(screen.getByTestId('connection-type-option-external-authzen-pdp')).toBeInTheDocument(); expect(screen.getByTestId('connection-type-option-sms-gateway')).toBeInTheDocument(); }); diff --git a/frontend/packages/configure-connections/src/config/__tests__/connectionFormFields.test.ts b/frontend/packages/configure-connections/src/config/__tests__/connectionFormFields.test.ts index de6aac9eee..56a7d39462 100644 --- a/frontend/packages/configure-connections/src/config/__tests__/connectionFormFields.test.ts +++ b/frontend/packages/configure-connections/src/config/__tests__/connectionFormFields.test.ts @@ -107,4 +107,17 @@ describe('fieldsForMode', () => { expect(headers).toMatchObject({kind: 'key-value', addLabelKey: 'connections:form.fields.httpHeaders.add'}); expect(headers?.required).toBeUndefined(); }); + + it('keeps External AuthZEN PDP creation focused on endpoint and runtime settings', () => { + expect(fieldNames(ConnectionTypes.EXTERNAL_AUTHZEN_PDP, 'create')).toEqual([ + 'name', + 'endpoint', + ]); + expect(fieldNames(ConnectionTypes.EXTERNAL_AUTHZEN_PDP, 'edit')).toEqual([ + 'name', + 'endpoint', + 'timeoutMs', + 'retryCount', + ]); + }); }); diff --git a/frontend/packages/configure-connections/src/config/connectionFormFields.ts b/frontend/packages/configure-connections/src/config/connectionFormFields.ts index 481cb303f2..1dc38b955b 100644 --- a/frontend/packages/configure-connections/src/config/connectionFormFields.ts +++ b/frontend/packages/configure-connections/src/config/connectionFormFields.ts @@ -46,7 +46,7 @@ export interface ConnectionFieldDef { section?: string; /** Renders only when the named switch field's value is truthy. */ revealedBy?: string; - /** Becomes required when the named switch field's value is truthy. */ + /** Becomes required when the named switch field is truthy. */ requiredWhen?: string; /** Which form mode renders this field (default 'both'). Optional fields are edit-only to keep create simple. */ visibility?: ConnectionFieldVisibility; @@ -368,6 +368,34 @@ export const CONNECTION_FORM_FIELDS: Record} />, + categories: ['authorization', 'custom'], + presentation: 'custom', + }, ]; /** diff --git a/frontend/packages/configure-connections/src/constants/connection-categories.ts b/frontend/packages/configure-connections/src/constants/connection-categories.ts index 468c05f35a..37dbd8172e 100644 --- a/frontend/packages/configure-connections/src/constants/connection-categories.ts +++ b/frontend/packages/configure-connections/src/constants/connection-categories.ts @@ -15,6 +15,7 @@ export const CONNECTION_CATEGORIES: ConnectionCategory[] = [ 'identity-verification', 'crm', 'data-store', + 'authorization', 'trusted-idp', 'custom', ]; diff --git a/frontend/packages/configure-connections/src/index.ts b/frontend/packages/configure-connections/src/index.ts index 232aa482dd..e0c80b4dd2 100644 --- a/frontend/packages/configure-connections/src/index.ts +++ b/frontend/packages/configure-connections/src/index.ts @@ -17,6 +17,8 @@ export {default as useUpdateConnection} from './api/useUpdateConnection'; export {default as AddCustomConnectionCard} from './components/AddCustomConnectionCard'; export {default as AttributeMappingSection} from './components/AttributeMappingSection'; export * from './components/AttributeMappingSection'; +export {default as SubjectMappingSection} from './components/SubjectMappingSection'; +export * from './components/SubjectMappingSection'; export {default as ConnectionCard} from './components/ConnectionCard'; export {default as ConnectionCategoryFilters} from './components/ConnectionCategoryFilters'; export {default as ConnectionDeleteDialog} from './components/ConnectionDeleteDialog'; diff --git a/frontend/packages/configure-connections/src/models/connection.ts b/frontend/packages/configure-connections/src/models/connection.ts index 92e3662ae1..bff409e7c3 100644 --- a/frontend/packages/configure-connections/src/models/connection.ts +++ b/frontend/packages/configure-connections/src/models/connection.ts @@ -14,6 +14,7 @@ export const ConnectionTypes = { TWILIO: 'twilio', VONAGE: 'vonage', SMS_GATEWAY: 'sms-gateway', + EXTERNAL_AUTHZEN_PDP: 'external-authzen-pdp', } as const; export type ConnectionType = (typeof ConnectionTypes)[keyof typeof ConnectionTypes]; @@ -31,6 +32,7 @@ export type ConnectionCategory = | 'identity-verification' | 'crm' | 'data-store' + | 'authorization' | 'trusted-idp' | 'custom'; @@ -40,6 +42,7 @@ export type ConnectionCategory = export const ConnectionInstanceCategories = { IDENTITY_PROVIDER: 'identity-provider', SMS_PROVIDER: 'sms-provider', + AUTHORIZATION_PDP: 'authorization-pdp', } as const; export type ConnectionInstanceCategory = @@ -240,16 +243,38 @@ export interface SMSGatewayConnectionRequest { httpHeaders?: string; } +export interface ExternalAuthZENPDPConnectionRequest { + name: string; + description?: string; + endpoint: string; + timeoutMs?: string; + retryCount?: string; + subjectProperties?: string; + subjectPropertyMappings?: string; + subjectAttributeMappings?: ExternalAuthZENPDPSubjectAttributeMapping[]; +} + +export interface ExternalAuthZENPDPSubjectAttributeMapping { + userType: string; + attributes: ExternalAuthZENPDPSubjectAttribute[]; +} + +export interface ExternalAuthZENPDPSubjectAttribute { + attribute: string; + pdpAttribute?: string; +} + export type ConnectionRequest = | OAuthConnectionRequest | OIDCConnectionRequest | OAuth2ConnectionRequest | TwilioConnectionRequest | VonageConnectionRequest - | SMSGatewayConnectionRequest; + | SMSGatewayConnectionRequest + | ExternalAuthZENPDPConnectionRequest; /** - * Vendor response — secrets returned masked as "******". A superset carrying every vendor's + * Vendor response — secrets are never returned. A superset carrying every vendor's * fields (IdP + SMS); the shared form mapping reads only the fields relevant to each type. */ export interface ConnectionResponse extends OIDCConnectionRequest { @@ -268,6 +293,13 @@ export interface ConnectionResponse extends OIDCConnectionRequest { httpMethod?: string; contentType?: string; httpHeaders?: string; + /** External AuthZEN PDP fields. */ + endpoint?: string; + timeoutMs?: number | string; + retryCount?: number | string; + subjectProperties?: string; + subjectPropertyMappings?: string; + subjectAttributeMappings?: ExternalAuthZENPDPSubjectAttributeMapping[]; } /** diff --git a/frontend/packages/configure-connections/src/pages/ConnectionCreateWizardPage.tsx b/frontend/packages/configure-connections/src/pages/ConnectionCreateWizardPage.tsx index afb87ee7a2..5e3c2c68f2 100644 --- a/frontend/packages/configure-connections/src/pages/ConnectionCreateWizardPage.tsx +++ b/frontend/packages/configure-connections/src/pages/ConnectionCreateWizardPage.tsx @@ -62,7 +62,7 @@ export default function ConnectionCreateWizardPage(): JSX.Element { const fields = CONNECTION_FORM_FIELDS[activeType]; const createFields = useMemo(() => fieldsForMode(activeType, 'create'), [activeType]); const redirectUri = getGateCallbackUrl(); - const emptyValues = useMemo(() => emptyFormValues(fields, redirectUri), [fields, redirectUri]); + const emptyValues = useMemo(() => emptyFormValues(createFields, redirectUri), [createFields, redirectUri]); // Only federated login providers carry a redirect URI to register with the provider. const usesRedirectUri: boolean = fields.some((field) => field.name === 'redirectUri'); diff --git a/frontend/packages/configure-connections/src/pages/ConnectionDetailPage.tsx b/frontend/packages/configure-connections/src/pages/ConnectionDetailPage.tsx index c944f8fff2..fee5ba910d 100644 --- a/frontend/packages/configure-connections/src/pages/ConnectionDetailPage.tsx +++ b/frontend/packages/configure-connections/src/pages/ConnectionDetailPage.tsx @@ -17,10 +17,11 @@ import AttributeMappingSection from '../components/AttributeMappingSection'; import ConnectionDeleteDialog from '../components/ConnectionDeleteDialog'; import ConnectionForm from '../components/ConnectionForm'; import ReadOnlyCopyField from '../components/ReadOnlyCopyField'; +import SubjectMappingSection, {type SubjectMappingValues} from '../components/SubjectMappingSection'; import {CONNECTION_FORM_FIELDS} from '../config/connectionFormFields'; import {VENDOR_META_BY_TYPE} from '../config/connectionVendorMeta'; import useConnectionRoutes from '../hooks/useConnectionRoutes'; -import type {AttributeConfiguration, ConnectionType} from '../models/connection'; +import {ConnectionTypes, type AttributeConfiguration, type ConnectionType} from '../models/connection'; import { type ConnectionFormValues, formValuesToRequest, @@ -75,6 +76,7 @@ export default function ConnectionDetailPage(): JSX.Element | null { const meta = VENDOR_META_BY_TYPE[connectionType]; const isCustom: boolean = meta?.presentation === 'custom'; const supportsAttributes: boolean = meta?.supportsAttributeMapping ?? false; + const supportsSubjectMapping: boolean = connectionType === ConnectionTypes.EXTERNAL_AUTHZEN_PDP; // Branded vendors are singletons and route without an id — resolve the single instance. const instancesQuery = useConnectionInstances(connectionType, {enabled: Boolean(meta) && !id}); @@ -83,6 +85,7 @@ export default function ConnectionDetailPage(): JSX.Element | null { const [activeTab, setActiveTab] = useState(0); const [editedValues, setEditedValues] = useState({}); + const [editedSubjectMapping, setEditedSubjectMapping] = useState>({}); const [secretReplacing, setSecretReplacing] = useState(false); const [editedAttr, setEditedAttr] = useState(null); const [attrValid, setAttrValid] = useState(true); @@ -110,6 +113,11 @@ export default function ConnectionDetailPage(): JSX.Element | null { [data, fields, redirectUri], ); const baselineAttr: AttributeConfiguration | undefined = data?.attributeConfiguration; + const baselineSubjectMapping: SubjectMappingValues = { + subjectProperties: data?.subjectProperties ?? '', + subjectPropertyMappings: data?.subjectPropertyMappings ?? '', + subjectAttributeMappings: data?.subjectAttributeMappings ?? [], + }; if (!meta) { return null; @@ -122,6 +130,7 @@ export default function ConnectionDetailPage(): JSX.Element | null { const resetEdits = (): void => { setEditedValues({}); + setEditedSubjectMapping({}); setSecretReplacing(false); setEditedAttr(null); setAttrValid(true); @@ -132,8 +141,13 @@ export default function ConnectionDetailPage(): JSX.Element | null { const formDirty: boolean = JSON.stringify(values) !== JSON.stringify(baseline) || secretReplacing; const attrDirty: boolean = editedAttr !== null && canonicalAttr(editedAttr) !== canonicalAttr(baselineAttr); - const dirty: boolean = formDirty || attrDirty; + const subjectMappingValues: SubjectMappingValues = {...baselineSubjectMapping, ...editedSubjectMapping}; + const subjectMappingDirty: boolean = + supportsSubjectMapping && JSON.stringify(subjectMappingValues) !== JSON.stringify(baselineSubjectMapping); + const dirty: boolean = formDirty || attrDirty || subjectMappingDirty; const valid: boolean = Object.keys(validateConnectionForm(values, fields, 'edit')).length === 0 && attrValid; + const subjectMappingTabIndex = supportsAttributes ? 2 : 1; + const advancedTabIndex = 1 + (supportsAttributes ? 1 : 0) + (supportsSubjectMapping ? 1 : 0); // A save failure is stale once the user edits any field. Only reset the mutation once it has // actually failed: resetting while it's still pending would flip isPending back to false and @@ -155,6 +169,7 @@ export default function ConnectionDetailPage(): JSX.Element | null { const payload = { ...formValuesToRequest(values, fields, {mode: 'edit', secretReplaced: secretReplacing}), ...(supportsAttributes ? {attributeConfiguration: editedAttr ?? baselineAttr} : {}), + ...(supportsSubjectMapping ? subjectMappingValues : {}), }; updateMutation .mutateAsync(payload) @@ -268,6 +283,13 @@ export default function ConnectionDetailPage(): JSX.Element | null { data-testid="connection-tab-attributes" /> )} + {supportsSubjectMapping && ( + + )} )} - + {supportsSubjectMapping && ( + + { + clearSaveError(); + setEditedSubjectMapping((prev) => ({...prev, [field]: value})); + }} + /> + + )} + + diff --git a/frontend/packages/configure-connections/src/pages/__tests__/ConnectionCreateWizardPage.test.tsx b/frontend/packages/configure-connections/src/pages/__tests__/ConnectionCreateWizardPage.test.tsx index 830350bf36..ef29fa0eb9 100644 --- a/frontend/packages/configure-connections/src/pages/__tests__/ConnectionCreateWizardPage.test.tsx +++ b/frontend/packages/configure-connections/src/pages/__tests__/ConnectionCreateWizardPage.test.tsx @@ -289,10 +289,10 @@ describe('ConnectionCreateWizardPage', () => { expect(screen.queryByTestId('custom-step')).not.toBeInTheDocument(); }); - it('shows four type cards including trusted-idp', () => { + it('shows five type cards including trusted-idp', () => { render(); - expect(screen.getAllByTestId(/^connection-type-option-/)).toHaveLength(4); + expect(screen.getAllByTestId(/^connection-type-option-/)).toHaveLength(5); expect(screen.getByTestId('connection-type-option-trusted-idp')).toBeInTheDocument(); }); }); diff --git a/frontend/packages/configure-connections/src/pages/__tests__/ConnectionDetailPage.test.tsx b/frontend/packages/configure-connections/src/pages/__tests__/ConnectionDetailPage.test.tsx index 8787f0d4b9..64bf57a873 100644 --- a/frontend/packages/configure-connections/src/pages/__tests__/ConnectionDetailPage.test.tsx +++ b/frontend/packages/configure-connections/src/pages/__tests__/ConnectionDetailPage.test.tsx @@ -12,6 +12,17 @@ const refetchMock = vi.fn().mockResolvedValue({}); const deleteMock = vi.fn((_id: string, opts: {onSuccess: () => void}) => opts.onSuccess()); const navigateMock = vi.fn(); const updateMutationState = {isPending: false, isError: false}; +const usagesQueryState: { + data?: {totalResults: number | null; count: number; summary: Record; usages: unknown[]}; + isLoading: boolean; + isError: boolean; + error: Error | null; +} = { + data: {totalResults: 0, count: 0, summary: {}, usages: []}, + isLoading: false, + isError: false, + error: null, +}; const ATTR_CONFIG = { userTypeResolution: {default: 'employee'}, @@ -51,6 +62,17 @@ const OIDC_CONNECTION = { redirectUri: 'https://id.acme.io/oauth/callback/oidc', }; +const AUTHZEN_PDP_CONNECTION = { + id: 'pdp1', + type: 'external-authzen-pdp', + name: 'External AuthZEN PDP', + endpoint: 'https://pdp.example.com/.well-known/authzen-configuration', + timeoutMs: 500, + retryCount: 1, + subjectProperties: 'email department', + subjectPropertyMappings: 'department: dept', +}; + const mockParams: {type: string; id: string} = {type: 'google', id: 'g1'}; const mockConn: {data: Record} = {data: CONNECTION}; @@ -85,7 +107,7 @@ vi.mock('../../api/useUpdateConnection', () => ({ })); vi.mock('../../api/useDeleteConnection', () => ({default: () => ({mutate: deleteMock, isPending: false})})); vi.mock('../../api/useGetConnectionUsages', () => ({ - default: () => ({data: {totalResults: 0, count: 0, summary: {}, usages: []}, isLoading: false}), + default: () => usagesQueryState, })); vi.mock('../../components/ConnectionForm', () => ({ @@ -119,6 +141,29 @@ vi.mock('../../components/AttributeMappingSection', () => ({ }, })); +vi.mock('../../components/SubjectMappingSection', () => ({ + default: function StubSubjectMappingSection({ + values, + onChange, + }: { + values: {subjectProperties?: string; subjectPropertyMappings?: string}; + onChange: (field: 'subjectProperties' | 'subjectPropertyMappings', value: string) => void; + }) { + return ( +
+ {values.subjectProperties} + +
+ ); + }, +})); + describe('ConnectionDetailPage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -127,6 +172,10 @@ describe('ConnectionDetailPage', () => { mockConn.data = CONNECTION; updateMutationState.isPending = false; updateMutationState.isError = false; + usagesQueryState.data = {totalResults: 0, count: 0, summary: {}, usages: []}; + usagesQueryState.isLoading = false; + usagesQueryState.isError = false; + usagesQueryState.error = null; }); it('renders the general tab with quick-copy and the credentials form', () => { @@ -217,6 +266,19 @@ describe('ConnectionDetailPage', () => { expect(navigateMock).toHaveBeenCalledWith('/connections'); }); + it('shows a connection usage check failure inside the delete dialog', () => { + usagesQueryState.data = undefined; + usagesQueryState.isError = true; + usagesQueryState.error = new Error('raw usage failure'); + + render(); + fireEvent.click(screen.getByTestId('connection-tab-advanced')); + fireEvent.click(screen.getByTestId('connection-delete-button')); + + expect(screen.getByText('Failed to check connection usage. Please try again.')).toBeInTheDocument(); + expect(screen.getByTestId('connection-delete-confirm')).toBeDisabled(); + }); + it('SMS vendor: hides the attribute-mapping tab and save omits attributeConfiguration', () => { mockParams.type = 'twilio'; mockParams.id = 'tw1'; @@ -238,4 +300,29 @@ describe('ConnectionDetailPage', () => { }); expect('attributeConfiguration' in payload).toBe(false); }); + + it('External AuthZEN PDP: shows subject mapping in its own tab and saves it with the connection', () => { + mockParams.type = 'external-authzen-pdp'; + mockParams.id = 'pdp1'; + mockConn.data = AUTHZEN_PDP_CONNECTION; + render(); + + expect(screen.getByTestId('connection-tab-subject-mapping')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('connection-tab-subject-mapping')); + expect(screen.getByTestId('stub-subject-mapping')).toHaveTextContent('email department'); + + fireEvent.click(screen.getByTestId('edit-subject-mapping')); + fireEvent.click(screen.getByTestId('save-bar')); + + expect(updateMock).toHaveBeenCalledTimes(1); + const payload = updateMock.mock.calls[0][0] as Record; + expect(payload).toMatchObject({ + name: 'External AuthZEN PDP', + endpoint: 'https://pdp.example.com/.well-known/authzen-configuration', + subjectProperties: 'email riskScore', + subjectPropertyMappings: 'department: dept', + }); + expect('resourceType' in payload).toBe(false); + expect('resourceId' in payload).toBe(false); + }); }); diff --git a/frontend/packages/configure-connections/src/utils/__tests__/connectionFormMapping.test.ts b/frontend/packages/configure-connections/src/utils/__tests__/connectionFormMapping.test.ts index 170544fdd9..8dca25dfbe 100644 --- a/frontend/packages/configure-connections/src/utils/__tests__/connectionFormMapping.test.ts +++ b/frontend/packages/configure-connections/src/utils/__tests__/connectionFormMapping.test.ts @@ -16,25 +16,27 @@ const OIDC_FIELDS = CONNECTION_FORM_FIELDS.oidc; const OAUTH_FIELDS = CONNECTION_FORM_FIELDS.oauth; const TWILIO_FIELDS = CONNECTION_FORM_FIELDS.twilio; const SMS_GATEWAY_FIELDS = CONNECTION_FORM_FIELDS['sms-gateway']; +const AUTHZEN_PDP_FIELDS = CONNECTION_FORM_FIELDS['external-authzen-pdp']; const REDIRECT = 'https://id.acme.io/oauth/callback/google'; const VALID_ACCOUNT_SID = `AC${'a1b2c3d4e5f6'.repeat(2)}01234567`; describe('emptyFormValues', () => { it('blanks every field except the derived redirect URI', () => { const values = emptyFormValues(GOOGLE_FIELDS, REDIRECT); - expect(values.redirectUri).toBe(REDIRECT); - expect(values.name).toBe(''); - expect(values.clientId).toBe(''); - expect(values.clientSecret).toBe(''); + expect(values['redirectUri']).toBe(REDIRECT); + expect(values['name']).toBe(''); + expect(values['clientId']).toBe(''); + expect(values['clientSecret']).toBe(''); }); it('prefills fields that declare a default value', () => { const values = emptyFormValues(SMS_GATEWAY_FIELDS, REDIRECT); - expect(values.httpMethod).toBe('POST'); - expect(values.contentType).toBe('JSON'); - expect(values.url).toBe(''); - expect(values.httpHeaders).toBe(''); + expect(values['httpMethod']).toBe('POST'); + expect(values['contentType']).toBe('JSON'); + expect(values['url']).toBe(''); + expect(values['httpHeaders']).toBe(''); }); + }); describe('responseToFormValues', () => { @@ -50,17 +52,17 @@ describe('responseToFormValues', () => { } as ConnectionResponse; const values = responseToFormValues(response, GOOGLE_FIELDS, REDIRECT); - expect(values.name).toBe('My Google'); - expect(values.clientId).toBe('abc'); - expect(values.clientSecret).toBe(''); - expect(values.scopes).toBe('openid email profile'); - expect(values.redirectUri).toBe('https://stored/callback'); + expect(values['name']).toBe('My Google'); + expect(values['clientId']).toBe('abc'); + expect(values['clientSecret']).toBe(''); + expect(values['scopes']).toBe('openid email profile'); + expect(values['redirectUri']).toBe('https://stored/callback'); }); it('falls back to the derived redirect URI when the response has none', () => { const response = {id: '1', type: 'google', name: 'X', clientId: 'y'} as ConnectionResponse; const values = responseToFormValues(response, GOOGLE_FIELDS, REDIRECT); - expect(values.redirectUri).toBe(REDIRECT); + expect(values['redirectUri']).toBe(REDIRECT); }); it('converts a boolean tokenExchangeEnabled into a "true"/"false" form string', () => { @@ -71,7 +73,7 @@ describe('responseToFormValues', () => { clientId: 'y', tokenExchangeEnabled: true, } as ConnectionResponse; - expect(responseToFormValues(enabled, OIDC_FIELDS, REDIRECT).tokenExchangeEnabled).toBe('true'); + expect(responseToFormValues(enabled, OIDC_FIELDS, REDIRECT)['tokenExchangeEnabled']).toBe('true'); const disabled = { id: '1', @@ -80,7 +82,22 @@ describe('responseToFormValues', () => { clientId: 'y', tokenExchangeEnabled: false, } as ConnectionResponse; - expect(responseToFormValues(disabled, OIDC_FIELDS, REDIRECT).tokenExchangeEnabled).toBe('false'); + expect(responseToFormValues(disabled, OIDC_FIELDS, REDIRECT)['tokenExchangeEnabled']).toBe('false'); + }); + + it('converts numeric AuthZEN PDP response fields into form strings', () => { + const response = { + id: '1', + type: 'external-authzen-pdp', + name: 'Cerbos PDP', + endpoint: 'http://localhost:3592/.well-known/authzen-configuration', + timeoutMs: 1000, + retryCount: 1, + } as ConnectionResponse; + + const values = responseToFormValues(response, AUTHZEN_PDP_FIELDS, REDIRECT); + expect(values['timeoutMs']).toBe('1000'); + expect(values['retryCount']).toBe('1'); }); }); @@ -91,8 +108,8 @@ describe('formValuesToRequest', () => { const payload = formValuesToRequest({...base, clientSecret: 's3cret'}, GOOGLE_FIELDS, { mode: 'create', }) as unknown as Record; - expect(payload.clientSecret).toBe('s3cret'); - expect(payload.scopes).toEqual(['openid', 'email']); + expect(payload['clientSecret']).toBe('s3cret'); + expect(payload['scopes']).toEqual(['openid', 'email']); }); it('includes trusted token audience when configured', () => { @@ -101,12 +118,13 @@ describe('formValuesToRequest', () => { ...base, authorizationEndpoint: 'https://i/a', tokenEndpoint: 'https://i/t', + tokenExchangeEnabled: 'true', trustedTokenAudience: 'my-external-client-id', }, OIDC_FIELDS, {mode: 'create'}, ) as unknown as Record; - expect(payload.trustedTokenAudience).toBe('my-external-client-id'); + expect(payload['trustedTokenAudience']).toBe('my-external-client-id'); }); it('sends the SMS gateway transport fields and omits empty optional headers', () => { @@ -124,6 +142,18 @@ describe('formValuesToRequest', () => { }); }); + it('sends AuthZEN PDP timing fields as numbers', () => { + const payload = formValuesToRequest( + {name: 'Cerbos PDP', endpoint: 'http://localhost:3592/.well-known/authzen-configuration', timeoutMs: '500', retryCount: '1'}, + AUTHZEN_PDP_FIELDS, + {mode: 'edit'}, + ) as unknown as Record; + + expect(payload).toMatchObject({timeoutMs: 500, retryCount: 1}); + expect(typeof payload['timeoutMs']).toBe('number'); + expect(typeof payload['retryCount']).toBe('number'); + }); + it('still sends the SMS gateway transport defaults now that neither field is required', () => { const values = { ...emptyFormValues(SMS_GATEWAY_FIELDS, REDIRECT), @@ -153,7 +183,7 @@ describe('formValuesToRequest', () => { mode: 'edit', secretReplaced: true, }) as unknown as Record; - expect(payload.clientSecret).toBe('new'); + expect(payload['clientSecret']).toBe('new'); }); it('omits the secret on edit when replacing but left empty', () => { @@ -183,8 +213,8 @@ describe('formValuesToRequest', () => { OIDC_FIELDS, {mode: 'create'}, ) as unknown as Record; - expect(payload.tokenExchangeEnabled).toBe(true); - expect(typeof payload.tokenExchangeEnabled).toBe('boolean'); + expect(payload['tokenExchangeEnabled']).toBe(true); + expect(typeof payload['tokenExchangeEnabled']).toBe('boolean'); }); it('emits tokenExchangeEnabled as false when the switch is off', () => { @@ -198,9 +228,10 @@ describe('formValuesToRequest', () => { OIDC_FIELDS, {mode: 'create'}, ) as unknown as Record; - expect(payload.tokenExchangeEnabled).toBe(false); + expect(payload['tokenExchangeEnabled']).toBe(false); }); + it('omits empty optional fields but keeps required ones', () => { const payload = formValuesToRequest( { @@ -219,7 +250,7 @@ describe('formValuesToRequest', () => { OIDC_FIELDS, {mode: 'create'}, ) as unknown as Record; - expect(payload.authorizationEndpoint).toBe('https://i/a'); + expect(payload['authorizationEndpoint']).toBe('https://i/a'); expect(payload).not.toHaveProperty('userInfoEndpoint'); expect(payload).not.toHaveProperty('issuer'); expect(payload).not.toHaveProperty('scopes'); @@ -229,9 +260,9 @@ describe('formValuesToRequest', () => { describe('validateConnectionForm', () => { it('flags required fields on create', () => { const errors = validateConnectionForm(emptyFormValues(GOOGLE_FIELDS, REDIRECT), GOOGLE_FIELDS, 'create'); - expect(errors.name).toBe('connections:validation.required'); - expect(errors.clientId).toBe('connections:validation.required'); - expect(errors.clientSecret).toBe('connections:validation.required'); + expect(errors['name']).toBe('connections:validation.required'); + expect(errors['clientId']).toBe('connections:validation.required'); + expect(errors['clientSecret']).toBe('connections:validation.required'); }); it('does not require the OAuth 2 user profile endpoint', () => { @@ -270,7 +301,7 @@ describe('validateConnectionForm', () => { OIDC_FIELDS, 'create', ); - expect(bad.authorizationEndpoint).toBe('connections:validation.url'); + expect(bad['authorizationEndpoint']).toBe('connections:validation.url'); const good = validateConnectionForm( { @@ -293,7 +324,7 @@ describe('validateConnectionForm', () => { TWILIO_FIELDS, 'create', ); - expect(errors.accountSid).toBe('connections:validation.accountSid'); + expect(errors['accountSid']).toBe('connections:validation.accountSid'); }); it('accepts a well-formed Twilio account SID', () => { @@ -311,7 +342,7 @@ describe('validateConnectionForm', () => { TWILIO_FIELDS, 'create', ); - expect(errors.accountSid).toBe('connections:validation.required'); + expect(errors['accountSid']).toBe('connections:validation.required'); }); it('requires issuer and jwksEndpoint only when tokenExchangeEnabled is on', () => { @@ -331,10 +362,11 @@ describe('validateConnectionForm', () => { expect(withExchangeOff).not.toHaveProperty('jwksEndpoint'); const withExchangeOn = validateConnectionForm({...base, tokenExchangeEnabled: 'true'}, OIDC_FIELDS, 'create'); - expect(withExchangeOn.issuer).toBe('connections:validation.required'); - expect(withExchangeOn.jwksEndpoint).toBe('connections:validation.required'); + expect(withExchangeOn['issuer']).toBe('connections:validation.required'); + expect(withExchangeOn['jwksEndpoint']).toBe('connections:validation.required'); }); + it('skips validation for a field hidden by revealedBy, even if it would otherwise be invalid', () => { const fields: ConnectionFieldDef[] = [ {name: 'gate', labelKey: 'x', kind: 'switch'}, @@ -345,6 +377,19 @@ describe('validateConnectionForm', () => { expect(hidden).not.toHaveProperty('child'); const shown = validateConnectionForm({gate: 'true', child: 'not-a-url'}, fields, 'create'); - expect(shown.child).toBe('connections:validation.url'); + expect(shown['child']).toBe('connections:validation.url'); + }); + + it('skips validation for a field hidden by revealedWhen, even if it would otherwise be invalid', () => { + const fields: ConnectionFieldDef[] = [ + {name: 'mode', labelKey: 'x', kind: 'select'}, + {name: 'child', labelKey: 'y', kind: 'url', required: true, revealedWhen: {field: 'mode', value: 'SHOW'}}, + ]; + + const hidden = validateConnectionForm({mode: 'HIDE', child: 'not-a-url'}, fields, 'create'); + expect(hidden).not.toHaveProperty('child'); + + const shown = validateConnectionForm({mode: 'SHOW', child: 'not-a-url'}, fields, 'create'); + expect(shown['child']).toBe('connections:validation.url'); }); }); diff --git a/frontend/packages/configure-connections/src/utils/connectionFormMapping.ts b/frontend/packages/configure-connections/src/utils/connectionFormMapping.ts index f42ae27283..ce847201f3 100644 --- a/frontend/packages/configure-connections/src/utils/connectionFormMapping.ts +++ b/frontend/packages/configure-connections/src/utils/connectionFormMapping.ts @@ -7,6 +7,8 @@ import type {ConnectionRequest, ConnectionResponse} from '../models/connection'; /** The placeholder value the API returns for stored secrets. Must never be sent back. */ export const MASKED_SECRET = '******'; +const NUMERIC_REQUEST_FIELDS = new Set(['timeoutMs', 'retryCount']); + /** Flat string-keyed form state shared by all per-vendor forms. */ export type ConnectionFormValues = Record; @@ -52,7 +54,13 @@ export function responseToFormValues( values[field.name] = raw === true ? 'true' : 'false'; continue; } - values[field.name] = typeof raw === 'string' && raw !== '' ? raw : (field.defaultValue ?? ''); + if (typeof raw === 'string' && raw !== '') { + values[field.name] = raw; + } else if (typeof raw === 'number' && Number.isFinite(raw)) { + values[field.name] = String(raw); + } else { + values[field.name] = field.defaultValue ?? ''; + } } return values; } @@ -107,7 +115,7 @@ export function formValuesToRequest( // Always include required fields and any non-empty value; omit empty optional fields. if (field.required || raw !== '') { - payload[field.name] = raw; + payload[field.name] = NUMERIC_REQUEST_FIELDS.has(field.name) ? Number(raw) : raw; } } diff --git a/frontend/packages/configure-resource-servers/src/api/useExternalAuthZENPDPConnections.ts b/frontend/packages/configure-resource-servers/src/api/useExternalAuthZENPDPConnections.ts new file mode 100644 index 0000000000..c356f3fc5f --- /dev/null +++ b/frontend/packages/configure-resource-servers/src/api/useExternalAuthZENPDPConnections.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useQuery, type UseQueryResult} from '@tanstack/react-query'; +import {useConfig} from '@thunderid/contexts'; +import {useThunderID} from '@thunderid/react'; +import ResourceServerQueryKeys from '../constants/resource-server-query-keys'; + +export interface ExternalAuthZENPDPConnectionSummary { + id: string; + name: string; + description?: string; +} + +export default function useExternalAuthZENPDPConnections(): UseQueryResult { + const {http} = useThunderID(); + const {getServerUrl} = useConfig(); + + return useQuery({ + queryKey: [ResourceServerQueryKeys.EXTERNAL_AUTHZEN_PDP_CONNECTIONS], + queryFn: async (): Promise => { + const serverUrl = getServerUrl(); + + const response: {data: ExternalAuthZENPDPConnectionSummary[]} = await http.request({ + url: `${serverUrl}/connections/external-authzen-pdp`, + method: 'GET', + } as unknown as Parameters[0]); + + return response.data; + }, + }); +} diff --git a/frontend/packages/configure-resource-servers/src/components/resource-server-detail/AdvancedTab.tsx b/frontend/packages/configure-resource-servers/src/components/resource-server-detail/AdvancedTab.tsx index db893175a9..0f798183e0 100644 --- a/frontend/packages/configure-resource-servers/src/components/resource-server-detail/AdvancedTab.tsx +++ b/frontend/packages/configure-resource-servers/src/components/resource-server-detail/AdvancedTab.tsx @@ -2,19 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 import {SettingsCard} from '@thunderid/components'; -import {FormControl, FormLabel, Stack, TextField} from '@wso2/oxygen-ui'; +import {FormControl, FormHelperText, FormLabel, MenuItem, Select, Stack, TextField} from '@wso2/oxygen-ui'; import type {JSX} from 'react'; import {useTranslation} from 'react-i18next'; +import useExternalAuthZENPDPConnections from '../../api/useExternalAuthZENPDPConnections'; import type {ResourceServer} from '../../models/resource-server'; interface AdvancedTabProps { resourceServer: ResourceServer; identifier: string; + authorizationEngine: string; + externalPDPConnectionId: string; onIdentifierChange: (value: string) => void; + onAuthorizationEngineChange: (value: string) => void; + onExternalPDPConnectionChange: (value: string) => void; } -export default function AdvancedTab({resourceServer, identifier, onIdentifierChange}: AdvancedTabProps): JSX.Element { +export default function AdvancedTab({ + resourceServer, + identifier, + authorizationEngine, + externalPDPConnectionId, + onIdentifierChange, + onAuthorizationEngineChange, + onExternalPDPConnectionChange, +}: AdvancedTabProps): JSX.Element { const {t} = useTranslation(); + const externalPDPConnections = useExternalAuthZENPDPConnections(); + const externalPDPOptionPrefix = 'external_authzen_pdp:'; + const authorizationEngineValue = + authorizationEngine === 'external_authzen_pdp' && externalPDPConnectionId + ? `${externalPDPOptionPrefix}${externalPDPConnectionId}` + : authorizationEngine ?? 'rbac'; + const hasSelectedExternalPDP = + authorizationEngine === 'external_authzen_pdp' && + externalPDPConnectionId && + !(externalPDPConnections.data ?? []).some((connection) => connection.id === externalPDPConnectionId); return ( @@ -61,6 +84,52 @@ export default function AdvancedTab({resourceServer, identifier, onIdentifierCha disabled={resourceServer.isReadOnly} /> + + + {t('resourceServers:edit.advanced.authorizationEngine.label', 'Authorization engine')} + + + + {externalPDPConnections.error + ? t( + 'resourceServers:edit.advanced.authorizationEngine.loadExternalPDPError', + 'Failed to load external PDP connections.', + ) + : t( + 'resourceServers:edit.advanced.authorizationEngine.hint', + 'Choose RBAC or a configured external PDP connection for this resource server.', + )} + +
); diff --git a/frontend/packages/configure-resource-servers/src/constants/resource-server-query-keys.ts b/frontend/packages/configure-resource-servers/src/constants/resource-server-query-keys.ts index 6e5aa2fac1..3fe5bd03ab 100644 --- a/frontend/packages/configure-resource-servers/src/constants/resource-server-query-keys.ts +++ b/frontend/packages/configure-resource-servers/src/constants/resource-server-query-keys.ts @@ -10,6 +10,7 @@ const ResourceServerQueryKeys = { RESOURCE_ACTIONS: 'resource-actions', SERVER_CONFIG: 'server-config', DEFAULT_RESOURCE_SERVER: 'defaultResourceServer', + EXTERNAL_AUTHZEN_PDP_CONNECTIONS: 'externalAuthZENPDPConnections', } as const; export default ResourceServerQueryKeys; diff --git a/frontend/packages/configure-resource-servers/src/index.ts b/frontend/packages/configure-resource-servers/src/index.ts index 69bf4e7e34..5e70e373a9 100644 --- a/frontend/packages/configure-resource-servers/src/index.ts +++ b/frontend/packages/configure-resource-servers/src/index.ts @@ -9,6 +9,8 @@ export {default as useUpdateResourceServer} from './api/useUpdateResourceServer' export {default as useDeleteResourceServer} from './api/useDeleteResourceServer'; export {default as useGetDefaultResourceServer} from './api/useGetDefaultResourceServer'; export {default as useSetDefaultResourceServer} from './api/useSetDefaultResourceServer'; +export {default as useExternalAuthZENPDPConnections} from './api/useExternalAuthZENPDPConnections'; +export type {ExternalAuthZENPDPConnectionSummary} from './api/useExternalAuthZENPDPConnections'; export {default as useGetResources} from './api/useGetResources'; export {default as useCreateResource} from './api/useCreateResource'; export {default as useUpdateResource} from './api/useUpdateResource'; @@ -36,6 +38,7 @@ export {default as ResourceServerQueryKeys} from './constants/resource-server-qu // Models export type { ResourceServer, + AuthorizationEngine, ResourceServerListResponse, Resource, ResourceListResponse, diff --git a/frontend/packages/configure-resource-servers/src/models/resource-server.ts b/frontend/packages/configure-resource-servers/src/models/resource-server.ts index b8a0114d74..a4df68d656 100644 --- a/frontend/packages/configure-resource-servers/src/models/resource-server.ts +++ b/frontend/packages/configure-resource-servers/src/models/resource-server.ts @@ -4,6 +4,7 @@ import type {PermissionDelimiter} from './permissions'; export type ResourceServerType = 'API' | 'MCP' | 'CUSTOM'; +export type AuthorizationEngine = 'rbac' | 'external_authzen_pdp'; const DEFAULT_ELIGIBLE_TYPES: readonly ResourceServerType[] = ['API', 'CUSTOM']; @@ -21,6 +22,8 @@ export interface ResourceServer { delimiter: string; isReadOnly?: boolean; type: ResourceServerType; + authorizationEngine?: string; + externalPDPConnectionId?: string; } export interface ResourceServerListResponse { @@ -81,6 +84,8 @@ export interface UpdateResourceServerRequest { description?: string | null; identifier: string; ouId: string; + authorizationEngine?: string; + externalPDPConnectionId?: string; } export interface CreateResourceRequest { diff --git a/frontend/packages/configure-resource-servers/src/pages/ResourceServerEditPage.tsx b/frontend/packages/configure-resource-servers/src/pages/ResourceServerEditPage.tsx index 0f1cc386b3..32bc44c7c6 100644 --- a/frontend/packages/configure-resource-servers/src/pages/ResourceServerEditPage.tsx +++ b/frontend/packages/configure-resource-servers/src/pages/ResourceServerEditPage.tsx @@ -81,9 +81,15 @@ export default function ResourceServerEditPage(): JSX.Element { const initialTab = searchParams.get('tab') === 'advanced' ? TAB_ADVANCED : TAB_RESOURCES; const [activeTab, setActiveTab] = useState(initialTab); - const [editedFields, setEditedFields] = useState>( - {}, - ); + const [editedFields, setEditedFields] = useState< + Partial<{ + name: string; + description: string; + identifier: string; + authorizationEngine: string; + externalPDPConnectionId: string; + }> + >({}); const [isEditingName, setIsEditingName] = useState(false); const [isEditingDescription, setIsEditingDescription] = useState(false); const [tempName, setTempName] = useState(''); @@ -95,7 +101,10 @@ export default function ResourceServerEditPage(): JSX.Element { setActiveTab(newValue); }; - const handleFieldChange = (field: 'name' | 'description' | 'identifier', value: string): void => { + const handleFieldChange = ( + field: 'name' | 'description' | 'identifier' | 'authorizationEngine' | 'externalPDPConnectionId', + value: string, + ): void => { if (updateRs.isError) { updateRs.reset(); // a save error is stale once the form changes } @@ -110,6 +119,8 @@ export default function ResourceServerEditPage(): JSX.Element { name: resourceServer?.name, description: resourceServer?.description, identifier: resourceServer?.identifier, + authorizationEngine: resourceServer?.authorizationEngine ?? 'rbac', + externalPDPConnectionId: resourceServer?.externalPDPConnectionId, }; return Object.entries(editedFields).some( ([key, value]) => !isEqualIgnoringEmpty(norm(value), norm(originalOf[key])), @@ -139,6 +150,11 @@ export default function ResourceServerEditPage(): JSX.Element { : (resourceServer.description ?? null), identifier: 'identifier' in editedFields ? nextIdentifier : resourceServer.identifier, ouId: resourceServer.ouId, + authorizationEngine: editedFields.authorizationEngine ?? resourceServer.authorizationEngine ?? 'rbac', + externalPDPConnectionId: + 'externalPDPConnectionId' in editedFields + ? editedFields.externalPDPConnectionId + : (resourceServer.externalPDPConnectionId ?? ''), }, }, { @@ -397,7 +413,16 @@ export default function ResourceServerEditPage(): JSX.Element { key={resourceServer.id} resourceServer={resourceServer} identifier={editedFields.identifier ?? resourceServer.identifier ?? ''} + authorizationEngine={editedFields.authorizationEngine ?? resourceServer.authorizationEngine ?? 'rbac'} + externalPDPConnectionId={editedFields.externalPDPConnectionId ?? resourceServer.externalPDPConnectionId ?? ''} onIdentifierChange={(v) => handleFieldChange('identifier', v)} + onAuthorizationEngineChange={(v) => { + handleFieldChange('authorizationEngine', v); + if (v !== 'external_authzen_pdp') { + handleFieldChange('externalPDPConnectionId', ''); + } + }} + onExternalPDPConnectionChange={(v) => handleFieldChange('externalPDPConnectionId', v)} /> {!resourceServer.isReadOnly && ( diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 14f9b1e2ac..f067a1e14d 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -1806,6 +1806,7 @@ const translations = { 'categories.identity-verification': 'Identity Verification', 'categories.crm': 'CRM', 'categories.data-store': 'Data store', + 'categories.authorization': 'Authorization', 'categories.trusted-idp': 'Trusted Token Issuer', 'categories.custom': 'Custom', @@ -1824,6 +1825,7 @@ const translations = { 'vendor.twilio.description': 'Send SMS one-time passcodes via Twilio.', 'vendor.vonage.description': 'Deliver SMS and email passcodes through Vonage.', 'vendor.sms-gateway.description': 'Route SMS through your own HTTP gateway.', + 'vendor.external-authzen-pdp.description': 'Delegate authorization decisions to an AuthZEN-compatible PDP.', 'vendor.trustedIdp.description': 'Trusted token issuer for token exchange and ID-JAG.', // Add custom connection wizard @@ -1843,6 +1845,10 @@ const translations = { 'wizard.type.sms.label': 'SMS gateway', 'wizard.type.sms.description': 'Route SMS through your own HTTP gateway.', 'wizard.type.sms.tag': 'Message sender · SMS', + 'wizard.type.externalAuthzenPdp.label': 'External AuthZEN PDP', + 'wizard.type.externalAuthzenPdp.description': + 'Call an external AuthZEN-compatible policy decision point for authorization evaluation.', + 'wizard.type.externalAuthzenPdp.tag': 'Authorization · PDP', 'wizard.type.trustedIdp.label': 'Trusted Token Issuer', 'wizard.type.trustedIdp.description': "Trust an external IdP's identity assertions and exchange them for access tokens.", @@ -1851,8 +1857,7 @@ const translations = { 'wizard.name.fieldLabel': 'Connection name', 'wizard.name.placeholder': 'Enter your connection name', 'wizard.configure.heading': 'Configure your connection', - 'wizard.configure.subheading': - 'Enter the credentials and endpoints for your custom connection. Secrets are stored write-only.', + 'wizard.configure.subheading': 'Configure the connection settings below.', 'wizard.configure.redirectHint': 'Register the redirect URI below with your identity provider as an allowed callback URL, then enter the credentials and endpoints it gives you.', @@ -1871,13 +1876,14 @@ const translations = { 'detail.notFound.description': 'This connection may have been deleted or the link is incorrect.', 'detail.tabs.general': 'General', 'detail.tabs.attributeMapping': 'Attribute Configuration', + 'detail.tabs.subjectMapping': 'Attribute Configuration', 'detail.tabs.advanced': 'Advanced', 'detail.quickCopy.title': 'Quick copy', 'detail.quickCopy.description': 'Copy connection identifiers for use in your integration.', 'detail.connectionId': 'Connection ID', 'detail.connectionId.hint': 'Unique identifier for this connection.', - 'detail.credentials.title': 'Credentials', - 'detail.credentials.description': 'Credentials and endpoints for this connection. Secrets are stored write-only.', + 'detail.credentials.title': 'Connection details', + 'detail.credentials.description': 'Provide the details ThunderID needs to connect to this service.', 'detail.dangerZone.title': 'Danger zone', 'detail.dangerZone.description': 'Actions in this section are irreversible. Proceed with caution.', 'detail.dangerZone.delete.title': 'Delete connection', @@ -1949,6 +1955,34 @@ const translations = { 'form.fields.httpHeaders.hint': 'Optional headers sent with every request. Commas are not supported in a name or value.', 'form.fields.httpHeaders.add': 'Add header', + 'form.fields.authzenEndpoint.label': 'AuthZEN configuration endpoint', + 'form.fields.timeoutMs.label': 'Timeout in milliseconds', + 'form.fields.timeoutMs.hint': 'Maximum time ThunderID waits for one PDP request.', + 'form.fields.retryCount.label': 'Retry count', + 'form.fields.retryCount.hint': 'Number of retries for transient PDP or network failures.', + 'subjectMapping.attributes.title': 'Subject attribute mapping', + 'subjectMapping.attributes.description': + 'Choose the additional user attributes this PDP needs and optionally rename them for the AuthZEN request.', + 'subjectMapping.selected.title': 'Selected attributes', + 'subjectMapping.attributes.userType.label': 'User type', + 'subjectMapping.attributes.userType.placeholder': 'Select a user type', + 'subjectMapping.attributes.label': 'Allowed subject attributes', + 'subjectMapping.attributes.placeholder': 'email department riskScore', + 'subjectMapping.attributes.hint': + 'Select known user attributes or type custom runtime attributes. Leave empty to send only the default subject fields.', + 'subjectMapping.mappings.title': 'Attribute Mappings', + 'subjectMapping.mappings.description': + 'Map ThunderID subject attribute names to the attribute names expected by the external PDP.', + 'subjectMapping.mappings.label': 'Additional attributes', + 'subjectMapping.mappings.hint': + 'Select extra user attributes to include in the PDP request. The PDP attribute is optional and is only needed when the PDP expects a different name.', + 'subjectMapping.mappings.thunderIdAttribute': 'ThunderID Attribute', + 'subjectMapping.mappings.pdpAttribute': 'PDP Attribute', + 'subjectMapping.mappings.pdpAttributeOptional': 'PDP Attribute (optional)', + 'subjectMapping.mappings.thunderIdPlaceholder': 'e.g. email', + 'subjectMapping.mappings.add': 'Add Mapping', + 'subjectMapping.mappings.addUserType': 'Add User Type', + 'subjectMapping.mappings.remove': 'Remove', 'form.keyValue.name': 'Name', 'form.keyValue.value': 'Value', 'form.keyValue.add': 'Add', @@ -2008,6 +2042,7 @@ const translations = { 'delete.title': 'Delete connection', 'delete.message': 'Are you sure you want to delete “{{name}}”? This action cannot be undone.', 'delete.usages.loading': 'Checking affected resources…', + 'delete.usages.error': 'Failed to check connection usage. Please try again.', 'delete.usages.more': '+{{count}} more', 'delete.blocking.title': 'This connection cannot be deleted until the following resources are updated or removed:',