diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 31ef9f8068..e7aa79d4f7 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -15,7 +15,7 @@ services: platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.16.0-SNAPSHOT + image: ghcr.io/wso2/api-platform/platform-api:0.17.0-SNAPSHOT restart: unless-stopped profiles: ["platform-api"] command: ["-config", "/etc/platform-api/config.toml"] diff --git a/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx b/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx index 533a7bce6b..4c0083fd04 100644 --- a/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx +++ b/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx @@ -13,7 +13,13 @@ import React, { useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { Box, Button, CircularProgress, Typography } from '@wso2/oxygen-ui'; +import { + Alert, + Box, + Button, + CircularProgress, + Typography, +} from '@wso2/oxygen-ui'; import { Plus } from '@wso2/oxygen-ui-icons-react'; import { FormattedMessage } from 'react-intl'; import type { Gateway } from '../../apis/gateway/types'; @@ -39,7 +45,8 @@ interface GatewayDeployMainSectionProps { export default function GatewayDeployMainSection({ showConfigureOption = true, }: GatewayDeployMainSectionProps = {}) { - const { gateways, isLoading, error } = useGatewayDeploy(); + const { gateways, isLoading, error, canDeploy, canViewDeployments } = + useGatewayDeploy(); const { currentOrganization } = useAppShell(); const [searchQuery, setSearchQuery] = useState(''); const [configDrawerOpen, setConfigDrawerOpen] = useState(false); @@ -78,6 +85,22 @@ export default function GatewayDeployMainSection({ ); } + // Without the deployment-read scope the gateway list is empty by design, not + // because the organization has no gateways — say so instead of inviting the + // user to create one they wouldn't be able to deploy to or even see. + if (!canViewDeployments) { + return ( + + + + + + ); + } + if (gateways.length === 0) { return ( + {/* The deploy/redeploy/restore/undeploy controls below are all disabled in + this case; without this the page would read as broken rather than as a + permission boundary. */} + {!canDeploy && ( + + + + )} ))} diff --git a/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx b/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx new file mode 100644 index 0000000000..7425234370 --- /dev/null +++ b/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Alert, Button } from '@wso2/oxygen-ui'; +import { RefreshCcw } from '@wso2/oxygen-ui-icons-react'; + +interface PartialLoadWarningProps { + /** Short, user-facing note about which source could not be loaded. */ + message: string; + onRetry: () => void; + retryLabel?: string; +} + +/** + * Non-blocking notice for a list assembled from several independent sources + * (for example, Policy Hub policies plus the organization's custom policies). + * One source failing must not hide the rest, so the caller keeps rendering + * whatever loaded and shows this above it with a retry for the failed source. + */ +export default function PartialLoadWarning({ + message, + onRetry, + retryLabel = 'Retry', +}: PartialLoadWarningProps) { + return ( + } + onClick={onRetry} + > + {retryLabel} + + } + > + {message} + + ); +} diff --git a/portals/ai-workspace/src/auth/permissions.ts b/portals/ai-workspace/src/auth/permissions.ts index 19147cbf45..7db037139b 100644 --- a/portals/ai-workspace/src/auth/permissions.ts +++ b/portals/ai-workspace/src/auth/permissions.ts @@ -200,6 +200,33 @@ export const SCOPES = { SECRET_MANAGE: 'ap:secret:manage', } as const; +/** + * Deployment scopes per deployable AI artifact kind, keyed by the same resource + * type `GatewayDeployProvider` takes. Shared by the deploy page/context and the + * "Deploy to Gateway" entry points on the overview pages, so one map decides + * both whether the deploy actions render and whether the button leading to them + * is reachable. + */ +export const DEPLOYMENT_SCOPES = { + provider: { + read: SCOPES.LLM_PROVIDER_DEPLOYMENT_READ, + create: SCOPES.LLM_PROVIDER_DEPLOYMENT_CREATE, + delete: SCOPES.LLM_PROVIDER_DEPLOYMENT_DELETE, + }, + proxy: { + read: SCOPES.LLM_PROXY_DEPLOYMENT_READ, + create: SCOPES.LLM_PROXY_DEPLOYMENT_CREATE, + delete: SCOPES.LLM_PROXY_DEPLOYMENT_DELETE, + }, + 'mcp-server': { + read: SCOPES.MCP_PROXY_DEPLOYMENT_READ, + create: SCOPES.MCP_PROXY_DEPLOYMENT_CREATE, + delete: SCOPES.MCP_PROXY_DEPLOYMENT_DELETE, + }, +} as const; + +export type DeployableResourceType = keyof typeof DEPLOYMENT_SCOPES; + /** * Scopes that must be held explicitly and are never derived from a broader * `:manage`. `ap:api_key:all:manage` is an ownership override (it widens which diff --git a/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx b/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx index fd7b062f93..8c839fd722 100644 --- a/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx +++ b/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx @@ -23,6 +23,11 @@ import { } from 'react'; import { logger } from '../utils/logger'; import { useAppShell } from './AppShellContext'; +import { useAppAuth } from './AppAuthContext'; +import { + DEPLOYMENT_SCOPES, + type DeployableResourceType, +} from '../auth/permissions'; import { getGateways } from '../apis/gatewayApis'; import { getLLMProviderDeployments, @@ -64,7 +69,7 @@ import { export type { HybridGateway, GatewayDeployment }; -type GatewayDeployResourceType = 'provider' | 'proxy' | 'mcp-server'; +type GatewayDeployResourceType = DeployableResourceType; const POLL_INTERVAL_MS = 2000; @@ -173,11 +178,19 @@ interface GatewayDeployContextValue { isPollingGateway: (gatewayId: string) => boolean; /** - * When true, the artifact is read-only (e.g. gateway-originated): its deployment - * lifecycle is owned by the gateway. The deployments remain viewable, but deploy/ - * redeploy/restore/undeploy actions are disabled. + * When true, the artifact's deployment lifecycle is not writable from here — + * either because the artifact is gateway-originated (owned by the gateway) or + * because the signed-in user lacks the deployment-create scope for this + * resource kind. The deployments remain viewable, but deploy/redeploy/restore/ + * undeploy actions are disabled. */ readOnly: boolean; + /** True when the user holds the deployment-create scope for this resource kind. */ + canDeploy: boolean; + /** True when the user holds the deployment-read scope for this resource kind. */ + canViewDeployments: boolean; + /** True when the user holds the deployment-delete scope for this resource kind. */ + canDelete: boolean; } const GatewayDeployContext = createContext( @@ -187,7 +200,11 @@ const GatewayDeployContext = createContext( interface GatewayDeployProviderProps { apiId: string; resourceType?: GatewayDeployResourceType; - /** Disable deploy/redeploy/restore/undeploy actions while keeping deployments visible. */ + /** + * Disable deploy/redeploy/restore/undeploy actions while keeping deployments + * visible. The user's scopes are checked independently and can force this on + * even when the caller passes `false`. + */ readOnly?: boolean; children: ReactNode; } @@ -199,8 +216,17 @@ export function GatewayDeployProvider({ children, }: GatewayDeployProviderProps) { const { currentOrganization } = useAppShell(); + const { hasPermission } = useAppAuth(); const organizationId = currentOrganization?.uuid ?? ''; + // Permission is the floor, not an override: a caller passing readOnly={false} + // (or omitting it) still cannot get writable actions without the scope, so a + // viewer sees the same read-only deploy surface as a gateway-owned artifact. + const canViewDeployments = hasPermission(DEPLOYMENT_SCOPES[resourceType].read); + const canDeploy = hasPermission(DEPLOYMENT_SCOPES[resourceType].create); + const canDelete = hasPermission(DEPLOYMENT_SCOPES[resourceType].delete); + const isReadOnly = readOnly || !canDeploy; + const [gateways, setGateways] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -223,6 +249,15 @@ export function GatewayDeployProvider({ const pollingDeploymentsRef = useRef(pollingDeployments); pollingDeploymentsRef.current = pollingDeployments; + /** + * Monotonic request tokens for the two loads. Every fetch captures the token it + * started with and discards its own result if the token has since moved on, so a + * response that lands after access was lost (or after a newer load started) + * cannot repopulate gateways/deployments behind an access message. + */ + const gatewaysRequestRef = useRef(0); + const deploymentsRequestRef = useRef(0); + /** * Deployments whose poll window expired while still transitional — the gateway's * acknowledgement never arrived, so they are surfaced as FAILED. The override only @@ -235,6 +270,11 @@ export function GatewayDeployProvider({ const fetchSingleDeploymentStatus = useCallback( async (deploymentId: string): Promise => { + // No deployment-read scope means no readable status; fail the read here + // rather than issuing a call that can only 403. + if (!canViewDeployments) { + throw new Error('Not permitted to read deployment status'); + } if (resourceType === 'proxy') { return getLLMProxyDeployment(apiId, deploymentId, organizationId, PLATFORM_API_BASE_URL); } else if (resourceType === 'mcp-server') { @@ -242,7 +282,7 @@ export function GatewayDeployProvider({ } return getLLMProviderDeployment(apiId, deploymentId, organizationId, PLATFORM_API_BASE_URL); }, - [apiId, organizationId, resourceType] + [apiId, organizationId, resourceType, canViewDeployments] ); /** @@ -290,14 +330,24 @@ export function GatewayDeployProvider({ ); const fetchGateways = useCallback(async () => { - if (!organizationId) { + // A user without the deployment-read scope has no readable deploy surface, + // so don't issue the gateway/deployment reads at all — they would only 403. + if (!organizationId || !canViewDeployments) { + // Bumping the token strands any in-flight load: its result is discarded + // instead of repopulating the list behind the access message. The error is + // cleared too, so consumers show "no access" rather than a stale failure. + gatewaysRequestRef.current += 1; + setGateways([]); + setError(null); setIsLoading(false); return; } + const requestId = (gatewaysRequestRef.current += 1); setIsLoading(true); setError(null); try { const response = await getGateways(organizationId); + if (gatewaysRequestRef.current !== requestId) return; const fetchedGateways: HybridGateway[] = (response.list || []).map( (gateway) => ({ ...gateway, @@ -308,25 +358,38 @@ export function GatewayDeployProvider({ ); setGateways(fetchedGateways); } catch (err) { + if (gatewaysRequestRef.current !== requestId) return; logger.error('Failed to fetch hybrid gateways:', err); setError( err instanceof Error ? err : new Error('Failed to fetch gateways') ); setGateways([]); } finally { - setIsLoading(false); + if (gatewaysRequestRef.current === requestId) { + setIsLoading(false); + } } - }, [organizationId]); + }, [organizationId, canViewDeployments]); useEffect(() => { fetchGateways(); }, [fetchGateways]); const refetchDeployments = useCallback(async () => { - if (!apiId || !organizationId) { + if (!apiId || !organizationId || !canViewDeployments) { + // Strand any in-flight load so its continuation can't repopulate the list + // or restart polling from stale transitional statuses. + deploymentsRequestRef.current += 1; setDeployments(null); + setDeploymentsError(null); + setIsLoadingDeployments(false); + // Drop any in-flight status watches: without the read scope every poll + // would only 403, so stop them instead of retrying until they expire. + setPollingDeployments((prev) => (prev.size === 0 ? prev : new Map())); + setTimedOutDeployments((prev) => (prev.size === 0 ? prev : new Set())); return; } + const requestId = (deploymentsRequestRef.current += 1); setIsLoadingDeployments(true); setDeploymentsError(null); try { @@ -355,6 +418,7 @@ export function GatewayDeployProvider({ ); const deploymentResponses = await Promise.all(deploymentPromises); + if (deploymentsRequestRef.current !== requestId) return; const allDeployments = deploymentResponses.flatMap( (response) => response.list ); @@ -366,17 +430,21 @@ export function GatewayDeployProvider({ organizationId, PLATFORM_API_BASE_URL ); + if (deploymentsRequestRef.current !== requestId) return; setDeployments(result); } } catch (err) { + if (deploymentsRequestRef.current !== requestId) return; logger.error(`Failed to fetch LLM ${resourceType} deployments:`, err); setDeploymentsError( err instanceof Error ? err : new Error('Failed to fetch deployments') ); } finally { - setIsLoadingDeployments(false); + if (deploymentsRequestRef.current === requestId) { + setIsLoadingDeployments(false); + } } - }, [apiId, organizationId, resourceType, gateways]); + }, [apiId, organizationId, resourceType, gateways, canViewDeployments]); useEffect(() => { if (apiId) { @@ -423,7 +491,7 @@ export function GatewayDeployProvider({ * poll window expires. */ useEffect(() => { - if (pollingDeployments.size === 0) return; + if (pollingDeployments.size === 0 || !canViewDeployments) return; let cancelled = false; @@ -496,11 +564,20 @@ export function GatewayDeployProvider({ cancelled = true; clearInterval(intervalId); }; - }, [pollingDeployments, fetchSingleDeploymentStatus, refetchDeployments]); + }, [ + pollingDeployments, + fetchSingleDeploymentStatus, + refetchDeployments, + canViewDeployments, + ]); const deployToGateway = useCallback( async (gatewayId: string, host: string): Promise => { if (!apiId || !organizationId) return false; + if (isReadOnly) { + logger.warn('Deploy blocked: deployment lifecycle is read-only here.'); + return false; + } setDeployingGatewayId(gatewayId); try { @@ -588,12 +665,17 @@ export function GatewayDeployProvider({ refetchDeployments, startPolling, resourceType, + isReadOnly, ] ); const undeployDeployment = useCallback( async (deploymentId: string, gatewayId: string): Promise => { if (!apiId || !organizationId || !deploymentId) return false; + if (isReadOnly) { + logger.warn('Undeploy blocked: deployment lifecycle is read-only here.'); + return false; + } setDeployingGatewayId(gatewayId); try { @@ -657,12 +739,17 @@ export function GatewayDeployProvider({ fetchSingleDeploymentStatus, startPolling, resourceType, + isReadOnly, ] ); const redeployDeployment = useCallback( async (deploymentId: string, gatewayId: string): Promise => { if (!apiId || !organizationId || !deploymentId) return false; + if (isReadOnly) { + logger.warn('Redeploy blocked: deployment lifecycle is read-only here.'); + return false; + } setDeployingGatewayId(gatewayId); try { @@ -722,12 +809,19 @@ export function GatewayDeployProvider({ refetchDeployments, startPolling, resourceType, + isReadOnly, ] ); const deleteDeployment = useCallback( async (deploymentId: string): Promise => { if (!apiId || !organizationId || !deploymentId) return false; + if (!canDelete) { + logger.warn( + 'Deployment delete blocked: missing deployment-delete scope.' + ); + return false; + } // Block deletion when deployment is DEPLOYED const deployment = deployments?.list.find( @@ -777,7 +871,14 @@ export function GatewayDeployProvider({ return false; } }, - [apiId, organizationId, deployments, refetchDeployments, resourceType] + [ + apiId, + organizationId, + deployments, + refetchDeployments, + resourceType, + canDelete, + ] ); const value = useMemo( @@ -797,7 +898,10 @@ export function GatewayDeployProvider({ deployingGatewayId, isDeployingToGateway, isPollingGateway, - readOnly, + readOnly: isReadOnly, + canDeploy, + canViewDeployments, + canDelete, }), [ gateways, @@ -815,7 +919,10 @@ export function GatewayDeployProvider({ deployingGatewayId, isDeployingToGateway, isPollingGateway, - readOnly, + isReadOnly, + canDeploy, + canViewDeployments, + canDelete, ] ); diff --git a/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx b/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx index ee333294b6..8f74708a4c 100644 --- a/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx +++ b/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx @@ -26,7 +26,12 @@ export type GatewayPolicyRow = { version: string; description: string; policyType: "Policy Hub" | "Custom"; - syncStatus: "N/A" | "Synced" | "Not synced"; + /** + * "Unknown" is used when the organization's custom policies could not be read + * (no scope, or a failed request), so whether this policy is synced cannot be + * determined — distinct from a policy known to be unsynced. + */ + syncStatus: "N/A" | "Synced" | "Not synced" | "Unknown"; customPolicyId?: string; }; @@ -34,11 +39,22 @@ type GatewayPoliciesContextValue = { policies: GatewayPolicyRow[]; isLoading: boolean; error: Error | null; + /** + * Non-fatal load failures: the manifest arrived but a supplementary source + * (the org's custom policies, or the Policy Hub catalogue) did not. The rows + * are still usable, so these are shown as warnings instead of replacing the + * table with an error. + */ + warnings: string[]; refresh: () => Promise; syncPolicy: (policyName: string, version: string) => Promise; syncingPolicyKey: string | null; - /** False when the caller lacks the scopes the policy view reads. */ + /** False when the caller can read neither source the policy view lists. */ canViewPolicies: boolean; + /** False when the caller cannot read this gateway's policy manifest. */ + canViewManifest: boolean; + /** False when the caller cannot read the organization's custom policies. */ + canViewCustomPolicies: boolean; /** False when the caller may view policies but not sync them into the org. */ canSyncPolicies: boolean; }; @@ -58,6 +74,19 @@ function mergePolicies( manifestPolicies: GatewayManifestPolicy[], customPolicies: GatewayCustomPolicy[], hubPolicies: PolicyHubPolicy[], + options: { + /** + * True when the org's custom policies could not be read, so sync status is + * reported as "Unknown" rather than being inferred from an empty list. + */ + syncStatusUnknown: boolean; + /** + * True when the manifest could not be read. The org's custom policies then + * become the only listable source, so they are surfaced as rows on their own + * instead of only enriching manifest rows. + */ + listCustomPoliciesAsRows: boolean; + }, ): GatewayPolicyRow[] { const rows = new Map(); const hubPolicyByKey = new Map( @@ -81,11 +110,36 @@ function mergePolicies( version: policy.version, description: policy.description || hubPolicy?.description || "—", policyType: isCustomPolicy ? "Custom" : "Policy Hub", - syncStatus: isCustomPolicy ? (syncedPolicy ? "Synced" : "Not synced") : "N/A", + syncStatus: !isCustomPolicy + ? "N/A" + : options.syncStatusUnknown + ? "Unknown" + : syncedPolicy + ? "Synced" + : "Not synced", customPolicyId: syncedPolicy?.uuid, }); }); + if (options.listCustomPoliciesAsRows) { + customPolicies.forEach((policy) => { + const key = policyKey(policy.name, policy.version); + if (rows.has(key)) return; + const hubPolicy = hubPolicyByKey.get(key); + rows.set(key, { + key, + policyName: policy.name, + name: policy.displayName || hubPolicy?.displayName || policy.name, + version: policy.version, + description: policy.description || hubPolicy?.description || "—", + policyType: "Custom", + // Present in the organization by definition — that is what this list is. + syncStatus: "Synced", + customPolicyId: policy.uuid, + }); + }); + } + return [...rows.values()].sort((left, right) => left.name.localeCompare(right.name)); } @@ -93,45 +147,115 @@ export function GatewayPoliciesProvider({ gatewayId, children }: { gatewayId: st const [policies, setPolicies] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [warnings, setWarnings] = useState([]); const [syncingPolicyKey, setSyncingPolicyKey] = useState(null); - // This view reads the gateway manifest and the org's custom policies. Without - // both scopes the platform API returns 403, so skip the request entirely and - // let the consumer render a permission notice instead of a load failure. + // This view lists two sources: the gateway's policy manifest and the org's + // custom policies. They are scoped independently, so each is requested only + // when its own scope is held — holding just one still gives a usable (if + // partial) list, and the missing half is reported as a warning. Only when + // neither is readable does the consumer fall back to a permission notice. const { hasPermission } = useAppAuth(); - const canViewPolicies = - hasPermission(SCOPES.GATEWAY_MANIFEST_READ) && - hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_READ); + const canViewManifest = hasPermission(SCOPES.GATEWAY_MANIFEST_READ); + const canViewCustomPolicies = hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_READ); + const canViewPolicies = canViewManifest || canViewCustomPolicies; const canSyncPolicies = hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_CREATE); const refresh = useCallback(async () => { if (!gatewayId) return; if (!canViewPolicies) { setPolicies([]); + setWarnings([]); + setError(null); setIsLoading(false); return; } setIsLoading(true); setError(null); - try { - const [manifest, customResponse, hubResponse] = await Promise.all([ - getGatewayPolicyManifest(gatewayId), - getGatewayCustomPolicies(), - getPolicies(), - ]); - setPolicies( - mergePolicies( - manifest.policies || [], - customResponse.list || [], - hubResponse.data || [], - ), + setWarnings([]); + + // Every readable source starts together. The manifest is awaited on its own + // so its failure is known without waiting on the supplementary requests; + // `supplementary` is an allSettled promise, so it never rejects even when the + // manifest fails first. Neither source is fatal by itself — the table is + // rendered from whichever ones arrived, and the rest become warnings. + const manifestPromise = canViewManifest + ? getGatewayPolicyManifest(gatewayId) + : null; + const supplementary = Promise.allSettled([ + canViewCustomPolicies ? getGatewayCustomPolicies() : Promise.resolve(null), + getPolicies(), + ]); + + let manifestPolicies: GatewayManifestPolicy[] | null = null; + let manifestFailure: unknown = null; + if (manifestPromise) { + try { + manifestPolicies = (await manifestPromise).policies || []; + } catch (cause) { + manifestFailure = cause; + } + } + const [customResult, hubResult] = await supplementary; + + const customPolicies = + customResult.status === "fulfilled" ? customResult.value?.list || [] : []; + const hubPolicies = + hubResult.status === "fulfilled" ? hubResult.value.data || [] : []; + const customPoliciesUnavailable = + !canViewCustomPolicies || customResult.status === "rejected"; + + // Nothing listable arrived from either source — that, and only that, is a + // full failure. Anything else degrades to a partial list plus a warning. + if (manifestPolicies === null && customPoliciesUnavailable) { + const cause = + manifestFailure ?? + (customResult.status === "rejected" ? customResult.reason : null); + setPolicies([]); + setWarnings([]); + setError( + cause instanceof Error + ? cause + : new Error("Failed to load gateway policies"), ); - } catch (cause) { - setError(cause instanceof Error ? cause : new Error("Failed to load gateway policies")); - } finally { setIsLoading(false); + return; } - }, [gatewayId, canViewPolicies]); + + const nextWarnings: string[] = []; + if (!canViewManifest) { + nextWarnings.push( + "Policies installed on this gateway are not shown — you do not have permission to read the gateway manifest. Only custom policies synced to the organization are listed.", + ); + } else if (manifestFailure) { + nextWarnings.push( + "The gateway policy manifest could not be loaded, so policies installed on the gateway are not listed.", + ); + } + if (!canViewCustomPolicies) { + nextWarnings.push( + "Sync status is not shown - you do not have permission to read the organization's custom policies.", + ); + } else if (customResult.status === "rejected") { + nextWarnings.push( + "Custom policies could not be loaded, so sync status is not shown.", + ); + } + if (hubResult.status === "rejected") { + nextWarnings.push( + "Policy Hub details could not be loaded, so some names and descriptions may be missing.", + ); + } + + setWarnings(nextWarnings); + setPolicies( + mergePolicies(manifestPolicies || [], customPolicies, hubPolicies, { + syncStatusUnknown: customPoliciesUnavailable, + listCustomPoliciesAsRows: manifestPolicies === null, + }), + ); + setIsLoading(false); + }, [gatewayId, canViewPolicies, canViewManifest, canViewCustomPolicies]); useEffect(() => { void refresh(); }, [refresh]); @@ -160,20 +284,26 @@ export function GatewayPoliciesProvider({ gatewayId, children }: { gatewayId: st policies, isLoading, error, + warnings, refresh, syncPolicy, syncingPolicyKey, canViewPolicies, + canViewManifest, + canViewCustomPolicies, canSyncPolicies, }), [ policies, isLoading, error, + warnings, refresh, syncPolicy, syncingPolicyKey, canViewPolicies, + canViewManifest, + canViewCustomPolicies, canSyncPolicies, ], ); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx index 5d0f6d91e2..e974438316 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx @@ -109,7 +109,11 @@ import { countActiveDeployments, } from '../../../../utils/artifactDeletion'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; -import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; +import { + DISABLED_ACTION_SX, + NO_PERMISSION_TOOLTIP, + SCOPES, +} from '../../../../auth/permissions'; function getInitials(name: string): string { const words = name.trim().split(/\s+/); @@ -242,6 +246,8 @@ export default function ExternalServersOverview(): JSX.Element { const showSnackbar = useAIWorkspaceSnackbar(); const { hasPermission } = useAppAuth(); const canDeleteMcpProxy = hasPermission(SCOPES.MCP_PROXY_DELETE); + const canDeployMcpProxy = hasPermission(SCOPES.MCP_PROXY_DEPLOYMENT_CREATE); + const canViewDeployments = hasPermission(SCOPES.MCP_PROXY_DEPLOYMENT_READ); const [server, setServer] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSavingChanges, setIsSavingChanges] = useState(false); @@ -1085,27 +1091,36 @@ export default function ExternalServersOverview(): JSX.Element { alignItems="flex-end" sx={{ alignSelf: 'stretch' }} > - {/* For gateway-created (read-only) proxies the deployments remain viewable - (deploy/redeploy/restore/undeploy are disabled on the page itself), so - the button navigates but is relabelled "View Deployments". */} - + + @@ -115,9 +119,32 @@ export default function GatewayPolicies() { ); } if (!policies.length) - return No gateway manifest received yet.; + return ( + <> + {warnings.map((warning) => ( + void refresh()} + /> + ))} + + {canViewManifest + ? "No gateway manifest received yet." + : "No custom policies have been synced to this organization yet."} + + + ); return ( + <> + {warnings.map((warning) => ( + void refresh()} + /> + ))} @@ -161,6 +188,12 @@ export default function GatewayPolicies() { N/A + ) : policy.syncStatus === "Unknown" ? ( + + + Unknown + + ) : policy.syncStatus === "Synced" ? (
+ ); } diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx index 8525a4e4b3..bfe815295d 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx @@ -56,6 +56,8 @@ import { DisabledActionTooltip, GATEWAY_MANAGED_ARTIFACT_TOOLTIP, } from '../../../../utils/readOnlyArtifacts'; +import { useAppAuth } from '../../../../contexts/AppAuthContext'; +import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; interface GatewayWithDeployment extends Gateway { deployment?: DeploymentResponse; @@ -72,6 +74,8 @@ export default function LLMProxyDeploymentsCard() { const [generatedKey, setGeneratedKey] = useState(null); const [keyError, setKeyError] = useState(null); const isReadOnlyProxy = Boolean(proxy?.readOnly); + const { hasPermission } = useAppAuth(); + const canCreateProxyApiKey = hasPermission(SCOPES.LLM_PROXY_API_KEY_CREATE); const apiKeyLocation = proxy?.security?.apiKey?.in ?? 'header'; const apiKeyName = proxy?.security?.apiKey?.key ?? 'X-API-Key'; @@ -377,12 +381,18 @@ export default function LLMProxyDeploymentsCard() { (['AI']); const [drawerGuardrails, setDrawerGuardrails] = useState([]); const [drawerGuardrailsLoading, setDrawerGuardrailsLoading] = useState(false); + const [drawerGuardrailsError, setDrawerGuardrailsError] = useState(null); const [customPolicies, setCustomPolicies] = useState([]); const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false); + const [customPoliciesError, setCustomPoliciesError] = useState(null); const [draggedGlobalPolicyIndex, setDraggedGlobalPolicyIndex] = useState< number | null >(null); @@ -265,8 +266,12 @@ export default function LLMProxyGuardrailsTab() { policyIndex: number; } | null>(null); + // The drawer list is assembled from two independent sources. Each records its + // own failure so one being down still shows the other's policies, with a + // retry for just the failed source. const fetchDrawerGuardrails = useCallback(async (categories: string[]) => { setDrawerGuardrailsLoading(true); + setDrawerGuardrailsError(null); try { const showAll = categories.length === 0 || @@ -275,8 +280,12 @@ export default function LLMProxyGuardrailsTab() { ? await getPolicies() : await getGuardrails(categories.join(',')); setDrawerGuardrails(response.data); - } catch { + } catch (e) { + logger.error('Failed to load Policy Hub guardrails:', e); setDrawerGuardrails([]); + setDrawerGuardrailsError( + e instanceof Error ? e : new Error('Failed to load guardrails') + ); } finally { setDrawerGuardrailsLoading(false); } @@ -284,12 +293,16 @@ export default function LLMProxyGuardrailsTab() { const fetchCustomPolicies = useCallback(async () => { setCustomPoliciesLoading(true); + setCustomPoliciesError(null); try { const response = await getGatewayCustomPolicies(); setCustomPolicies(response.list || []); } catch (e) { logger.error('Failed to load custom policies:', e); setCustomPolicies([]); + setCustomPoliciesError( + e instanceof Error ? e : new Error('Failed to load custom policies') + ); } finally { setCustomPoliciesLoading(false); } @@ -1394,28 +1407,10 @@ export default function LLMProxyGuardrailsTab() { - {isLoadingGuardrails ? ( - - - - - - - ) : guardrailsError ? ( - - { - void refreshGuardrails(); - }} - /> - - ) : ( + {/* The shared guardrails context only supplies display names, so + neither its loading nor its error state gates the drawer — the + drawer's own two sources report themselves below. */} + {( <> {!isDetailView ? ( <> @@ -1446,6 +1441,23 @@ export default function LLMProxyGuardrailsTab() { }} /> + {!drawerItemsLoading && drawerGuardrailsError && ( + { + void fetchDrawerGuardrails(selectedCategories); + void refreshGuardrails(); + }} + /> + )} + {!drawerItemsLoading && customPoliciesError && ( + { + void fetchCustomPolicies(); + }} + /> + )} {drawerItemsLoading ? ( diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx index 064e6ad99c..f11ede546a 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx @@ -167,6 +167,8 @@ function ProxyOverviewContent() { const showSnackbar = useAIWorkspaceSnackbar(); const { hasPermission } = useAppAuth(); const canDeleteProxy = hasPermission(SCOPES.LLM_PROXY_DELETE); + const canDeployProxy = hasPermission(SCOPES.LLM_PROXY_DEPLOYMENT_CREATE); + const canViewDeployments = hasPermission(SCOPES.LLM_PROXY_DEPLOYMENT_READ); const canUpdateProxy = hasPermission(SCOPES.LLM_PROXY_UPDATE); const navigate = useNavigate(); const location = useLocation(); @@ -521,16 +523,27 @@ function ProxyOverviewContent() { alignItems="flex-end" sx={{ alignSelf: 'stretch' }} > - {/* Deployments remain viewable for gateway-created proxies (deploy/ - redeploy/restore/undeploy are disabled on the page itself), so the - button navigates but is relabelled "View Deployments". */} - + + { isMounted = false; }; - }, [currentOrganization?.uuid, proxy?.id]); + }, [currentOrganization?.uuid, proxy?.id, canViewProxyDeployments]); useEffect(() => { setLatestGeneratedKey(null); @@ -327,7 +338,9 @@ export default function LLMProxyOverviewTab() { useEffect(() => { const organizationId = currentOrganization?.uuid; const proxyId = proxy?.id; - if (!organizationId || !proxyId) { + // Without the api-key read scope the list request can only 403, which would + // surface as a failure snackbar rather than as a permission boundary. + if (!organizationId || !proxyId || !canReadProxyApiKey) { fetchedApiKeysProxyIdRef.current = null; fetchingApiKeysProxyIdRef.current = null; setApiKeys([]); @@ -377,7 +390,13 @@ export default function LLMProxyOverviewTab() { setKeysLoading(false); } }; - }, [currentOrganization?.uuid, getProxyAPIKeys, proxy?.id, showSnackbar]); + }, [ + currentOrganization?.uuid, + getProxyAPIKeys, + proxy?.id, + showSnackbar, + canReadProxyApiKey, + ]); const handleCopyGatewayUrl = async () => { if (!generatedGatewayUrl) return; @@ -391,7 +410,8 @@ export default function LLMProxyOverviewTab() { }; const handleGenerateAPIKey = async () => { - if (isReadOnlyProxy) return; + // Re-checked at submit time: the dialog can be open across a permission change. + if (isReadOnlyProxy || !canCreateProxyApiKey) return; if (!currentOrganization?.uuid || !proxy?.id) { return; } @@ -418,15 +438,19 @@ export default function LLMProxyOverviewTab() { setGeneratedKey(response.apiKey); setLatestGeneratedKey(response.apiKey); setIsApiKeyModalOpen(true); - try { - const refreshedApiKeys = await getProxyAPIKeys(); - setApiKeys(refreshedApiKeys.list || []); - } catch (fetchError) { - logger.error( - `Failed to refresh API keys for proxy ${proxy.id}:`, - fetchError - ); - showSnackbar('Failed to refresh API keys.', 'error'); + // A create-only user has no readable key list — skip the refresh instead + // of issuing a request that can only 403 and surface a false failure. + if (canReadProxyApiKey) { + try { + const refreshedApiKeys = await getProxyAPIKeys(); + setApiKeys(refreshedApiKeys.list || []); + } catch (fetchError) { + logger.error( + `Failed to refresh API keys for proxy ${proxy.id}:`, + fetchError + ); + showSnackbar('Failed to refresh API keys.', 'error'); + } } } catch (apiKeyError) { logger.error('Failed to generate API key:', apiKeyError); @@ -481,7 +505,8 @@ export default function LLMProxyOverviewTab() { }; const handleDeleteApiKey = async () => { - if (isReadOnlyProxy || !deleteTargetKeyName || !proxy?.id) { + // Re-checked at submit time: the dialog can be open across a permission change. + if (isReadOnlyProxy || !canDeleteProxyApiKey || !deleteTargetKeyName || !proxy?.id) { return; } @@ -686,12 +711,18 @@ export default function LLMProxyOverviewTab() { Generate API Key @@ -786,12 +819,18 @@ export default function LLMProxyOverviewTab() { { void handleDeleteApiKey(); }} - disabled={isDeletingKey} + disabled={isDeletingKey || !canDeleteProxyApiKey} > {isDeletingKey ? ( <> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx index 9061e841c1..7ca85d2226 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx @@ -55,6 +55,7 @@ import { parsePolicyYaml } from '../../../PolicyParameterEditor/yamlParser'; import type { GuardrailSelection } from './serviceProviderTypes'; import { FormattedMessage } from 'react-intl'; import ErrorAlert from '../../../../../Components/common/ErrorAlert'; +import PartialLoadWarning from '../../../../../Components/common/PartialLoadWarning'; import { autoAttachesCostPolicy } from '../../../../../utils/providerTemplateDisplay'; import type { PolicyHubPolicy } from '../../../../../utils/types'; import { logger } from '../../../../../utils/logger'; @@ -167,6 +168,7 @@ export default function GuardrailsSection({ const [isLoadingMoreGuardrails, setIsLoadingMoreGuardrails] = useState(false); const [customPolicies, setCustomPolicies] = useState([]); const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false); + const [customPoliciesError, setCustomPoliciesError] = useState(null); const [draggedGuardrailId, setDraggedGuardrailId] = useState( null ); @@ -236,12 +238,16 @@ export default function GuardrailsSection({ const fetchCustomPolicies = useCallback(async () => { setCustomPoliciesLoading(true); + setCustomPoliciesError(null); try { const response = await getGatewayCustomPolicies(); setCustomPolicies(response.list || []); } catch (e) { logger.error('Failed to load custom policies:', e); setCustomPolicies([]); + setCustomPoliciesError( + e instanceof Error ? e : new Error('Failed to load custom policies') + ); } finally { setCustomPoliciesLoading(false); } @@ -521,15 +527,6 @@ export default function GuardrailsSection({ /> - ) : drawerGuardrailsError ? ( - - { - void fetchDrawerGuardrails(selectedCategories, 0, false); - }} - /> - ) : ( + {/* One source failing leaves the other's policies + selectable — warn and offer a retry instead of + replacing the whole list with an error. */} + {drawerGuardrailsError && ( + { + // Retry reloads the first page, so the pagination + // cursor has to go back with it — otherwise the next + // "load more" resumes from the pre-failure offset and + // skips a page of guardrails. + setGuardrailsOffset(0); + void fetchDrawerGuardrails(selectedCategories, 0, false); + }} + /> + )} + {customPoliciesError && ( + { + void fetchCustomPolicies(); + }} + /> + )} {drawerItems .filter((g) => { diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx index 796145d692..45cd685abe 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx @@ -74,6 +74,7 @@ import type { AccessControl, PolicyHubPolicy } from '../../../../utils/types'; import { parsePolicyYaml } from '../../PolicyParameterEditor/yamlParser'; import { FormattedMessage } from 'react-intl'; import ErrorAlert from '../../../../Components/common/ErrorAlert'; +import PartialLoadWarning from '../../../../Components/common/PartialLoadWarning'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; import { @@ -230,8 +231,6 @@ export default function ServiceProviderGuardrailsTab() { const lockedActionTooltip = canEditProvider ? undefined : NO_PERMISSION_TOOLTIP; const { guardrails: availableGuardrails = [], - isLoading: isLoadingGuardrails, - error: guardrailsError, refreshGuardrails, getGuardrailDefinition, } = useGuardrails(); @@ -263,8 +262,10 @@ export default function ServiceProviderGuardrailsTab() { const [selectedCategories, setSelectedCategories] = useState(['AI']); const [drawerGuardrails, setDrawerGuardrails] = useState([]); const [drawerGuardrailsLoading, setDrawerGuardrailsLoading] = useState(false); + const [drawerGuardrailsError, setDrawerGuardrailsError] = useState(null); const [customPolicies, setCustomPolicies] = useState([]); const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false); + const [customPoliciesError, setCustomPoliciesError] = useState(null); const [draggedGlobalPolicyIndex, setDraggedGlobalPolicyIndex] = useState< number | null >(null); @@ -282,8 +283,12 @@ export default function ServiceProviderGuardrailsTab() { const reorderInFlightRef = useRef(false); const showSnackbar = useAIWorkspaceSnackbar(); + // The drawer list is assembled from two independent sources. Each records its + // own failure so one being down still shows the other's policies, with a + // retry for just the failed source. const fetchDrawerGuardrails = useCallback(async (categories: string[]) => { setDrawerGuardrailsLoading(true); + setDrawerGuardrailsError(null); try { const showAll = categories.length === 0 || @@ -292,8 +297,12 @@ export default function ServiceProviderGuardrailsTab() { ? await getPolicies() : await getGuardrails(categories.join(',')); setDrawerGuardrails(response.data); - } catch { + } catch (e) { + logger.error('Failed to load Policy Hub guardrails:', e); setDrawerGuardrails([]); + setDrawerGuardrailsError( + e instanceof Error ? e : new Error('Failed to load guardrails') + ); } finally { setDrawerGuardrailsLoading(false); } @@ -301,12 +310,16 @@ export default function ServiceProviderGuardrailsTab() { const fetchCustomPolicies = useCallback(async () => { setCustomPoliciesLoading(true); + setCustomPoliciesError(null); try { const response = await getGatewayCustomPolicies(); setCustomPolicies(response.list || []); } catch (e) { logger.error('Failed to load custom policies:', e); setCustomPolicies([]); + setCustomPoliciesError( + e instanceof Error ? e : new Error('Failed to load custom policies') + ); } finally { setCustomPoliciesLoading(false); } @@ -1480,28 +1493,10 @@ export default function ServiceProviderGuardrailsTab() { - {isLoadingGuardrails ? ( - - - - - - - ) : guardrailsError ? ( - - { - void refreshGuardrails(); - }} - /> - - ) : ( + {/* The shared guardrails context only supplies display names, so + neither its loading nor its error state gates the drawer — the + drawer's own two sources report themselves below. */} + {( <> {!isDetailView ? ( <> @@ -1530,6 +1525,23 @@ export default function ServiceProviderGuardrailsTab() { }} /> + {!drawerItemsLoading && drawerGuardrailsError && ( + { + void fetchDrawerGuardrails(selectedCategories); + void refreshGuardrails(); + }} + /> + )} + {!drawerItemsLoading && customPoliciesError && ( + { + void fetchCustomPolicies(); + }} + /> + )} {drawerItemsLoading ? ( diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index 5847b9eb5b..57668b84ea 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -95,7 +95,11 @@ import { } from '../../../../utils/providerTemplateDisplay'; import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; -import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; +import { + DISABLED_ACTION_SX, + NO_PERMISSION_TOOLTIP, + SCOPES, +} from '../../../../auth/permissions'; import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; import { AIEntityProvider, @@ -568,6 +572,8 @@ function ServiceProviderOverviewContent() { }, [isReadOnlyProvider, activeDeploymentCount, linkedProxyCount]); const canCreateProxy = hasPermission(SCOPES.LLM_PROXY_CREATE); + const canDeployProvider = hasPermission(SCOPES.LLM_PROVIDER_DEPLOYMENT_CREATE); + const canViewDeployments = hasPermission(SCOPES.LLM_PROVIDER_DEPLOYMENT_READ); const isCreateProxyDisabled = !provider?.id || isProxyQuotaReached || !canCreateProxy; const createProxyTooltip = !canCreateProxy @@ -1622,28 +1628,41 @@ function ServiceProviderOverviewContent() { }} > - {/* For gateway-created (read-only) providers the deployments remain - viewable (deploy/redeploy/restore/undeploy are disabled on the page - itself), so the button navigates but is relabelled "View Deployments". */} - + + (null); const fetchingApiKeysProviderIdRef = useRef(null); const [gateways, setGateways] = useState([]); @@ -889,12 +895,18 @@ export default function ServiceProviderOverviewTab({ /> - + @@ -975,9 +987,11 @@ export default function ServiceProviderOverviewTab({ @@ -989,7 +1003,11 @@ export default function ServiceProviderOverviewTab({ key.id?.trim() || null ) } - disabled={!key.id || isDeletingKey} + disabled={ + !canDeleteProviderApiKey || + !key.id || + isDeletingKey + } >