@@ -77,12 +77,12 @@ auth:
7777
7878### Available roles
7979
80- | Role | Grants access to |
81- | ------------------ | ------------------------------------------------------------- |
82- | ` superAdmin ` | All operations; bypasses all claim checks |
83- | ` manageSources ` | Create, update, delete, and list sources via the admin API |
84- | ` manageRegistries ` | Create, update, delete, and list registries via the admin API |
85- | ` manageEntries ` | Publish and delete MCP server versions and skills |
80+ | Role | Grants access to |
81+ | ------------------ | -------------------------------------------------------------------- |
82+ | ` superAdmin ` | All operations; bypasses all claim checks |
83+ | ` manageSources ` | Create, update, delete, and list sources via the admin API |
84+ | ` manageRegistries ` | Create, update, delete, and list registries via the admin API |
85+ | ` manageEntries ` | Publish, delete, and manage claims on MCP server versions and skills |
8686
8787### Role rule matching
8888
@@ -197,10 +197,30 @@ A caller who requests `GET /registry/platform/v0.1/servers` must have JWT claims
197197that include `org : " acme" ` and ` team: "platform"`. Otherwise, the server returns
198198` 403 Forbidden` .
199199
200- # ## Claim containment
200+ # ## Claim matching rules
201201
202- The server uses **containment** (superset check) for claim validation : the
203- caller's claims must be a superset of the resource's claims. For example :
202+ The server compares caller claims against a resource's claims with two rules
203+ that differ only in how they handle array-valued claims. Every check does an AND
204+ across keys (the caller must satisfy every key present on the resource), and
205+ both rules default-deny on unlabeled resources.
206+
207+ | Rule | Within-array test | Used for |
208+ | -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
209+ | **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. |
210+ | **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. |
211+
212+ For scalar (string) claims the two rules behave identically. The difference only
213+ shows up on array values.
214+
215+ For example, a resource tagged `team : ["platform", "data"]` is an allow-list:
216+ under the visibility rule, a caller whose JWT has `team : " platform" ` can see and
217+ delete it. Under the subset rule, the same caller could **not** create or update
218+ a resource tagged that way, because they don't hold both ` platform` and `data`
219+ in their own JWT. This split prevents a caller from stamping a resource with
220+ wider visibility than their own identity while keeping shared resources visible
221+ to every eligible team.
222+
223+ Examples on scalar claims :
204224
205225| Resource claims | Caller JWT claims | Result |
206226| --------------------------------- | --------------------------------- | ---------------------------------- |
@@ -209,6 +229,19 @@ caller's claims must be a superset of the resource's claims. For example:
209229| `{}` (no claims) | `{org : " acme" }` | Denied (default-deny on unlabeled) |
210230| `{org : " acme" }` | `{org: "contoso"}` | Denied |
211231
232+ :::info[Upgrading from earlier releases]
233+
234+ Registry Server v1.4.x treated array-valued claims as AND-within-array for every
235+ check, so a resource tagged `team : ["platform", "data"]` was invisible to a
236+ caller who held only one of the team values. From v1.5.0, reads and access gates
237+ use the visibility (OR) rule instead, so callers who share any of the tagged
238+ values can see and delete the resource. Writes still enforce the subset (AND)
239+ rule to prevent visibility escalation. If you relied on AND-within-array to keep
240+ an array-tagged resource hidden from callers who held only one value, retag the
241+ resource with the specific set you want to require.
242+
243+ :: :
244+
212245# ## Unlabeled resources
213246
214247When `auth.authz` is configured, sources, registries, and entries with no claims
@@ -288,11 +321,32 @@ curl -X POST \
288321See the [Registry API reference](../reference/registry-api.mdx) for the full
289322server payload schema, including `packages`, `remotes`, and `_meta` fields.
290323
291- # ## Update claims on an existing entry
324+ # ## Read and update claims on an existing entry
292325
293- Use `PUT /v1/entries/{type}/{name}/claims` to change the claims on every version
294- of a previously published entry. The `type` path parameter is either `server` or
295- ` skill` .
326+ Use `GET /v1/entries/{type}/{name}/claims` to fetch the claims on a previously
327+ published entry, and `PUT /v1/entries/{type}/{name}/claims` to change them. The
328+ ` type` path parameter is either `server` or `skill`, and the endpoints require
329+ the `manageEntries` role. Because entry names contain a slash separating
330+ namespace from server name, URL-encode the slash as `%2F` so the path is treated
331+ as a single `{name}` path parameter.
332+
333+ ` ` ` bash title="Read claims on a published server"
334+ curl -X GET \
335+ "https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \
336+ -H "Authorization: Bearer $TOKEN"
337+ ` ` `
338+
339+ The response envelope is always a JSON object, even when the entry has no
340+ claims :
341+
342+ ```json title="Response : 200 OK"
343+ {
344+ " claims " : {
345+ " org " : " acme" ,
346+ " team " : " platform"
347+ }
348+ }
349+ ```
296350
297351``` bash title="Update claims on a published server"
298352curl -X PUT \
@@ -307,35 +361,52 @@ curl -X PUT \
307361 }'
308362```
309363
310- Because entry names contain a slash separating namespace from server name,
311- URL-encode the slash as `%2F` so the path is treated as a single `{name}` path
312- parameter.
364+ Pass an empty ` claims ` object (` {"claims": {}} ` ) to clear claims entirely.
365+ Successful updates return ` 204 No Content ` .
366+
367+ Both endpoints apply the standard [ claim matching rules] ( #claim-matching-rules )
368+ to the caller's JWT:
369+
370+ - The GET returns ` 403 Forbidden ` unless the caller can see the entry under the
371+ visibility rule (or is a super-admin).
372+ - The PUT requires that the caller can see the existing entry (visibility)
373+ ** and** that the new claims are a subset of the caller's JWT (subset), so a
374+ caller can't broaden an entry's visibility past their own identity.
313375
314- Pass an empty `claims` object (`{"claims" : {}}`) to clear claims entirely. The
315- same authorization rules apply : your JWT claims must satisfy both the existing
316- claims on the entry (so you can modify it) and the new claims you're setting (so
317- you can't escalate visibility beyond what you're entitled to). Successful
318- updates return `204 No Content`.
376+ Both endpoints are scoped to managed-source entries. Entries synced from a Git,
377+ API, file, or Kubernetes source get their claims from upstream (the source
378+ manifest, or the ` toolhive.stacklok.dev/authz-claims ` annotation for
379+ Kubernetes), and every sync overwrites them, so any API write would be
380+ ephemeral. To inspect the claims on synced entries, use
381+ ` /v1/sources/{name}/entries ` or ` /v1/registries/{name}/entries ` .
319382
320383## Admin API claim scoping
321384
322385When authorization is enabled, the admin API endpoints for managing sources and
323386registries are also scoped by claims:
324387
325- - **List sources/registries**: Only returns resources whose claims the caller's
326- JWT satisfies .
388+ - ** List sources/registries** : Only returns resources the caller can see under
389+ the visibility rule .
327390- ** Get source/registry by name** : Returns ` 404 Not Found ` (not ` 403 ` ) when the
328- caller's claims don 't match . This prevents information disclosure about
391+ caller can 't see the resource . This prevents information disclosure about
329392 resources the caller cannot access.
330393- ** List source/registry entries** : ` GET /v1/sources/{name}/entries ` and
331394 ` GET /v1/registries/{name}/entries ` return the raw entries (servers and skills
332395 with versions and claims) in a source or registry. These endpoints follow the
333396 same claim scoping: the parent source or registry must be accessible to the
334397 caller.
335398- ** Create source/registry** : The request claims must be a subset of the
336- caller's JWT claims.
337- - **Update/delete source/registry**: The caller's JWT claims must satisfy the
338- existing resource's claims.
399+ caller's JWT claims (subset rule).
400+ - ** Update source/registry** : The caller must be able to see the existing
401+ resource (visibility rule) and the incoming claims must be a subset of the
402+ caller's JWT (subset rule).
403+ - ** Delete source/registry** : The caller must be able to see the resource
404+ (visibility rule). On shared resources tagged with an array of allowed values,
405+ any caller who can see the resource can delete it, so list, get, and delete
406+ now agree.
407+
408+ See [ Claim matching rules] ( #claim-matching-rules ) for how visibility and subset
409+ differ on array-valued claims.
339410
340411## Auth-only mode
341412
0 commit comments