Skip to content

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.

  • 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.js and plugin-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 embedded console/ 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.

  1. The console lists the cluster’s installations: GET <kube-api-proxy>/clusters/<cluster-id>/apis/plugins.fundament.io/v1/plugininstallations.
  2. It keeps installations with status.phase Running and status.ready.
  3. For each, it fetches the definition from organization-api: organization.v1.PluginService/GetPluginDefinition with organization, plugin and version.
  4. menu entries appear in the sidebar under the plugin’s display name.

The definition advertises:

  • menu: which CRDs appear at organization and project level.
  • customComponents: a Kind → { list?, detail?, create?, edit? } map of files under console/. Kinds without an entry get the generated UI. edit only ever opens in a sheet.
  • 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.

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
  • :pluginName is the PluginInstallation’s metadata.name (<organizationName>--<pluginName>); :resourceKind is <plural>.<group>.
  • Each component looks up customComponents.<Kind>.list, .detail or .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 create component.

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 spec fields rendered from the schema, a status section (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 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) and Cache-Control: private, max-age=31536000, immutable: a version’s assets never change.

The iframe is created with sandbox="allow-scripts allow-same-origin allow-forms".

  • allow-same-origin is required: the page runs on the plugin-proxy origin, a different site from the console. The CSP’s script-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-forms serves create pages whose submits stay in the frame. allow-top-navigation and allow-popups are not granted.

A create or edit form, or a detail that belongs over the page rather than in place of it, opens in a sheet the console draws: its backdrop covers the whole window, as every console sheet does. A sheet a page draws inside its own frame dims and covers only the frame, and is only as tall as the frame is.

  • fundament.openSheet({ view: 'create' }) asks for one. The console builds the URL from the same installation’s customComponents[kind][view], kind defaulting to the page’s own, and refuses a page the definition does not declare. A request never carries a URL.
  • The page in the sheet is a plugin page like any other: the same sandbox, the same plugin-proxy origin, the same installation’s token.
  • The console writes the sheet’s title (New <kind>, Edit <name>, <name>) and says which plugin it comes from. The plugin does not choose it.
  • edit and detail need a resource. params is at most 16 string values, handed to the sheet page’s init as is.
  • One sheet at a time: a request while one is open is ignored. A request from the sheet’s own page replaces it.
  • The page in the sheet leaves with fundament.closeSheet({ changed: true }) after saving, or plugin:navigate to show a resource it made, which closes the sheet and opens that resource’s detail. The page under the sheet gets fundament:resources-changed in both cases, and when the sheet is replaced, and reads again: register with fundament.onResourcesChanged().
  • A create page also works on its create route, where closeSheet() goes back to the list.

The SDK sends and handles most messages itself.

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
plugin:open-sheet Open one of the plugin’s pages in a sheet { kind?, view, resource?, params? } Through fundament.openSheet()
plugin:close-sheet From a sheet: done { changed? } Through fundament.closeSheet()
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' }
fundament:resources-changed A sheet over the page saved something, or was replaced by another none
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', 'create' or 'edit'
presentation 'page' on a route, 'sheet' in a sheet
resource Detail and edit views: { name, namespace? }
params Pages in a sheet: the params the opening page passed to openSheet()
namespaces List, create and detail views in a project, and sheets over them: the project’s namespaces, by their name on the cluster (tnt-<project>--<name>). Pass these to the Kubernetes API as is
namespaceDisplayNames Wherever namespaces is set: maps each entry of namespaces to the name the console shows for it, to use as the label
kubeApiProxyUrl, clusterId Base for Kubernetes calls
token, tokenExpiresAt The PluginToken

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.

  • 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-refresh and retries once; a second 401 rejects with SdkError('unauthorized').
  • 403 rejects with SdkError('forbidden'), other HTTP errors with SdkError('http') carrying the Kubernetes Status message, network errors with SdkError('transport').

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;
openSheet(request: { kind?; view: 'create' | 'edit' | 'detail'; resource?; params? }): void;
closeSheet(options?: { changed?: boolean }): void;
onResourcesChanged(cb: () => void): () => void;
}

The SDK also, on its own:

  • Applies light/dark as a class on <body> on fundament:init and on every fundament:theme-changed.
  • Reports plugin:resize once stylesheets have loaded and on every ResizeObserver callback.
  • 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/.

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.

For a fundament.k8s.list({ group: 'cert-manager.io', version: 'v1', resource: 'certificates' }) call from inside the iframe:

  1. The console mints a PluginToken for the user and the installation (authn-api MintPluginToken: 15 minutes, aud=fundament-plugin) and sends it in fundament:init.
  2. The SDK builds <kubeApiProxyUrl>/clusters/<cluster-id>/apis/<group>/<version>/[namespaces/<ns>/]<resource> (/api/<version>/… for core resources) and calls it with Authorization: Bearer <PluginToken>.
  3. kube-api-proxy checks, in order:
    1. the token
    2. the cluster in the token matches the path
    3. OpenFGA can_view on the cluster
    4. a SubjectAccessReview for the user’s ServiceAccount fundament-system/fundament-<user-id>
  4. It forwards the call with a token for the plugin’s ServiceAccount: the cluster’s RBAC from permissions.rbac decides. The effective permission is the intersection of the user’s and the plugin’s.
  5. It logs a plugin gateway request with user, installation, plugin, version, definition hash and decision.
  6. The SDK resolves the promise with the response, or rejects with an SdkError.
  • /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_view on the cluster, then the call is forwarded.
  • Plugin pages are never served here, in any mode: plugin-proxy serves them (see URL construction).

KUBE_API_PROXY_MODE=mock (default) answers from fixtures:

  • Resources: cert-manager (Certificates, CertificateRequests, Issuers, ClusterIssuers), CloudNativePG (Databases, Backups, Subscriptions), demo.fundament.io DemoItems and OpenFSC FSCInstallations (including create).
  • PluginInstallations: GET, POST and DELETE, 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 asset page, which sends no protocol messages.

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).

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 answers 503 service account sync pending.
  • PluginToken path: SubjectAccessReview for the user on the shoot, then the plugin ServiceAccount token.
  • 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-dev runs the Vite dev server with HMR against a live cluster, and just openfsc console-preview serves the built pages.
  • Shoot clusters and per-user ServiceAccounts: real mode only.
Terminal window
just dev-hotreload # mock mode
just plugin-sandbox-kubeconfig # sandbox mode, see Local development
just test-env up # real mode against a local Gardener, in a VM

Real mode runs in the test-env VM, without hot reload: just test-env redeploy deploys a change there. See Test environment.

  1. Declare it in definition.yaml: map your HTML files to CRD kinds in spec.customComponents, and give the plugin the RBAC its pages need in spec.permissions.rbac.

  2. Embed the assets: put your HTML/JS/CSS under console/ and return them from ConsoleAssets():

    //go:embed console/*
    var consoleFiles embed.FS
    func (p *MyPluginPlugin) ConsoleAssets() http.FileSystem {
    return console.NewFileSystem(consoleFiles, "console")
    }
  3. Load the SDK from plugin-proxy: /plugins/sdk/v1/plugin-sdk.js and .css, with a .js module per page: the CSP blocks inline scripts. Copy loadSdk() from the cert-manager plugin’s _shared.js.

  4. Render: await fundament.init for the context, call fundament.k8s.* for data, and post plugin:navigate to open a detail view.

When changing anything on the Console-plugin boundary, walk through the full path in sandbox mode:

  1. Install a plugin with custom pages as in Testing plugins locally, then open a project with a cluster and open the plugin’s section.
  2. In browser devtools:
    • The iframe src is on plugin-proxy and carries the installation name and version; the response has the plugin CSP.
    • The iframe posts plugin:ready and the console answers with fundament:init.
    • Data requests go to kube-api-proxy with Authorization: Bearer.
    • Clicking a row posts plugin:navigate and the detail view loads.
  3. kubectl --context k3d-fundament -n fundament logs deploy/kube-api-proxy shows a plugin gateway request per call with its decision.
  4. Authorization spot-check: call a resource outside the plugin’s permissions.rbac. The call should reject with SdkError('forbidden'); in the sandbox it succeeds until the TODO under Kubernetes call path is fixed.