diff --git a/console-extensions.json b/console-extensions.json index 7400539e..7405d4ad 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -325,6 +325,14 @@ "component": { "$codeRef": "KuadrantRateLimitPolicyCreatePage" } } }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": "/k8s/ns/:ns/authpolicy/name/:name/edit", + "component": { "$codeRef": "KuadrantAuthPolicyCreatePage" } + } + }, { "type": "console.page/route", "properties": { @@ -489,6 +497,17 @@ "provider": { "$codeRef": "useRateLimitPolicyActions" } } }, + { + "type": "console.action/resource-provider", + "properties": { + "model": { + "group": "kuadrant.io", + "version": "v1", + "kind": "AuthPolicy" + }, + "provider": { "$codeRef": "useAuthPolicyActions" } + } + }, { "type": "console.action/resource-provider", "properties": { diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index ddd51c1a..0607d187 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -40,6 +40,7 @@ "Add a tag to your API product": "Add a tag to your API product", "Add address": "Add address", "Add API and Associate route": "Add API and Associate route", + "Add Authentication Rule": "Add Authentication Rule", "Add certificate reference": "Add certificate reference", "Add Condition": "Add Condition", "Add Counter": "Add Counter", @@ -90,6 +91,7 @@ "An offline server does not have a `true` status for the `Ready` condition.": "An offline server does not have a `true` status for the `Ready` condition.", "An online server has a `true` status for the `Ready` condition.": "An online server has a `true` status for the `Ready` condition.", "An unhealthy gateway has a `false` status for the `Accepted` and/or `Programmed` conditions.": "An unhealthy gateway has a `false` status for the `Accepted` and/or `Programmed` conditions.", + "Anonymous": "Anonymous", "API Documentation": "API Documentation", "API Key": "API Key", "API key approval": "API Key approval", @@ -126,7 +128,10 @@ "Attached Policies": "Attached Policies", "Attached Resources": "Attached Resources", "Auth": "Auth", + "Auth Type": "Auth Type", + "Authentication": "Authentication", "Authentication Methods": "Authentication Methods", + "Authentication Rules": "Authentication Rules", "Authorization servers": "Authorization servers", "AuthPolicy": "AuthPolicy", "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.": "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.", @@ -135,6 +140,7 @@ "Backend": "Backend", "Backend references": "Backend references", "Backend Services": "Backend Services", + "Basic": "Basic", "Cancel": "Cancel", "CEL expression that must evaluate to true for this limit to apply": "CEL expression that must evaluate to true for this limit to apply", "CEL expression to match this plan's subscribers": "CEL expression to match this plan's subscribers", @@ -174,6 +180,7 @@ "Contact Slack": "Contact Slack", "Contact Team": "Contact Team", "Contact URL": "Contact URL", + "Content-Type header value for unauthorized response (optional)": "Content-Type header value for unauthorized response (optional)", "Controls catalog visibility (Draft = hidden from consumers)": "Controls catalog visibility (Draft = hidden from consumers)", "Copied": "Copied", "Copy": "Copy", @@ -214,6 +221,7 @@ "Creating and verifying your MCP server registration. Resources will be removed if registration fails.": "Creating and verifying your MCP server registration. Resources will be removed if registration fails.", "Custom": "Custom", "Custom expiry date": "Custom expiry date", + "Custom Header Name": "Custom Header Name", "Custom Limits": "Custom Limits", "Custom rate limits (limit + time window, e.g. 500 per 1h)": "Custom rate limits (limit + time window, e.g. 500 per 1h)", "Daily Limit": "Daily Limit", @@ -278,6 +286,7 @@ "Edit API Product": "Edit API Product", "Edit APIProduct": "Edit APIProduct", "Edit approval mode": "Edit approval mode", + "Edit AuthPolicy": "Edit AuthPolicy", "Edit description": "Edit description", "Edit display name": "Edit display name", "Edit DNS Policy": "Edit DNS Policy", @@ -318,7 +327,6 @@ "Error Code": "Error Code", "Error Codes": "Error Codes", "Error creating {{policyType}}": "Error creating {{policyType}}", - "Error creating AuthPolicy": "Error creating AuthPolicy", "Error creating resources": "Error creating resources", "Error denying API keys": "Error denying API keys", "Error loading {{kind}}": "Error loading {{kind}}", @@ -423,6 +431,7 @@ "Hostname": "Hostname", "Hostnames": "Hostnames", "Hostnames for this HTTPRoute": "Hostnames for this HTTPRoute", + "HTTP header name for API key credential (default: X-API-Key)": "HTTP header name for API key credential (default: X-API-Key)", "HTTP method": "HTTP method", "HTTP Route Target Reference": "HTTP Route Target Reference", "HTTPRoute": "HTTPRoute", @@ -448,6 +457,7 @@ "Issuer URL": "Issuer URL", "Issuer: Reference to the issuer for the created certificate. To create an additional Issuer go to": "Issuer: Reference to the issuer for the created certificate. To create an additional Issuer go to", "It indicates the current operational state of the resource and reflects whether its configuration is applied and functioning correctly.": "It indicates the current operational state of the resource and reflects whether its configuration is applied and functioning correctly.", + "JWT": "JWT", "kebab dropdown toggle": "kebab dropdown toggle", "Key": "Key", "Keys are created without need to be approved.": "Keys are created without need to be approved.", @@ -579,7 +589,6 @@ "OIDC": "OIDC", "OIDCPolicy": "OIDCPolicy", "OIDCPolicy configures OIDC authentication for your gateway": "OIDCPolicy configures OIDC authentication for your gateway", - "OK": "OK", "Online": "Online", "Only HTTPRoute is supported by this Gateway.": "Only HTTPRoute is supported by this Gateway.", "OpenAPI Spec URL": "OpenAPI Spec URL", @@ -639,6 +648,7 @@ "remove": "remove", "Remove": "Remove", "Remove address": "Remove address", + "Remove Authentication Rule": "Remove Authentication Rule", "Remove custom limit": "Remove custom limit", "Remove label": "Remove label", "Remove listener": "Remove listener", @@ -666,6 +676,7 @@ "Resource is ready": "Resource is ready", "Resource is running and healthy": "Resource is running and healthy", "Resource name": "Resource name", + "Response": "Response", "Response Header Modifier": "Response Header Modifier", "Restricts the types of Route resources that can attach to this listener (e.g., only HTTPRoute).": "Restricts the types of Route resources that can attach to this listener (e.g., only HTTPRoute).", "Retired": "Retired", @@ -679,6 +690,7 @@ "Route": "Route", "Route Kind": "Route Kind", "Rule ID": "Rule ID", + "Rule Name": "Rule Name", "Rules": "Rules", "Rules define how to route HTTP requests to backend services": "Rules define how to route HTTP requests to backend services", "Rules table": "Rules table", @@ -839,10 +851,12 @@ "Types": "Types", "Unable to determine current user — approve and reject actions are disabled": "Unable to determine current user — approve and reject actions are disabled", "Unable to fetch user information. Please try refreshing the page.": "Unable to fetch user information. Please try refreshing the page.", + "Unauthorized response body (optional, JSON)": "Unauthorized response body (optional, JSON)", "Unhealthy": "Unhealthy", "Unhealthy Gateways": "Unhealthy Gateways", "Unique identifier for this rate limit": "Unique identifier for this rate limit", "Unique identifier for this rule": "Unique identifier for this rule", + "Unique name of the AuthPolicy": "Unique name of the AuthPolicy", "Unique name of the DNS Policy": "Unique name of the DNS Policy", "Unique name of the HTTPRoute": "Unique name of the HTTPRoute", "Unique name of the OIDC Policy": "Unique name of the OIDC Policy", @@ -873,6 +887,7 @@ "When predicate": "When predicate", "when: {{predicates}}": "when: {{predicates}}", "Window": "Window", + "X-API-Key": "X-API-Key", "YAML": "YAML", "YAML View": "YAML View", "Yearly Limit": "Yearly Limit", diff --git a/package.json b/package.json index e2eb2d62..72a6b0f3 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "useAPIProductActions": "./components/apiproduct/useAPIProductActions", "useDNSPolicyActions": "./components/dnspolicy/useDNSPolicyActions", "useRateLimitPolicyActions": "./components/ratelimitpolicy/useRateLimitPolicyActions", + "useAuthPolicyActions": "./components/authpolicy/useAuthPolicyActions", "useTLSPolicyActions": "./components/tlspolicy/useTLSPolicyActions", "useOIDCPolicyActions": "./components/oidcpolicy/useOIDCPolicyActions", "useTokenRateLimitPolicyActions": "./components/tokenratelimitpolicy/useTokenRateLimitPolicyActions", diff --git a/src/components/DropdownWithKebab.tsx b/src/components/DropdownWithKebab.tsx index aeefdb32..19e38c26 100644 --- a/src/components/DropdownWithKebab.tsx +++ b/src/components/DropdownWithKebab.tsx @@ -78,7 +78,6 @@ const DropdownWithKebab: React.FC = ({ obj, onDeleteClic }/${obj.metadata.name}/edit`, }); } else if ( - obj.kind === 'AuthPolicy' || obj.kind === 'MCPGatewayExtension' || obj.kind === 'MCPServerRegistration' || obj.kind === 'ReferenceGrant' diff --git a/src/components/KuadrantAuthPolicyCreatePage.tsx b/src/components/KuadrantAuthPolicyCreatePage.tsx index d3d93559..2c99655d 100644 --- a/src/components/KuadrantAuthPolicyCreatePage.tsx +++ b/src/components/KuadrantAuthPolicyCreatePage.tsx @@ -1,92 +1,493 @@ import * as React from 'react'; import Helmet from 'react-helmet'; import { + PageSection, + Title, + TextInput, + TextArea, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Form, + Radio, Button, - ButtonVariant, - Modal, - ModalVariant, - ModalBody, - ModalFooter, + ActionGroup, + Tabs, + Tab, + TabTitleText, } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; -import { ResourceYAMLEditor, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; +import './kuadrant.css'; +import './css/gateway-api-plugin.css'; +import { + ResourceYAMLEditor, + getGroupVersionKindForResource, + useK8sModel, + useK8sWatchResource, + K8sResourceCommon, + useActiveNamespace, +} from '@openshift-console/dynamic-plugin-sdk'; +import { useNavigate, useLocation } from 'react-router'; +import HTTPRouteSelect from './httproute/HTTPRouteSelect'; +import * as yaml from 'js-yaml'; +import KuadrantCreateUpdate from './KuadrantCreateUpdate'; +import { handleCancel } from '../utils/cancel'; import { resourceGVKMapping } from '../utils/resources'; +type AuthType = 'apiKey' | 'anonymous' | 'jwt'; + +interface AuthRule { + name: string; + type: AuthType; + headerName?: string; + issuerUrl?: string; +} + +interface AuthPolicyEdit extends K8sResourceCommon { + spec?: { + targetRef?: { + group?: string; + kind?: string; + name?: string; + }; + rules?: { + authentication?: Record; + response?: { + unauthorized?: { + body?: { value?: string }; + headers?: { 'content-type'?: { value?: string } }; + }; + }; + }; + }; +} + const KuadrantAuthPolicyCreatePage: React.FC = () => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); + const [policyName, setPolicyName] = React.useState(''); const [selectedNamespace] = useActiveNamespace(); + const [selectedRoute, setSelectedRoute] = React.useState({ name: '', namespace: '' }); + const [authRules, setAuthRules] = React.useState([ + { name: '', type: 'apiKey', headerName: 'X-API-Key' }, + ]); + const [unauthorizedBody, setUnauthorizedBody] = React.useState(''); + const [contentType, setContentType] = React.useState('application/json'); + const [creationTimestamp, setCreationTimestamp] = React.useState(''); + const [resourceVersion, setResourceVersion] = React.useState(''); + const [formDisabled, setFormDisabled] = React.useState(false); + const [create, setCreate] = React.useState(true); + const [activeTab, setActiveTab] = React.useState(0); - const yamlResource = { - apiVersion: - resourceGVKMapping['AuthPolicy'].group + '/' + resourceGVKMapping['AuthPolicy'].version, - kind: resourceGVKMapping['AuthPolicy'].kind, - metadata: { - name: 'example-authpolicy', - namespace: selectedNamespace, - }, - spec: { - rules: { - authorization: { - denyAll: { - opa: { - rego: 'allow = false', + function createAuthPolicy() { + const authentication: Record = {}; + authRules.forEach((rule) => { + if (rule.name === '') return; + if (rule.type === 'anonymous') { + authentication[rule.name] = { anonymous: {} }; + } else if (rule.type === 'apiKey') { + authentication[rule.name] = { + apiKey: { + selector: { + matchLabels: { 'authorino.kuadrant.io/managed-by': 'authorino' }, }, + allNamespaces: true, }, + credentials: { + customHeader: { name: rule.headerName || 'X-API-Key' }, + }, + }; + } else if (rule.type === 'jwt') { + authentication[rule.name] = { + jwt: { issuerUrl: rule.issuerUrl || '' }, + }; + } + }); + + return { + apiVersion: + resourceGVKMapping['AuthPolicy'].group + '/' + resourceGVKMapping['AuthPolicy'].version, + kind: resourceGVKMapping['AuthPolicy'].kind, + metadata: { + name: policyName, + namespace: selectedNamespace, + ...(creationTimestamp ? { creationTimestamp } : {}), + ...(resourceVersion ? { resourceVersion } : {}), + }, + spec: { + targetRef: { + group: 'gateway.networking.k8s.io', + kind: 'HTTPRoute', + name: selectedRoute.name, }, - response: { - unauthorized: { - body: { - value: JSON.stringify( - { - error: 'Forbidden', - message: 'Access denied by default. Create a specific auth policy for the route.', + rules: { + ...(Object.keys(authentication).length > 0 ? { authentication } : {}), + ...(unauthorizedBody || contentType + ? { + response: { + unauthorized: { + ...(unauthorizedBody ? { body: { value: unauthorizedBody } } : {}), + ...(contentType ? { headers: { 'content-type': { value: contentType } } } : {}), + }, }, - null, - 2, - ), - }, - headers: { - 'content-type': { - value: 'application/json', - }, - }, - }, + } + : {}), }, }, - targetRef: { - group: 'gateway.networking.k8s.io', - kind: 'Gateway', - name: 'prod-web', - }, - }, + }; + } + + const [yamlInput, setYamlInput] = React.useState(createAuthPolicy); + const authPolicy = createAuthPolicy(); + const authPolicyGVK = getGroupVersionKindForResource({ + apiVersion: `${resourceGVKMapping['AuthPolicy'].group}/${resourceGVKMapping['AuthPolicy'].version}`, + kind: resourceGVKMapping['AuthPolicy'].kind, + }); + const [authPolicyModel] = useK8sModel({ + group: authPolicyGVK.group, + version: authPolicyGVK.version, + kind: authPolicyGVK.kind, + }); + + const navigate = useNavigate(); + const location = useLocation(); + const pathSplit = location.pathname.split('/'); + const nameEdit = pathSplit[6]; + const namespaceEdit = pathSplit[3]; + + const authResource = nameEdit + ? { + groupVersionKind: authPolicyGVK, + isList: false, + name: nameEdit, + namespace: namespaceEdit, + } + : null; + + const [authData, authLoaded, authError] = useK8sWatchResource(authResource); + const hasInitializedFromResource = React.useRef(false); + + React.useEffect(() => { + if (authLoaded && !authError && authData) { + if (!Array.isArray(authData)) { + const authPolicyUpdate = authData as AuthPolicyEdit; + setCreationTimestamp(authPolicyUpdate.metadata?.creationTimestamp || ''); + setResourceVersion(authPolicyUpdate.metadata?.resourceVersion || ''); + if (!hasInitializedFromResource.current) { + setFormDisabled(true); + setCreate(false); + setPolicyName(authPolicyUpdate.metadata?.name || ''); + setSelectedRoute({ + name: authPolicyUpdate.spec?.targetRef?.name || '', + namespace: authPolicyUpdate.metadata?.namespace || '', + }); + const rules = authPolicyUpdate.spec?.rules?.authentication || {}; + const reconstructed: AuthRule[] = Object.entries(rules).map(([name, value]) => { + const rule = value as { + anonymous?: unknown; + jwt?: { issuerUrl?: string }; + apiKey?: unknown; + credentials?: { customHeader?: { name?: string } }; + }; + if (rule.anonymous !== undefined) return { name, type: 'anonymous' }; + if (rule.jwt) return { name, type: 'jwt', issuerUrl: rule.jwt.issuerUrl || '' }; + return { + name, + type: 'apiKey', + headerName: rule.credentials?.customHeader?.name || '', + }; + }); + setAuthRules( + reconstructed.length > 0 + ? reconstructed + : [{ name: '', type: 'apiKey', headerName: 'X-API-Key' }], + ); + setUnauthorizedBody( + authPolicyUpdate.spec?.rules?.response?.unauthorized?.body?.value || '', + ); + setContentType( + authPolicyUpdate.spec?.rules?.response?.unauthorized?.headers?.['content-type'] + ?.value || '', + ); + hasInitializedFromResource.current = true; + } + } + } else if (authError) { + console.error('Failed to fetch the resource:', authError); + } + }, [authData, authLoaded, authError]); + + const handleYAMLChange = (yamlInputValue: string) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsedYaml = yaml.load(yamlInputValue) as Record; + setPolicyName(parsedYaml.metadata?.name || ''); + setSelectedRoute({ + name: parsedYaml.spec?.targetRef?.name || '', + namespace: parsedYaml.metadata?.namespace || '', + }); + const rules = parsedYaml.spec?.rules?.authentication || {}; + const reconstructed: AuthRule[] = Object.entries(rules).map(([name, value]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rule = value as any; + if (rule.anonymous !== undefined) return { name, type: 'anonymous' }; + if (rule.jwt) return { name, type: 'jwt', issuerUrl: rule.jwt.issuerUrl || '' }; + return { + name, + type: 'apiKey', + headerName: rule.credentials?.customHeader?.name || '', + }; + }); + setAuthRules( + reconstructed.length > 0 + ? reconstructed + : [{ name: '', type: 'apiKey', headerName: 'X-API-Key' }], + ); + setUnauthorizedBody(parsedYaml.spec?.rules?.response?.unauthorized?.body?.value || ''); + setContentType( + parsedYaml.spec?.rules?.response?.unauthorized?.headers?.['content-type']?.value || '', + ); + } catch (e) { + console.error(t('Error parsing YAML:'), e); + } }; - const [isErrorModalOpen, setIsErrorModalOpen] = React.useState(false); - const [errorModalMsg] = React.useState(''); + React.useEffect(() => { + setYamlInput(authPolicy); + }, [ + policyName, + selectedNamespace, + selectedRoute, + authRules, + unauthorizedBody, + contentType, + creationTimestamp, + resourceVersion, + ]); + + const addAuthRule = () => + setAuthRules([...authRules, { name: '', type: 'apiKey', headerName: 'X-API-Key' }]); + + const removeAuthRule = (i: number) => { + if (authRules.length > 1) setAuthRules(authRules.filter((_, idx) => idx !== i)); + }; + + const updateAuthRule = (i: number, field: keyof AuthRule, value: string) => { + const updated = [...authRules]; + updated[i] = { ...updated[i], [field]: value }; + setAuthRules(updated); + }; + + const isFormValid = !!( + policyName && + selectedRoute.name && + authRules.some((rule) => rule.name !== '') + ); + + const handleCancelResource = () => { + handleCancel(navigate); + }; return ( <> - {t('Create AuthPolicy')} + + {create ? t('Create AuthPolicy') : t('Edit AuthPolicy')} + - - - - setIsErrorModalOpen(false)} - variant={ModalVariant.medium} - title={t('Error creating AuthPolicy')} - > - - {errorModalMsg} - - - + + ))} + + + + + {t('Response')}}> + + +