Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/upstream-projects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ projects:

- id: toolhive
repo: stacklok/toolhive
version: v0.28.2
version: v0.28.3
# 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/,
Expand Down
39 changes: 38 additions & 1 deletion docs/toolhive/concepts/cedar-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,20 @@ cedar:
supported)
- `cedar`: The Cedar-specific configuration
- `policies`: An array of Cedar policy strings
- `entities_json`: A JSON string representing Cedar entities
- `entities_json`: A JSON string representing Cedar entities. Defaults to
`"[]"` when empty. Required when policies use transitive `in` checks against
a static entity store (for example, `ClaimGroup in PlatformRole`).
- `group_claim_name`: Optional custom JWT claim name for group membership (for
example, `https://example.com/groups`)
- `role_claim_name`: Optional custom JWT claim name for role membership,
separate from group claims. Use this when your identity provider provides
roles in a different claim than groups (for example, Entra ID `roles`
claim). If not set, roles are extracted from the same claims as groups.
- `group_entity_type`: Optional Cedar entity type name used for principal
parent UIDs synthesized from JWT group and role claims. Defaults to
`THVGroup`. Set this when your entity store uses a different type name (for
example, `ClaimGroup`) so transitive `in` checks resolve against the
entities you provide in `entities_json`.

## Writing effective policies

Expand Down Expand Up @@ -564,6 +571,36 @@ With both fields configured, ToolHive extracts group membership from the
`THVGroup` parent entities, so you can write policies that reference either
groups or roles using the same `principal in THVGroup::"..."` syntax.

### Customizing the group entity type

By default, group and role claims are mapped to `THVGroup` parent entities. If
your authorization model uses a static entity store (provided through
`entities_json`) with a different entity type, set `group_entity_type` so the
synthesized principal parents match the entities defined in your store. For
example, you might want `ClaimGroup` entities synthesized from JWT claims to
belong to `PlatformRole` parents defined in your entity store:

```json
{
"version": "1.0",
"type": "cedarv1",
"cedar": {
"policies": [
"permit(principal in PlatformRole::\"admin\", action, resource);"
],
"entities_json": "[{\"uid\": {\"type\": \"ClaimGroup\", \"id\": \"engineering\"}, \"parents\": [{\"type\": \"PlatformRole\", \"id\": \"admin\"}]}]",
"group_claim_name": "groups",
"group_entity_type": "ClaimGroup"
}
}
```

Without `group_entity_type`, Cedar synthesizes `THVGroup::"engineering"`
principal parents, which do not match the `ClaimGroup` entities in
`entities_json`. Transitive `in` checks against `PlatformRole` silently evaluate
to false, so every request is denied. Setting `group_entity_type` aligns the
synthesized parents with your entity store.

### How it works

1. The embedded authorization server authenticates the user with your upstream
Expand Down
33 changes: 33 additions & 0 deletions docs/toolhive/guides-k8s/auth-k8s.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,22 @@ kubectl logs -n toolhive-system -l app.kubernetes.io/name=weather-server-k8s
- Check the ConfigMap content:
`kubectl get configmap authz-config -n toolhive-system -o yaml`

**Authorization ConfigMap not resolved:**

When `spec.authzConfig.type: configMap`, the controller pre-validates the
referenced ConfigMap and surfaces failures on the `AuthConfigured` condition
with one of two reasons:

- `AuthzConfigMapNotFound`: the ConfigMap does not exist in the namespace.
Create it before reconciling, or correct the `name`/`namespace` reference.
- `AuthzConfigMapInvalid`: the ConfigMap exists but the payload is missing the
configured `key`, is empty, has malformed YAML or JSON, or fails Cedar
validation. Inspect the payload and the Cedar configuration shape.

```bash
kubectl describe mcpserver -n toolhive-system <name>
```

**OIDC configuration issues:**

- For external IdP: Ensure the issuer URL is accessible from within the cluster
Expand Down Expand Up @@ -1182,6 +1198,23 @@ kubectl logs -n toolhive-system -l app.kubernetes.io/name=weather-server-k8s
- Verify the upstream provider's client ID and redirect URI are correctly
configured in the `MCPExternalAuthConfig`

**Consumer reports `ExternalAuthConfigValidated=False`:**

When the referenced `MCPExternalAuthConfig` has its own `Valid` condition set to
`False`, the consumer resource (`MCPServer`, `MCPRemoteProxy`, or
`VirtualMCPServer`) mirrors that condition onto its own status with the same
reason and message. Check both objects:

```bash
kubectl describe mcpserver -n toolhive-system <name>
kubectl describe mcpexternalauthconfig -n toolhive-system <authConfigName>
```

A reason like `EnterpriseRequired` on the consumer indicates the source
`MCPExternalAuthConfig` is using a type (such as OBO) that requires Stacklok
Enterprise. Fix the configuration on the `MCPExternalAuthConfig` and the
consumer's mirrored condition clears on the next reconcile.

**Token validation failures after restart:**

- Ensure you have configured `signingKeySecretRefs` and `hmacSecretRefs` with
Expand Down
32 changes: 32 additions & 0 deletions docs/toolhive/guides-vmcp/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,38 @@ providers, see
The [complete example](#complete-example) below shows full provider
configurations.

When multiple upstream providers are configured, Cedar reads JWT claims from the
first upstream's access token by default. Pin a specific provider with
`spec.authServerConfig.primaryUpstreamProvider`. The value must match one of the
names in `upstreamProviders`; unresolvable values are rejected at admission with
`AuthServerConfigValidated=False`:

```yaml
spec:
authServerConfig:
issuer: https://auth.example.com
primaryUpstreamProvider: okta
upstreamProviders:
- name: okta
type: oidc
# ...
- name: github
type: oauth2
# ...
```

:::note[Deprecated location]

`primaryUpstreamProvider` previously lived under
`spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. The old location
is still read for backward compatibility, but the VirtualMCPServer controller
emits a Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated`
whenever it reads the value. Move the field to
`spec.authServerConfig.primaryUpstreamProvider` to clear the warning. Removal of
the deprecated location is planned for the release after the deprecation cycle.

:::

:::tip[Non-standard token responses]

Some OAuth 2.0 providers nest tokens under non-standard paths instead of
Expand Down
31 changes: 31 additions & 0 deletions docs/toolhive/reference/authz-policy-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,37 @@ cedar:
entities_json: '[]'
```

### Customizing the group entity type

By default, group and role claims become `THVGroup` parent entities. If your
authorization model defines a static entity store (provided through
`entities_json`) using a different entity type, set `group_entity_type` so the
synthesized parents match. This is required for transitive policies that walk a
hierarchy such as `ClaimGroup in PlatformRole`:

```yaml title="authz-config.yaml"
version: '1.0'
type: cedarv1
cedar:
group_claim_name: 'groups'
group_entity_type: 'ClaimGroup'
policies:
- 'permit(principal in PlatformRole::"admin", action, resource);'
entities_json: |
[
{
"uid": {"type": "ClaimGroup", "id": "engineering"},
"parents": [{"type": "PlatformRole", "id": "admin"}]
}
]
```

Without `group_entity_type`, Cedar synthesizes `THVGroup::"engineering"` for the
principal's parent. Because the entity store contains `ClaimGroup` entries, the
transitive `in PlatformRole` check evaluates to false and every request is
denied. Namespaced names (`Foo::Bar`) are not supported; the type must be a bare
Cedar identifier.

### How roles are resolved

In addition to group claims, ToolHive can extract role claims from a separate
Expand Down
7 changes: 7 additions & 0 deletions static/api-specs/crds/mcpexternalauthconfigs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@
"pattern": "^https?://[^\\s?#]+[^/\\s?#]$",
"type": "string"
},
"primaryUpstreamProvider": {
"description": "PrimaryUpstreamProvider names the upstream IDP whose access token Cedar\nshould read claims from when authorising a request. Must match the name\nof one of the entries in UpstreamProviders. When empty, the controller\nauto-selects the first entry of UpstreamProviders.\n\nOnly meaningful on VirtualMCPServer, where multiple upstream providers\ncan be configured and Cedar needs to pick which token's claims to\nevaluate. The VirtualMCPServer controller validates this field against\nUpstreamProviders at admission and rejects unresolvable values.\n\nOn MCPServer and MCPRemoteProxy this field is structurally present (the\nEmbeddedAuthServerConfig struct is shared) but has no runtime effect:\nthose CRDs are restricted to a single upstream so there is no choice to\nmake. Setting it on those CRDs is silently ignored.",
"maxLength": 63,
"minLength": 1,
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
"type": "string"
},
"signingKeySecretRefs": {
"description": "SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.\nSupports key rotation by allowing multiple keys (oldest keys are used for verification only).\nIf not specified, an ephemeral signing key will be auto-generated (development only -\nJWTs will be invalid after restart).",
"items": {
Expand Down
20 changes: 18 additions & 2 deletions static/api-specs/crds/mcpremoteproxies.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,23 @@
],
"type": "object"
},
"groupClaimName": {
"description": "GroupClaimName is the JWT claim key that contains group membership for the\nprincipal. When set, takes priority over the well-known defaults\n(\"groups\", \"roles\", \"cognito:groups\"). Use this for IDPs that place\ngroups under a URI-style claim (e.g. \"https://example.com/groups\"). When\nType is \"configMap\", a group_claim_name entry in the referenced ConfigMap\nis overridden by this field if both are set.",
"maxLength": 253,
"type": "string"
},
"groupEntityType": {
"description": "GroupEntityType is the Cedar entity type name used for principal parent\nUIDs synthesised from JWT group/role claims. Defaults to \"THVGroup\" when\nempty. Must match the entity type used in the static entity store for\ntransitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.\nNamespaced names (`Foo::Bar`) are not yet supported. When Type is\n\"configMap\", a group_entity_type entry in the referenced ConfigMap is\noverridden by this field if both are set.",
"maxLength": 63,
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"type": "string"
},
"inline": {
"description": "Inline contains direct authorization configuration\nOnly used when Type is \"inline\"",
"properties": {
"entitiesJson": {
"default": "[]",
"description": "EntitiesJSON is a JSON string representing Cedar entities",
"description": "EntitiesJSON is a JSON string representing Cedar entities. Required when\ntransitive policies (e.g. `ClaimGroup → PlatformRole`) need a static\nentity store; defaults to \"[]\".",
"type": "string"
},
"policies": {
Expand All @@ -88,7 +99,7 @@
"x-kubernetes-list-type": "atomic"
},
"primaryUpstreamProvider": {
"description": "PrimaryUpstreamProvider names the upstream IDP whose access token's claims\nCedar should evaluate. Currently honored only when the parent\nAuthzConfigRef.Type is \"inline\"; configMap-sourced policies will support\nthis in a future release (see #5208). Only meaningful for VirtualMCPServer\nwith an embedded auth server. When empty and an embedded auth server has\nupstreams configured, the controller defaults to the first upstream\nprovider. The name must match one of the upstreams declared on\nspec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is\nrejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy\nhave no embedded auth server; setting this field on those CRs surfaces an\nAuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.",
"description": "PrimaryUpstreamProvider names the upstream IDP whose access token's\nclaims Cedar should evaluate.\n\nDeprecated: on VirtualMCPServer this field has moved to\nspec.authServerConfig.primaryUpstreamProvider. The old location is\nstill read for one release for backward compatibility; the\nVirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated\nWarning event whenever it is consumed, and removal is planned for the\nrelease after the deprecation cycle.\n\nOn MCPServer and MCPRemoteProxy this field has always been a structural\nno-op (those CRDs do not run an embedded auth server). Setting it\ncontinues to surface the AuthzPrimaryUpstreamProviderIgnored advisory\ncondition; the deprecation does not change that behaviour.",
"maxLength": 63,
"minLength": 1,
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
Expand All @@ -100,6 +111,11 @@
],
"type": "object"
},
"roleClaimName": {
"description": "RoleClaimName is the JWT claim key that contains role membership for the\nprincipal. When set, the claim is extracted separately from GroupClaimName\nand both are mapped to the configured GroupEntityType. When Type is\n\"configMap\", a role_claim_name entry in the referenced ConfigMap is\noverridden by this field if both are set.",
"maxLength": 253,
"type": "string"
},
"type": {
"default": "configMap",
"description": "Type is the type of authorization configuration",
Expand Down
20 changes: 18 additions & 2 deletions static/api-specs/crds/mcpservers.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,23 @@
],
"type": "object"
},
"groupClaimName": {
"description": "GroupClaimName is the JWT claim key that contains group membership for the\nprincipal. When set, takes priority over the well-known defaults\n(\"groups\", \"roles\", \"cognito:groups\"). Use this for IDPs that place\ngroups under a URI-style claim (e.g. \"https://example.com/groups\"). When\nType is \"configMap\", a group_claim_name entry in the referenced ConfigMap\nis overridden by this field if both are set.",
"maxLength": 253,
"type": "string"
},
"groupEntityType": {
"description": "GroupEntityType is the Cedar entity type name used for principal parent\nUIDs synthesised from JWT group/role claims. Defaults to \"THVGroup\" when\nempty. Must match the entity type used in the static entity store for\ntransitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.\nNamespaced names (`Foo::Bar`) are not yet supported. When Type is\n\"configMap\", a group_entity_type entry in the referenced ConfigMap is\noverridden by this field if both are set.",
"maxLength": 63,
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"type": "string"
},
"inline": {
"description": "Inline contains direct authorization configuration\nOnly used when Type is \"inline\"",
"properties": {
"entitiesJson": {
"default": "[]",
"description": "EntitiesJSON is a JSON string representing Cedar entities",
"description": "EntitiesJSON is a JSON string representing Cedar entities. Required when\ntransitive policies (e.g. `ClaimGroup → PlatformRole`) need a static\nentity store; defaults to \"[]\".",
"type": "string"
},
"policies": {
Expand All @@ -96,7 +107,7 @@
"x-kubernetes-list-type": "atomic"
},
"primaryUpstreamProvider": {
"description": "PrimaryUpstreamProvider names the upstream IDP whose access token's claims\nCedar should evaluate. Currently honored only when the parent\nAuthzConfigRef.Type is \"inline\"; configMap-sourced policies will support\nthis in a future release (see #5208). Only meaningful for VirtualMCPServer\nwith an embedded auth server. When empty and an embedded auth server has\nupstreams configured, the controller defaults to the first upstream\nprovider. The name must match one of the upstreams declared on\nspec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is\nrejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy\nhave no embedded auth server; setting this field on those CRs surfaces an\nAuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.",
"description": "PrimaryUpstreamProvider names the upstream IDP whose access token's\nclaims Cedar should evaluate.\n\nDeprecated: on VirtualMCPServer this field has moved to\nspec.authServerConfig.primaryUpstreamProvider. The old location is\nstill read for one release for backward compatibility; the\nVirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated\nWarning event whenever it is consumed, and removal is planned for the\nrelease after the deprecation cycle.\n\nOn MCPServer and MCPRemoteProxy this field has always been a structural\nno-op (those CRDs do not run an embedded auth server). Setting it\ncontinues to surface the AuthzPrimaryUpstreamProviderIgnored advisory\ncondition; the deprecation does not change that behaviour.",
"maxLength": 63,
"minLength": 1,
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
Expand All @@ -108,6 +119,11 @@
],
"type": "object"
},
"roleClaimName": {
"description": "RoleClaimName is the JWT claim key that contains role membership for the\nprincipal. When set, the claim is extracted separately from GroupClaimName\nand both are mapped to the configured GroupEntityType. When Type is\n\"configMap\", a role_claim_name entry in the referenced ConfigMap is\noverridden by this field if both are set.",
"maxLength": 253,
"type": "string"
},
"type": {
"default": "configMap",
"description": "Type is the type of authorization configuration",
Expand Down
Loading