From 9a6088c5d1d6fac9e2f70f6acfa007c2b95d00d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:06:51 +0000 Subject: [PATCH 1/7] Update stacklok/toolhive to v0.47.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/upstream-projects.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/upstream-projects.yaml b/.github/upstream-projects.yaml index 2561a31f..087ded53 100644 --- a/.github/upstream-projects.yaml +++ b/.github/upstream-projects.yaml @@ -44,7 +44,7 @@ projects: - id: toolhive repo: stacklok/toolhive - version: v0.46.0 + version: v0.47.0 # toolhive is a monorepo covering the CLI, the Kubernetes # operator, and the vMCP gateway. It also introduces cross- # cutting features that land in concepts/, integrations/, From e9cb771770c779f8f0b7a2a390c306f3d29c33a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 15:07:56 +0000 Subject: [PATCH 2/7] Refresh reference assets for toolhive v0.47.0 --- .../reference/cli/thv_ai-plugin_install.md | 1 + .../reference/cli/thv_ai-plugin_push.md | 4 +- .../reference/cli/thv_ai-plugin_sync.md | 4 +- .../reference/cli/thv_ai-plugin_upgrade.md | 2 - docs/toolhive/reference/cli/thv_proxy.md | 1 + docs/toolhive/reference/cli/thv_run.md | 1 + .../reference/cli/thv_skill_install.md | 1 + docs/toolhive/reference/cli/thv_skill_push.md | 2 +- .../reference/crds/mcpexternalauthconfig.mdx | 3 - static/api-specs/toolhive-api.yaml | 380 ++++++++- static/api-specs/toolhive-crds/index.json | 6 +- .../mcpexternalauthconfigs.example.yaml | 3 - .../mcpexternalauthconfigs.schema.json | 628 +++++++++++++- .../toolhive-crds/mcpoidcconfigs.schema.json | 8 +- .../mcpserverentries.schema.json | 8 +- .../toolhive-crds/mcpservers.schema.json | 6 + .../mcptelemetryconfigs.schema.json | 8 +- .../virtualmcpservers.schema.json | 780 +++++++++++++++++- 18 files changed, 1765 insertions(+), 81 deletions(-) diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_install.md b/docs/toolhive/reference/cli/thv_ai-plugin_install.md index bb5636c1..fd2f770d 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_install.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_install.md @@ -31,6 +31,7 @@ thv ai-plugin install [plugin-name] [flags] --group string Group to add the plugin to after installation -h, --help help for install --project-root string Project root path for project-scoped installs + --public-key string Path to the cosign public key (cosign.pub) a key-pair-signed plugin must verify against. Required the first time such a plugin is installed project-scoped; the key is then pinned in the lock file and reused automatically --scope string Installation scope (user, project) (default "user") ``` diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_push.md b/docs/toolhive/reference/cli/thv_ai-plugin_push.md index 56af0314..ed832ec8 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_push.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_push.md @@ -24,7 +24,9 @@ thv ai-plugin push [reference] [flags] ### Options ``` - -h, --help help for push + -h, --help help for push + --identity-token string OIDC identity token (or a path to a file containing one) for keyless signing. If omitted, one is acquired automatically: from the GitHub Actions OIDC token when running with id-token: write permission, otherwise via an interactive browser sign-in + --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) ``` ### Options inherited from parent commands diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_sync.md b/docs/toolhive/reference/cli/thv_ai-plugin_sync.md index 36b47fdc..a7d2a08b 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_sync.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_sync.md @@ -26,8 +26,6 @@ Unless --check is set, sync prompts for confirmation before installing — plugin content is a set of AI-followed instructions. Pass --yes to skip the prompt (required in non-interactive contexts such as CI). -Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true. - ``` thv ai-plugin sync [flags] ``` @@ -36,7 +34,7 @@ thv ai-plugin sync [flags] ``` --adopt Write lock entries for existing unmanaged project-scope installs - --allow-unsigned Allow adopting plugins whose signature state cannot be established (recorded as unsigned) + --allow-unsigned Record plugins as unsigned in the lock file: when adopting installs whose signature state cannot be established (--adopt), and when repairing an entry that records no trust decision and whose content is unsigned --check Report drift without installing, writing, or removing anything --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client --format string Output format (json, text) (default "text") diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md b/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md index af0ff7d3..8027179e 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md @@ -31,8 +31,6 @@ Unless --preview is set, upgrade prompts for confirmation before installing — plugin content is a set of AI-followed instructions. Pass --yes to skip the prompt (required in non-interactive contexts such as CI). -Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true. - ``` thv ai-plugin upgrade [plugin-name...] [flags] ``` diff --git a/docs/toolhive/reference/cli/thv_proxy.md b/docs/toolhive/reference/cli/thv_proxy.md index 6cbc09c2..9b240699 100644 --- a/docs/toolhive/reference/cli/thv_proxy.md +++ b/docs/toolhive/reference/cli/thv_proxy.md @@ -100,6 +100,7 @@ thv proxy [flags] SERVER_NAME --allowed-origins stringArray Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; loopback binds derive a default allowlist automatically, non-loopback binds log a warning when no value is supplied. Example: https://my-mcp.example.com -h, --help help for proxy --host string Host for the HTTP proxy to listen on (IP or hostname) (default "127.0.0.1") + --max-request-body-size int Maximum inbound request body size in bytes; zero uses the default (8 MiB) --oidc-audience string Expected audience for the token --oidc-client-id string OIDC client ID --oidc-client-secret string OIDC client secret (optional, for introspection) diff --git a/docs/toolhive/reference/cli/thv_run.md b/docs/toolhive/reference/cli/thv_run.md index 6402c980..87fa8800 100644 --- a/docs/toolhive/reference/cli/thv_run.md +++ b/docs/toolhive/reference/cli/thv_run.md @@ -133,6 +133,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --jwks-allow-private-ip Allow JWKS/OIDC endpoints on private IP addresses (use with caution) (default false) --jwks-auth-token-file string Path to file containing bearer token for authenticating JWKS/OIDC requests -l, --label stringArray Set labels on the container (format: key=value) + --max-request-body-size int Maximum inbound request body size in bytes; zero uses the default (8 MiB) --name string Name of the MCP server (default to auto-generated from image) --network string Connect the container to a network (e.g., 'host' for host networking). Note: 'host' and 'none' cannot enforce network isolation, so isolation is dropped for those modes. --oidc-audience string Expected audience for the token diff --git a/docs/toolhive/reference/cli/thv_skill_install.md b/docs/toolhive/reference/cli/thv_skill_install.md index 268829aa..842d687a 100644 --- a/docs/toolhive/reference/cli/thv_skill_install.md +++ b/docs/toolhive/reference/cli/thv_skill_install.md @@ -31,6 +31,7 @@ thv skill install [skill-name] [flags] --group string Group to add the skill to after installation -h, --help help for install --project-root string Project root path for project-scoped installs + --public-key string Path to the cosign public key (cosign.pub) a key-pair-signed skill must verify against. Required the first time such a skill is installed project-scoped; the key is then pinned in the lock file and reused automatically --scope string Installation scope (user, project) (default "user") ``` diff --git a/docs/toolhive/reference/cli/thv_skill_push.md b/docs/toolhive/reference/cli/thv_skill_push.md index fbe5895d..39779c6f 100644 --- a/docs/toolhive/reference/cli/thv_skill_push.md +++ b/docs/toolhive/reference/cli/thv_skill_push.md @@ -26,7 +26,7 @@ thv skill push [reference] [flags] ``` -h, --help help for push --identity-token string OIDC identity token (or a path to a file containing one) for keyless signing. Mutually exclusive with --key. If omitted, one is acquired automatically: from the ambient CI OIDC token when running with id-token: write permission, otherwise via an interactive browser sign-in - --key string Path to a cosign private key to sign the pushed artifact. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing + --key string Path to a cosign private key to sign the pushed artifact. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing. Consumers installing the result project-scoped must pass --public-key with the matching cosign public key the first time; distribute it alongside the artifact. Keyless signing needs no such out-of-band step, since the signer identity is verifiable from the artifact itself --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) ``` diff --git a/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx b/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx index da5380c0..74903222 100644 --- a/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx +++ b/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx @@ -22,9 +22,6 @@ spec: type: embeddedAuthServer embeddedAuthServer: issuer: https://example.com - upstreamProviders: - - name: example - type: oidc ``` ## Schema diff --git a/static/api-specs/toolhive-api.yaml b/static/api-specs/toolhive-api.yaml index 6cb036d1..7ad50b82 100644 --- a/static/api-specs/toolhive-api.yaml +++ b/static/api-specs/toolhive-api.yaml @@ -273,6 +273,51 @@ components: Required when IdentityFromToken is set. type: string type: object + authserver.InboundGrantsRunConfig: + description: |- + InboundGrants declares canonical inbound grant configuration, including + SPIFFE client authentication, delegate clients, and issuer policy. A + non-nil value explicitly controls grant-family enablement. + properties: + jwt_bearer: + $ref: '#/components/schemas/authserver.JWTBearerInboundGrantRunConfig' + spiffe_client_auth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth + client identities and permissions. See SPIFFEClientAuthRunConfig. + items: + $ref: '#/components/schemas/authserver.SPIFFEClientAuthRunConfig' + type: array + uniqueItems: false + token_exchange: + $ref: '#/components/schemas/authserver.TokenExchangeInboundGrantRunConfig' + type: object + authserver.JWTBearerInboundGrantRunConfig: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuer_policies: + items: + $ref: '#/components/schemas/authserver.JWTBearerIssuerPolicyRunConfig' + type: array + uniqueItems: false + type: object + authserver.JWTBearerIssuerPolicyRunConfig: + properties: + accepted_audiences: + items: + type: string + type: array + uniqueItems: false + issuer_ref: + type: string + max_assertion_age: + type: string + subject_bindings: + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding' + type: array + uniqueItems: false + type: object authserver.OAuth2UpstreamRunConfig: description: |- OAuth2Config contains OAuth 2.0-specific configuration. @@ -486,7 +531,8 @@ components: BaselineClientScopes is a baseline set of OAuth 2.0 scopes unioned into every DCR registration. All values must appear in ScopesSupported; the auth server rejects this RunConfig at startup otherwise. Empty means current behavior is - preserved (registered scope = client-requested, or DefaultScopes if empty). + preserved (registered scope = client-requested, or the intersection of + DefaultScopes with ScopesSupported if the client requested none). When ScopesSupported is empty, the subset check uses registration.DefaultScopes (the same set applyDefaults would substitute at startup) — so BaselineClientScopes containing standard OIDC scopes works without enumerating @@ -503,6 +549,8 @@ components: authorization-server startup, including clients intended for RFC 8693 token exchange. + This legacy field is deprecated; use InboundGrants.TokenExchange.DelegateClients. + Independent of AllowConfidentialClientRegistration: declaring a client here does not require or enable self-service confidential DCR, and setting that flag does not declare or enable any client here. They @@ -574,6 +622,8 @@ components: type: string type: array uniqueItems: false + inbound_grants: + $ref: '#/components/schemas/authserver.InboundGrantsRunConfig' insecure_allow_confidential_over_loopback_http: description: |- InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential clients @@ -618,17 +668,25 @@ components: uniqueItems: false signing_key_config: $ref: '#/components/schemas/authserver.SigningKeyRunConfig' + spiffe_trust_domains: + description: |- + SPIFFETrustDomains declares SPIFFE trust roots. Each declaration must be + referenced by an InboundGrants.SPIFFEClientAuth entry. + items: + $ref: '#/components/schemas/authserver.SPIFFETrustDomainRunConfig' + type: array + uniqueItems: false storage: $ref: '#/components/schemas/storage.RunConfig' token_lifespans: $ref: '#/components/schemas/authserver.TokenLifespanRunConfig' trusted_issuers: description: |- - TrustedIssuers lists external OIDC issuers whose tokens are accepted as - RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with - jwtBearerGrant enabled may be used for the JWT-bearer grant without an - RFC 8693 delegation policy. Empty (the default) means only self-issued - subject tokens are accepted. + TrustedIssuers lists external OIDC trust declarations. + + This legacy field is deprecated; RFC 8693 and JWT-bearer policies embedded in these entries + remain supported for compatibility. New configurations should put policy + under InboundGrants and reference a named trusted issuer. See tokenexchange.TrustedIssuer for the per-issuer field reference, and docs/arch/17-token-exchange-delegation.md for the trust model, consent @@ -641,14 +699,125 @@ components: uniqueItems: false upstreams: description: |- - Upstreams configures connections to upstream Identity Providers. - At least one upstream is required - the server delegates authentication to these providers. + Upstreams configures connections to upstream Identity Providers for + interactive authorization. It may be empty only when DelegateClients or a + TrustedIssuer with JWTBearerGrant enables token-only operation. Multiple upstreams are supported for sequential authorization chains. items: $ref: '#/components/schemas/authserver.UpstreamRunConfig' type: array uniqueItems: false type: object + authserver.SPIFFEBundleEndpointSourceRunConfig: + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). Required, since the future bundle loader cannot + otherwise know which trust anchor to use for the initial connection. + type: string + url: + type: string + type: object + authserver.SPIFFEBundleSourceRunConfig: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + $ref: '#/components/schemas/authserver.SPIFFEBundleEndpointSourceRunConfig' + type: + type: string + workload_api: + $ref: '#/components/schemas/authserver.SPIFFEWorkloadAPIBundleSourceRunConfig' + type: object + authserver.SPIFFEClientAuthRunConfig: + properties: + audiences: + description: |- + Audiences are RFC 8693 token audiences this association may request. + This is an independent request dimension from Resources: it is not + bounded by allowed_audiences (which is an RFC 8707 resource-URI list) + and may contain non-URI logical audience identifiers. + items: + type: string + type: array + uniqueItems: false + client_id: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + type: string + grant_types: + description: |- + GrantTypes are the OAuth grant types this association may use. Client + authentication does not by itself confer any grant. + items: + type: string + type: array + uniqueItems: false + methods: + items: + type: string + type: array + uniqueItems: false + principal_pattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist + (RunConfig.AllowedAudiences) — the same RFC 8707 resource-URI list + DelegateClientRunConfig.Audiences is validated against. Distinct from + Audiences: a resource permission does not imply the same value is also + a permitted token audience, or vice versa. + items: + type: string + type: array + uniqueItems: false + scopes: + description: |- + Scopes are OAuth scopes granted to this association. They must be a + subset of the server's effective supported scopes. + items: + type: string + type: array + uniqueItems: false + trust_domain_ref: + description: |- + TrustDomainRef identifies the SPIFFE trust-domain declaration governing + this association policy. + type: string + type: object + authserver.SPIFFETrustDomainRunConfig: + properties: + bundle_source: + $ref: '#/components/schemas/authserver.SPIFFEBundleSourceRunConfig' + methods: + description: |- + Methods explicitly enables the supported credential types for this trust + domain. No authentication method is enabled when the list is empty. + items: + type: string + type: array + uniqueItems: false + name: + description: |- + Name uniquely identifies this declaration and is referenced by + InboundGrants.SPIFFEClientAuth entries. + type: string + trust_domain: + description: TrustDomain is the SPIFFE trust domain accepted by this declaration. + type: string + type: object + authserver.SPIFFEWorkloadAPIBundleSourceRunConfig: + type: object authserver.SigningKeyRunConfig: description: |- SigningKeyConfig configures the signing key provider for JWT operations. @@ -675,6 +844,43 @@ components: This key is used for signing new tokens. type: string type: object + authserver.TokenExchangeInboundGrantRunConfig: + description: TokenExchange configures RFC 8693 inbound clients and issuer policies. + properties: + delegate_clients: + items: + $ref: '#/components/schemas/authserver.DelegateClientRunConfig' + type: array + uniqueItems: false + issuer_policies: + items: + $ref: '#/components/schemas/authserver.TokenExchangeIssuerPolicyRunConfig' + type: array + uniqueItems: false + type: object + authserver.TokenExchangeIssuerPolicyRunConfig: + properties: + actor_claim: + type: string + actor_matcher: + type: string + allow_may_act: + type: boolean + allowed_actors: + items: + type: string + type: array + uniqueItems: false + allowed_delegate_clients: + items: + type: string + type: array + uniqueItems: false + expected_audience: + type: string + issuer_ref: + type: string + type: object authserver.TokenLifespanRunConfig: description: |- TokenLifespans configures the duration that various tokens are valid. @@ -963,6 +1169,16 @@ components: This is required and must match a configured upstream provider name. type: string type: object + github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding: + properties: + allowed_resources: + items: + type: string + type: array + uniqueItems: false + subject: + type: string + type: object github_com_stacklok_toolhive_pkg_authz.Config: description: |- DEPRECATED: Middleware configuration. @@ -1259,6 +1475,18 @@ components: type: string type: array uniqueItems: false + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ProvenanceInfo' + trust_unrecorded: + description: |- + TrustUnrecorded reports that the project's lock file has an entry for + this plugin which records neither a signer identity nor an unsigned + exception — an entry written before verification existed, or + hand-edited. It exists to keep that state distinguishable from having no + lock entry at all, which leaves Provenance and Unsigned equally empty: + sync reports this one as drift and can repair it, so Info must not + render it as if nothing were pinning the plugin. + type: boolean unmaterialized_components: additionalProperties: items: @@ -1270,6 +1498,11 @@ components: by Info by diffing InstalledPlugin.Components against each installed client adapter's SupportedComponents. type: object + unsigned: + description: |- + Unsigned reports that the lock file records an explicit unsigned + exception for this plugin. + type: boolean type: object github_com_stacklok_toolhive_pkg_plugins.PluginMetadata: description: Metadata contains the plugin's metadata. @@ -1296,6 +1529,51 @@ components: description: Version is the semantic version of the plugin. type: string type: object + github_com_stacklok_toolhive_pkg_plugins.ProvenanceInfo: + description: |- + Provenance is the signer identity the project's lock file records + for this skill, when project-scoped and lock-managed. + properties: + cert_issuer: + description: CertIssuer is the OIDC issuer that authenticated the signer. + type: string + provisional: + description: |- + Provisional marks provenance with a documented verification gap + (git signatures until transparency-log validation lands). + type: boolean + public_key: + description: |- + PublicKey is the base64-encoded DER SPKI cosign public key a + key-pair-signed entry is pinned to. Set only when SignerIdentity and + CertIssuer are empty: the two anchors are mutually exclusive. + type: string + repository_ref: + description: |- + RepositoryRef is the git ref the signing workflow ran on, from Fulcio + certificate extension 1.3.6.1.4.1.57264.1.14. Empty means + unconstrained, matching lock files written before the field existed. + type: string + repository_uri: + description: |- + RepositoryURI is the source repository from the certificate + extensions, when present. + type: string + runner_environment: + description: |- + RunnerEnvironment is the runner class the signing workflow executed in + (e.g. "github-hosted"), from Fulcio certificate extension + 1.3.6.1.4.1.57264.1.11. Empty means unconstrained. + type: string + signer_identity: + description: |- + SignerIdentity is the certificate subject identity (workflow path for + GitHub Actions certificates, SAN verbatim otherwise). + type: string + sigstore_url: + description: SigstoreURL is the Sigstore instance the signature chains to. + type: string + type: object github_com_stacklok_toolhive_pkg_plugins.Scope: description: Scope for the installation enum: @@ -1560,6 +1838,12 @@ components: K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template Only applicable when using Kubernetes runtime type: string + max_request_body_size: + description: |- + MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes. + Zero uses the default limit of 8 MiB. Negative values are rejected + when the RunConfig is built or used at runtime. + type: integer mcpserver_generation: description: |- MCPServerGeneration is the K8s .metadata.generation of the MCPServer CR that rendered @@ -1808,6 +2092,7 @@ components: - signature-invalid - signer-mismatch - provenance-field-mismatch + - key-signed-unverifiable - unsigned-rejected - unknown type: string @@ -1819,6 +2104,7 @@ components: - FailureReasonSignatureInvalid - FailureReasonSignerMismatch - FailureReasonProvenanceFieldMismatch + - FailureReasonKeySigned - FailureReasonUnsignedRejected - FailureReasonUnknown github_com_stacklok_toolhive_pkg_skills.InstallStatus: @@ -1912,6 +2198,12 @@ components: Provisional marks provenance with a documented verification gap (git signatures until transparency-log validation lands). type: boolean + public_key: + description: |- + PublicKey is the base64-encoded DER SPKI cosign public key a + key-pair-signed entry is pinned to. Set only when SignerIdentity and + CertIssuer are empty: the two anchors are mutually exclusive. + type: string repository_ref: description: |- RepositoryRef is the git ref the signing workflow ran on, from Fulcio @@ -2834,6 +3126,10 @@ components: image: description: Docker image to use type: string + max_request_body_size: + description: Maximum inbound MCP proxy request body size in bytes. Zero + uses the default limit of 8 MiB. + type: integer name: description: Name of the workload type: string @@ -3056,6 +3352,13 @@ components: project_root: description: ProjectRoot is the project root path for project-scoped installs type: string + public_key: + description: |- + PublicKey is the base64-encoded DER SPKI cosign public key the artifact + must verify against, for artifacts signed with a cosign key pair rather + than keylessly. Required the first time such an artifact is installed + project-scoped, and pinned in the lock file from then on. + type: string scope: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope' version: @@ -3067,6 +3370,11 @@ components: properties: plugin: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin' + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ProvenanceInfo' + unsigned: + description: Whether the install was recorded as an explicit unsigned exception. + type: boolean type: object pkg_api_v1.installSkillRequest: description: Request to install a skill @@ -3098,6 +3406,13 @@ components: project_root: description: ProjectRoot is the project root path for project-scoped installs type: string + public_key: + description: |- + PublicKey is the base64-encoded DER SPKI cosign public key the artifact + must verify against, for artifacts signed with a cosign key pair rather + than keylessly. Required the first time such an artifact is installed + project-scoped, and pinned in the lock file from then on. + type: string scope: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.Scope' version: @@ -3234,11 +3549,24 @@ components: type: boolean type: object pkg_api_v1.pushPluginRequest: - description: Request to push a built plugin artifact + description: Request to push a built plugin artifact. Exactly one of identity_token + or no_sign is required. properties: + identity_token: + description: |- + IdentityToken is a short-lived OIDC identity token used for keyless + signing. Plugin signing is keyless-only: there is deliberately no key + field, because ToolHive cannot verify key-signed artifacts at install + time and would publish an uninstallable plugin (#6442) + type: string + no_sign: + description: NoSign pushes without signing + type: boolean reference: description: OCI reference to push type: string + required: + - reference type: object pkg_api_v1.pushSkillRequest: description: Request to push a built skill artifact @@ -3438,8 +3766,11 @@ components: type: boolean allow_unsigned: description: |- - AllowUnsigned permits adopting plugins whose signature state cannot be - established, recording them as unsigned + AllowUnsigned permits recording a plugin as unsigned in the lock file, + in two cases: adopting an install whose signature state cannot be + established (see Adopt), and repairing an entry that records no trust + decision at all, whose reinstall otherwise fails closed on unsigned + content. type: boolean check: description: Check verifies on-disk content against the lock file without @@ -3546,6 +3877,10 @@ components: image: description: Docker image to use type: string + max_request_body_size: + description: Maximum inbound MCP proxy request body size in bytes. Zero + uses the default limit of 8 MiB. + type: integer network_isolation: description: |- Whether network isolation is turned on. This applies the rules in the permission profile. @@ -4840,6 +5175,9 @@ components: It accepts assertions from this issuer without client authentication and limits their maximum age, subjects, and RFC 8707 resources. It is independent from RFC 8693 delegation policy. + + This legacy field is deprecated; configure RFC 7523 policy under + inbound_grants.jwt_bearer.issuer_policies. properties: accepted_audiences: description: |- @@ -4938,6 +5276,13 @@ components: type: string type: array uniqueItems: false + ca_file_path: + description: |- + CAFilePath is the path to a PEM CA bundle added to the system roots when + fetching this issuer's OIDC discovery document and JWKS. Trust is additive + and scoped to this issuer: the public roots still apply, and no other + issuer's client is affected. + type: string expected_audience: description: |- ExpectedAudience is the expected "aud" claim value that must appear @@ -4945,6 +5290,9 @@ components: not a client ID — required for delegation unless JWTBearerGrant is configured; see looksLikeResourceIdentifier). RFC 7523 assertions use the token endpoint as their audience instead. + + This legacy field is deprecated; configure RFC 8693 policy under + inbound_grants.token_exchange.issuer_policies. See docs/arch/17-token-exchange-delegation.md ("ID/access-token discrimination") for why and its limits. type: string @@ -4969,6 +5317,10 @@ components: type: string jwt_bearer_grant: $ref: '#/components/schemas/tokenexchange.JWTBearerGrantPolicy' + name: + description: Name optionally identifies this trust declaration for canonical + issuer_ref references. + type: string type: object types.MiddlewareConfig: properties: @@ -5502,6 +5854,12 @@ paths: schema: type: string description: Unauthorized (registry refused credentials) + "403": + content: + application/json: + schema: + type: string + description: Forbidden (signature verification or trust check failed) "404": content: application/json: diff --git a/static/api-specs/toolhive-crds/index.json b/static/api-specs/toolhive-crds/index.json index acfa1bf2..64291ef7 100644 --- a/static/api-specs/toolhive-crds/index.json +++ b/static/api-specs/toolhive-crds/index.json @@ -91,7 +91,8 @@ "sourceKind": "VirtualMCPServer", "paths": [ "spec.outgoingAuth.default.externalAuthConfigRef", - "status.discoveredBackends[].authConfigRef" + "status.discoveredBackends[].authConfigRef", + "status.runtime.discoveredBackends[].authConfigRef" ] } ], @@ -463,7 +464,8 @@ "targetKind": "MCPExternalAuthConfig", "paths": [ "spec.outgoingAuth.default.externalAuthConfigRef", - "status.discoveredBackends[].authConfigRef" + "status.discoveredBackends[].authConfigRef", + "status.runtime.discoveredBackends[].authConfigRef" ] }, { diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml index 3a0beeb9..de531b7f 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml @@ -7,6 +7,3 @@ spec: type: embeddedAuthServer embeddedAuthServer: issuer: https://example.com - upstreamProviders: - - name: example - type: oidc diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json index 442f9648..5ac406db 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json @@ -178,7 +178,7 @@ "type": "object" }, "delegateClients": { - "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.", + "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.\nThis legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients.", "items": { "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", "properties": { @@ -251,7 +251,7 @@ }, "disableUpstreamTokenInjection": { "default": false, - "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange or AWS STS,\nwhich would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", + "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange, AWS STS, or OBO\nmiddleware, which would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", "type": "boolean" }, "forceConfidentialRedirectUris": { @@ -287,6 +287,353 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "inboundGrants": { + "description": "InboundGrants configures canonical inbound OAuth grant families.", + "properties": { + "jwtBearer": { + "description": "JWTBearer configures RFC 7523 issuer policies.", + "properties": { + "issuerPolicies": { + "description": "IssuerPolicies binds RFC 7523 policy to named trusted issuers.", + "items": { + "description": "JWTBearerIssuerPolicyConfig binds RFC 7523 policy to a named trusted issuer.", + "properties": { + "acceptedAudiences": { + "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "issuerRef": { + "description": "IssuerRef references trustedIssuers[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "maxAssertionAge": { + "description": "MaxAssertionAge caps the exp-iat interval independently of exp.", + "type": "string" + }, + "subjectBindings": { + "description": "SubjectBindings maps an exact external subject to allowed RFC 8707\nresources.", + "items": { + "description": "JWTBearerSubjectBinding configures the exact subject and allowed resources\nfor one RFC 7523 JWT-bearer assertion identity.", + "properties": { + "allowedResources": { + "description": "AllowedResources is the exact set of RFC 8707 resources this subject may\nrequest.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "subject": { + "description": "Subject is an exact assertion sub value. Wildcards are not supported.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[^*]+$", + "type": "string" + } + }, + "required": [ + "allowedResources", + "subject" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "issuerRef", + "maxAssertionAge", + "subjectBindings" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "maxAssertionAge must be greater than zero", + "rule": "duration(self.maxAssertionAge) > duration('0s')" + }, + { + "message": "subjectBindings must not contain duplicate subjects", + "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" + } + ] + }, + "maxItems": 20, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + }, + "spiffeClientAuth": { + "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit\nOAuth client identities and permissions. A sibling of TokenExchange and\nJWTBearer below, not nested under either: client authentication does\nnot by itself confer a grant. See SPIFFEClientConfig.", + "items": { + "description": "SPIFFEClientConfig associates one SPIFFE principal pattern from a declared\ntrust domain with an explicit OAuth client identity and permissions.\nConfiguration is not authentication: configured SPIFFE clients remain\nnon-public OAuth clients without a secret until live SPIFFE credential\nvalidation is implemented (see SPIFFETrustDomainConfig's doc comment).\n\nGrantTypes is deliberately not exposed here: the runtime only accepts\nexactly the RFC 8693 token-exchange grant for a SPIFFE client\n(validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter\nalways supplies it instead of letting it be configured.", + "properties": { + "audiences": { + "description": "Audiences are RFC 8693 token audiences this association may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "clientId": { + "description": "ClientID is the explicit OAuth client_id. It is never derived from a\nSPIFFE ID.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "methods": { + "description": "Methods are the credential types this association may authenticate\nwith. Must be a subset of the referenced trust domain's methods.", + "items": { + "description": "SPIFFEAuthenticationMethod identifies the credential type permitted for a\nSPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field;\nruntime parsing remains authoritative.", + "enum": [ + "spiffe_x509", + "spiffe_jwt" + ], + "type": "string" + }, + "maxItems": 2, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "principalPattern": { + "description": "PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within\nthe declared trust domain. This pattern is a best-effort CRD-level\napproximation; runtime parsing via spiffeid.FromString remains\nauthoritative.", + "maxLength": 2048, + "pattern": "^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\\*)?|/\\*)$", + "type": "string" + }, + "resources": { + "description": "Resources are RFC 8707 resource indicators this association may\nrequest. Must be a subset of the server's allowed_audiences allowlist,\nwhich is derived at reconcile time and not available on this CRD, so\nallowlist membership is validated at reconcile time, not admission.\nShape (a well-formed absolute HTTP(S) URI) is independent of that\nderived allowlist and is validated here. Distinct from Audiences: a\nresource permission does not imply the same value is also a permitted\ntoken audience, or vice versa.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^@#[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "scopes": { + "description": "Scopes are OAuth scopes granted to this association. Must be a subset\nof the server's effective supported scopes.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "trustDomainRef": { + "description": "TrustDomainRef references spiffeTrustDomains[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "audiences", + "clientId", + "methods", + "principalPattern", + "scopes", + "trustDomainRef" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "principalPattern path must not contain . or .. segments", + "rule": "self.principalPattern.split('/').all(segment, segment != '.' && segment != '..')" + } + ] + }, + "maxItems": 100, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "tokenExchange": { + "description": "TokenExchange configures RFC 8693 clients and issuer policies.", + "properties": { + "delegateClients": { + "description": "DelegateClients configures pre-provisioned confidential clients.", + "items": { + "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", + "properties": { + "audiences": { + "description": "Audiences is the narrowed set of RFC 8707 resources this client may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "clientId": { + "description": "ClientID is the OAuth client_id presented at the token endpoint.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "clientSecretRef": { + "description": "ClientSecretRef references the Kubernetes Secret key containing the client secret.", + "properties": { + "key": { + "description": "Key is the key within the secret", + "type": "string" + }, + "name": { + "description": "Name is the name of the secret", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "scopes": { + "description": "Scopes is the narrowed set of OAuth scopes this client may request.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "audiences", + "clientId", + "clientSecretRef", + "scopes" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "clientSecretRef.name and clientSecretRef.key are required and must be non-empty", + "rule": "has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0" + } + ] + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "issuerPolicies": { + "description": "IssuerPolicies binds RFC 8693 policy to named trusted issuers.", + "items": { + "description": "TokenExchangeIssuerPolicyConfig binds RFC 8693 policy to a named trusted issuer.", + "properties": { + "actorClaim": { + "description": "ActorClaim names the claim containing the external actor identity.", + "maxLength": 64, + "type": "string" + }, + "actorMatcher": { + "maxLength": 4096, + "type": "string" + }, + "allowMayAct": { + "type": "boolean" + }, + "allowedActors": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "allowedDelegateClients": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "expectedAudience": { + "description": "ExpectedAudience is the required RFC 8693 subject-token audience.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "issuerRef": { + "description": "IssuerRef references trustedIssuers[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "allowedDelegateClients", + "expectedAudience", + "issuerRef" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs", + "rule": "!('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" + }, + { + "message": "allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"", + "rule": "!(self.allowMayAct && '*' in self.allowedDelegateClients)" + }, + { + "message": "actorClaim must name a readable claim; use client_id or a non-reserved claim such as azp, appid, or cid", + "rule": "!has(self.actorClaim) || !(self.actorClaim in ['sub', 'iss', 'aud', 'exp', 'iat', 'nbf', 'jti', 'name', 'email', 'scope', 'scp', 'may_act'])" + } + ] + }, + "maxItems": 20, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + } + }, + "type": "object" + }, "insecureAllowConfidentialOverLoopbackHTTP": { "default": false, "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential\nDynamic Client Registration (DCR) and delegate clients when issuer is a\nplain-HTTP loopback URL (e.g. \"http://localhost:8080\"). Without this\nflag, that combination is rejected at reconcile time: a loopback http://\nissuer is normally fine for local development since the traffic never\nleaves the machine, but confidential clients send secrets over cleartext.\nForcing TLS onto every loopback deployment instead would just push\noperators toward insecureAllowHTTP, which is worse: that also disables\nthe non-loopback host check. Has no effect when there are no confidential\nclients or issuer is https.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", @@ -294,7 +641,7 @@ }, "insecureAllowHTTP": { "default": false, - "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside allowConfidentialClientRegistration, which\nwould issue client secrets in cleartext over an unauthenticated registration\nendpoint (see the XValidation rule on EmbeddedAuthServerConfig).", + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside confidential client registration or\ndelegate clients, which would issue or use client secrets in cleartext over\nan unauthenticated endpoint (see the XValidation rule on\nEmbeddedAuthServerConfig).", "type": "boolean" }, "issuer": { @@ -333,6 +680,108 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "spiffeTrustDomains": { + "description": "SPIFFETrustDomains declares SPIFFE trust domains for\ninboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's\ndoc comment for why declaring a domain does not by itself enable\nauthentication in this build.", + "items": { + "description": "SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the\nembedded authorization server. Configuration is not authentication: no\nlive X.509-SVID or JWT-SVID validation exists yet, so a declared trust\ndomain does not by itself let any workload authenticate — RunConfig.Validate\n(pkg/authserver/config.go) currently hard-rejects any non-empty\nspiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced,\na deliberate placeholder until real SVID verification lands.", + "properties": { + "bundleSource": { + "description": "BundleSource declares exactly one future trust-bundle source. It is\nvalidated for shape only; fetching or loading a bundle from it is a\nlater step.", + "properties": { + "endpoint": { + "description": "Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is\n\"bundle_endpoint\".", + "properties": { + "profile": { + "description": "Profile selects how the endpoint's TLS connection is authenticated:\nSPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or\nSPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed\nX.509-SVID root).", + "enum": [ + "https_web", + "https_spiffe" + ], + "type": "string" + }, + "url": { + "description": "URL is the HTTPS SPIFFE Bundle Endpoint URL.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile", + "url" + ], + "type": "object" + }, + "type": { + "description": "Type selects the trust-bundle source.", + "enum": [ + "bundle_endpoint", + "workload_api" + ], + "type": "string" + }, + "workloadAPI": { + "description": "WorkloadAPI selects the local SPIFFE Workload API. Required when Type\nis \"workload_api\".", + "type": "object" + } + }, + "required": [ + "type" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "endpoint configuration must be set if and only if type is 'bundle_endpoint'", + "rule": "self.type == 'bundle_endpoint' ? has(self.endpoint) : !has(self.endpoint)" + }, + { + "message": "workloadAPI configuration must be set if and only if type is 'workload_api'", + "rule": "self.type == 'workload_api' ? has(self.workloadAPI) : !has(self.workloadAPI)" + } + ] + }, + "methods": { + "description": "Methods explicitly enables the supported credential types for this\ntrust domain.", + "items": { + "description": "SPIFFEAuthenticationMethod identifies the credential type permitted for a\nSPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field;\nruntime parsing remains authoritative.", + "enum": [ + "spiffe_x509", + "spiffe_jwt" + ], + "type": "string" + }, + "maxItems": 2, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "name": { + "description": "Name uniquely identifies this declaration and is referenced by\ninboundGrants.spiffeClientAuth[].trustDomainRef.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "trustDomain": { + "description": "TrustDomain is the SPIFFE trust domain accepted by this declaration.\nThis pattern is a best-effort CRD-level approximation of the SPIFFE\ntrust-domain grammar; runtime parsing via\nspiffeid.TrustDomainFromString remains authoritative.", + "maxLength": 255, + "minLength": 1, + "pattern": "^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\\.[a-z0-9_-])*[a-z0-9_])$", + "type": "string" + } + }, + "required": [ + "bundleSource", + "methods", + "name", + "trustDomain" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "storage": { "description": "Storage configures the storage backend for the embedded auth server.\nIf not specified, defaults to in-memory storage.", "properties": { @@ -568,29 +1017,29 @@ "trustedIssuers": { "description": "TrustedIssuers configures external OIDC issuers whose tokens are\naccepted as RFC 8693 subject tokens during token exchange, in addition\nto self-issued subject tokens. Empty (the default) means only\nself-issued subject tokens are accepted. See\ndocs/arch/17-token-exchange-delegation.md for the trust model.", "items": { - "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during\ntoken exchange. It mirrors tokenexchange.TrustedIssuer\n(pkg/authserver/server/tokenexchange), the runtime type the operator converts\nthis into directly — no secret is referenced by this type, so no SecretKeyRef\nindirection is needed, unlike DelegateClientConfig.\n\nexpectedAudience is exempted only for a grant-only issuer: jwtBearerGrant\npresent and none of actorClaim, actorMatcher, allowMayAct, or allowedActors\nset. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct,\nallowedActors) still requires expectedAudience, even when combined with\njwtBearerGrant.\n\nThe allowedDelegateClients rule below mirrors validateDelegationPolicy\n(pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is\nkeyed on whether ANY delegation field is set (expectedAudience,\nactorClaim, actorMatcher, allowMayAct), not merely on whether\njwtBearerGrant is absent — an issuer can combine jwtBearerGrant with\nexpectedAudience for RFC 8693 delegation on the same issuer, and that\ncombination still requires allowedDelegateClients at the Go level.", + "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust\nfields remain top-level; canonical grant policy references this declaration by\nName. The embedded grant-policy fields are retained for released CRD compatibility.", "properties": { "actorClaim": { - "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.", + "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 64, "type": "string" }, "actorMatcher": { - "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.", + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 4096, "type": "string" }, "allowMayAct": { "default": false, - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.", + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "type": "boolean" }, "allowPrivateIPs": { - "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer\nto resolve to a private or loopback address. Use only when the issuer\nis hosted inside the same cluster and has no public endpoint. Requires\njwksUrl to be set explicitly (enforced at reconcile time), since\notherwise OIDC discovery — fetched from the external issuer itself —\nwould choose the private dial target.", + "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer\nto resolve to a private or loopback address. Use only when the issuer\nis hosted inside the same cluster and has no public endpoint. Requires\njwksUrl to be set explicitly (enforced at admission and by shared\nvalidation), since otherwise OIDC discovery — fetched from the external\nissuer itself — would choose the private dial target.", "type": "boolean" }, "allowedActors": { - "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.", + "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "items": { "maxLength": 256, "minLength": 1, @@ -601,7 +1050,7 @@ "x-kubernetes-list-type": "atomic" }, "allowedDelegateClients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.", + "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "items": { "maxLength": 256, "minLength": 1, @@ -612,8 +1061,43 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "caBundleRef": { + "description": "CABundleRef references a ConfigMap containing PEM CA certificates used when\nfetching this issuer's OIDC discovery document and JWKS. The bundle is added\nto the system roots for this issuer's client only; public roots still apply\nand other issuers are unaffected. Write access to the referenced ConfigMap is\nequivalent to controlling this issuer's trust anchor for subject-token\nvalidation — restrict it with the same care as a signing-key Secret.", + "properties": { + "configMapRef": { + "description": "ConfigMapRef references a ConfigMap containing the CA certificate bundle.\nThe ConfigMap key is required by the API. If omitted in a stored object, it\ndefaults to \"ca.crt\" for backwards compatibility.", + "properties": { + "key": { + "description": "The key to select.", + "type": "string" + }, + "name": { + "default": "", + "description": "Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + "type": "string" + }, + "optional": { + "description": "Specify whether the ConfigMap or its key must be defined", + "type": "boolean" + } + }, + "required": [ + "key" + ], + "type": "object", + "x-kubernetes-map-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] + }, "expectedAudience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 2048, "minLength": 1, "type": "string" @@ -634,7 +1118,7 @@ "type": "string" }, "jwtBearerGrant": { - "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.", + "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.\nThis legacy field is deprecated; configure RFC 7523 policy under\ninboundGrants.jwtBearer.issuerPolicies.", "properties": { "acceptedAudiences": { "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", @@ -705,6 +1189,12 @@ "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" } ] + }, + "name": { + "description": "Name optionally identifies this trust declaration for canonical issuerRef references.\nNames must be unique within trustedIssuers when set.", + "maxLength": 253, + "minLength": 1, + "type": "string" } }, "required": [ @@ -729,12 +1219,12 @@ "rule": "!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != \"\")" }, { - "message": "expectedAudience is required unless jwtBearerGrant is configured without actorClaim, actorMatcher, allowMayAct, or allowedActors", - "rule": "(has(self.jwtBearerGrant) && !((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" + "message": "expectedAudience is required when legacy RFC 8693 policy is configured", + "rule": "!((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" }, { - "message": "allowedDelegateClients is required when expectedAudience, actorClaim, actorMatcher, or allowMayAct is set", - "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" + "message": "allowedDelegateClients is required when legacy RFC 8693 policy is configured", + "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" } ] }, @@ -743,7 +1233,7 @@ "x-kubernetes-list-type": "atomic" }, "upstreamProviders": { - "description": "UpstreamProviders configures connections to upstream Identity Providers.\nThe embedded auth server delegates authentication to these providers.\nMCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple.", + "description": "UpstreamProviders configures connections to upstream Identity Providers.\nWhen configured, the embedded auth server delegates interactive authentication\nto these providers. It may be omitted only when delegateClients or a trusted\nissuer with jwtBearerGrant enables token-only operation.\nMCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple.", "items": { "description": "UpstreamProviderConfig defines configuration for an upstream Identity Provider.\n\nExactly one of OIDCConfig or OAuth2Config must be set and must match the\ndeclared Type: oidc-typed providers set OIDCConfig, oauth2-typed providers\nset OAuth2Config. The CEL rule below enforces the pairing at admission; the\nmatching Go-level check in validateUpstreamProvider provides defense-in-depth\nfor stored objects.\n\nThe rule is structured as a chain of equality checks ending in an explicit\n`false`, so adding a new UpstreamProviderType value without extending this\nrule fails admission instead of silently demanding the OAuth2 shape. When\nadding a new type, extend both this rule and validateUpstreamProvider.", "properties": { @@ -801,7 +1291,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "clientId": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", @@ -1069,7 +1565,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "clientId": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IdP.", @@ -1203,7 +1705,6 @@ } ] }, - "minItems": 1, "type": "array", "x-kubernetes-list-map-keys": [ "name" @@ -1212,22 +1713,97 @@ } }, "required": [ - "issuer", - "upstreamProviders" + "issuer" ], "type": "object", "x-kubernetes-validations": [ { - "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", - "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" + "message": "at least one upstream provider or inbound grant family is required", + "rule": "(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0)))" + }, + { + "message": "spiffeTrustDomains and inboundGrants.spiffeClientAuth must be configured together", + "rule": "((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0))" + }, + { + "message": "spiffeTrustDomains must not contain duplicate names", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.name == domain.name).size() == 1)" + }, + { + "message": "spiffeTrustDomains must not contain duplicate trust domains", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() == 1)" + }, + { + "message": "every SPIFFE trust domain must be referenced by a SPIFFE client", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef == domain.name))" + }, + { + "message": "every SPIFFE client trustDomainRef must reference a declared trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))" + }, + { + "message": "spiffeClientAuth methods must be enabled by the referenced trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.methods.all(method, method in domain.methods)))" + }, + { + "message": "spiffeClientAuth principalPattern trust domain must match the referenced trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.principalPattern.startsWith('spiffe://' + domain.trustDomain + '/')))" + }, + { + "message": "spiffeClientAuth must not contain duplicate client IDs", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.clientId == client.clientId).size() == 1)" + }, + { + "message": "spiffeClientAuth must not contain duplicate principal patterns", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.principalPattern == client.principalPattern).size() == 1)" + }, + { + "message": "spiffeClientAuth clientId must not use the reserved synthetic: prefix", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith('synthetic:'))" + }, + { + "message": "spiffeClientAuth clientId must not be an absolute URL", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches('^[A-Za-z][A-Za-z0-9+.-]*://.+'))" + }, + { + "message": "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint", + "rule": "!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) > 0)))" }, { "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" }, { - "message": "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", - "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)" + "message": "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", + "rule": "!self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) || size(self.inboundGrants.tokenExchange.delegateClients) == 0))" + }, + { + "message": "canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || ((!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) || (has(issuer.allowMayAct) && issuer.allowMayAct))))" + }, + { + "message": "canonical jwtBearer conflicts with legacy jwtBearerGrant", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))" + }, + { + "message": "trustedIssuers must not contain duplicate names", + "rule": "!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) && other.name == issuer.name).size() == 1)" + }, + { + "message": "every tokenExchange issuerRef must reference a named trusted issuer", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))" + }, + { + "message": "tokenExchange issuerPolicies must not contain duplicate issuerRef values", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, self.inboundGrants.tokenExchange.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)" + }, + { + "message": "every jwtBearer issuerRef must reference a named trusted issuer", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))" + }, + { + "message": "jwtBearer issuerPolicies must not contain duplicate issuerRef values", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)" } ] }, diff --git a/static/api-specs/toolhive-crds/mcpoidcconfigs.schema.json b/static/api-specs/toolhive-crds/mcpoidcconfigs.schema.json index db3e5c22..9c83e5ee 100644 --- a/static/api-specs/toolhive-crds/mcpoidcconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcpoidcconfigs.schema.json @@ -44,7 +44,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "clientId": { "description": "ClientID is the OIDC client ID", diff --git a/static/api-specs/toolhive-crds/mcpserverentries.schema.json b/static/api-specs/toolhive-crds/mcpserverentries.schema.json index 2cdec0e1..8205f970 100644 --- a/static/api-specs/toolhive-crds/mcpserverentries.schema.json +++ b/static/api-specs/toolhive-crds/mcpserverentries.schema.json @@ -46,7 +46,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "externalAuthConfigRef": { "description": "ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange\nwhen connecting to the remote MCP server. The referenced MCPExternalAuthConfig must\nexist in the same namespace as this MCPServerEntry.", diff --git a/static/api-specs/toolhive-crds/mcpservers.schema.json b/static/api-specs/toolhive-crds/mcpservers.schema.json index d749959c..3b1667df 100644 --- a/static/api-specs/toolhive-crds/mcpservers.schema.json +++ b/static/api-specs/toolhive-crds/mcpservers.schema.json @@ -230,6 +230,12 @@ "description": "Image is the container image for the MCP server", "type": "string" }, + "maxRequestBodySize": { + "description": "MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.\nZero uses the default limit of 8 MiB.", + "format": "int64", + "minimum": 0, + "type": "integer" + }, "mcpPort": { "description": "MCPPort is the port that MCP server listens to", "format": "int32", diff --git a/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json b/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json index 25cda8de..9c5d2115 100644 --- a/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json @@ -44,7 +44,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "enabled": { "default": false, diff --git a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json index 58f6a28d..5a9e1c53 100644 --- a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json +++ b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json @@ -69,7 +69,7 @@ "type": "object" }, "delegateClients": { - "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.", + "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.\nThis legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients.", "items": { "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", "properties": { @@ -142,7 +142,7 @@ }, "disableUpstreamTokenInjection": { "default": false, - "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange or AWS STS,\nwhich would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", + "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange, AWS STS, or OBO\nmiddleware, which would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", "type": "boolean" }, "forceConfidentialRedirectUris": { @@ -178,6 +178,353 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "inboundGrants": { + "description": "InboundGrants configures canonical inbound OAuth grant families.", + "properties": { + "jwtBearer": { + "description": "JWTBearer configures RFC 7523 issuer policies.", + "properties": { + "issuerPolicies": { + "description": "IssuerPolicies binds RFC 7523 policy to named trusted issuers.", + "items": { + "description": "JWTBearerIssuerPolicyConfig binds RFC 7523 policy to a named trusted issuer.", + "properties": { + "acceptedAudiences": { + "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "issuerRef": { + "description": "IssuerRef references trustedIssuers[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "maxAssertionAge": { + "description": "MaxAssertionAge caps the exp-iat interval independently of exp.", + "type": "string" + }, + "subjectBindings": { + "description": "SubjectBindings maps an exact external subject to allowed RFC 8707\nresources.", + "items": { + "description": "JWTBearerSubjectBinding configures the exact subject and allowed resources\nfor one RFC 7523 JWT-bearer assertion identity.", + "properties": { + "allowedResources": { + "description": "AllowedResources is the exact set of RFC 8707 resources this subject may\nrequest.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "subject": { + "description": "Subject is an exact assertion sub value. Wildcards are not supported.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[^*]+$", + "type": "string" + } + }, + "required": [ + "allowedResources", + "subject" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "issuerRef", + "maxAssertionAge", + "subjectBindings" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "maxAssertionAge must be greater than zero", + "rule": "duration(self.maxAssertionAge) > duration('0s')" + }, + { + "message": "subjectBindings must not contain duplicate subjects", + "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" + } + ] + }, + "maxItems": 20, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + }, + "spiffeClientAuth": { + "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit\nOAuth client identities and permissions. A sibling of TokenExchange and\nJWTBearer below, not nested under either: client authentication does\nnot by itself confer a grant. See SPIFFEClientConfig.", + "items": { + "description": "SPIFFEClientConfig associates one SPIFFE principal pattern from a declared\ntrust domain with an explicit OAuth client identity and permissions.\nConfiguration is not authentication: configured SPIFFE clients remain\nnon-public OAuth clients without a secret until live SPIFFE credential\nvalidation is implemented (see SPIFFETrustDomainConfig's doc comment).\n\nGrantTypes is deliberately not exposed here: the runtime only accepts\nexactly the RFC 8693 token-exchange grant for a SPIFFE client\n(validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter\nalways supplies it instead of letting it be configured.", + "properties": { + "audiences": { + "description": "Audiences are RFC 8693 token audiences this association may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "clientId": { + "description": "ClientID is the explicit OAuth client_id. It is never derived from a\nSPIFFE ID.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "methods": { + "description": "Methods are the credential types this association may authenticate\nwith. Must be a subset of the referenced trust domain's methods.", + "items": { + "description": "SPIFFEAuthenticationMethod identifies the credential type permitted for a\nSPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field;\nruntime parsing remains authoritative.", + "enum": [ + "spiffe_x509", + "spiffe_jwt" + ], + "type": "string" + }, + "maxItems": 2, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "principalPattern": { + "description": "PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within\nthe declared trust domain. This pattern is a best-effort CRD-level\napproximation; runtime parsing via spiffeid.FromString remains\nauthoritative.", + "maxLength": 2048, + "pattern": "^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\\*)?|/\\*)$", + "type": "string" + }, + "resources": { + "description": "Resources are RFC 8707 resource indicators this association may\nrequest. Must be a subset of the server's allowed_audiences allowlist,\nwhich is derived at reconcile time and not available on this CRD, so\nallowlist membership is validated at reconcile time, not admission.\nShape (a well-formed absolute HTTP(S) URI) is independent of that\nderived allowlist and is validated here. Distinct from Audiences: a\nresource permission does not imply the same value is also a permitted\ntoken audience, or vice versa.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^@#[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "scopes": { + "description": "Scopes are OAuth scopes granted to this association. Must be a subset\nof the server's effective supported scopes.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "trustDomainRef": { + "description": "TrustDomainRef references spiffeTrustDomains[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "audiences", + "clientId", + "methods", + "principalPattern", + "scopes", + "trustDomainRef" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "principalPattern path must not contain . or .. segments", + "rule": "self.principalPattern.split('/').all(segment, segment != '.' && segment != '..')" + } + ] + }, + "maxItems": 100, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "tokenExchange": { + "description": "TokenExchange configures RFC 8693 clients and issuer policies.", + "properties": { + "delegateClients": { + "description": "DelegateClients configures pre-provisioned confidential clients.", + "items": { + "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", + "properties": { + "audiences": { + "description": "Audiences is the narrowed set of RFC 8707 resources this client may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "clientId": { + "description": "ClientID is the OAuth client_id presented at the token endpoint.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "clientSecretRef": { + "description": "ClientSecretRef references the Kubernetes Secret key containing the client secret.", + "properties": { + "key": { + "description": "Key is the key within the secret", + "type": "string" + }, + "name": { + "description": "Name is the name of the secret", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "scopes": { + "description": "Scopes is the narrowed set of OAuth scopes this client may request.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "audiences", + "clientId", + "clientSecretRef", + "scopes" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "clientSecretRef.name and clientSecretRef.key are required and must be non-empty", + "rule": "has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0" + } + ] + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "issuerPolicies": { + "description": "IssuerPolicies binds RFC 8693 policy to named trusted issuers.", + "items": { + "description": "TokenExchangeIssuerPolicyConfig binds RFC 8693 policy to a named trusted issuer.", + "properties": { + "actorClaim": { + "description": "ActorClaim names the claim containing the external actor identity.", + "maxLength": 64, + "type": "string" + }, + "actorMatcher": { + "maxLength": 4096, + "type": "string" + }, + "allowMayAct": { + "type": "boolean" + }, + "allowedActors": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "allowedDelegateClients": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "expectedAudience": { + "description": "ExpectedAudience is the required RFC 8693 subject-token audience.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "issuerRef": { + "description": "IssuerRef references trustedIssuers[].name.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "allowedDelegateClients", + "expectedAudience", + "issuerRef" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs", + "rule": "!('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" + }, + { + "message": "allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"", + "rule": "!(self.allowMayAct && '*' in self.allowedDelegateClients)" + }, + { + "message": "actorClaim must name a readable claim; use client_id or a non-reserved claim such as azp, appid, or cid", + "rule": "!has(self.actorClaim) || !(self.actorClaim in ['sub', 'iss', 'aud', 'exp', 'iat', 'nbf', 'jti', 'name', 'email', 'scope', 'scp', 'may_act'])" + } + ] + }, + "maxItems": 20, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + } + }, + "type": "object" + }, "insecureAllowConfidentialOverLoopbackHTTP": { "default": false, "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential\nDynamic Client Registration (DCR) and delegate clients when issuer is a\nplain-HTTP loopback URL (e.g. \"http://localhost:8080\"). Without this\nflag, that combination is rejected at reconcile time: a loopback http://\nissuer is normally fine for local development since the traffic never\nleaves the machine, but confidential clients send secrets over cleartext.\nForcing TLS onto every loopback deployment instead would just push\noperators toward insecureAllowHTTP, which is worse: that also disables\nthe non-loopback host check. Has no effect when there are no confidential\nclients or issuer is https.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", @@ -185,7 +532,7 @@ }, "insecureAllowHTTP": { "default": false, - "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside allowConfidentialClientRegistration, which\nwould issue client secrets in cleartext over an unauthenticated registration\nendpoint (see the XValidation rule on EmbeddedAuthServerConfig).", + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside confidential client registration or\ndelegate clients, which would issue or use client secrets in cleartext over\nan unauthenticated endpoint (see the XValidation rule on\nEmbeddedAuthServerConfig).", "type": "boolean" }, "issuer": { @@ -224,6 +571,108 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "spiffeTrustDomains": { + "description": "SPIFFETrustDomains declares SPIFFE trust domains for\ninboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's\ndoc comment for why declaring a domain does not by itself enable\nauthentication in this build.", + "items": { + "description": "SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the\nembedded authorization server. Configuration is not authentication: no\nlive X.509-SVID or JWT-SVID validation exists yet, so a declared trust\ndomain does not by itself let any workload authenticate — RunConfig.Validate\n(pkg/authserver/config.go) currently hard-rejects any non-empty\nspiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced,\na deliberate placeholder until real SVID verification lands.", + "properties": { + "bundleSource": { + "description": "BundleSource declares exactly one future trust-bundle source. It is\nvalidated for shape only; fetching or loading a bundle from it is a\nlater step.", + "properties": { + "endpoint": { + "description": "Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is\n\"bundle_endpoint\".", + "properties": { + "profile": { + "description": "Profile selects how the endpoint's TLS connection is authenticated:\nSPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or\nSPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed\nX.509-SVID root).", + "enum": [ + "https_web", + "https_spiffe" + ], + "type": "string" + }, + "url": { + "description": "URL is the HTTPS SPIFFE Bundle Endpoint URL.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile", + "url" + ], + "type": "object" + }, + "type": { + "description": "Type selects the trust-bundle source.", + "enum": [ + "bundle_endpoint", + "workload_api" + ], + "type": "string" + }, + "workloadAPI": { + "description": "WorkloadAPI selects the local SPIFFE Workload API. Required when Type\nis \"workload_api\".", + "type": "object" + } + }, + "required": [ + "type" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "endpoint configuration must be set if and only if type is 'bundle_endpoint'", + "rule": "self.type == 'bundle_endpoint' ? has(self.endpoint) : !has(self.endpoint)" + }, + { + "message": "workloadAPI configuration must be set if and only if type is 'workload_api'", + "rule": "self.type == 'workload_api' ? has(self.workloadAPI) : !has(self.workloadAPI)" + } + ] + }, + "methods": { + "description": "Methods explicitly enables the supported credential types for this\ntrust domain.", + "items": { + "description": "SPIFFEAuthenticationMethod identifies the credential type permitted for a\nSPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field;\nruntime parsing remains authoritative.", + "enum": [ + "spiffe_x509", + "spiffe_jwt" + ], + "type": "string" + }, + "maxItems": 2, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "set" + }, + "name": { + "description": "Name uniquely identifies this declaration and is referenced by\ninboundGrants.spiffeClientAuth[].trustDomainRef.", + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "trustDomain": { + "description": "TrustDomain is the SPIFFE trust domain accepted by this declaration.\nThis pattern is a best-effort CRD-level approximation of the SPIFFE\ntrust-domain grammar; runtime parsing via\nspiffeid.TrustDomainFromString remains authoritative.", + "maxLength": 255, + "minLength": 1, + "pattern": "^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\\.[a-z0-9_-])*[a-z0-9_])$", + "type": "string" + } + }, + "required": [ + "bundleSource", + "methods", + "name", + "trustDomain" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "storage": { "description": "Storage configures the storage backend for the embedded auth server.\nIf not specified, defaults to in-memory storage.", "properties": { @@ -459,29 +908,29 @@ "trustedIssuers": { "description": "TrustedIssuers configures external OIDC issuers whose tokens are\naccepted as RFC 8693 subject tokens during token exchange, in addition\nto self-issued subject tokens. Empty (the default) means only\nself-issued subject tokens are accepted. See\ndocs/arch/17-token-exchange-delegation.md for the trust model.", "items": { - "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during\ntoken exchange. It mirrors tokenexchange.TrustedIssuer\n(pkg/authserver/server/tokenexchange), the runtime type the operator converts\nthis into directly — no secret is referenced by this type, so no SecretKeyRef\nindirection is needed, unlike DelegateClientConfig.\n\nexpectedAudience is exempted only for a grant-only issuer: jwtBearerGrant\npresent and none of actorClaim, actorMatcher, allowMayAct, or allowedActors\nset. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct,\nallowedActors) still requires expectedAudience, even when combined with\njwtBearerGrant.\n\nThe allowedDelegateClients rule below mirrors validateDelegationPolicy\n(pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is\nkeyed on whether ANY delegation field is set (expectedAudience,\nactorClaim, actorMatcher, allowMayAct), not merely on whether\njwtBearerGrant is absent — an issuer can combine jwtBearerGrant with\nexpectedAudience for RFC 8693 delegation on the same issuer, and that\ncombination still requires allowedDelegateClients at the Go level.", + "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust\nfields remain top-level; canonical grant policy references this declaration by\nName. The embedded grant-policy fields are retained for released CRD compatibility.", "properties": { "actorClaim": { - "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.", + "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 64, "type": "string" }, "actorMatcher": { - "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.", + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 4096, "type": "string" }, "allowMayAct": { "default": false, - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.", + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "type": "boolean" }, "allowPrivateIPs": { - "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer\nto resolve to a private or loopback address. Use only when the issuer\nis hosted inside the same cluster and has no public endpoint. Requires\njwksUrl to be set explicitly (enforced at reconcile time), since\notherwise OIDC discovery — fetched from the external issuer itself —\nwould choose the private dial target.", + "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer\nto resolve to a private or loopback address. Use only when the issuer\nis hosted inside the same cluster and has no public endpoint. Requires\njwksUrl to be set explicitly (enforced at admission and by shared\nvalidation), since otherwise OIDC discovery — fetched from the external\nissuer itself — would choose the private dial target.", "type": "boolean" }, "allowedActors": { - "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.", + "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "items": { "maxLength": 256, "minLength": 1, @@ -492,7 +941,7 @@ "x-kubernetes-list-type": "atomic" }, "allowedDelegateClients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.", + "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "items": { "maxLength": 256, "minLength": 1, @@ -503,8 +952,43 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "caBundleRef": { + "description": "CABundleRef references a ConfigMap containing PEM CA certificates used when\nfetching this issuer's OIDC discovery document and JWKS. The bundle is added\nto the system roots for this issuer's client only; public roots still apply\nand other issuers are unaffected. Write access to the referenced ConfigMap is\nequivalent to controlling this issuer's trust anchor for subject-token\nvalidation — restrict it with the same care as a signing-key Secret.", + "properties": { + "configMapRef": { + "description": "ConfigMapRef references a ConfigMap containing the CA certificate bundle.\nThe ConfigMap key is required by the API. If omitted in a stored object, it\ndefaults to \"ca.crt\" for backwards compatibility.", + "properties": { + "key": { + "description": "The key to select.", + "type": "string" + }, + "name": { + "default": "", + "description": "Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + "type": "string" + }, + "optional": { + "description": "Specify whether the ConfigMap or its key must be defined", + "type": "boolean" + } + }, + "required": [ + "key" + ], + "type": "object", + "x-kubernetes-map-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] + }, "expectedAudience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.\nThis legacy field is deprecated; configure RFC 8693 policy under\ninboundGrants.tokenExchange.issuerPolicies.", "maxLength": 2048, "minLength": 1, "type": "string" @@ -525,7 +1009,7 @@ "type": "string" }, "jwtBearerGrant": { - "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.", + "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.\nThis legacy field is deprecated; configure RFC 7523 policy under\ninboundGrants.jwtBearer.issuerPolicies.", "properties": { "acceptedAudiences": { "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", @@ -596,6 +1080,12 @@ "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" } ] + }, + "name": { + "description": "Name optionally identifies this trust declaration for canonical issuerRef references.\nNames must be unique within trustedIssuers when set.", + "maxLength": 253, + "minLength": 1, + "type": "string" } }, "required": [ @@ -620,12 +1110,12 @@ "rule": "!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != \"\")" }, { - "message": "expectedAudience is required unless jwtBearerGrant is configured without actorClaim, actorMatcher, allowMayAct, or allowedActors", - "rule": "(has(self.jwtBearerGrant) && !((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" + "message": "expectedAudience is required when legacy RFC 8693 policy is configured", + "rule": "!((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" }, { - "message": "allowedDelegateClients is required when expectedAudience, actorClaim, actorMatcher, or allowMayAct is set", - "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" + "message": "allowedDelegateClients is required when legacy RFC 8693 policy is configured", + "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" } ] }, @@ -634,7 +1124,7 @@ "x-kubernetes-list-type": "atomic" }, "upstreamProviders": { - "description": "UpstreamProviders configures connections to upstream Identity Providers.\nThe embedded auth server delegates authentication to these providers.\nMCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple.", + "description": "UpstreamProviders configures connections to upstream Identity Providers.\nWhen configured, the embedded auth server delegates interactive authentication\nto these providers. It may be omitted only when delegateClients or a trusted\nissuer with jwtBearerGrant enables token-only operation.\nMCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple.", "items": { "description": "UpstreamProviderConfig defines configuration for an upstream Identity Provider.\n\nExactly one of OIDCConfig or OAuth2Config must be set and must match the\ndeclared Type: oidc-typed providers set OIDCConfig, oauth2-typed providers\nset OAuth2Config. The CEL rule below enforces the pairing at admission; the\nmatching Go-level check in validateUpstreamProvider provides defense-in-depth\nfor stored objects.\n\nThe rule is structured as a chain of equality checks ending in an explicit\n`false`, so adding a new UpstreamProviderType value without extending this\nrule fails admission instead of silently demanding the OAuth2 shape. When\nadding a new type, extend both this rule and validateUpstreamProvider.", "properties": { @@ -692,7 +1182,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "clientId": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", @@ -960,7 +1456,13 @@ "x-kubernetes-map-type": "atomic" } }, - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "configMapRef.name is required and must be non-empty", + "rule": "has(self.configMapRef) && size(self.configMapRef.name) > 0" + } + ] }, "clientId": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IdP.", @@ -1094,7 +1596,6 @@ } ] }, - "minItems": 1, "type": "array", "x-kubernetes-list-map-keys": [ "name" @@ -1103,22 +1604,97 @@ } }, "required": [ - "issuer", - "upstreamProviders" + "issuer" ], "type": "object", "x-kubernetes-validations": [ { - "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", - "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" + "message": "at least one upstream provider or inbound grant family is required", + "rule": "(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0)))" + }, + { + "message": "spiffeTrustDomains and inboundGrants.spiffeClientAuth must be configured together", + "rule": "((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0))" + }, + { + "message": "spiffeTrustDomains must not contain duplicate names", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.name == domain.name).size() == 1)" + }, + { + "message": "spiffeTrustDomains must not contain duplicate trust domains", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() == 1)" + }, + { + "message": "every SPIFFE trust domain must be referenced by a SPIFFE client", + "rule": "!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef == domain.name))" + }, + { + "message": "every SPIFFE client trustDomainRef must reference a declared trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))" + }, + { + "message": "spiffeClientAuth methods must be enabled by the referenced trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.methods.all(method, method in domain.methods)))" + }, + { + "message": "spiffeClientAuth principalPattern trust domain must match the referenced trust domain", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.principalPattern.startsWith('spiffe://' + domain.trustDomain + '/')))" + }, + { + "message": "spiffeClientAuth must not contain duplicate client IDs", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.clientId == client.clientId).size() == 1)" + }, + { + "message": "spiffeClientAuth must not contain duplicate principal patterns", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.principalPattern == client.principalPattern).size() == 1)" + }, + { + "message": "spiffeClientAuth clientId must not use the reserved synthetic: prefix", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith('synthetic:'))" + }, + { + "message": "spiffeClientAuth clientId must not be an absolute URL", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches('^[A-Za-z][A-Za-z0-9+.-]*://.+'))" + }, + { + "message": "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint", + "rule": "!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) > 0)))" }, { "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" }, { - "message": "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", - "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)" + "message": "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", + "rule": "!self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) || size(self.inboundGrants.tokenExchange.delegateClients) == 0))" + }, + { + "message": "canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || ((!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) || (has(issuer.allowMayAct) && issuer.allowMayAct))))" + }, + { + "message": "canonical jwtBearer conflicts with legacy jwtBearerGrant", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))" + }, + { + "message": "trustedIssuers must not contain duplicate names", + "rule": "!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) && other.name == issuer.name).size() == 1)" + }, + { + "message": "every tokenExchange issuerRef must reference a named trusted issuer", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))" + }, + { + "message": "tokenExchange issuerPolicies must not contain duplicate issuerRef values", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, self.inboundGrants.tokenExchange.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)" + }, + { + "message": "every jwtBearer issuerRef must reference a named trusted issuer", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))" + }, + { + "message": "jwtBearer issuerPolicies must not contain duplicate issuerRef values", + "rule": "!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)" } ] }, @@ -3378,6 +3954,158 @@ ], "type": "string" }, + "runtime": { + "description": "Runtime is the status snapshot written exclusively by the vMCP process.\nThe operator projects it into the top-level compatibility fields.", + "properties": { + "backendCount": { + "description": "BackendCount is the number of routable backends observed by the runtime.", + "format": "int32", + "type": "integer" + }, + "conditions": { + "description": "Conditions contains runtime health observations.", + "items": { + "description": "Condition contains details for one aspect of the current state of this API Resource.", + "properties": { + "lastTransitionTime": { + "description": "lastTransitionTime is the last time the condition transitioned from one status to another.\nThis should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.", + "format": "date-time", + "type": "string" + }, + "message": { + "description": "message is a human readable message indicating details about the transition.\nThis may be an empty string.", + "maxLength": 32768, + "type": "string" + }, + "observedGeneration": { + "description": "observedGeneration represents the .metadata.generation that the condition was set based upon.\nFor instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date\nwith respect to the current state of the instance.", + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "reason": { + "description": "reason contains a programmatic identifier indicating the reason for the condition's last transition.\nProducers of specific condition types may define expected values and meanings for this field,\nand whether the values are considered a guaranteed API.\nThe value should be a CamelCase string.\nThis field may not be empty.", + "maxLength": 1024, + "minLength": 1, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "description": "status of the condition, one of True, False, Unknown.", + "enum": [ + "True", + "False", + "Unknown" + ], + "type": "string" + }, + "type": { + "description": "type of condition in CamelCase or in foo.example.com/CamelCase.", + "maxLength": 316, + "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", + "type": "string" + } + }, + "required": [ + "lastTransitionTime", + "message", + "reason", + "status", + "type" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-map-keys": [ + "type" + ], + "x-kubernetes-list-type": "map" + }, + "discoveredBackends": { + "description": "DiscoveredBackends contains the runtime's latest backend observations.", + "items": { + "description": "DiscoveredBackend represents a backend server discovered by vMCP runtime.\nThis type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends).", + "properties": { + "authConfigRef": { + "description": "AuthConfigRef is the name of the discovered MCPExternalAuthConfig (if any)", + "type": "string" + }, + "authType": { + "description": "AuthType is the type of authentication configured", + "type": "string" + }, + "circuitBreakerState": { + "description": "CircuitBreakerState is the current circuit breaker state (closed, open, half-open).\nEmpty when circuit breaker is disabled or not configured.", + "enum": [ + "closed", + "open", + "half-open" + ], + "type": "string" + }, + "circuitLastChanged": { + "description": "CircuitLastChanged is the timestamp when the circuit breaker state last changed.\nEmpty when circuit breaker is disabled or has never changed state.", + "format": "date-time", + "type": "string" + }, + "consecutiveFailures": { + "description": "ConsecutiveFailures is the current count of consecutive health check failures.\nResets to 0 when the backend becomes healthy again.", + "type": "integer" + }, + "lastHealthCheck": { + "description": "LastHealthCheck is the timestamp of the last health check", + "format": "date-time", + "type": "string" + }, + "mcpRevision": { + "description": "MCPRevision is the backend's negotiated MCP protocol revision\n(\"2026-07-28\" or \"2025-11-25\"). Empty when the backend has not been probed.", + "type": "string" + }, + "message": { + "description": "Message provides additional information about the backend status", + "type": "string" + }, + "name": { + "description": "Name is the name of the backend MCPServer", + "type": "string" + }, + "status": { + "description": "Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown).\nUse BackendHealthStatus.ToCRDStatus() to populate this field.", + "type": "string" + }, + "url": { + "description": "URL is the URL of the backend MCPServer", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-map-keys": [ + "name" + ], + "x-kubernetes-list-type": "map" + }, + "message": { + "description": "Message provides detail about the runtime phase.", + "type": "string" + }, + "phase": { + "description": "Phase is the lifecycle phase observed by the running vMCP process.", + "enum": [ + "Pending", + "Ready", + "Degraded", + "Failed" + ], + "type": "string" + } + }, + "type": "object" + }, "telemetryConfigHash": { "description": "TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection.\nOnly populated when TelemetryConfigRef is set.", "type": "string" From f85af5c88c229258fb143b87b17af2c9bb612dc1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:22:10 +0000 Subject: [PATCH 3/7] Document toolhive v0.47.0 user-facing changes - Add signing (keyless default, --identity-token, --no-sign) and sync/upgrade lifecycle sections to the ai-plugins guide; add a --public-key install section for key-pair-signed plugins - Add a --public-key install note for key-pair-signed skills - Add --max-request-body-size coverage to the CLI run guide and a spec.maxRequestBodySize example to the K8s run guide (default 8 MiB) - Note OBO middleware (Stacklok Enterprise) alongside tokenExchange and awsSts as incompatible with disableUpstreamTokenInjection --- docs/toolhive/guides-cli/ai-plugins.mdx | 127 ++++++++++++++++++ docs/toolhive/guides-cli/run-mcp-servers.mdx | 25 ++++ .../toolhive/guides-cli/skills-management.mdx | 16 +++ .../guides-k8s/embedded-auth-server-k8s.mdx | 20 +-- docs/toolhive/guides-k8s/run-mcp-k8s.mdx | 21 +++ 5 files changed, 199 insertions(+), 10 deletions(-) diff --git a/docs/toolhive/guides-cli/ai-plugins.mdx b/docs/toolhive/guides-cli/ai-plugins.mdx index 30a268e1..17b2e63a 100644 --- a/docs/toolhive/guides-cli/ai-plugins.mdx +++ b/docs/toolhive/guides-cli/ai-plugins.mdx @@ -112,6 +112,24 @@ thv ai-plugin install my-plugin --force thv ai-plugin install my-plugin --group development ``` +### Install a key-pair-signed plugin + +When a plugin was signed with a cosign key pair rather than keyless, the first +project-scoped install must supply the matching cosign public key (`cosign.pub`) +with `--public-key`: + +```bash +thv ai-plugin install my-plugin --scope project --project-root . \ + --public-key ./cosign.pub +``` + +ToolHive verifies the signature against the key, then pins it in the project's +`toolhive.lock.yaml` so subsequent `thv ai-plugin sync` and +`thv ai-plugin upgrade` runs reuse it automatically. Distribute the public key +alongside the artifact so consumers can supply it. Keyless-signed plugins need +no such out-of-band step, since the signer identity is verifiable from the +transparency-log entry. + ## List installed plugins ```bash @@ -234,9 +252,118 @@ After building, push to a remote OCI registry: thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 ``` +`thv ai-plugin push` signs the pushed artifact by default using Sigstore keyless +signing. ToolHive acquires a short-lived OIDC identity token and records the +signature in the public transparency log. In GitHub Actions with +`id-token: write` permission, the ambient OIDC token is used automatically; on +an interactive terminal, ToolHive prompts for browser sign-in; in any other +environment the push fails with an actionable error before publishing anything. + +To supply a pre-acquired identity token, pass `--identity-token`: + +```bash +thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 \ + --identity-token "$IDENTITY_TOKEN" +``` + +To publish without a signature, pass `--no-sign`: + +```bash +thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 --no-sign +``` + +Consumers who install an unsigned plugin project-scoped must pass +`--allow-unsigned` on `thv ai-plugin install`. + +:::note + +Plugin push is keyless-only. If you need to sign with a cosign key pair, publish +a [skill](./skills-management.mdx) instead, which supports both keyless and +key-pair signing via `--key`. + +::: + Push uses your existing container registry credentials (for example, from `docker login` or `podman login`). Authenticate before pushing. +## Reproduce and upgrade project-scoped plugins + +Project-scoped installs record their source, resolved digest, and signer +identity in a `toolhive.lock.yaml` file at the project root. This lets you +reinstall the same plugin content on another machine, detect drift in CI, and +upgrade to newer content when the catalog moves forward. User-scoped installs +don't participate in the lock file. + +### Restore plugins from the lock file + +To reinstall the plugins recorded in the lock file (for example, after cloning +the project on a new machine), run `thv ai-plugin sync` from the project +directory: + +```bash +thv ai-plugin sync --project-root . +``` + +Missing or drifted plugins are reinstalled at their pinned digest. Sync prompts +for confirmation before installing; pass `--yes` in non-interactive environments +such as CI. + +By default, `thv ai-plugin sync` targets every plugin-supporting client +installed on the machine. Pass `--clients` to constrain sync to a specific set: + +```bash +thv ai-plugin sync --project-root . --clients claude-code +``` + +Use `--check` to report drift without installing or writing anything. This is +useful as a CI gate: + +```bash +thv ai-plugin sync --project-root . --check +``` + +If you have project-scoped plugins that were installed before the lock file +existed, `--adopt` records lock entries for them so subsequent syncs include +them: + +```bash +thv ai-plugin sync --project-root . --adopt +``` + +Use `--prune` to remove installs that are no longer in the lock file. + +See the +[`thv ai-plugin sync` command reference](../reference/cli/thv_ai-plugin_sync.md) +for all options. + +### Upgrade project-scoped plugins + +Upgrade the project's plugins to newer pinned content: + +```bash +thv ai-plugin upgrade --project-root . +``` + +A version or tag change within the same OCI repository proceeds without +`--allow-ref-change`, including a move to an older tag. ToolHive pins the +resolved digest and blocks signer changes. If the catalog moves the plugin to a +different repository, organization, or registry, the upgrade is blocked. Review +the new source, then repeat the command with `--allow-ref-change` if you intend +to permit that repository move. ToolHive prompts before installing the planned +changes; pass `--yes` in non-interactive environments. + +Plugins pinned to an immutable reference (an OCI digest or a full Git commit +hash) are reported as not upgradable, because there is nothing newer to resolve +to. + +Use `--preview` to see what would change without persisting anything, or +`--fail-on-changes` as a CI freshness gate that reports pending upgrades without +installing them. + +See the +[`thv ai-plugin upgrade` command reference](../reference/cli/thv_ai-plugin_upgrade.md) +for all options. + ## Manage local builds The `builds` subcommand exposes the local OCI store where `thv ai-plugin build` diff --git a/docs/toolhive/guides-cli/run-mcp-servers.mdx b/docs/toolhive/guides-cli/run-mcp-servers.mdx index b4549120..93241b5c 100644 --- a/docs/toolhive/guides-cli/run-mcp-servers.mdx +++ b/docs/toolhive/guides-cli/run-mcp-servers.mdx @@ -417,6 +417,31 @@ transport. ::: +### Limit inbound request body size + +ToolHive caps the size of inbound HTTP request bodies to the proxy so a large +payload can't exhaust proxy memory before authentication or MCP parsing runs. +The default cap is 8 MiB. + +To raise or lower the limit for a specific workload, pass +`--max-request-body-size` on `thv run` (or `thv proxy`) with a value in bytes. +Zero uses the default; negative values are rejected: + +```bash +thv run --max-request-body-size 16777216 # 16 MiB +``` + +The same limit applies to `thv proxy` when you're fronting an existing HTTP +endpoint: + +```bash +thv proxy --max-request-body-size 16777216 +``` + +For Kubernetes deployments, set `spec.maxRequestBodySize` on the `MCPServer` +resource. See [MCPServer](../reference/crds/mcpserver.mdx) for the field +reference. + ### Add a custom CA certificate In corporate environments with TLS inspection or custom certificate authorities, diff --git a/docs/toolhive/guides-cli/skills-management.mdx b/docs/toolhive/guides-cli/skills-management.mdx index aea29c2b..83adf17d 100644 --- a/docs/toolhive/guides-cli/skills-management.mdx +++ b/docs/toolhive/guides-cli/skills-management.mdx @@ -440,6 +440,22 @@ a consumer installs an unsigned skill project-scoped, they must pass `--allow-unsigned` to `thv skill install`. User-scoped installs do not enforce signatures. +When a skill was signed with `--key` rather than keyless, the first +project-scoped install must also pass `--public-key` with the matching cosign +public key (`cosign.pub`): + +```bash +thv skill install my-skill --scope project --project-root . \ + --public-key ./cosign.pub +``` + +ToolHive verifies the signature against the key, then pins the key in the +project's `toolhive.lock.yaml` so subsequent `thv skill sync` and +`thv skill upgrade` runs reuse it automatically. Distribute the public key +alongside the artifact so consumers can supply it. Keyless-signed skills need no +such out-of-band step, since the signer identity is verifiable from the +transparency-log entry. + :::note Pushing to a remote registry uses your existing container registry credentials diff --git a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx index 5a0cddd9..bb80d882 100644 --- a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx +++ b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx @@ -210,16 +210,16 @@ with a generic "authorization header required" error. **Configuration reference:** -| Field | Description | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `issuer` | HTTPS URL identifying this authorization server. Appears in the `iss` claim of issued JWTs. Must be a bare host with no path. | -| `signingKeySecretRefs` | References to Secrets containing JWT signing keys. First key is active; additional keys support rotation. | -| `hmacSecretRefs` | References to Secrets with symmetric keys for signing authorization codes and refresh tokens. | -| `tokenLifespans` | Configurable durations for access tokens (default: 1h), refresh tokens (default: 168h), and auth codes (default: 10m). | -| `upstreamProviders` | Configuration for upstream identity providers. MCPServer and MCPRemoteProxy support one provider; VirtualMCPServer supports multiple providers for sequential authentication. | -| `cimd` | Optional Client ID Metadata Document (CIMD) configuration. When `cimd.enabled` is `true`, the auth server accepts HTTPS URLs as `client_id` values and resolves them via CIMD, letting clients (for example, VS Code) authenticate without prior Dynamic Client Registration. See [Enable CIMD for zero-registration clients](#enable-cimd-for-zero-registration-clients). | -| `baselineClientScopes` | Optional list of OAuth 2.0 scopes merged into every DCR-registered client's scope set. Use this when MCP clients register with a narrowed `scope` field but then request wider scopes at `/oauth/authorize`. See [Enable baseline scopes for DCR clients](#enable-baseline-scopes-for-dcr-clients). | -| `disableUpstreamTokenInjection` | Optional. When `true`, the embedded auth server authenticates clients normally but the proxy strips `Authorization`, `Cookie`, and `Proxy-Authorization` from forwarded requests instead of swapping the JWT for an upstream token. Use this for public backends (such as documentation servers) that you still want to gate behind client auth. Cannot be combined with `tokenExchange` or `awsSts` on the same workload. | +| Field | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `issuer` | HTTPS URL identifying this authorization server. Appears in the `iss` claim of issued JWTs. Must be a bare host with no path. | +| `signingKeySecretRefs` | References to Secrets containing JWT signing keys. First key is active; additional keys support rotation. | +| `hmacSecretRefs` | References to Secrets with symmetric keys for signing authorization codes and refresh tokens. | +| `tokenLifespans` | Configurable durations for access tokens (default: 1h), refresh tokens (default: 168h), and auth codes (default: 10m). | +| `upstreamProviders` | Configuration for upstream identity providers. MCPServer and MCPRemoteProxy support one provider; VirtualMCPServer supports multiple providers for sequential authentication. | +| `cimd` | Optional Client ID Metadata Document (CIMD) configuration. When `cimd.enabled` is `true`, the auth server accepts HTTPS URLs as `client_id` values and resolves them via CIMD, letting clients (for example, VS Code) authenticate without prior Dynamic Client Registration. See [Enable CIMD for zero-registration clients](#enable-cimd-for-zero-registration-clients). | +| `baselineClientScopes` | Optional list of OAuth 2.0 scopes merged into every DCR-registered client's scope set. Use this when MCP clients register with a narrowed `scope` field but then request wider scopes at `/oauth/authorize`. See [Enable baseline scopes for DCR clients](#enable-baseline-scopes-for-dcr-clients). | +| `disableUpstreamTokenInjection` | Optional. When `true`, the embedded auth server authenticates clients normally but the proxy strips `Authorization`, `Cookie`, and `Proxy-Authorization` from forwarded requests instead of swapping the JWT for an upstream token. Use this for public backends (such as documentation servers) that you still want to gate behind client auth. Cannot be combined with `tokenExchange`, `awsSts`, or OBO (Stacklok Enterprise) on the same workload, since each would re-add credentials after the strip. | ## Step 5: Create the MCPOIDCConfig and MCPServer resources diff --git a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx index 0c418d8f..44ddb747 100644 --- a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx +++ b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx @@ -318,6 +318,27 @@ process. ::: +### Limit inbound request body size + +The proxy caps inbound request bodies at 8 MiB by default so a large payload +can't exhaust proxy memory before authentication or MCP parsing runs. Set +`spec.maxRequestBodySize` to override the cap for a specific workload. The value +is in bytes; zero uses the default: + +```yaml title="my-mcpserver-body-limit.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPServer +metadata: + name: fetch + namespace: development +spec: + image: ghcr.io/stackloklabs/gofetch/server + transport: streamable-http + mcpPort: 8080 + proxyPort: 8080 + maxRequestBodySize: 16777216 # 16 MiB +``` + ### Run a server with secrets When your MCP servers require authentication tokens or other secrets, ToolHive From d97e1e93d9cd0d0af1c46d8436065eefa764c2c5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:29:38 +0000 Subject: [PATCH 4/7] Refine wording in v0.47.0 release notes Replace "consumers" (flagged as PR jargon by the project style guide) with reader-focused wording in the newly added key-pair signing sections. Also rewrite the "unsigned plugin project-scoped" sentence into a direct imperative for the reader. Co-Authored-By: Claude Opus 4.7 --- docs/toolhive/guides-cli/ai-plugins.mdx | 10 +++++----- docs/toolhive/guides-cli/skills-management.mdx | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/toolhive/guides-cli/ai-plugins.mdx b/docs/toolhive/guides-cli/ai-plugins.mdx index 17b2e63a..95dd84d7 100644 --- a/docs/toolhive/guides-cli/ai-plugins.mdx +++ b/docs/toolhive/guides-cli/ai-plugins.mdx @@ -126,9 +126,9 @@ thv ai-plugin install my-plugin --scope project --project-root . \ ToolHive verifies the signature against the key, then pins it in the project's `toolhive.lock.yaml` so subsequent `thv ai-plugin sync` and `thv ai-plugin upgrade` runs reuse it automatically. Distribute the public key -alongside the artifact so consumers can supply it. Keyless-signed plugins need -no such out-of-band step, since the signer identity is verifiable from the -transparency-log entry. +alongside the artifact so users installing the plugin can supply it. +Keyless-signed plugins need no such out-of-band step, since the signer identity +is verifiable from the transparency-log entry. ## List installed plugins @@ -272,8 +272,8 @@ To publish without a signature, pass `--no-sign`: thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 --no-sign ``` -Consumers who install an unsigned plugin project-scoped must pass -`--allow-unsigned` on `thv ai-plugin install`. +To install an unsigned plugin project-scoped, pass `--allow-unsigned` to +`thv ai-plugin install`. :::note diff --git a/docs/toolhive/guides-cli/skills-management.mdx b/docs/toolhive/guides-cli/skills-management.mdx index 83adf17d..88938f19 100644 --- a/docs/toolhive/guides-cli/skills-management.mdx +++ b/docs/toolhive/guides-cli/skills-management.mdx @@ -452,9 +452,9 @@ thv skill install my-skill --scope project --project-root . \ ToolHive verifies the signature against the key, then pins the key in the project's `toolhive.lock.yaml` so subsequent `thv skill sync` and `thv skill upgrade` runs reuse it automatically. Distribute the public key -alongside the artifact so consumers can supply it. Keyless-signed skills need no -such out-of-band step, since the signer identity is verifiable from the -transparency-log entry. +alongside the artifact so users installing the skill can supply it. +Keyless-signed skills need no such out-of-band step, since the signer identity +is verifiable from the transparency-log entry. :::note From db07b6e1fd19bdd0dd79875168a0735858e9e44c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:46:41 +0000 Subject: [PATCH 5/7] Drop key-pair-signed plugin install docs Upstream work for key-pair-signed AI plugin installs is not fully merged yet, so remove the section documenting `--public-key` on `thv ai-plugin install`. The auto-generated CLI reference still lists the flag. Co-authored-by: Samuele V <4377202+samuv@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- docs/toolhive/guides-cli/ai-plugins.mdx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/toolhive/guides-cli/ai-plugins.mdx b/docs/toolhive/guides-cli/ai-plugins.mdx index 95dd84d7..c2ac0a46 100644 --- a/docs/toolhive/guides-cli/ai-plugins.mdx +++ b/docs/toolhive/guides-cli/ai-plugins.mdx @@ -112,24 +112,6 @@ thv ai-plugin install my-plugin --force thv ai-plugin install my-plugin --group development ``` -### Install a key-pair-signed plugin - -When a plugin was signed with a cosign key pair rather than keyless, the first -project-scoped install must supply the matching cosign public key (`cosign.pub`) -with `--public-key`: - -```bash -thv ai-plugin install my-plugin --scope project --project-root . \ - --public-key ./cosign.pub -``` - -ToolHive verifies the signature against the key, then pins it in the project's -`toolhive.lock.yaml` so subsequent `thv ai-plugin sync` and -`thv ai-plugin upgrade` runs reuse it automatically. Distribute the public key -alongside the artifact so users installing the plugin can supply it. -Keyless-signed plugins need no such out-of-band step, since the signer identity -is verifiable from the transparency-log entry. - ## List installed plugins ```bash From 8ccf1dfbac48e998bcaff5d34a68c7f64a5f48fc Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 9 Sep 2026 09:27:52 +0200 Subject: [PATCH 6/7] Remove request body size limit section from CLI/K8s guides The flag and CRD field are already fully documented in the auto-generated CLI reference (thv_run.md, thv_proxy.md), so the hand-written guide sections just duplicated it with extra internal rationale that doesn't help readers decide when to use it. Co-Authored-By: Claude Sonnet 5 --- docs/toolhive/guides-cli/run-mcp-servers.mdx | 25 -------------------- docs/toolhive/guides-k8s/run-mcp-k8s.mdx | 21 ---------------- 2 files changed, 46 deletions(-) diff --git a/docs/toolhive/guides-cli/run-mcp-servers.mdx b/docs/toolhive/guides-cli/run-mcp-servers.mdx index 93241b5c..b4549120 100644 --- a/docs/toolhive/guides-cli/run-mcp-servers.mdx +++ b/docs/toolhive/guides-cli/run-mcp-servers.mdx @@ -417,31 +417,6 @@ transport. ::: -### Limit inbound request body size - -ToolHive caps the size of inbound HTTP request bodies to the proxy so a large -payload can't exhaust proxy memory before authentication or MCP parsing runs. -The default cap is 8 MiB. - -To raise or lower the limit for a specific workload, pass -`--max-request-body-size` on `thv run` (or `thv proxy`) with a value in bytes. -Zero uses the default; negative values are rejected: - -```bash -thv run --max-request-body-size 16777216 # 16 MiB -``` - -The same limit applies to `thv proxy` when you're fronting an existing HTTP -endpoint: - -```bash -thv proxy --max-request-body-size 16777216 -``` - -For Kubernetes deployments, set `spec.maxRequestBodySize` on the `MCPServer` -resource. See [MCPServer](../reference/crds/mcpserver.mdx) for the field -reference. - ### Add a custom CA certificate In corporate environments with TLS inspection or custom certificate authorities, diff --git a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx index 44ddb747..0c418d8f 100644 --- a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx +++ b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx @@ -318,27 +318,6 @@ process. ::: -### Limit inbound request body size - -The proxy caps inbound request bodies at 8 MiB by default so a large payload -can't exhaust proxy memory before authentication or MCP parsing runs. Set -`spec.maxRequestBodySize` to override the cap for a specific workload. The value -is in bytes; zero uses the default: - -```yaml title="my-mcpserver-body-limit.yaml" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPServer -metadata: - name: fetch - namespace: development -spec: - image: ghcr.io/stackloklabs/gofetch/server - transport: streamable-http - mcpPort: 8080 - proxyPort: 8080 - maxRequestBodySize: 16777216 # 16 MiB -``` - ### Run a server with secrets When your MCP servers require authentication tokens or other secrets, ToolHive From 65b7e8184331b076c093610898f0a238a226645b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 9 Sep 2026 09:28:08 +0200 Subject: [PATCH 7/7] Fix broken MCPExternalAuthConfig example embeddedAuthServer requires at least one of upstreamProviders, delegateClients, or a trustedIssuers entry with jwtBearerGrant, but that's an "at least one of" CEL rule the example generator's required-field walk can't see, so upstreamProviders silently dropped out of the generated example when it became optional upstream. The issuer-only example that resulted fails admission. Add exampleForceFields to crd-intros.mjs so an override can restore a sibling field the schema doesn't mark required, and use it to put upstreamProviders back in the embeddedAuthServer example. Co-Authored-By: Claude Sonnet 5 --- docs/toolhive/reference/crds/index.mdx | 5 ++- .../reference/crds/mcpexternalauthconfig.mdx | 3 ++ scripts/extract-crd-schemas.mjs | 36 +++++++++++++++++++ scripts/lib/crd-intros.mjs | 6 ++++ .../mcpexternalauthconfigs.example.yaml | 3 ++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/toolhive/reference/crds/index.mdx b/docs/toolhive/reference/crds/index.mdx index 97cec1f8..56b2716b 100644 --- a/docs/toolhive/reference/crds/index.mdx +++ b/docs/toolhive/reference/crds/index.mdx @@ -6,7 +6,10 @@ description: import DocCard from '@theme/DocCard'; -The ToolHive operator manages MCP workloads using Kubernetes custom resources. Each page below documents one resource - its fields, defaults, validation rules, and a minimal example manifest - and links to the other resources it references. +The ToolHive operator manages MCP workloads using Kubernetes custom resources. +Each page below documents one resource - its fields, defaults, validation +rules, and a minimal example manifest - and links to the other resources it +references. ## Core workloads diff --git a/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx b/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx index 74903222..da5380c0 100644 --- a/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx +++ b/docs/toolhive/reference/crds/mcpexternalauthconfig.mdx @@ -22,6 +22,9 @@ spec: type: embeddedAuthServer embeddedAuthServer: issuer: https://example.com + upstreamProviders: + - name: example + type: oidc ``` ## Schema diff --git a/scripts/extract-crd-schemas.mjs b/scripts/extract-crd-schemas.mjs index 95c6eab3..6b7577cf 100644 --- a/scripts/extract-crd-schemas.mjs +++ b/scripts/extract-crd-schemas.mjs @@ -179,6 +179,37 @@ function buildRequiredExample(schema, picks = {}) { return out; } +// `exampleForceFields` in crd-intros.mjs adds an optional sibling field the +// schema's `required` list and discriminator-CEL detection can't capture - +// typically an "at least one of A/B/C" composite rule, where the field is +// technically optional but the example is incomplete/inadmissible without it. +// Dot-separated path, resolved against `spec` (e.g. +// 'embeddedAuthServer.upstreamProviders'). +function applyForceFields(specSchema, specExample, forceFields = []) { + for (const dotPath of forceFields) { + const parts = dotPath.split('.'); + let schema = specSchema; + let example = specExample; + for (let i = 0; i < parts.length - 1; i++) { + schema = schema?.properties?.[parts[i]]; + if (!schema) break; + if (!(parts[i] in example)) example[parts[i]] = {}; + example = example[parts[i]]; + } + if (!schema) continue; + const leafKey = parts[parts.length - 1]; + const leafSchema = schema.properties?.[leafKey]; + if (!leafSchema || leafKey in example) continue; + if (leafSchema.type === 'array') { + example[leafKey] = arrayExample({ ...leafSchema, minItems: 1 }); + } else if (leafSchema.type === 'object' && leafSchema.properties) { + example[leafKey] = buildRequiredExample(leafSchema); + } else { + example[leafKey] = placeholder(leafSchema); + } + } +} + function buildYamlSkeleton({ group, version, kind, scope, schema }) { const example = { apiVersion: `${group}/${version}`, @@ -193,6 +224,11 @@ function buildYamlSkeleton({ group, version, kind, scope, schema }) { const picks = preferredType ? { type: preferredType } : {}; example.spec = buildRequiredExample(schema.properties.spec, picks); if (example.spec === undefined) example.spec = {}; + applyForceFields( + schema.properties.spec, + example.spec, + intros[kind]?.exampleForceFields + ); } return yaml.stringify(example, { indent: 2, lineWidth: 0 }); } diff --git a/scripts/lib/crd-intros.mjs b/scripts/lib/crd-intros.mjs index 0730a504..145fc91f 100644 --- a/scripts/lib/crd-intros.mjs +++ b/scripts/lib/crd-intros.mjs @@ -159,6 +159,12 @@ export const intros = { intro: '`MCPExternalAuthConfig` configures how an MCP server or proxy authenticates to external services via token exchange or an embedded authorization server. It is referenced by [MCPServer](./mcpserver.mdx), [MCPRemoteProxy](./mcpremoteproxy.mdx), [MCPServerEntry](./mcpserverentry.mdx), and [VirtualMCPServer](./virtualmcpserver.mdx).', preferredType: 'embeddedAuthServer', + // `embeddedAuthServer` requires at least one of `upstreamProviders`, + // `delegateClients`, or a `trustedIssuers` entry with `jwtBearerGrant` (a + // CEL cross-field rule the generator's required-list walk can't see), so + // an `issuer`-only example fails admission. Force the most representative + // shape back in. + exampleForceFields: ['embeddedAuthServer.upstreamProviders'], }, MCPTelemetryConfig: { slug: 'mcptelemetryconfig', diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml index de531b7f..3a0beeb9 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.example.yaml @@ -7,3 +7,6 @@ spec: type: embeddedAuthServer embeddedAuthServer: issuer: https://example.com + upstreamProviders: + - name: example + type: oidc