Skip to content

fix(secrets): gate /api/secrets behind operator auth (#79) - #122

Merged
dev-dull merged 2 commits into
mainfrom
fix/79-secrets-operator-only
Oct 10, 2026
Merged

dev-dull merged 2 commits into
mainfrom
fix/79-secrets-operator-only

Conversation

@dev-dull

@dev-dull dev-dull commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #79. The /api/secrets routes exposed full CRUD over cluster secrets in the workloads namespace with no authentication. Build/run pods call the platform API directly (bypassing the Authelia forward-auth proxy), so any LLM-built app could read the operator's sus-git-token / sus-anthropic-api-key in plaintext and create, overwrite, or delete any secret — an app→platform privilege escalation.

Posture (confirmed with maintainer): operator-only. Pods get no secrets self-service.

Changes

  • Auth gate. New require_operator dependency, applied at the /api/secrets router level so every current and future route is covered. Pods resolve to the anonymous guest identity and are now rejected with 403. No-op in single-user / no-auth deployments, where every request is the local operator by design.
  • Defence in depth. A denylist of platform-owned secrets — sourced from the git-token / api-key managers that own them (so a rename can't silently un-protect them), extensible via SUS_PROTECTED_SECRETS — is never listed, read-with-values, or mutated through this API, even for an authenticated operator. This is what protects those credentials in single-user / open-proxy modes, where a pod can appear authenticated.
  • Docs. Removed the pod-facing secrets API section from build-pod/CLAUDE.md.
  • Tests. New landing/tests/test_secrets_auth.py: guests rejected on every verb; operator listings exclude protected secrets; protected names blocked before any K8s call. Full landing suite: 32 passed.

Not in scope (follow-ups)

🤖 Generated with Claude Code

The /api/secrets routes exposed full CRUD over cluster secrets with no
authentication. Build/run pods call the platform API directly (bypassing
the forward-auth proxy), so any LLM-built app could read the operator's
sus-git-token / sus-anthropic-api-key in plaintext and create, overwrite,
or delete any secret — an app→platform privilege escalation.

- Add a require_operator dependency and apply it at the /api/secrets
  router level. Pods resolve to the anonymous "guest" identity and are
  now rejected (403). No-op in single-user/no-auth deployments.
- Defence in depth: a denylist of platform-owned secrets (sourced from
  the git-token/api-key managers, extensible via SUS_PROTECTED_SECRETS)
  is never listed, read-with-values, or mutated through this API, even
  for an authenticated operator. This protects those credentials in
  single-user / open-proxy modes where a pod can appear authenticated.
- Remove the pod-facing secrets docs from build-pod/CLAUDE.md.
- Add tests: guests rejected on every verb; operator listings exclude
  protected secrets; protected names blocked before any K8s call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvLgS2ry8vNHC6sW8aDZsY
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Claude finished @dev-dull's task in 1m 21s —— View job


Review: fix(secrets): gate /api/secrets behind operator auth

  • Read the diff
  • Check the identity providers and proxy trust (how pods resolve)
  • Check other code that reaches the same secrets
  • Post the review

Overall: The core fix is solid. require_operator is applied at the router level, so any future /api/secrets route is covered too. A pod's identity does resolve to guest under ProxyHeaderProvider when trusted_proxies is set, and under LocalDatabaseProvider when there is no cookie, so it gets a 403. The denylist uses the managers' own SECRET_NAME constants, and those managers use the same SUS_WORKLOADS_NAMESPACE as this router, so the names line up. The checks run before any Kubernetes call, and in POST they run after .strip(). The tests cover both the gate and the denylist. I found no issues in the changed code itself.

1. The same credentials are still writable through /setup (inline comment on secrets.py:33-40)

  • setup_router (landing/app/routes/setup.py) has no auth. Any pod can call POST /setup/git-token, /setup/api-key or /setup/repo-url.
  • That lets a pod overwrite sus-git-token or sus-anthropic-api-key, the secrets this PR protects.
  • It can also point the platform's catalog sync at a repo it controls.
  • This is the same app→platform gap as Security: /api/secrets is unauthenticated — built apps can exfiltrate operator credentials #79, so the PR's claim that these secrets are "never mutated" only holds for /api/secrets. Gating the setup router with the same require_operator dependency is a one-line change. If you'd rather keep this PR narrow, a follow-up issue works.

2. Outdated docs (minor) – these still describe /api/secrets as available to apps or not yet authenticated:

Notes:

Comment on lines +33 to +40
# Platform-owned secrets that must never be exposed or mutated through this
# generic API, even to an authenticated operator. Sourced from the managers
# that own them so a rename can't silently un-protect them. Extend via the
# ``SUS_PROTECTED_SECRETS`` env var (comma-separated).
_DEFAULT_PROTECTED_SECRETS = frozenset({
_GIT_TOKEN_SECRET,
_API_KEY_SECRET,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These same secrets can still be overwritten through /setup, so the denylist doesn't fully deliver "never mutated through this API".

landing/app/routes/setup.py has POST /setup/api-key, POST /setup/git-token and POST /setup/repo-url. They write sus-anthropic-api-key and sus-git-token through the managers and call clone_or_pull(). The setup_router is included in main.py:46 with no dependency, so a build/run pod that resolves to guest can still:

  • replace the operator's git token or API key with its own (a write rather than a read, but still a takeover of platform credentials), and
  • point SUS_GIT_REPO_URL at a repo it controls. The platform would then sync its catalog and skills from that repo, which is arguably worse than reading the token.

This is the same app→platform gap as #79, one router over. The cheapest fix fits in this PR: give the setup router the same gate, APIRouter(prefix="/setup", dependencies=[Depends(require_operator)]), at least for the POST routes. If you'd rather keep this PR narrow, please open a follow-up. Either way, the PR description's "never … mutated … even for an operator" claim only holds for /api/secrets.

Fix this →

Review (GHA Claude + mAIndmeld) surfaced that the fix was incomplete:

- G1: the /setup router was unauthenticated, so a build/run pod could
  POST /setup/git-token, /setup/api-key, or /setup/repo-url and overwrite
  the very credentials /api/secrets now protects (and repoint catalog
  sync at an attacker repo) — the same #79 escalation via another router.
  Gate the /setup router with require_operator. Safe re: bootstrap: /setup
  is reached from the authenticated landing page, so login precedes setup.
- F2: in single-user / open-proxy modes the auth gate is bypassed and the
  denylist is the sole defence, so it must cover every platform-managed
  secret. Add a test that discovers all modules defining SECRET_NAME and
  asserts each is in _DEFAULT_PROTECTED_SECRETS.
- Add a test that /setup routes reject guests.
- Docs: correct README, Platform Design, and the networkpolicy comment
  that still described /api/secrets as app-accessible / #79 as open.

34 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvLgS2ry8vNHC6sW8aDZsY
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Claude finished @dev-dull's task in 28s —— View job


Re-review after 30ea50f

  • Read the new diff (git diff origin/main...HEAD)
  • Check whether the earlier review's findings were addressed
  • Look for anything new in the changed code
  • Post the summary

Both findings from the last review are fixed. This looks good to merge.

  1. /setup is now gated. setup.py:24 applies require_operator at the router level. That covers POST /setup/{api-key,git-token,repo-url} as well as GET /setup and /setup/api/status. A pod that resolves to guest can no longer overwrite the git token or API key, or point catalog sync at its own repo. test_setup_routes_require_operator covers every one of these routes.
  2. The outdated docs are fixed. README.md:295, SUS - Platform Design.md:303 and the networkpolicy.yaml header comment now describe the operator-only posture.

test_denylist_covers_all_platform_secret_names is a good addition. If a module later defines a SECRET_NAME that isn't in the denylist, the test fails. That matters because the denylist is the only protection in single-user and open-proxy modes. The test also fails if the scan finds nothing, so it can't pass vacuously.

Minor notes (no change needed):

Notes:

  • The chart change is only a comment, so I didn't run helm template.
  • I couldn't run pytest here because it needs approval in this environment. I reviewed the tests by reading them.

@dev-dull
dev-dull merged commit 0b247e5 into main Oct 10, 2026
1 check passed
@dev-dull
dev-dull deleted the fix/79-secrets-operator-only branch October 10, 2026 08:02
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.

Security: /api/secrets is unauthenticated — built apps can exfiltrate operator credentials

1 participant