Commit Graph

20 Commits

Author SHA1 Message Date
Stéphane Tailland 62d0b371f6 Add the restocker role, assigned to Supply's service account
Copies the shape of read_only rather than inventing the schema. Supply keeps
read_only for its existing read tools and gains restocker for the one
patch-product tool it now needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:18:23 +02:00
Stéphane Tailland dc1e7de204 Rename the supply agent's Keycloak client and scope
Its Vault key moves with it; the secret value is unchanged, so nothing has to
be re-issued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:58:41 +02:00
Stéphane Tailland 9f2bd159d8 feat(realm): a machine identity for the autonomous run
serviceAccountsEnabled on the Approvisionnement client, and its service account
holds read_only — the least privilege its periodic run needs. The MCP server
maps that to a consumer group, so the catalog's write tool is invisible to this
identity rather than merely unused.

The classic-models-mandate client added earlier stays, unused. An autonomous run
acts for itself; a mandate belongs where a person's own authority is in play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:51:20 +02:00
Stéphane Tailland 5048a154c5 feat(realm): grant Approvisionnement read access to sales
Needed to deduct quantities committed on open orders from availability. Not
granted: catalog:write, which the design's scope list names but which has no
reachable target — the schema has no purchase order and the catalog's only
write tool belongs to the Catalogue agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:36:20 +02:00
Stéphane Tailland 04d0d65863 feat(realm): add the Approvisionnement client
Its own client so the exchanged token carries azp = the agent that acted, and
an audience scope so Catalogue, the Assistant and librechat can name it as a
callee. Granted mcp-catalog, the token-exchange broker and llm-proxy — not
sales, and no write scope, which is what the autonomous half will need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:51:59 +02:00
Stéphane Tailland 7e7e9d741b feat(realm): add the Assistant client and its audience scope
The orchestrator is its own client so the exchanged token carries azp = the
agent doing the asking. It is granted two optional scopes — the Catalogue
agent's audience, and llm-proxy-audience because it has to reason — and no
domain audience at all. Every mcp-* and *-api scope it could request comes back
Invalid scopes, which is what makes 'the orchestrator holds nothing' a property
of the realm rather than a line in a prompt.

Note for the next client added by hand: CLIENT.DESCRIPTION is varchar(255), and
a longer one crash-loops the import with 'value too long' rather than a
validation message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:27:47 +02:00
Stéphane Tailland eed8c3da3b refactor(realm): rename the token-exchange broker, drop the middleware's name
Every agent has to name this client in the audience scopes it requests, so the
one thing an agent could not avoid naming was the very thing it should know
nothing about. The gateway serving those URLs is replaceable; the contract
should not imply otherwise.

kong-token-exchange-broker becomes token-exchange-broker, same credential, same
behaviour. The rename was rolled out in order — new client alongside the old,
then the callers, then the gateways, then removal — so no call ever hit a realm
that had forgotten the name its caller was still using. Followed through the
client scope, the audience mappers, the fine-grained admin policy
allow-broker-client, and the roles map.

The Vault property keeps the old name. Renaming it there means re-issuing the
value to every gateway holding a copy, for no gain; the ExternalSecret maps it
to KC_SECRET_TOKEN_EXCHANGE_BROKER instead, so everything the cluster can see
is neutral.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:52:58 +02:00
Stéphane Tailland 6c89f6124d feat(realm): add the llm-proxy audience so agents can authenticate to the AI gateway
The AI gateway's openid-connect plugin requires audience ["llm-proxy"], but no
client or client scope in the realm could mint it, so every OIDC call fell
through to the anonymous consumer and was terminated with 401 "Authentication
required". Adds the bearer-only llm-proxy client, the llm-proxy-audience scope,
and grants it to the catalogue agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:49:19 +02:00
Stéphane Tailland f9900a5fb9 Take the realm's client secrets and user passwords out of git
configmap-realm.yaml carried fourteen client secrets and five user
passwords in clear, in a tracked file. Five were there from its first
commit; I added the fourteenth yesterday by following the existing pattern
without questioning it. Making this file authoritative on every start did
not increase the exposure, but it did make the file the place those
secrets live for good — and it is inconsistent with the rest of the
estate, where infra-observability and kong-v2 both take theirs from Vault.

Keycloak resolves ${PLACEHOLDER} in a realm import file from the
environment, so the structure stays in git and the values move to Vault at
secrets/keycloak/classic-models-realm, reaching the import container
through External Secrets.

A store of its own, vault-keycloak, rather than reusing vault-kong-v2 or
vault-observability: each carries a policy scoped to its own path, so a
reader for one estate cannot see another's. Widening an existing policy
would have handed Kong's reader every client secret in the realm.

dataFrom.extract pulls the whole path, so adding a client means adding its
secret in Vault with no edit to the ExternalSecret.

Verified after the switch: the import logs "Realm 'classic-models' imported"
under OVERWRITE_EXISTING, and all three credential paths still work — the
broker's client_credentials, librechat with pm-user's password, and the
Catalogue agent's exchange reaching catalog-mcp with 200. Re-checked that
none of the nineteen original values appears anywhere in the file.

Note the values themselves were committed and are in this repo's history.
They are lab credentials behind a private CA with no real data, but they
should be rotated if that ever stops being true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:13:40 +02:00
Stéphane Tailland a97399c2bf Import the realm on every start, so the file is the source of truth
configmap-realm.yaml was a first-install seed: `start --import-realm`
skips a realm that already exists and logs "Import skipped", so editing
the file changed nothing on a restart and the truth drifted into the
database. It already had — four scope grants existed only in the running
realm.

There is no option to make the startup import overwrite. The --override
flag exists only on the standalone `import` command, and the Keycloak
documentation is explicit that re-creating realms means running that
command before starting the server. So the import moves into an init
container that runs `import --dir ... --override true`, and --import-realm
comes off the main container's args.

The init container empties JAVA_OPTS_APPEND for itself. It inherits the
main container's env, which loads the OpenTelemetry agent from a volume
the other init container downloads into and this one does not mount;
inherited verbatim, the JVM fails before the import runs.

What this costs, deliberately: the realm is replaced at every start, so
anything created at runtime and not written back to this file is lost, and
active sessions are dropped. Checked before enabling it — the five users
in the realm are the five declared here, and the only live sessions were
two LibreChat ones. Nothing is to be created through the admin console
expecting it to last.

Verified after the switch: the import logs "Strategy: OVERWRITE_EXISTING",
the realm still has its 21 clients with no optionalClientScopes
divergence, and the full identity chain still returns 200 — pm-user token,
exchanged by the Catalogue agent, accepted by catalog-mcp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:37:35 +02:00
Stéphane Tailland 1b613768e7 Realign the realm seed with what Keycloak actually runs
This file is a bootstrap seed, not the live realm. Keycloak starts with
--import-realm under IGNORE_EXISTING and logs "Realm 'classic-models'
already exists. Import skipped", so editing it changes nothing on a
restart and the truth lives in Keycloak's database.

That divergence had already opened, in the direction that hurts: four
authorisations existed only in the running realm, granted through the
admin API while working out which audiences the gateway actually needs.

  classic-models-agent-catalogue  +catalog-api-audience
                                  +sales-api-audience
                                  +kong-token-exchange-broker-audience
  librechat                       +classic-models-agent-catalogue-audience

Rebuilding the realm from this file would have left the Catalogue agent
with two of its five scopes and no indication why it stopped working.

Compared object by object against the live realm: the inventories already
matched — 21 clients, 38 client scopes, none missing either way — so the
drift was in contents, not in the lists. optionalClientScopes are now
copied back for every client that differed, and a re-comparison reports no
remaining gap.

This closes today's drift; it does not fix the mechanism that allows it.
Keeping a seed honest by hand is the same arrangement the Flux entrypoints
had before they were made self-reconciling, and it will drift again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:18:09 +02:00
Stéphane Tailland c1eaead5d2 Declare the Catalogue agent as a Keycloak client
First of the six agents to need an identity. It exchanges the end user's
token for one narrowly-scoped token per MCP server, so `azp` names the
agent while `sub` stays the human — which is what makes the audit trail
able to answer "which agent asked, on whose behalf".

Deliberately narrower than the librechat client it is modelled on:

  * no browser flow at all. standardFlowEnabled, implicitFlow and
    directAccessGrants are off, because the agent never logs a user in —
    it receives an already-issued token over A2A. Any of those three
    would be a way to obtain a token it has no business obtaining.
  * no service account. It has no standing access of its own; everything
    it can reach, it reaches on behalf of a user who asked.
  * two optional scopes, mcp-catalog-audience and mcp-sales-audience, and
    nothing else. librechat carries all of them because it fronts every
    domain; an agent serving the product manager must not be able to mint
    a payments token.

Adds classic-models-agent-catalogue-audience, the scope whoever issues
the user's token requests so the agent appears in that token's audience.
Keycloak's Standard Token Exchange refuses otherwise, with "Client is not
within the token audience" — which reads as a permissions problem on the
target rather than a missing audience on the source.

Verified against the running realm: catalog and sales exchange
successfully and return aud=[mcp-catalog] / aud=[mcp-sales] with
azp=classic-models-agent-catalogue, while payments is refused with
invalid_scope. The confinement is enforced by Keycloak, not by the
agent's own restraint.

Note this file is a bootstrap seed, not the live realm: Keycloak starts
with --import-realm under IGNORE_EXISTING and logs "Realm
'classic-models' already exists. Import skipped", so editing it changes
nothing on a restart. The client and scope were created through the admin
API and recorded here so a fresh install matches. That divergence is
worth closing separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:57:50 +02:00
Stéphane Tailland 80a2a27ca9 Stop versioning the real secret.yaml
k8s/secret.yaml held the actual PostgreSQL password, Keycloak admin
password and OIDC client secrets, and .gitignore only covered .DS_Store
and *.tmp. Untrack it and ignore it, matching what infra-librechat already
does, and extend the ignore list to TLS key material.

kustomization.yaml still references the file, so the README now documents
creating it from secret.example.yaml — whose key set is identical — before
the first apply.

This only stops future leaks. The values remain in commit 6d3431c and on
the remote, so they must still be treated as compromised and rotated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:30:20 +02:00
Stéphane Tailland 01d2082f3b Send traces to Tempo and expose metrics to Prometheus
Point the OTel Java agent at Tempo instead of Jaeger, so Keycloak's traces
land in the same backend as the rest of the stack and correlate with the
Loki logs of the same requests.

Annotate the pod for scraping. KC_METRICS_ENABLED already served /metrics
on the management port, so this only makes Prometheus discover it — on
port 9000, never on the application port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:27:14 +02:00
Stéphane Tailland aac85b2f7a Update of classic models realm config 2026-08-23 17:22:57 +02:00
Stéphane Tailland cf456f10a1 fix(realm): Persist client secret and realm CaC 2026-08-23 15:54:16 +02:00
Stéphane Tailland d5242f68ca fix(secrets): Update kong-token-exchange-broker secret to match Vault/ExternalSecret 2026-08-23 08:05:23 +02:00
Stéphane Tailland b9287761bf feat(telemetry): Enable OpenTelemetry distributed tracing and JSON logging with MDC 2026-08-23 07:45:23 +02:00
Stéphane Tailland 08200a7df0 feat(k8s): Add Keycloak 26.6.0 with PostgreSQL 16 and classic-models realm CaC 2026-08-23 07:40:40 +02:00
sttlab 31e6b1d6d7 first commit 2026-05-07 21:08:39 +02:00