MastertheMesh
Solo · AgentRegistry · AccessPolicy · OIDC · Keycloak · RBAC · kind
Built · partition and denials captured live on the part-1 cluster

AgentRegistry end to end, part 2: one realm, two teams, a policy-partitioned catalog

Part 1 built the paved road: scaffold, publish, deploy, invoke, with one admin and zero arguments about who may see what. Part 2 is what happens when the second team shows up. On the same kind cluster, the AgentRegistry enterprise daemon moves off its embedded demo IdP and onto the same Keycloak realm that already fronts kagent. The token's groups claim maps to registry roles, carol becomes the superuser, and the catalog goes default-deny: alice and bob get empty lists until two AccessPolicy resources carve out their team lanes. Then every enforcement edge is exercised live: partitioned lists where other teams' resources are absent, not denied, a forbidden by-name get, publish grants scoped to individual resource names, and policy management that only the admin can touch. Every output below is captured from the live run.

Series: AgentRegistry end to end. This is part 2, and it runs on the cluster part 1 deploys: the same kind cluster, Keycloak realm, registry daemon, and the catalog part 1 published (the summarizer agent, the textkit MCP server, the summary-style skill). Bring part 1 up first; part 2 flips the registry's identity model and partitions that catalog, and quick.sh down hands the cluster back to part 1 exactly as it was.

The use case

A catalog with one admin user is an inventory. A catalog multiple teams can share safely is governance, and it needs three things: callers identified by your IdP rather than a shared token, a default that exposes nothing, and policies that grant each team exactly its lane, both for reading and for publishing. That maps onto questions a platform team will actually be asked. Can trial users see the internal agents? Who is allowed to publish, and can they publish anything they like? If a team cannot use a resource, should they even know it exists? This lab answers all of those with the enterprise registry's own primitives: OIDC, role mapping, a superuser role, and AccessPolicy.

Three identities, one realm

The Keycloak realm is untouched from part 1: same users, same groups, same public client. The only thing that changes is who consumes the tokens. In part 1 the kagent controller validated them; now the registry does too, and each group lands a different registry persona:

group field-admin · superuser

carol

maps to
RBAC_SUPERUSER_ROLE
catalog
Everything, no policy needed
extra
Only she can manage AccessPolicies
group field-fte · team lane

alice

maps to
role field-fte
catalog
The part-1 summarizer stack
extra
May publish one named new skill
group field-trial · read-only lane

bob

maps to
role field-trial
catalog
One skill, nothing else
extra
No publish rights anywhere

The flow

KIND CLUSTER · arctl-lab Keycloak · realm solo carol/field-admin · alice/field-fte bob/field-trial NodePort 30080 on the kind node Docker DNS alias socat ":80 → node:30080" alias = in-cluster hostname AgentRegistry daemon OIDC validate · :12121 groups → roles · superuser AccessPolicy filter carol field-admin alice field-fte bob field-trial Bearer (Keycloak) carol sees everything + AccessPolicies runtimes · deployments alice sees summarizer · acme/textkit summary-style · +publish 1 bob sees summary-style · read-only everything else: absent

Step 1: the same realm, now in front of the registry

The daemon reads its OIDC and RBAC settings from the environment when arctl daemon start brings it up, so the switch is a restart with the right variables. The issuer is the in-cluster Keycloak, the role claim is the token's groups, and the group field-admin is the superuser role:

export OIDC_AUTO_AUTH_ENABLED=false
export OIDC_ISSUER=http://keycloak.keycloak.svc.cluster.local/realms/solo
export OIDC_CLIENT_ID=kagent
export RBAC_ROLE_CLAIM=groups          # Keycloak emits lowercase `groups`
export RBAC_SUPERUSER_ROLE=field-admin
arctl daemon stop && arctl daemon start

There is one genuinely interesting problem in the middle of that: the token's iss is pinned to the in-cluster hostname (keycloak.keycloak.svc.cluster.local, from part 1's KC_HOSTNAME), and the daemon validates by OIDC discovery at exactly that URL. But the daemon is a Docker container outside the cluster, where that hostname does not exist. The lab bridges it with two small pieces: a NodePort exposing Keycloak on the kind node, and a socat container whose Docker network alias is the in-cluster hostname, forwarding port 80 to the NodePort. Docker's embedded DNS then resolves the issuer for any container sharing a network with the alias, so discovery, JWKS, and the iss match all line up with zero changes to Keycloak:

kubectl apply -f yaml/keycloak-nodeport.yaml      # keycloak on the node, :30080

docker run -d --name arctl-keycloak-alias --network kind \
  --network-alias keycloak.keycloak.svc.cluster.local \
  alpine/socat TCP-LISTEN:80,fork,reuseaddr TCP:arctl-lab-control-plane:30080

# the daemon does discovery at startup on its own compose network,
# so give that network the same alias
docker network connect --alias keycloak.keycloak.svc.cluster.local \
  agentregistry_agentregistry-network arctl-keycloak-alias

Step 2: default deny

With OIDC on, the registry's posture flips. No token is a 401. A valid token with no matching policy is an empty catalog, and only carol's field-admin group, mapped to the superuser role, sees the part-1 catalog at all. This is the live baseline before any policy exists:

$ curl -s -o /dev/null -w '%{http_code}' localhost:12121/v0/agents
401

$ arctl get agents --registry-token "$(./scripts/tokens.sh carol)"
NAME         TAG      PROVIDER    MODEL
summarizer   latest   anthropic   claude-haiku-4-5

$ arctl get agents --registry-token "$(./scripts/tokens.sh alice)"
No agents found.

$ arctl get agents --registry-token "$(./scripts/tokens.sh bob)"
No agents found.

Step 3: two AccessPolicies, three catalogs

An AccessPolicy binds principals (here, roles mapped from the token) to rules: actions over named resources. alice's team gets read and publish on the part-1 summarizer stack plus one new skill name; bob's team gets read on a single skill. Note the publish grant names an individual resource, that detail pays off in step 4:

yamlyaml/policies/team-fte.yaml
apiVersion: ar.dev/v1alpha1
kind: AccessPolicy
metadata:
  name: team-fte
spec:
  description: Field FTEs may see and publish the summarizer stack.
  principals:
    - kind: Role
      name: field-fte
  rules:
    - actions: ["registry:read", "registry:publish"]
      resources:
        - kind: agent
          name: summarizer
        - kind: server
          name: acme/textkit
        - kind: skill
          name: summary-style
        - kind: skill
          name: release-notes-style
yamlyaml/policies/team-trial.yaml
apiVersion: ar.dev/v1alpha1
kind: AccessPolicy
metadata:
  name: team-trial
spec:
  description: Trial users may read the published house-style skill, nothing else.
  principals:
    - kind: Role
      name: field-trial
  rules:
    - actions: ["registry:read"]
      resources:
        - kind: skill
          name: summary-style

carol applies both, and the same arctl get now returns three different catalogs. The interesting part is what bob's output does not contain: no agents, no MCP servers, no error message either. The resources outside his lane are simply absent:

carol · superuser

$ arctl get agents
NAME         TAG     MODEL
summarizer   latest  claude-haiku-4-5

$ arctl get skills
summary-style
release-notes-style

alice · field-fte

$ arctl get agents
NAME         TAG     MODEL
summarizer   latest  claude-haiku-4-5

$ arctl get skills
summary-style
release-notes-style

bob · field-trial

$ arctl get agents
No agents found.

$ arctl get skills
summary-style

Step 4: the enforcement edges

Filtered lists are the friendly half of governance. The teeth are in what happens when someone steps outside their lane, and all four edges below are captured from the live run:

bob fetches the agent by name (he knows it exists from part 1)

$ arctl get agent summarizer --registry-token "$BOB"
Error: getting agent "summarizer": forbidden

bob publishes a skill (no publish grant anywhere in his policy)

$ arctl apply -f artifacts/release-notes-style/skill.yaml --registry-token "$BOB"
✗ Skill/release-notes-style failed: forbidden

alice publishes a skill her policy does not name

$ arctl apply -f yaml/rogue-skill.yaml --registry-token "$ALICE"
✗ Skill/rogue-skill failed: forbidden

alice tries to manage AccessPolicies (even listing them)

$ arctl get accesspolicies --registry-token "$ALICE"
Error: listing accesspolicies: 403 Forbidden: registry admin required

Two details worth registering. The publish denial for alice proves grants are name-scoped, not kind-scoped: she holds registry:publish on four named resources, and rogue-skill is not one of them. And policy management is its own privilege tier: an AccessPolicy can grant teams read, publish, edit, deploy, or delete on resources, but the policies themselves stay with the registry admin. When a whole kind should be open, a resource name of "*" grants every resource of that kind, verified live as well.

Step 5: publishing inside the lane

Governance that only says no is a bottleneck. The same policy that blocked rogue-skill lets alice ship the artifact her team owns, with no admin in the loop. She publishes the release-notes-style skill, the one named in her policy, and the partition holds after the publish: she and carol see it, bob's catalog is unchanged:

$ arctl apply -f artifacts/release-notes-style/skill.yaml --registry-token "$ALICE"
✓ Skill/release-notes-style (latest) created

$ arctl get skills --registry-token "$ALICE"
NAME                  TAG      DESCRIPTION
release-notes-style   latest   The house format for release notes. One headline, grouped...
summary-style         latest   The house format for summaries — length budget, bullets...

$ arctl get skills --registry-token "$BOB"
NAME            TAG      DESCRIPTION
summary-style   latest   The house format for summaries — length budget, bullets...

Sharp edges worth knowing

Run it yourself

Everything here, the scripts, the policies, and the skill artifact, is in the public solo-labs repo. Part 1 must be up first, this lab reuses its cluster, Keycloak, catalog, and daemon:

git clone https://github.com/tjorourke/solo-labs.git

cd solo-labs/agentregistry-arctl-kind        # part 1, if not already up
export ANTHROPIC_API_KEY=sk-ant-... SOLO_LICENSE_KEY=...
./scripts/quick.sh up

cd ../agentregistry-governance-kind          # part 2
./scripts/quick.sh up                        # OIDC switch → policies → publish demo

./scripts/quick.sh status                    # the three catalog views at a glance
TOKEN=$(./scripts/tokens.sh bob)             # poke at it as any identity
arctl get skills --registry-token "$TOKEN"

./scripts/quick.sh down                      # back to part 1 exactly

Where to take it next

See also