Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions console-extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@
"component": { "$codeRef": "APIProductPoliciesTab" }
}
},
{
"type": "console.tab/horizontalNav",
"properties": {
"model": {
"group": "devportal.kuadrant.io",
"version": "v1alpha1",
"kind": "APIProduct"
},
"page": {
"name": "Accepted Paths",
"href": "acceptedpaths"
},
"component": { "$codeRef": "APIProductAcceptedPathsTab" }
}
},
{
"type": "console.tab/horizontalNav",
"properties": {
Expand Down
6 changes: 6 additions & 0 deletions locales/en/plugin__kuadrant-console-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"All API key requests have been reviewed": "All API key requests have been reviewed",
"All API Products": "All API Products",
"All dependencies for the policy are successfully resolved.": "All dependencies for the policy are successfully resolved.",
"All methods": "All methods",
"All Policies": "All Policies",
"All references for the resource have been resolved.": "All references for the resource have been resolved.",
"An HTTPRoute mapping that routes incoming traffic from an API Product's public endpoint to the corresponding upstream service.": "An HTTPRoute mapping that routes incoming traffic from an API Product's public endpoint to the corresponding upstream service.",
Expand Down Expand Up @@ -251,6 +252,8 @@
"Loading topology...": "Loading topology...",
"Loading user information...": "Loading user information...",
"Loading...": "Loading...",
"Match type": "Match type",
"Method": "Method",
"More info": "More info",
"Must be no more than 253 characters": "Must be no more than 253 characters",
"Must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character": "Must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character",
Expand All @@ -262,6 +265,7 @@
"Namespace filtering": "Namespace filtering",
"Need manual approval": "Need manual approval",
"No": "No",
"No accepted paths found": "No accepted paths found",
"No API key requests": "No API key requests",
"No API Key Requests found": "No API Key Requests found",
"No API Key requests have been made for this API Product.": "No API Key requests have been made for this API Product.",
Expand Down Expand Up @@ -293,6 +297,7 @@
"Operations & Tools": "Operations & Tools",
"Overview": "Overview",
"Owner": "Owner",
"Path": "Path",
"Pending": "Pending",
"Plan": "Plan",
"Plan Tiers": "Plan Tiers",
Expand Down Expand Up @@ -385,6 +390,7 @@
"The resource is programmed but not fully enforced.": "The resource is programmed but not fully enforced.",
"The status of the resource is unknown.": "The status of the resource is unknown.",
"The target for the resource was not found and it is not accepted.": "The target for the resource was not found and it is not accepted.",
"The target HTTPRoute does not define any routing rules.": "The target HTTPRoute does not define any routing rules.",
"There are no": "There are no",
"There are no API key requests yet": "There are no API key requests yet",
"There are no API Keys to display - request access to an API Product to get started.": "There are no API Keys to display - request access to an API Product to get started.",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"APIProductsListPage": "./components/apiproduct/APIProductsListPage",
"APIProductOverviewTab": "./components/apiproduct/APIProductOverviewTab",
"APIProductPoliciesTab": "./components/apiproduct/APIProductPoliciesTab",
"APIProductAcceptedPathsTab": "./components/apiproduct/APIProductAcceptedPathsTab",
"APIProductDefinitionTab": "./components/apiproduct/APIProductDefinitionTab",
"APIProductAPIKeysTab": "./components/apiproduct/APIProductAPIKeysTab",
"MyAPIKeysPage": "./components/apikey/MyAPIKeysPage",
Expand Down
277 changes: 277 additions & 0 deletions src/components/apiproduct/APIProductAcceptedPathsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom-v5-compat';
import {
PageSection,
Title,
Content,
ContentVariants,
EmptyState,
EmptyStateBody,
Alert,
Spinner,
Label,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons';
import {
useK8sWatchResource,
useActiveNamespace,
useAccessReview,
K8sResourceCommon,
VirtualizedTable,
TableData,
RowProps,
TableColumn,
} from '@openshift-console/dynamic-plugin-sdk';
import { APIProduct } from './types';
import { HTTPRouteMatch, HTTPMethod } from '../ratelimitpolicy/types';
import { RESOURCES } from '../../utils/resources';
import extractResourceNameFromURL from '../../utils/nameFromPath';
import { getResourceNameFromKind } from '../../utils/getModelFromResource';
import NoPermissionsView from '../NoPermissionsView';
import '../kuadrant.css';

// Minimal shape of the targeted HTTPRoute - we only read the routing rules to
// derive the accepted (method, path) pairs the API Product exposes.
interface RoutedHTTPRoute extends K8sResourceCommon {
spec?: {
rules?: {
matches?: HTTPRouteMatch[];
}[];
};
}

// A single accepted path row: one method/path-match pair flattened from the
// HTTPRoute rules. When a match omits the method, all methods are accepted.
interface AcceptedPath {
id: string;
method: HTTPMethod | 'ALL';
pathType: string;
pathValue: string;
}

// Gateway API defaults: a rule with no matches accepts every path ("/" prefix),
// and a match with no method accepts every method.
const DEFAULT_PATH_TYPE = 'PathPrefix';
const DEFAULT_PATH_VALUE = '/';

const APIProductAcceptedPathsTab: React.FC = () => {
const { t } = useTranslation('plugin__kuadrant-console-plugin');
const [activeNamespace] = useActiveNamespace();
const location = useLocation();
const productName = extractResourceNameFromURL(location.pathname);

const [canGet, canGetLoading] = useAccessReview({
group: RESOURCES.APIProduct.gvk.group,
resource: getResourceNameFromKind(RESOURCES.APIProduct.gvk.kind),
verb: 'get',
namespace: activeNamespace,
name: productName,
Comment on lines +60 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the resource’s namespace here, not activeNamespace.

Line 60 makes the watch/access-review namespace depend on the global namespace selector, but this is a named details view. In #ALL_NS# mode, or after switching projects, the tab can query the wrong namespace and fail to load both the APIProduct and its fallback HTTPRoute. Derive the APIProduct namespace from the route/resource context, then fall back from targetRef.namespace to that namespace instead of activeNamespace.

As per coding guidelines, “Support namespace handling for both single namespace and all-namespaces mode (#ALL_NS#) in resource queries”.

Also applies to: 73-79, 95-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/apiproduct/APIProductAcceptedPathsTab.tsx` around lines 60 -
69, The access-review and fallback lookups in APIProductAcceptedPathsTab should
not depend on the global activeNamespace; instead, derive the APIProduct’s
namespace from the current route/resource context and use that for the
useAccessReview call and related resource queries. Update the logic around
useLocation, extractResourceNameFromURL, and the APIProduct/HTTPRoute fallback
paths so targetRef.namespace falls back to the resource namespace rather than
activeNamespace, ensuring the tab works in both single-namespace and `#ALL_NS`#
modes.

Source: Coding guidelines

});

// Fetch the APIProduct
const [apiProduct, productLoaded, productLoadError] = useK8sWatchResource<APIProduct>(
canGet && !canGetLoading
? {
groupVersionKind: RESOURCES.APIProduct.gvk,
namespace: activeNamespace,
name: productName,
isList: false,
}
: null,
);

// Extract target HTTPRoute reference, caching it so a transient watch
// reconnection (where spec is briefly absent) doesn't drop the linked route.
const targetRef = apiProduct?.spec?.targetRef;
const [cachedTargetRef, setCachedTargetRef] = React.useState<typeof targetRef>(undefined);
React.useEffect(() => {
if (targetRef) {
setCachedTargetRef(targetRef);
}
}, [targetRef]);
const targetRefToUse = targetRef || cachedTargetRef;

const httprouteNamespace = targetRefToUse?.namespace || activeNamespace;
const httprouteName = targetRefToUse?.name;

// Fetch the target HTTPRoute
const [httpRoute, httprouteLoaded, httprouteLoadError] = useK8sWatchResource<RoutedHTTPRoute>(
targetRefToUse && targetRefToUse.kind === 'HTTPRoute'
? {
groupVersionKind: RESOURCES.HTTPRoute.gvk,
namespace: httprouteNamespace,
name: httprouteName,
isList: false,
}
: null,
Comment on lines +98 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Gate the HTTPRoute watch behind its own access review.

Line 99 starts the HTTPRoute watch without checking RBAC for that resource. If the user can read the APIProduct but not the referenced route, line 233 shows a generic load error instead of the expected permission state.

As per coding guidelines, “Use useAccessReviews hook to check RBAC permissions before rendering protected features”.

Also applies to: 230-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/apiproduct/APIProductAcceptedPathsTab.tsx` around lines 98 -
107, The HTTPRoute fetch in APIProductAcceptedPathsTab should be gated by its
own RBAC check instead of always starting the watch. Add a `useAccessReviews`
check for the referenced `HTTPRoute` before calling `useK8sWatchResource`, and
only render/load the route when access is allowed. Update the
`httpRoute`/load-error handling around the `useK8sWatchResource` and the error
UI near the existing load-error branch so users without route permissions see
the correct permission state rather than a generic load failure.

Source: Coding guidelines

);

// Flatten the HTTPRoute rules into one row per accepted (method, path) pair.
const acceptedPaths = React.useMemo<AcceptedPath[]>(() => {
const rules = httpRoute?.spec?.rules;
if (!rules) return [];

const paths: AcceptedPath[] = [];
rules.forEach((rule, ruleIndex) => {
// A rule with no matches accepts all paths/methods.
const matches: HTTPRouteMatch[] = rule.matches?.length ? rule.matches : [{}];
matches.forEach((match, matchIndex) => {
paths.push({
id: `${ruleIndex}-${matchIndex}`,
method: match.method || 'ALL',
pathType: match.path?.type || DEFAULT_PATH_TYPE,
pathValue: match.path?.value || DEFAULT_PATH_VALUE,
});
});
});
return paths;
}, [httpRoute]);

const methodColor = (method: AcceptedPath['method']) => {
switch (method) {
case 'GET':
case 'HEAD':
return 'blue';
case 'POST':
case 'PUT':
case 'PATCH':
return 'green';
case 'DELETE':
return 'red';
default:
return 'grey';
}
};

const columns: TableColumn<AcceptedPath>[] = [
{
title: t('Method'),
id: 'method',
},
{
title: t('Path'),
id: 'path',
},
{
title: t('Match type'),
id: 'pathType',
},
];

const AcceptedPathRow: React.FC<RowProps<AcceptedPath>> = ({ obj, activeColumnIDs }) => (
<>
<TableData id="method" activeColumnIDs={activeColumnIDs}>
<Label isCompact color={methodColor(obj.method)}>
{obj.method === 'ALL' ? t('All methods') : obj.method}
</Label>
</TableData>
<TableData id="path" activeColumnIDs={activeColumnIDs}>
{obj.pathValue}
</TableData>
<TableData id="pathType" activeColumnIDs={activeColumnIDs}>
{obj.pathType}
</TableData>
</>
);

if (canGetLoading) {
return (
<PageSection hasBodyWrapper={false}>
<Spinner size="lg" />
</PageSection>
);
}

if (!canGet) {
return (
<NoPermissionsView primaryMessage={t('You do not have permission to view API Products')} />
);
}

if (productLoadError) {
return (
<PageSection hasBodyWrapper={false}>
<Alert variant="danger" isInline title={t('Error loading API Product')}>
{productLoadError.message}
</Alert>
</PageSection>
);
}

if (!productLoaded || !apiProduct) {
return (
<PageSection hasBodyWrapper={false}>
<Content component={ContentVariants.p}>{t('Loading...')}</Content>
</PageSection>
);
}

// Only show this empty state if we've never had a targetRef (initial load).
if (!targetRefToUse || targetRefToUse.kind !== 'HTTPRoute') {
return (
<PageSection hasBodyWrapper={false}>
<EmptyState
titleText={
<Title headingLevel="h4" size="lg">
{t('No target HTTPRoute configured')}
</Title>
}
icon={SearchIcon}
>
<EmptyStateBody>
{t('This API Product does not have a target HTTPRoute configured.')}
</EmptyStateBody>
</EmptyState>
</PageSection>
);
}

if (httprouteLoadError) {
return (
<PageSection hasBodyWrapper={false}>
<Alert variant="danger" isInline title={t('Error loading HTTPRoute')}>
{httprouteLoadError.message}
</Alert>
</PageSection>
);
}

if (!httprouteLoaded || !httpRoute) {
return (
<PageSection hasBodyWrapper={false}>
<Content component={ContentVariants.p}>{t('Loading HTTPRoute...')}</Content>
</PageSection>
);
}

return (
<PageSection hasBodyWrapper={false}>
{acceptedPaths.length === 0 ? (
<EmptyState
titleText={
<Title headingLevel="h4" size="lg">
{t('No accepted paths found')}
</Title>
}
icon={SearchIcon}
>
<EmptyStateBody>
{t('The target HTTPRoute does not define any routing rules.')}
</EmptyStateBody>
</EmptyState>
) : (
<VirtualizedTable<AcceptedPath>
data={acceptedPaths}
unfilteredData={acceptedPaths}
loaded={httprouteLoaded}
loadError={null}
columns={columns}
Row={AcceptedPathRow}
/>
)}
</PageSection>
);
};

export default APIProductAcceptedPathsTab;
Loading