Console integration
How a Fundament plugin renders inside the Console: discovery, the iframe boundary, the postMessage SDK, and the Kubernetes call path through kube-api-proxy in mock, sandbox and real mode. Design: FUN-17 Plugin Authorization.
This document is the architecture reference for anyone working on the Console-plugin boundary. For a plugin author’s how-to, start with Writing a plugin and Custom UI. For the container lifecycle (controller, RBAC, install/uninstall), see the Plugins overview.
Overview
Section titled “Overview”- Console frontend (
console-frontend/): the host Angular app. Discovers installed plugins, renders the sidebar and routes, mounts plugin iframes and mints PluginTokens for them. - Plugin SDK (
console-frontend/src/plugin-sdk/):plugin-sdk.jsandplugin-sdk.css, loaded by plugin pages. Handles the postMessage protocol, the PluginToken and Kubernetes calls. - plugin-proxy (
plugin-proxy/): serves plugin pages and the SDK on its own origin. - authn-api (
authn-api/): mints PluginTokens. - kube-api-proxy (
kube-api-proxy/): the gateway for every cluster request, from the console and from plugin pages. Runs in mock, sandbox or real mode. - Plugin runtime (
plugin-sdk/pluginruntime/): the Go framework each plugin embeds. Serves the plugin’s metadata API and its embeddedconsole/assets.
browser ┌──────────────────────────────────────────────────────────────┐ │ console origin plugin-proxy origin │ │ ┌──────────────────┐ postMessage ┌──────────────────────────┐│ │ │ console-frontend │◄───────────►│ iframe: console/*.html ││ │ └────┬────────┬────┘ │ + /plugins/sdk/v1/ SDK ││ │ │ │ └──────┬─────────────┬─────┘│ └──────┼────────┼─────────────────────────┼─────────────┼──────┘ │ │ │ │ ▼ ▼ ▼ ▼ organization-api kube-api-proxy ◄──────────── k8s.* calls (definitions) (installations) plugin-proxy (pages, SDK) │ │ ▼ ▼ cluster ◄──────────── plugin pod (/console/)Plugin pages run on the plugin-proxy origin and never see the Console user’s cookies. They call kube-api-proxy with a PluginToken: the user, acting through one plugin installation.
Discovery and registration
Section titled “Discovery and registration”- The console lists the cluster’s installations:
GET <kube-api-proxy>/clusters/<cluster-id>/apis/plugins.fundament.io/v1/plugininstallations. - It keeps installations with
status.phaseRunningandstatus.ready. - For each, it fetches the definition from organization-api:
organization.v1.PluginService/GetPluginDefinitionwith organization, plugin and version. menuentries appear in the sidebar under the plugin’s display name.
The definition advertises:
menu: which CRDs appear at organization and project level.customComponents: aKind→{ list?, detail?, create? }map of files underconsole/. Kinds without an entry get the generated UI.permissions.rbac: the plugin’s ServiceAccount RBAC, which also limits the plugin’s page calls.allowedResources: the resources the plugin’s pages read.crds: the CRDs the plugin manages.
Routing and rendering
Section titled “Routing and rendering”The plugin routes live under plugin-resources/, at organization level and under projects/:id/:
| Route | Component |
|---|---|
plugin-resources/:pluginName/:resourceKind |
ResourceListComponent |
plugin-resources/:pluginName/:resourceKind/create |
ResourceCreateComponent |
plugin-resources/:pluginName/:resourceKind/:resourceId |
ResourceDetailComponent |
:pluginNameis the PluginInstallation’smetadata.name(<organizationName>--<pluginName>);:resourceKindis<plural>.<group>.- Each component looks up
customComponents.<Kind>.list,.detailor.create. If present, it mounts the plugin iframe; if absent, it renders the generated view. - The list shows a create action only when the kind has a
createcomponent.
Generated fallback UI
Section titled “Generated fallback UI”When a plugin provides no customComponents entry for a CRD kind, the console renders a generated view from the CRD’s OpenAPI v3 schema (loaded from apiextensions.k8s.io/v1/customresourcedefinitions):
- List: a table whose columns come from the CRD’s
additionalPrinterColumns(falling back to Name + Age), with a row per object and a link to the detail view. - Detail: object metadata, the
specfields rendered from the schema, astatussection (including a conditions table when present), and a delete action.
The generated UI does not create or edit resources. Ship a custom UI for write actions or a bespoke layout.
The iframe boundary
Section titled “The iframe boundary”URL construction
Section titled “URL construction”The console turns the file from customComponents into the iframe src:
https://<plugin-proxy>/clusters/<cluster-id>/plugins/<installation-name>/<version>/console/<file>- plugin-proxy checks the user’s access to the cluster, confirms the installed version, and fetches the file from the plugin pod through the cluster’s API-server service proxy (
/api/v1/namespaces/<plugin-namespace>/services/<service>/proxy/console/<file>). - The response carries
Content-Security-Policy(Custom UI) andCache-Control: private, max-age=31536000, immutable: a version’s assets never change.
Sandbox
Section titled “Sandbox”The iframe is created with sandbox="allow-scripts allow-same-origin allow-forms".
allow-same-originis required: the page runs on the plugin-proxy origin, a different site from the console. The CSP’sscript-src 'self'needs a real origin to resolve, and postMessage target-origin pinning needs a checkable origin at both ends.- The same-origin policy between sites still blocks access to the console’s document, and the user’s HttpOnly cookie stays unreachable.
allow-formsserves create pages whose submits stay in the frame.allow-top-navigationandallow-popupsare not granted.
The postMessage protocol
Section titled “The postMessage protocol”The SDK sends and handles most messages itself.
Plugin → host
Section titled “Plugin → host”| Type | When | Payload | Sent by SDK |
|---|---|---|---|
plugin:ready |
The SDK script loads | none | Yes |
plugin:resize |
Content height changes (debounced 50 ms, ResizeObserver) |
{ height } |
Yes |
plugin:request-token-refresh |
A call returned 401 | none | Yes |
plugin:navigate |
Open another resource | { name, namespace? } |
No: call from your code |
plugin:create |
Open the create route | No | |
plugin:navigate-back |
Back to the list | No |
Host → plugin
Section titled “Host → plugin”| Type | When | Payload |
|---|---|---|
fundament:init |
After plugin:ready; first message |
Init payload, see below |
fundament:theme-changed |
User toggles the Console theme | { theme: 'light' | 'dark' } |
fundament:token-refreshed |
The console minted a new PluginToken | { token, tokenExpiresAt } |
fundament:auth-failed |
Minting keeps failing | { reason: 'mint_failed' | 'unauthorized' | 'revoked' } |
Init payload fields
Section titled “Init payload fields”| Field | Description |
|---|---|
protocolVersion |
1; the SDK ignores other versions |
theme |
'light' or 'dark'; the SDK sets it as a class on <body> |
pluginName |
The installed plugin’s name |
crdKind |
The CRD kind being rendered |
view |
'list', 'detail' or 'create' |
resource |
Detail views: { name, namespace? } |
namespaces |
Create views in a project: the project’s namespaces |
kubeApiProxyUrl, clusterId |
Base for Kubernetes calls |
token, tokenExpiresAt |
The PluginToken |
Origin pinning
Section titled “Origin pinning”The SDK accepts messages only from window.parent. The first accepted message must be fundament:init; the SDK pins its origin and drops any later message from another origin. Outbound messages that carry nothing sensitive use '*' until init arrives; plugin:request-token-refresh is sent only to the pinned origin.
Request lifecycle
Section titled “Request lifecycle”- A call waits up to 20 s for a token, then fails.
- Each request is aborted after 30 s and rejects with
SdkError('timeout'). - On 401 the SDK drops the token, sends
plugin:request-token-refreshand retries once; a second 401 rejects withSdkError('unauthorized'). - 403 rejects with
SdkError('forbidden'), other HTTP errors withSdkError('http')carrying the KubernetesStatusmessage, network errors withSdkError('transport').
The SDK surface
Section titled “The SDK surface”The SDK sets a single global, window.fundament:
interface FundamentSdk { init: Promise<InitContext>; readonly parentOrigin: string | null; getToken(): Promise<string>; fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; k8s: { list<T>(args: { group; version; resource; namespace? }): Promise<{ items: T[] }>; get<T>(args: { group; version; resource; name; namespace? }): Promise<T>; create<T>(args: { group; version; resource; namespace? }, body: unknown): Promise<T>; patch<T>(args: { group; version; resource; name; namespace? }, body: unknown): Promise<T>; // merge patch delete<T>(args: { group; version; resource; name; namespace? }): Promise<T>; }; onThemeChange(cb: (theme: 'light' | 'dark') => void): () => void;}The SDK also, on its own:
- Applies
light/darkas a class on<body>onfundament:initand on everyfundament:theme-changed. - Reports
plugin:resizeonce stylesheets have loaded and on everyResizeObservercallback. - Pins the parent origin and attaches and refreshes the PluginToken.
plugin-proxy serves the bundle at /plugins/sdk/v1/plugin-sdk.js (and .css); the Console serves the same files at /plugin-ui/.
How a plugin loads the SDK
Section titled “How a plugin loads the SDK”The page CSP allows scripts and styles only from the page’s own origin, so pages load the SDK from plugin-proxy at /plugins/sdk/v1/. The cert-manager plugin’s _shared.js is the reference implementation:
export function loadSdk() { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/plugins/sdk/v1/plugin-sdk.css'; document.head.appendChild(link);
return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '/plugins/sdk/v1/plugin-sdk.js'; script.onload = () => resolve(window.fundament); script.onerror = () => reject(new Error('failed to load plugin-sdk.js')); document.head.appendChild(script); });}Every cert-manager page script starts with await loadSdk(); await fundament.init; and then fetches its data through fundament.k8s.list / .get.
Kubernetes call path
Section titled “Kubernetes call path”For a fundament.k8s.list({ group: 'cert-manager.io', version: 'v1', resource: 'certificates' }) call from inside the iframe:
- The console mints a PluginToken for the user and the installation (authn-api
MintPluginToken: 15 minutes,aud=fundament-plugin) and sends it infundament:init. - The SDK builds
<kubeApiProxyUrl>/clusters/<cluster-id>/apis/<group>/<version>/[namespaces/<ns>/]<resource>(/api/<version>/…for core resources) and calls it withAuthorization: Bearer <PluginToken>. - kube-api-proxy checks, in order:
- the token
- the cluster in the token matches the path
- OpenFGA
can_viewon the cluster - a SubjectAccessReview for the user’s ServiceAccount
fundament-system/fundament-<user-id>
- It forwards the call with a token for the plugin’s ServiceAccount: the cluster’s RBAC from
permissions.rbacdecides. The effective permission is the intersection of the user’s and the plugin’s. - It logs a
plugin gateway requestwith user, installation, plugin, version, definition hash and decision. - The SDK resolves the promise with the response, or rejects with an
SdkError.
kube-api-proxy: mock, sandbox and real
Section titled “kube-api-proxy: mock, sandbox and real”Shared behavior
Section titled “Shared behavior”/clusters/<cluster-id>/{api|apis|openapi|version}/…is forwarded to the cluster handler; other roots return 404.- A PluginToken takes the gateway path above.
- A UserToken or the console’s cookie takes the user path: token validation, OpenFGA
can_viewon the cluster, then the call is forwarded. - Plugin pages are never served here, in any mode: plugin-proxy serves them (see URL construction).
Mock mode
Section titled “Mock mode”KUBE_API_PROXY_MODE=mock (default) answers from fixtures:
- Resources: cert-manager (Certificates, CertificateRequests, Issuers, ClusterIssuers), CloudNativePG (Databases, Backups, Subscriptions),
demo.fundament.ioDemoItems and OpenFSC FSCInstallations (including create). - PluginInstallations:
GET,POSTandDELETE, held in memory per cluster. A restart loses them. - PluginToken path: the user SubjectAccessReview allows all, and the plugin ServiceAccount token is a placeholder.
- Plugin pages: without a sandbox cluster, plugin-proxy answers every page request with a bare
mock assetpage, which sends no protocol messages.
Sandbox mode
Section titled “Sandbox mode”just plugin-sandbox-kubeconfig sets PLUGIN_SANDBOX_KUBECONFIG, which switches mock mode to the k3d-fundament-plugin cluster:
- User path: forwarded with the sandbox kubeconfig’s credentials.
- PluginToken path: SubjectAccessReview for the user against the sandbox, then the plugin ServiceAccount token (see the TODO under Kubernetes call path).
Real mode
Section titled “Real mode”KUBE_API_PROXY_MODE=real with GARDENER_KUBECONFIG:
- Clusters: the proxy fetches each shoot’s admin kubeconfig from Gardener, caches it and refreshes it at 70 % of its TTL.
- User path: each request uses a token for the user’s ServiceAccount
fundament-system/fundament-<user-id>, requested through the TokenRequest API, cached per user and cluster, refreshed at 80 % of its TTL, with concurrent requests deduplicated. Before the ServiceAccount exists the proxy answers503 service account sync pending. - PluginToken path: SubjectAccessReview for the user on the shoot, then the plugin ServiceAccount token.
Implications
Section titled “Implications”- Console work without a cluster: mock mode; the fixtures cover the resources above.
- Plugin runtime and plugin pages: sandbox mode. The plugin’s own container runs, its pages load from plugin-proxy, and the metadata API is answered by the plugin.
- Plugin page iteration: the plugin’s own preview loop, not the console. For OpenFSC,
just openfsc console-devruns the Vite dev server with HMR against a live cluster, andjust openfsc console-previewserves the built pages. - Shoot clusters and per-user ServiceAccounts: real mode only.
Local dev shortcuts
Section titled “Local dev shortcuts”just dev-hotreload # mock modejust plugin-sandbox-kubeconfig # sandbox mode, see Local developmentjust dev -p local-gardener # real mode against a local GardenerPlugin author’s quick guide
Section titled “Plugin author’s quick guide”-
Declare it in
definition.yaml: map your HTML files to CRD kinds inspec.customComponents, and give the plugin the RBAC its pages need inspec.permissions.rbac. -
Embed the assets: put your HTML/JS/CSS under
console/and return them fromConsoleAssets()://go:embed console/*var consoleFiles embed.FSfunc (p *MyPluginPlugin) ConsoleAssets() http.FileSystem {return console.NewFileSystem(consoleFiles, "console")} -
Load the SDK from plugin-proxy:
/plugins/sdk/v1/plugin-sdk.jsand.css, with a.jsmodule per page: the CSP blocks inline scripts. CopyloadSdk()from the cert-manager plugin’s_shared.js. -
Render:
await fundament.initfor the context, callfundament.k8s.*for data, and postplugin:navigateto open a detail view.
Verifying the integration end-to-end
Section titled “Verifying the integration end-to-end”When changing anything on the Console-plugin boundary, walk through the full path in sandbox mode:
- Install a plugin with custom pages as in Testing plugins locally, then open a project with a cluster and open the plugin’s section.
- In browser devtools:
- The iframe
srcis on plugin-proxy and carries the installation name and version; the response has the plugin CSP. - The iframe posts
plugin:readyand the console answers withfundament:init. - Data requests go to kube-api-proxy with
Authorization: Bearer. - Clicking a row posts
plugin:navigateand the detail view loads.
- The iframe
kubectl --context k3d-fundament -n fundament logs deploy/kube-api-proxyshows aplugin gateway requestper call with its decision.- Authorization spot-check: call a resource outside the plugin’s
permissions.rbac. The call should reject withSdkError('forbidden'); in the sandbox it succeeds until the TODO under Kubernetes call path is fixed.