Skip to content
Open
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
24 changes: 24 additions & 0 deletions portals/ai-workspace/bff/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ type ControlPlaneConfig struct {
// TLSSkipVerify disables upstream certificate verification entirely. Last-resort
// escape hatch for dev/demo only; prefer CAFile.
TLSSkipVerify bool `koanf:"tls_skip_verify"`
// CloudURL is an optional second hop for Moesif analytics (wso2cloud platform-api).
// When set, <base>/proxy/cloud/* is proxied there instead of the primary control
// plane. Include the /cloud path prefix (e.g. http://host:8081/cloud).
CloudURL string `koanf:"cloud_url"`
// CloudCAFile / CloudTLSSkipVerify apply only to CloudURL when that hop uses TLS.
CloudCAFile string `koanf:"cloud_ca_file"`
CloudTLSSkipVerify bool `koanf:"cloud_tls_skip_verify"`
}

// SessionConfig is [ai_workspace.session]: server-side session lifetime.
Expand Down Expand Up @@ -334,6 +341,7 @@ func (c *Config) normalize() {
c.Auth.Authorization.Mode = strings.ToLower(c.Auth.Authorization.Mode)

c.ControlPlane.URL = strings.TrimRight(c.ControlPlane.URL, "/")
c.ControlPlane.CloudURL = strings.TrimRight(c.ControlPlane.CloudURL, "/")
c.Auth.OIDC.Issuer = strings.TrimRight(c.Auth.OIDC.Issuer, "/")

c.Cookie = CookieConfig{Name: cookieName, Secure: true, SameSite: "lax"}
Expand Down Expand Up @@ -417,6 +425,22 @@ func (c *Config) validate() error {
"Trust the upstream certificate with [control_plane] ca_file instead.")
}

if c.ControlPlane.CloudURL != "" {
cu, err := url.Parse(c.ControlPlane.CloudURL)
if err != nil || (cu.Scheme != "http" && cu.Scheme != "https") || cu.Host == "" {
return fmt.Errorf("[control_plane] cloud_url must be an absolute http:// or https:// URL, got %q", c.ControlPlane.CloudURL)
}
if cu.Scheme == "http" {
if c.ControlPlane.CloudCAFile != "" || c.ControlPlane.CloudTLSSkipVerify {
return fmt.Errorf("[control_plane] cloud_ca_file / cloud_tls_skip_verify are set but cloud_url is http:// (no TLS on that hop)")
}
}
if cu.Scheme == "https" && c.ControlPlane.CloudTLSSkipVerify {
slog.Warn("[control_plane] cloud_tls_skip_verify = true — cloud upstream certificate verification is DISABLED. " +
"Trust the upstream certificate with [control_plane] cloud_ca_file instead.")
}
}

if c.Auth.OIDCEnabled() {
if c.Auth.OIDC.Issuer == "" || c.Auth.OIDC.ClientID == "" || c.Auth.OIDC.ClientSecret == "" || c.Auth.OIDC.RedirectURL == "" {
return fmt.Errorf("OIDC mode requires [auth.oidc] authority, client_id, client_secret and redirect_url")
Expand Down
26 changes: 26 additions & 0 deletions portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,29 @@ func TestReverseProxy_StripsBasePathAndPrefix(t *testing.T) {
t.Errorf("upstream path = %q, want /api/v0.9/projects (base path + prefix not stripped)", gotPath)
}
}

// Cloud analytics hop: browser calls /ai-workspace/proxy/cloud/analytics/id-token;
// CloudURL is http://host/cloud, so after stripping <base>/proxy/cloud the upstream
// path must be /cloud/analytics/id-token.
func TestReverseProxy_CloudPrefixJoinsTargetPath(t *testing.T) {
var gotPath string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

target, _ := url.Parse(backend.URL + "/cloud")
rp := ReverseProxy(target, "/ai-workspace/proxy/cloud", backend.Client().Transport)

req := httptest.NewRequest(http.MethodGet, "/ai-workspace/proxy/cloud/analytics/id-token", nil)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, WithToken(req, "tok"))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if gotPath != "/cloud/analytics/id-token" {
t.Errorf("upstream path = %q, want /cloud/analytics/id-token", gotPath)
}
}
18 changes: 17 additions & 1 deletion portals/ai-workspace/bff/internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"errors"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
Expand Down Expand Up @@ -184,6 +185,21 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
// it upstream. No server-side lookup is involved unless the token is an OIDC
// access token that is near expiry and must be refreshed.
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
s.serveProxy(s.proxy, w, r)
}

// handleCloudProxy (<base>/proxy/cloud/*) — same session cookie injection as
// handleProxy, but against the optional Moesif / cloud analytics upstream.
func (s *Server) handleCloudProxy(w http.ResponseWriter, r *http.Request) {
s.serveProxy(s.cloudProxy, w, r)
}

func (s *Server) serveProxy(rp *httputil.ReverseProxy, w http.ResponseWriter, r *http.Request) {
if rp == nil {
http.NotFound(w, r)
return
}

jwt, ok := s.tokenFromCookie(r)
if !ok {
writeErrorJSON(w, http.StatusUnauthorized, "NOT_AUTHENTICATED", "not authenticated")
Expand All @@ -209,7 +225,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
}
}

s.proxy.ServeHTTP(w, proxy.WithToken(r, jwt))
rp.ServeHTTP(w, proxy.WithToken(r, jwt))
}

// ---------------------------------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion portals/ai-workspace/bff/internal/server/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,18 @@ func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler
}

// securityHeaders sets global response headers (ports the nginx index.html block).
//
// Referrer-Policy is strict-origin-when-cross-origin (not no-referrer) so Moesif
// wrap/basic embeds can read the parent console origin for embed allowlisting.
// Matches the Insights iframe referrerPolicy attribute.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Permitted-Cross-Domain-Policies", "none")
next.ServeHTTP(w, r)
})
Expand Down
8 changes: 5 additions & 3 deletions portals/ai-workspace/bff/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST "+s.path("/api/llm-providers"), s.handleCreateLLMProvider)
mux.HandleFunc("POST "+s.path("/api/mcp-proxies"), s.handleCreateMCPServer)

// Same-origin reverse proxy to the Platform API. The proxy's Rewrite hook
// strips the base path and the proxy prefix before forwarding (see
// server.New), so we register the subtree directly.
// Same-origin reverse proxy to the Platform API. Optional cloud hop is more
// specific (/proxy/cloud/) and must be registered before the catch-all /proxy/.
if s.cloudProxy != nil {
mux.HandleFunc(s.path(paths.Proxy)+"/cloud/", s.handleCloudProxy)
}
mux.HandleFunc(s.path(paths.Proxy)+"/", s.handleProxy)

// SPA static files + client-side routing fallback (must be last). The prefix is
Expand Down
31 changes: 24 additions & 7 deletions portals/ai-workspace/bff/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ type refreshLock struct {

// Server holds the BFF dependencies and HTTP handler.
type Server struct {
cfg *config.Config
claims session.ClaimMapping
store session.Store
fileBased *auth.FileBased
oidc *auth.OIDC
proxy *httputil.ReverseProxy
handler http.Handler
cfg *config.Config
claims session.ClaimMapping
store session.Store
fileBased *auth.FileBased
oidc *auth.OIDC
proxy *httputil.ReverseProxy
cloudProxy *httputil.ReverseProxy
handler http.Handler

refreshMu sync.Mutex
refreshLocks map[string]*refreshLock
Expand Down Expand Up @@ -95,6 +96,22 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
refreshLocks: make(map[string]*refreshLock),
}

if cfg.ControlPlane.CloudURL != "" {
cloudTarget, err := url.Parse(cfg.ControlPlane.CloudURL)
if err != nil {
return nil, err
}
cloudTransport, err := proxy.NewTransport(cfg.HTTPClient, proxy.TLSClientOptions{
CAFile: cfg.ControlPlane.CloudCAFile,
SkipVerify: cfg.ControlPlane.CloudTLSSkipVerify,
})
if err != nil {
return nil, err
}
// Strip <base>/proxy/cloud so /analytics/id-token joins onto CloudURL's /cloud.
s.cloudProxy = proxy.ReverseProxy(cloudTarget, paths.Base+paths.Proxy+"/cloud", cloudTransport)
}

if cfg.Auth.OIDCEnabled() {
// The session store exists only to hold OIDC refresh/id tokens for renewal.
// File-based sessions are fully self-contained in the cookie JWT.
Expand Down
59 changes: 57 additions & 2 deletions portals/ai-workspace/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import { Box, Button, Stack, Typography } from '@wso2/oxygen-ui';
import OoopsImage from './assets/images/Ooops.svg';
import {
AI_WORKSPACE_GATEWAYS_SLOT,
AI_WORKSPACE_INSIGHTS_SLOT,
AI_WORKSPACE_SIDEBAR_SLOT,
ExtensionsProvider,
hiddenRegionsOf,
Expand All @@ -97,6 +98,43 @@ import {
import { Hideable, HiddenRegionsProvider, useSlot } from './slots';
import { usePort } from './hostPort';

/** Moesif wrap hosts the cloud Insights embed trusts (keep in sync with the plugin allowlist). */
const ALLOWED_MOESIF_ORIGINS = new Set([
'https://www.moesif.com',
'https://web-dev.moesif.com',
]);

/**
* Whether cloud Insights can embed Moesif. Kept in App (not imported from the
* plugin) so the OSS portal builds without `@wso2-enterprise/apip-cloud-ui-insights`.
* When false, InsightsRoute keeps the built-in page even if a cloud override is registered.
*/
function isCloudInsightsMoesifConfigured(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟡 minor
Subject: Moesif config resolution is duplicated here with a different key set and different precedence from the plugin

Description
Hand-copying ALLOWED_MOESIF_ORIGINS and the config reader so the OSS portal builds without the plugin is a reasonable goal, but the two readers disagree. The plugin's configuredMoesifAppUrl() also accepts MOESIF_BASIC_INSIGHTS_URL / moesifBasicInsightsUrl / VITE_MOESIF_BASIC_INSIGHTS_URL; this one does not. And the plugin merges {...__RUNTIME_CONFIG__, ...config} so window.config wins, while this chains __RUNTIME_CONFIG__?.X || config?.X so __RUNTIME_CONFIG__ wins. A deployment that sets only a BASIC_INSIGHTS key, or sets both objects with different values, gets InsightsRoute and the plugin disagreeing about whether Moesif is configured — the built-in page renders while the plugin would have embedded fine. Confirmed by reading this file and portals/cloud-plugins/apip-cloud-ui-insights/src/config/runtimeConfig.ts. The plugin already exports isInsightsMoesifConfigured for exactly this, and nothing uses it.

How to verify
Set window.config = { moesifBasicInsightsUrl: 'https://www.moesif.com' } with __RUNTIME_CONFIG__ empty and load /ai-workspace/insights: the built-in page renders even though the plugin's own insightsRuntimeConfig.moesifAppUrl resolves.

Suggested fix
Keep one reader. Either import isInsightsMoesifConfigured in the cloud host file (apip-cloud-ui/src/hosts/ai-workspace.tsx) and let the registered override decide, so App.tsx needs no plugin knowledge at all; or, if the check must stay here, make the key list and the config/__RUNTIME_CONFIG__ precedence identical to the plugin's and cross-reference both in comments.

Prompt for Claude

In portals/ai-workspace/src/App.tsx lines 99-136 and
portals/cloud-plugins/apip-cloud-ui-insights/src/config/runtimeConfig.ts.

1. FIRST verify the divergence: list the keys each reader accepts, and compare the
   window.config vs window.__RUNTIME_CONFIG__ precedence in each. If the key sets and
   precedence already match, STOP — the finding is wrong.
2. Preferred fix: move the "is Moesif configured" decision out of App.tsx into the cloud host
   registration (portals/cloud-plugins/apip-cloud-ui/src/hosts/ai-workspace.tsx), using the
   plugin's exported isInsightsMoesifConfigured, so InsightsRoute only checks whether an
   override is registered. Verify the OSS ai-workspace build still compiles with no import of
   @wso2-enterprise/apip-cloud-ui-insights in portals/ai-workspace/src — that constraint is
   the whole reason the helper was inlined.
3. If step 2 is not possible, instead align App.tsx's key list and precedence exactly with the
   plugin's and cross-reference both in comments.
4. Do not change the fallback behaviour: when Moesif is unconfigured, InsightsRoute must keep
   rendering the built-in <Insights /> page.
5. Confirm with `npm run typecheck` and `npm test` in portals/ai-workspace.

const runtimeWindow = window as Window & {
__RUNTIME_CONFIG__?: Record<string, string | undefined>;
config?: Record<string, string | undefined>;
};
const configured =
runtimeWindow.__RUNTIME_CONFIG__?.APIP_AIW_MOESIF_WEB_URL ||
runtimeWindow.__RUNTIME_CONFIG__?.moesifAppUrl ||
runtimeWindow.__RUNTIME_CONFIG__?.MOESIF_APP_URL ||
runtimeWindow.config?.APIP_AIW_MOESIF_WEB_URL ||
runtimeWindow.config?.moesifAppUrl ||
import.meta.env.APIP_AIW_MOESIF_WEB_URL ||
import.meta.env.VITE_MOESIF_APP_URL ||
'';
const trimmed = String(configured).trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return (
parsed.protocol === 'https:' && ALLOWED_MOESIF_ORIGINS.has(parsed.origin)
);
} catch {
return false;
}
}

/**
* Only allow same-origin relative paths as return URLs to prevent open redirects.
* Rejects protocol-relative URLs (//evil.com) and absolute URLs.
Expand Down Expand Up @@ -311,6 +349,23 @@ function GatewaysRoute() {
);
}

// Same Slot/Hideable pattern as GatewaysRoute for the built-in Insights page
// (org + project `/insights`). Cloud registers embedProfile="ai-workspace".
// If the Moesif origin is not configured, keep the built-in page instead of
// replacing it with "Insights is not configured for this deployment."
function InsightsRoute() {
const port = usePort();
const [override] = useSlot<AIWorkspacePageOverride>(AI_WORKSPACE_INSIGHTS_SLOT);
if (override && isCloudInsightsMoesifConfigured()) {
return <>{override.render(port)}</>;
}
return (
<Hideable name={AI_WORKSPACE_INSIGHTS_SLOT}>
<Insights />
</Hideable>
);
}

export type AppProps = {
extensions?: readonly AIWorkspaceCloudEntry[];
};
Expand Down Expand Up @@ -584,7 +639,7 @@ function WorkspaceRoutes({ extensions = [] }: AppProps) {
path="insights"
element={
<WithPageBoundary>
<Insights />
<InsightsRoute />
</WithPageBoundary>
}
/>
Expand Down Expand Up @@ -837,7 +892,7 @@ function WorkspaceRoutes({ extensions = [] }: AppProps) {
path="insights"
element={
<WithPageBoundary>
<Insights />
<InsightsRoute />
</WithPageBoundary>
}
/>
Expand Down
7 changes: 7 additions & 0 deletions portals/ai-workspace/src/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export type AIWorkspaceExtension = SlotEntry & {
*/
export const AI_WORKSPACE_GATEWAYS_SLOT = 'page.gateways';

/**
* Slot for overriding the built-in Insights page (org + project `/insights`)
* with the shared cloud Moesif embed. Same Slot/Hideable split as gateways —
* the built-in sidebar item and routes stay; only the page body changes.
*/
export const AI_WORKSPACE_INSIGHTS_SLOT = 'page.insights';

/**
* A host-injected replacement for a specific built-in page. Unlike
* `AIWorkspaceExtension`, this isn't a new sidebar item — the built-in
Expand Down
8 changes: 7 additions & 1 deletion portals/ai-workspace/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client", "node"]
"baseUrl": ".",
"types": ["vite/client", "node"],
"paths": {
"@wso2-enterprise/apip-cloud-ui-insights": [
"../cloud-plugins/apip-cloud-ui-insights/src/index.ts"
]
}
},
"include": ["src"]
}
16 changes: 16 additions & 0 deletions portals/ai-workspace/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import basicSsl from '@vitejs/plugin-basic-ssl'
// Pinned to 7.1.0: its peer range is vite >=4 <=7. Newer major releases (8.x, 9.x)
// require vite >=7, incompatible with this project's vite@5.4.21.
import istanbul from 'vite-plugin-istanbul'
import fs from 'node:fs'
import path from 'path'
import { fileURLToPath } from 'url'

Expand All @@ -32,6 +33,17 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const repoRoot = path.resolve(__dirname, '../../..')
const cloudPluginsRoot = path.resolve(__dirname, '../cloud-plugins')
const insightsLocalEntry = path.resolve(
cloudPluginsRoot,
'apip-cloud-ui-insights/src/index.ts'
)
// Monorepo: alias to the sibling plugin source. Docker SaaS images only copy
// portals/ai-workspace into /web and install Insights into node_modules — the
// sibling path is absent there, so skip the alias and let Node resolve the package.
const insightsAlias = fs.existsSync(insightsLocalEntry)
? { '@wso2-enterprise/apip-cloud-ui-insights': insightsLocalEntry }
: {}
const rushTemp = path.resolve(repoRoot, 'common/temp')
const aiTemp = path.resolve(rushTemp, 'ai-workspace')
const aiNodeModules = path.resolve(aiTemp, 'node_modules')
Expand Down Expand Up @@ -147,12 +159,16 @@ export default defineConfig({
envPrefix: browserSafeEnvVars,
resolve: {
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
alias: {
...insightsAlias,
},
},
server: {
port: 9643,
fs: {
allow: [
path.resolve(__dirname),
...(fs.existsSync(cloudPluginsRoot) ? [cloudPluginsRoot] : []),
repoRoot,
rushTemp,
aiTemp,
Expand Down
3 changes: 3 additions & 0 deletions portals/api-control-plane/bff/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ type ControlPlaneConfig struct {
// Upstreams are additional named backends, each proxied same-origin at
// {name-derived prefix}/*. Optional; empty for every standalone deployment.
Upstreams []UpstreamConfig `koanf:"upstreams"`
// MoesifAppURL is the HTTPS origin of the Moesif wrap/basic host (e.g.
// https://www.moesif.com). Bridged to the SPA as moesifAppUrl when set.
MoesifAppURL string `koanf:"moesif_app_url"`
}

// UpstreamConfig is one [[api_control_plane.control_plane.upstreams]] entry: a
Expand Down
12 changes: 10 additions & 2 deletions portals/api-control-plane/bff/internal/config/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,19 @@ func buildRuntimeConfig(cfg *Config) map[string]string {
// false client-side) for every deployment that doesn't configure one —
// every standalone deployment today.
for _, u := range cfg.ControlPlane.Upstreams {
if u.Name == "billing" {
switch u.Name {
case "billing":
out["billingProxyEnabled"] = "true"
break
case "cloud":
out["cloudProxyEnabled"] = "true"
}
}

// Moesif wrap/basic iframe origin for cloud Insights embeds. Emitted
// explicitly so the SPA never guesses from environmentName.
if cfg.ControlPlane.MoesifAppURL != "" {
out["moesifAppUrl"] = cfg.ControlPlane.MoesifAppURL
}

return out
}
Loading
Loading