Skip to content

Nibz/claude/api keys - #168

Open
nibalizer wants to merge 12 commits into
mainfrom
nibz/claude/api-keys
Open

nibalizer wants to merge 12 commits into
mainfrom
nibz/claude/api-keys

Conversation

@nibalizer

Copy link
Copy Markdown
Collaborator

This adds 'api keys' directly to atryum open source.

This separates how users and agents authenticate to atryum.

Users authenticate with oauth2. Agents authenticate with their api key.

Authenticated users can create api keys, the keys are bound to a specific agent.

This sets up a few awesome behaviors:

  • Normal use by a single developer with a single claude code just works simply - atryum setup claude
  • Advanced users can set up a specific agent with sandbox+harness+atryum agent (implies charters).
  • Keys are revocable and usage tracked (can be forwarded to logging system/siem)
  • This doesn't ask very much of the IDP. Oauth2, OIDC for usernames, custom scopes optional for gating access, Device grant flow for logging in via the cli.
[nibz@pauli atryum]$ ./atryum setup claude
Atryum server: http://localhost:8080
Logging in to http://localhost:8080 via auth0 (https://login.dev.vm.validmind.ai)
2026/09/10 10:43:45 device authorization response had no device_code^C
[nibz@pauli atryum]$ vim atryum.toml
[nibz@pauli atryum]$ ./atryum setup claude
Atryum server: http://localhost:8080
Logging in to http://localhost:8080 via auth0 (https://login.dev.vm.validmind.ai)

To sign in, open this URL in a browser:

    https://redacted-urlactivate?user_code=GLXK-ZXSQ

(code: XXXX-YYYY)

Waiting for approval...
Logged in.
Signed in as spencer@validmind.ai (admin)
Create agent "Claude Code on pauli"? [y/N]: y
Created agent "Claude Code on pauli" (c8dbeb4d-3d02-4d96-bffc-fb6fcb02ebe6)
Issue an API key for "Claude Code on pauli", save it to /home/nibz/.atryum/agent-key, and install Claude Code hooks? [y/N]: y
Saved API key atr_2bC6jCy2… to /home/nibz/.atryum/agent-key
installed hooks for claude-code in /home/nibz/.claude/settings.json
restart your editor/agent tool to apply hook changes

Done. Claude Code will authenticate to Atryum as this agent.
Revoke access any time with: atryum agent key revoke "Claude Code on pauli" 7270255f-a6c6-468c-9585-6c32d1b9fee0
./atryum agent list
ID                                    NAME                              ENABLED  SOURCE
9e512739-745e-466b-9b9f-4c561c226688  AP / Treasury Disbursement Agent  true     validmind
14079491-21a4-4684-8220-43f50223df89  Claude Code on pauli              true     local
fa99aa80-ec7f-45cb-89d8-8c7ed1a091fe  Credit-Memo-Analyst               true     validmind
301e4232-1dfa-44b4-9f8b-bc1e98639441  Customer Servicing Agent          true     validmind
abae31ce-1389-4aa8-8afc-698ce070b713  GDPR Compliant Agent              true     validmind
aedaea4f-7a5f-48b1-ba18-0fa67bd4fb4d  Microsoft Copilot                 true     local
ca09b6f5-d962-4109-947c-10cdab710608  OpenCode                          true     local
aef094c0-5967-4a97-9949-edd133b3737b  Portfolio Rebalancing Agent       true     validmind
4fadba43-9e18-406f-bb2d-2340a97ea72a  Spencer Test Agent                true     local

nibalizer and others added 10 commits September 9, 2026 16:39
Introduce the open-core identity model: a just-in-time provisioned users
table keyed by (issuer, subject), an agent_members join table, and api_keys
that act as one agent. Keys are presented as "Bearer atr_..." on the agent
runtime routes alongside IdP JWTs; only a SHA-256 is stored and a key stops
validating when revoked, expired, or its creator is disabled.

The operator middleware now attaches an authz.Principal for any user from a
login-enabled issuer; legacy operator routes keep admin-only semantics via
RequireAdmin, while agents, keys and members are authorized per resource
through the new pkg/authz seam (WithAuthorizer, WithAuthenticatedRoutes for
embedding programs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Open an agent to find two new tabs: API keys (generate with optional expiry,
token shown once with copy, revoke) and Members (list; admins add/remove).
A Users page lets admins disable/re-enable users and flip roles. The sidebar
hides admin-only pages from members, who now get a membership-filtered
agent list and can self-serve keys for their agents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`atryum login` signs in through the server's IdP with the OAuth 2.0 device
grant and stores the session in ~/.atryum/credentials.json (0600), refreshing
silently when it expires. `atryum agent list|create` and `atryum agent key
list|create|revoke` drive the new operator endpoints. `atryum setup claude`
chains it all: sign in if the server requires it, pick or create the agent,
issue a key into ~/.atryum/agent-key (0600), and install the Claude Code hooks
pointed at the server with ATRYUM_TOKEN_COMMAND reading that file, replacing
any previously installed Atryum hook commands. The Keycloak dev realm now
enables the device grant on the public admin client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rors

Auth0 only allows the device authorization grant on Native applications, so
the CLI cannot reuse the SPA admin client there. [[auth]] gains an optional
cli_client_id (defaults to admin_client_id) that /api/v1/auth/config
advertises and `atryum login` uses. The device-authorization error now quotes
the IdP's error and description and names the client, instead of a bare
"response had no device_code".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…info

Access tokens from Auth0 (and some other IdPs) carry only a sub, so users
showed up as "google-oauth2|…". [[auth]] gains email_claims / name_claims
lookup lists (defaults cover OIDC, Okta, Keycloak and Entra claim names) and
a userinfo toggle (default on): when a verified token has no email, Atryum
calls the issuer's discovered OIDC userinfo endpoint once with that token and
stores what it learns. UpsertLogin no longer overwrites known email/name with
empty values, so later bare tokens keep the enriched row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Invocations made with an agent API key carry the agent record's own id, but
the Invocations page only mapped alias agent_ids and Claude bindings, so they
rendered as "Unassociated".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… hook hardening

Users UI
- Fix the "you" badge stacking under the user's name; it now sits beside
  the email, which links into a new per-user view. Rows gain Agents/Keys
  links.
- New /ui/users/{id}/agents and /ui/users/{id}/keys tabs listing every
  agent the user belongs to (with membership removal) and every API key
  they issued across agents (with revocation in place).
- Backed by admin-only GET /api/v1/users/{id}/agents and
  GET /api/v1/users/{id}/keys, plus a store join returning a user's
  memberships with agent display fields. Key rows carry agent_name.

ATRYUM_HOME as the single root for the Claude hook
- setup claude / hooks install claude-code place the hook script under
  $ATRYUM_HOME/hooks/, reference it by absolute path, and bake
  ATRYUM_STATE_DIR=$ATRYUM_HOME/agent-hook-state into the hook commands
  whenever the home is not the stock ~/.atryum.
- The claude-code target honours CLAUDE_CONFIG_DIR for settings.json.
- Default agent and key names gain the home's basename so two instances
  never share an agent record. One env pair per instance:
  ATRYUM_HOME=~/.atryum-b CLAUDE_CONFIG_DIR=~/.claude-b atryum setup claude
- The shared hook falls back to $ATRYUM_HOME/agent-hook-state.

Security
- The hook no longer writes atr_ API keys to its on-disk token cache; the
  token command already reads them from disk.
- setup claude and the hook refuse to send a bearer over plain http to a
  non-loopback host. --allow-insecure-http / ATRYUM_ALLOW_INSECURE_HTTP=1
  opts back in.

Tests
- isolateHome clears CLAUDE_CONFIG_DIR and ATRYUM_STATE_DIR so the CLI
  tests can never rewrite the live Claude Code settings they run under.
- End-to-end coverage for both new endpoints (403/404/405, revoke cascade
  after membership removal), the multi-home install layout, the insecure
  transport refusal, and the instance label.

Docs: README, CHANGELOG, claude-code-hook and nono-profile READMEs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@even-steven

Copy link
Copy Markdown
Contributor

Code review

Reviewed the full diff (46 files, ~6,000 added lines: users/agent-members/api_keys stores, migration 030, the pkg/authz seam, key + user auth middleware, the atryum login/agent/setup claude CLI, and the UI). go build, go vet, and go test ./internal/... ./pkg/... all pass on the branch.

Findings

1. resolveAgentRecordForRules was not updated for API-key agent IDs — internal/api/handlers.go:1431

Key-authenticated agents carry the agents.id primary key as Identity.AgentID (identity_adapters.go:63), but resolveAgentRecordForRules calls agentsRepo.GetByAgentID, which matches only entries inside the agents.agent_ids JSON array (store/agents.go:207). A UUID never matches, so it silently falls through to the sync settings' DefaultAgentVMCUID — meaning GET /api/v1/agent/rules and the MCP tools/list [atryum policy: …] annotations (second call site at handlers.go:1989) report a different agent's scoped rules, or none.

pkg/atryum/atryum.go:658 adds exactly the primary-key-first fallback for the enforcement path (agentsLookupAdapter), with a comment describing this case — but these two call sites go straight to the repo and were missed. So the advisory/preview path and the enforcement path now disagree for every agent this PR is designed to onboard.

2. Hook uninstall only runs when env is non-empty — pkg/atryum/commands.go:609

atryum setup claude installs hook commands prefixed with ATRYUM_URL=… ATRYUM_TOKEN_COMMAND='cat …' . If the user later runs atryum hooks install claude-code, env is nil so applyUninstallHookConfig is skipped, and appendUniqueNestedHookEntry (commands.go:931) dedupes by exact command string — the bare command differs from the prefixed one, so it is appended as a second entry. Every SessionStart/PreToolUse/PostToolUse then fires the Atryum hook twice, and the second copy carries no key so it hits Atryum unauthenticated. The function's own doc comment claims previously installed hooks "are replaced", which holds only on the len(env) > 0 branch.

3. Postgres published on all interfaces with static credentials — docker-compose.yml:9

Moving from expose to ports: "5432:5432" binds Postgres to 0.0.0.0 on the host while the credentials stay the hardcoded atryum/atryum pair in the same file. On a laptop on shared wifi or a cloud VM, anyone who can reach the host can psql -h <host> -U atryum -d atryum and read or modify the whole database — including the new api_keys and users tables. Docker's iptables rules also bypass most host firewalls. 127.0.0.1:5432:5432 gives the same local tooling access without the exposure.

4. Agent existence probed before authorization in agentMembers — internal/api/identity_handlers.go:290

agentMembers calls agentsRepo.Get before any h.can check, so for a user who belongs to no agents a non-existent id answers 404 agent not found while an existing id answers 403 forbidden — an enumeration oracle for every agent id on the instance. agentKeys (identity_handlers.go:275) gets the order right; worth matching it.

5. SetRole is silently reverted by the next login — internal/store/users.go:180

UpsertLogin recomputes role from the IdP admin claim and issues Set("role", role) on every login, so a promotion made via PATCH /api/v1/users/{id} disappears the next time that user signs in, with no error anywhere. The dropdown in ui/src/pages/Users.tsx stays fully interactive and reports success. The page prose mentions refresh-on-login, but the control still looks like it works. Either disable it when an admin_claim is configured, or have UpsertLogin leave role alone for manually-set users.

6. atryum setup claude --help prints the wrong usage — pkg/atryum/commands.go:112

setupUsage() tells the user claude … (see "atryum setup claude --help"). That exact command hits hasHelpArg(args) before subcommand dispatch — it scans every argument, including --help after claude — and prints setupUsage() again, so setupClaudeUsage() with the --url/--agent/-y docs is unreachable.

Checked and satisfied

  • authz.Default fails closed on a nil membership store and on missing principals (RequireAdmin → 401).
  • ResolveActive's LEFT JOIN users … WHERE u.disabled_at IS NULL correctly keeps no-auth keys (created_by NULL) valid while blocking disabled creators' keys.
  • CLI agent references resolve through OperatorAgent.CUID, which is agents.id — the right value for the /api/v1/agents/{id}/keys paths.
  • Config.Normalized() is applied in NewValidator, so the new cli_client_id / email_claims / name_claims defaults are actually in effect.

Design note (not a defect)

POST /api/v1/agents moved from admin-only to any authenticated user, and the creator can set charter at creation — but ActionAgentWrite stays admin-only, so owners cannot edit the agent afterwards. The PR describes the first half as intended; the asymmetry may be worth a deliberate call either way.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants