diff --git a/.github/upstream-projects.yaml b/.github/upstream-projects.yaml index 2a2bace0..e00fbb8d 100644 --- a/.github/upstream-projects.yaml +++ b/.github/upstream-projects.yaml @@ -29,7 +29,7 @@ projects: - id: toolhive-registry-server repo: stacklok/toolhive-registry-server - version: v1.4.13 + version: v1.5.0 docs_paths: - docs/toolhive/guides-registry - docs/toolhive/concepts/registry-criteria.mdx diff --git a/docs/toolhive/guides-registry/audit-logging.mdx b/docs/toolhive/guides-registry/audit-logging.mdx index 3947c0b1..17b9185f 100644 --- a/docs/toolhive/guides-registry/audit-logging.mdx +++ b/docs/toolhive/guides-registry/audit-logging.mdx @@ -69,6 +69,7 @@ Resource lifecycle events: | `registry.entries.list` | Entries listed for a registry | | `entry.publish` | Entry published to a managed source | | `entry.delete` | Entry deleted | +| `entry.claims.read` | Entry claims retrieved | | `entry.claims.update` | Entry claims updated | | `user.info` | Caller identity info retrieved (`/v1/me`) | diff --git a/docs/toolhive/guides-registry/authorization.mdx b/docs/toolhive/guides-registry/authorization.mdx index 1fc7c62b..ed9fa238 100644 --- a/docs/toolhive/guides-registry/authorization.mdx +++ b/docs/toolhive/guides-registry/authorization.mdx @@ -77,12 +77,12 @@ auth: ### Available roles -| Role | Grants access to | -| ------------------ | ------------------------------------------------------------- | -| `superAdmin` | All operations; bypasses all claim checks | -| `manageSources` | Create, update, delete, and list sources via the admin API | -| `manageRegistries` | Create, update, delete, and list registries via the admin API | -| `manageEntries` | Publish and delete MCP server versions and skills | +| Role | Grants access to | +| ------------------ | -------------------------------------------------------------------- | +| `superAdmin` | All operations; bypasses all claim checks | +| `manageSources` | Create, update, delete, and list sources via the admin API | +| `manageRegistries` | Create, update, delete, and list registries via the admin API | +| `manageEntries` | Publish, delete, and manage claims on MCP server versions and skills | ### Role rule matching @@ -197,10 +197,30 @@ A caller who requests `GET /registry/platform/v0.1/servers` must have JWT claims that include `org: "acme"` and `team: "platform"`. Otherwise, the server returns `403 Forbidden`. -### Claim containment +### Claim matching rules -The server uses **containment** (superset check) for claim validation: the -caller's claims must be a superset of the resource's claims. For example: +The server compares caller claims against a resource's claims with two rules +that differ only in how they handle array-valued claims. Every check does an AND +across keys (the caller must satisfy every key present on the resource), and +both rules default-deny on unlabeled resources. + +| Rule | Within-array test | Used for | +| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Visibility** | OR - the caller must share **any one** array value | Reads and access gates: list and get on sources, registries, and entries; the registry access gate on `/registry/{name}`; delete on sources, registries, and entry versions. | +| **Subset** | AND - the caller must hold **every** array value | Writes: create or update a source or registry; publish an entry; update the claims on a published entry. Applied to the **incoming** claims in the request, not to the resource's existing tags. | + +For scalar (string) claims the two rules behave identically. The difference only +shows up on array values. + +For example, a resource tagged `team: ["platform", "data"]` is an allow-list: +under the visibility rule, a caller whose JWT has `team: "platform"` can see and +delete it. Under the subset rule, the same caller could **not** create or update +a resource tagged that way, because they don't hold both `platform` and `data` +in their own JWT. This split prevents a caller from stamping a resource with +wider visibility than their own identity while keeping shared resources visible +to every eligible team. + +Examples on scalar claims: | Resource claims | Caller JWT claims | Result | | --------------------------------- | --------------------------------- | ---------------------------------- | @@ -209,6 +229,19 @@ caller's claims must be a superset of the resource's claims. For example: | `{}` (no claims) | `{org: "acme"}` | Denied (default-deny on unlabeled) | | `{org: "acme"}` | `{org: "contoso"}` | Denied | +:::info[Upgrading from earlier releases] + +Registry Server v1.4.x treated array-valued claims as AND-within-array for every +check, so a resource tagged `team: ["platform", "data"]` was invisible to a +caller who held only one of the team values. From v1.5.0, reads and access gates +use the visibility (OR) rule instead, so callers who share any of the tagged +values can see and delete the resource. Writes still enforce the subset (AND) +rule to prevent visibility escalation. If you relied on AND-within-array to keep +an array-tagged resource hidden from callers who held only one value, retag the +resource with the specific set you want to require. + +::: + ### Unlabeled resources When `auth.authz` is configured, sources, registries, and entries with no claims @@ -288,11 +321,32 @@ curl -X POST \ See the [Registry API reference](../reference/registry-api.mdx) for the full server payload schema, including `packages`, `remotes`, and `_meta` fields. -### Update claims on an existing entry +### Read and update claims on an existing entry -Use `PUT /v1/entries/{type}/{name}/claims` to change the claims on every version -of a previously published entry. The `type` path parameter is either `server` or -`skill`. +Use `GET /v1/entries/{type}/{name}/claims` to fetch the claims on a previously +published entry, and `PUT /v1/entries/{type}/{name}/claims` to change them. The +`type` path parameter is either `server` or `skill`, and the endpoints require +the `manageEntries` role. Because entry names contain a slash separating +namespace from server name, URL-encode the slash as `%2F` so the path is treated +as a single `{name}` path parameter. + +```bash title="Read claims on a published server" +curl -X GET \ + "https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \ + -H "Authorization: Bearer $TOKEN" +``` + +The response envelope is always a JSON object, even when the entry has no +claims: + +```json title="Response: 200 OK" +{ + "claims": { + "org": "acme", + "team": "platform" + } +} +``` ```bash title="Update claims on a published server" curl -X PUT \ @@ -307,25 +361,34 @@ curl -X PUT \ }' ``` -Because entry names contain a slash separating namespace from server name, -URL-encode the slash as `%2F` so the path is treated as a single `{name}` path -parameter. +Pass an empty `claims` object (`{"claims": {}}`) to clear claims entirely. +Successful updates return `204 No Content`. + +Both endpoints apply the standard [claim matching rules](#claim-matching-rules) +to the caller's JWT: + +- The GET returns `403 Forbidden` unless the caller can see the entry under the + visibility rule (or is a super-admin). +- The PUT requires that the caller can see the existing entry (visibility) + **and** that the new claims are a subset of the caller's JWT (subset), so a + caller can't broaden an entry's visibility past their own identity. -Pass an empty `claims` object (`{"claims": {}}`) to clear claims entirely. The -same authorization rules apply: your JWT claims must satisfy both the existing -claims on the entry (so you can modify it) and the new claims you're setting (so -you can't escalate visibility beyond what you're entitled to). Successful -updates return `204 No Content`. +Both endpoints are scoped to managed-source entries. Entries synced from a Git, +API, file, or Kubernetes source get their claims from upstream (the source +manifest, or the `toolhive.stacklok.dev/authz-claims` annotation for +Kubernetes), and every sync overwrites them, so any API write would be +ephemeral. To inspect the claims on synced entries, use +`/v1/sources/{name}/entries` or `/v1/registries/{name}/entries`. ## Admin API claim scoping When authorization is enabled, the admin API endpoints for managing sources and registries are also scoped by claims: -- **List sources/registries**: Only returns resources whose claims the caller's - JWT satisfies. +- **List sources/registries**: Only returns resources the caller can see under + the visibility rule. - **Get source/registry by name**: Returns `404 Not Found` (not `403`) when the - caller's claims don't match. This prevents information disclosure about + caller can't see the resource. This prevents information disclosure about resources the caller cannot access. - **List source/registry entries**: `GET /v1/sources/{name}/entries` and `GET /v1/registries/{name}/entries` return the raw entries (servers and skills @@ -333,9 +396,17 @@ registries are also scoped by claims: same claim scoping: the parent source or registry must be accessible to the caller. - **Create source/registry**: The request claims must be a subset of the - caller's JWT claims. -- **Update/delete source/registry**: The caller's JWT claims must satisfy the - existing resource's claims. + caller's JWT claims (subset rule). +- **Update source/registry**: The caller must be able to see the existing + resource (visibility rule) and the incoming claims must be a subset of the + caller's JWT (subset rule). +- **Delete source/registry**: The caller must be able to see the resource + (visibility rule). On shared resources tagged with an array of allowed values, + any caller who can see the resource can delete it, so list, get, and delete + now agree. + +See [Claim matching rules](#claim-matching-rules) for how visibility and subset +differ on array-valued claims. ## Auth-only mode diff --git a/docs/toolhive/guides-registry/configuration.mdx b/docs/toolhive/guides-registry/configuration.mdx index d65bf36d..9bf04c8a 100644 --- a/docs/toolhive/guides-registry/configuration.mdx +++ b/docs/toolhive/guides-registry/configuration.mdx @@ -248,6 +248,7 @@ sources: - name: mcp-upstream api: endpoint: https://registry.modelcontextprotocol.io + timeout: '30s' syncPolicy: interval: '1h' @@ -260,6 +261,11 @@ registries: - `endpoint` (required): Base URL of the upstream MCP Registry API (without path) +- `timeout` (optional): Per-request HTTP timeout as a Go duration (for example, + `30s`, `1m`). Must be greater than zero and no larger than `5m`. Defaults to + `10s`. Raise this for public or occasionally slow upstreams whose + time-to-first-byte exceeds the default. The same bound is enforced whether the + source is loaded from the config file or created through the admin API. :::note diff --git a/docs/toolhive/guides-registry/publish-servers.mdx b/docs/toolhive/guides-registry/publish-servers.mdx index efadb407..09fdd59e 100644 --- a/docs/toolhive/guides-registry/publish-servers.mdx +++ b/docs/toolhive/guides-registry/publish-servers.mdx @@ -337,10 +337,24 @@ subsequent lookups. ::: -### Update claims on a published server +### Read and update claims on a published server -To change the authorization claims on an existing server, send a `PUT` request -to the claims path: +To read the current claims on a published server, send a `GET` request to the +claims path. The response envelope is always a JSON object, even when the entry +has no claims: + +```bash +curl -X GET \ + https://registry.example.com/v1/entries/server/io.github.acme%2Fdeploy-helper/claims \ + -H "Authorization: Bearer " +``` + +```json title="Response: 200 OK" +{ "claims": { "org": "acme", "team": "platform" } } +``` + +To change the claims on an existing server, send a `PUT` request to the same +path: ```bash curl -X PUT \ @@ -350,10 +364,12 @@ curl -X PUT \ -d '{"claims": {"org": "acme", "team": "platform"}}' ``` -The JWT presented on both the original publish and this update must satisfy the -claims being set. See +Both endpoints require the `manageEntries` role, and your JWT must satisfy the +entry's existing claims. On update, the new claims must also be a subset of your +JWT. These endpoints only cover managed-source entries; synced entries get their +claims from upstream. See [Claims on published entries](./authorization.mdx#claims-on-published-entries) -for the authorization rules. +for the full authorization rules. ## Next steps diff --git a/static/api-specs/toolhive-registry-api.yaml b/static/api-specs/toolhive-registry-api.yaml index 1e54981e..87982090 100644 --- a/static/api-specs/toolhive-registry-api.yaml +++ b/static/api-specs/toolhive-registry-api.yaml @@ -1,5 +1,53 @@ components: schemas: + github_com_stacklok_toolhive-registry-server_internal_config.APIConfig: + description: API endpoint source + properties: + endpoint: + description: |- + Endpoint is the base API URL (without path) + The registry handler will append the appropriate paths for the MCP Registry API v0.1: + - /v0.1/servers - List all servers + - /v0.1/servers/{name}/versions - List server versions + - /v0.1/servers/{name}/versions/{version} - Get specific version + Example: "http://my-registry-api.default.svc.cluster.local/registry" + type: string + timeout: + description: |- + Timeout is the per-request timeout for HTTP requests to the API endpoint + Accepts a Go duration string (e.g., "30s", "1m"); must be > 0 and <= 5m + Defaults to 10s if not specified + Useful for public or occasionally-slow upstreams where the default is too aggressive + type: string + type: object + github_com_stacklok_toolhive-registry-server_internal_config.FileConfig: + description: Local file or URL source + properties: + data: + description: |- + Data is the inline registry data as a JSON string + Mutually exclusive with Path and URL - exactly one must be specified + Useful for API-created registries where the data is provided directly + type: string + path: + description: |- + Path is the path to the registry.json file on the local filesystem + Can be absolute or relative to the working directory + Mutually exclusive with URL and Data - exactly one must be specified + type: string + timeout: + description: |- + Timeout is the timeout for HTTP requests when using URL + Defaults to 30s if not specified + Only applicable when URL is set + type: string + url: + description: |- + URL is the HTTP/HTTPS URL to fetch the registry file from + Mutually exclusive with Path and Data - exactly one must be specified + HTTPS is required unless the host is localhost or THV_REGISTRY_INSECURE_URL=true + type: string + type: object github_com_stacklok_toolhive-registry-server_internal_config.FilterConfig: description: Filtering rules properties: @@ -8,6 +56,48 @@ components: tags: $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.TagFilterConfig' type: object + github_com_stacklok_toolhive-registry-server_internal_config.GitAuthConfig: + description: Auth contains optional authentication for private repositories + properties: + passwordFile: + description: |- + PasswordFile is the path to a file containing the Git password/token + Must be an absolute path; whitespace is trimmed from the content + type: string + username: + description: Username is the Git username for HTTP Basic authentication + type: string + type: object + github_com_stacklok_toolhive-registry-server_internal_config.GitConfig: + description: Git repository source + properties: + auth: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.GitAuthConfig' + branch: + description: Branch is the Git branch to use (mutually exclusive with Tag + and Commit) + type: string + commit: + description: Commit is the Git commit SHA to use (mutually exclusive with + Branch and Tag) + type: string + path: + description: Path is the path to the registry file within the repository + type: string + repository: + description: Repository is the Git repository URL (HTTP/HTTPS/SSH) + type: string + tag: + description: Tag is the Git tag to use (mutually exclusive with Branch and + Commit) + type: string + type: object + github_com_stacklok_toolhive-registry-server_internal_config.KubernetesConfig: + description: Kubernetes discovery source + type: object + github_com_stacklok_toolhive-registry-server_internal_config.ManagedConfig: + description: Managed registry (no sync) + type: object github_com_stacklok_toolhive-registry-server_internal_config.NameFilterConfig: properties: exclude: @@ -36,6 +126,12 @@ components: - SourceTypeFile - SourceTypeManaged - SourceTypeKubernetes + github_com_stacklok_toolhive-registry-server_internal_config.SyncPolicyConfig: + description: Sync schedule configuration + properties: + interval: + type: string + type: object github_com_stacklok_toolhive-registry-server_internal_config.TagFilterConfig: properties: exclude: @@ -71,6 +167,19 @@ components: version: type: string type: object + github_com_stacklok_toolhive-registry-server_internal_service.RegistryCreateRequest: + properties: + claims: + additionalProperties: {} + description: Authorization claims + type: object + sources: + description: ordered list of source names + items: + type: string + type: array + uniqueItems: false + type: object github_com_stacklok_toolhive-registry-server_internal_service.RegistryEntriesResponse: properties: entries: @@ -206,6 +315,27 @@ components: url: type: string type: object + github_com_stacklok_toolhive-registry-server_internal_service.SourceCreateRequest: + properties: + api: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.APIConfig' + claims: + additionalProperties: {} + description: Authorization claims + type: object + file: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.FileConfig' + filter: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.FilterConfig' + git: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.GitConfig' + kubernetes: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.KubernetesConfig' + managed: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.ManagedConfig' + syncPolicy: + $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_config.SyncPolicyConfig' + type: object github_com_stacklok_toolhive-registry-server_internal_service.SourceEntriesResponse: properties: entries: @@ -290,6 +420,12 @@ components: description: Number of skills in registry type: integer type: object + internal_api_v1.entryClaimsResponse: + properties: + claims: + additionalProperties: {} + type: object + type: object internal_api_v1.meResponse: properties: roles: @@ -884,11 +1020,6 @@ paths: name: version schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -942,11 +1073,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1007,11 +1133,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1078,11 +1199,6 @@ paths: name: cursor schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1133,11 +1249,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1196,11 +1307,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1265,11 +1371,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1356,6 +1457,76 @@ paths: tags: - v1 /v1/entries/{type}/{name}/claims: + get: + description: |- + Get the claims for an API-published entry name within the managed source. + Claims are stored at the entry-name level and are shared by every version of that name. + Synced-source entries (git/api/file/kubernetes) are out of scope: their claims come from + upstream (the source manifest or the `toolhive.stacklok.dev/authz-claims` annotation) and + are surfaced through the `/v1/sources/{name}/entries` and `/v1/registries/{name}/entries` lists. + parameters: + - description: Entry Type (server or skill) + in: path + name: type + required: true + schema: + type: string + - description: Entry Name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_api_v1.entryClaimsResponse' + description: Entry claims + "400": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Bad request + "403": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Forbidden + "404": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Not found + "500": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Internal server error + "503": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: No managed source available + summary: Get entry claims + tags: + - v1 put: description: Update claims for a published entry name parameters: @@ -1450,11 +1621,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "204": description: No Content @@ -1509,11 +1675,6 @@ paths: /v1/registries: get: description: List all registries - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1542,11 +1703,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "204": description: Registry deleted @@ -1594,11 +1750,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1646,7 +1797,13 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_service.RegistryCreateRequest' + description: Registry configuration + summary: request + description: Registry configuration + required: true responses: "200": content: @@ -1697,11 +1854,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1739,11 +1891,6 @@ paths: /v1/sources: get: description: List all sources - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1772,11 +1919,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "204": description: Source deleted @@ -1832,11 +1974,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: @@ -1884,7 +2021,13 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - $ref: '#/components/schemas/github_com_stacklok_toolhive-registry-server_internal_service.SourceCreateRequest' + description: Source configuration + summary: request + description: Source configuration + required: true responses: "200": content: @@ -1943,11 +2086,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - type: object responses: "200": content: