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.
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:
carol
- maps to
RBAC_SUPERUSER_ROLE- catalog
- Everything, no policy needed
- extra
- Only she can manage AccessPolicies
alice
- maps to
- role
field-fte - catalog
- The part-1 summarizer stack
- extra
- May publish one named new skill
bob
- maps to
- role
field-trial - catalog
- One skill, nothing else
- extra
- No publish rights anywhere
The flow
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
- The issuer must resolve from the daemon container. The daemon does OIDC discovery at startup and restarts until it succeeds, so bring the DNS alias up first, and attach it to the daemon's own compose network, not just the kind network. A restarting container cannot be connected to a network mid-crash-loop, which is why the alias joins the daemon's network rather than the other way round.
- Claim case, again. The registry's default role claim is
Groupswith a capital G, and Keycloak emitsgroups. Exactly the same gotcha as the kagent role mapper in part 1: leave it unset and every caller maps to no roles. SetRBAC_ROLE_CLAIM=groups. - Part 1's admin helper changes meaning. While part 2 is
up, the embedded demo IdP is off, so the part-1 scripts' minted admin
bearer no longer works; registry calls need a Keycloak token
(
tokens.sh carolfor admin work). The hosted agent itself is untouched: part 1'sask.shgoes through the kagent controller, not the registry, and keeps working unchanged, verified while part 2 was live. - Everything reverts.
quick.sh downdeletes the policies and the published skill, removes the alias container and the NodePort, and restarts the daemon on its embedded IdP, putting the cluster back exactly where part 1 left it.
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
- Tool-level lanes. AccessPolicy rules on MCP servers
accept subresources of the form
tool/<name>, so a team can be granted some of a server's tools and not others, the registry-side sibling of the gateway-enforced tool RBAC in the per-user MCP RBAC lab. - Deployment principals. Principals can also be Kubernetes Deployments rather than roles, which lets a running workload, not just a human, carry a catalog lane of its own.
- Runtime invocation policy. The action set extends past
the catalog:
runtime:invokescopes who may call deployed agents, picking up where the registry-to-kagent hop from part 1 ends. - Agentic traces and shadow workloads. The remaining governance jobs from this series' roadmap: per-interaction traces with token cost, and surfacing workloads deployed around the registry rather than through it.