Skip to content

feat(server): authenticate in-cluster webhook receivers via K8s ServiceAccount identity - #9

Open
btli wants to merge 9 commits into
mainfrom
feat/incluster-sa-auth
Open

feat(server): authenticate in-cluster webhook receivers via K8s ServiceAccount identity#9
btli wants to merge 9 commits into
mainfrom
feat/incluster-sa-auth

Conversation

@btli

@btli btli commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Related issue

Summary

Lets the in-cluster webhook receiver authenticate to this server with its own
Kubernetes ServiceAccount identity instead of a human-minted omnigent login +
manually-refreshed 30-day JWT. Server-side only; no receiver/k8s/deployment
files are touched here.

  • server/auth.pyUnifiedAuthProvider._check_cookie's
    Authorization: Bearer fallback, on HS256 session-decode failure, now also
    tries verifying the token as a Kubernetes ServiceAccount JWT (new
    _check_k8s_service_account), reusing the existing jwt.PyJWKClient
    verification pattern already used by routes/auth.py's
    _resolve_oidc_email. On success, the token's sub claim maps to
    user_id. All three of issuer, audience, and an exact subject
    allowlist are pinned before a token is accepted.
  • server/routes/_host_launch.pyresolve_host_owner gains an
    exact-match (host_id, identity) carve-out (new
    resolve_host_launch_allowlist) so an allowlisted non-owner identity may
    launch a runner on one specific host without becoming that host's owner —
    the maintainer's existing ownership of server1 is untouched.

Default-off

Both behaviors are inert unless explicitly configured:

  • The K8s SA branch only activates when OMNIGENT_K8S_SA_AUTH_ENABLED is
    truthy. Unset (the default), _check_k8s_service_account returns None
    immediately and a Bearer token that fails the primary decode is rejected
    exactly as it is today. Once enabled, the issuer/audience/subjects vars are
    all required — a half-configured deployment fails loud at startup
    (RuntimeError), the same posture OIDCConfig.from_env /
    AccountsConfig.from_env already use for their own required vars.
  • The host-launch allowlist is empty/unset by default, reproducing today's
    403 behavior exactly. A malformed OMNIGENT_HOST_LAUNCH_ALLOWLIST entry
    fails closed to an empty allowlist (logged, not silently partially
    applied), so a typo can only ever narrow access back to owner-only, never
    widen it.

New env config (all optional)

Var Purpose
OMNIGENT_K8S_SA_AUTH_ENABLED Enable flag for the SA bearer fallback
OMNIGENT_K8S_SA_ISSUER Expected iss, e.g. https://kubernetes.default.svc.cluster.local
OMNIGENT_K8S_SA_AUDIENCE Expected aud, e.g. omnigent-webhook-receiver
OMNIGENT_K8S_SA_SUBJECTS Comma-separated exact sub allowlist, e.g. system:serviceaccount:webhooks:webhook
OMNIGENT_K8S_SA_JWKS_URI Override JWKS endpoint; defaults to <issuer>/openid/v1/jwks
OMNIGENT_K8S_SA_CA_BUNDLE Override CA bundle for the JWKS fetch; defaults to the kubelet-projected /var/run/secrets/kubernetes.io/serviceaccount/ca.crt when present, else the interpreter's default trust store
OMNIGENT_HOST_LAUNCH_ALLOWLIST Exact host_id=identity[,host_id=identity...] map, e.g. server1=system:serviceaccount:webhooks:webhook

Security invariants

  1. Audience + issuer scoping. _check_k8s_service_account verifies
    signature, iss, and aud together in one jwt.decode call, then
    additionally requires the decoded sub to exact-match the configured
    allowlist (and rejects the reserved local/__public__ names even if
    someone misconfigures them into the allowlist). A valid signature alone
    only proves the token came from some ServiceAccount in the cluster —
    the allowlist is what scopes it to the one specific identity this
    deployment trusts. Verification failure of any kind (bad signature,
    wrong issuer/audience, unlisted subject, expired, malformed, or a
    JWKS-fetch error) returns None and falls through to the ordinary 401 —
    no claim contents are logged.
  2. Exact-match host-launch carve-out. resolve_host_owner does a plain
    (host_id, identity) in allowlist set-membership check — no
    prefix/substring/wildcard matching anywhere in the parse or lookup path.
    Covered by tests asserting a near-miss identity (...-imposter) and a
    wrong-but-allowlisted-elsewhere host_id both still 403.

Human-login-unchanged proof

_check_cookie tries the self-minted HS256 decode first; only a Bearer
token that fails that decode is ever handed to
_check_k8s_service_account. test_human_session_token_unaffected_by_k8s_branch
proves this directly: it monkeypatches PyJWKClient.get_signing_key_from_jwt
to raise AssertionError if ever called, then verifies a valid human/CLI
session cookie still authenticates successfully — i.e. the SA verification
path is provably never reached for a human token, not just untested.

Test Plan

New tests (tests/server/test_k8s_sa_auth.py, 22 tests) cover, using
genuinely RS256-signed JWTs verified through the real jwt.decode call
(only the JWKS network fetch is stubbed, same boundary the existing
test_oidc_callback.py stubs):

  • Valid SA token accepted, user_id == sub
  • Wrong audience / wrong issuer / other-namespace subject / near-miss
    (prefix) subject / expired / malformed token — all rejected
  • Reserved subject name (local) rejected even if present in the allowlist
  • Feature disabled (no k8s_sa_config) → inert, immediate None
  • End-to-end via get_user_id: SA bearer accepted, garbage bearer rejected
    when disabled, human HS256 session token unaffected (see above)
  • resolve_k8s_sa_auth_config(): disabled by default, explicit 0,
    fail-loud on each missing required var, full config builds correctly,
    JWKS URI override, CA bundle override builds a real ssl.SSLContext,
    comma-separated multi-subject parsing

Extended tests (tests/server/routes/test_host_launch.py, +18 tests) cover:

  • Allowlisted identity permitted though not owner
  • Non-allowlisted identity still 403
  • Near-miss identity still 403 (wildcard guard)
  • Same identity allowlisted for a different host still 403 (wildcard guard)
  • Real owner (maintainer) still permitted with an allowlist configured
  • Empty allowlist / unset env var both reproduce today's 403 exactly
  • resolve_host_launch_allowlist() parsing: unset, blank, single/multi
    entry, whitespace tolerance, and three distinct malformed-entry shapes
    all failing closed to an empty set

Gates run

  • uv run --no-sync pytest tests/server/3515 passed, 9 failed
    (pre-existing, unrelated), 1 skipped, 3 xfailed
    in tests/server/routes/test_sessions_snapshot.py
    (session/model-catalog snapshot tests with zero references to
    auth.py/_host_launch.py; all 41 tests in that file pass when run in
    isolation — confirmed test-order/pollution flakes in the pre-existing
    suite, not a regression from this change)
  • uv run --no-sync ruff check . — clean on all changed files
  • uv run --no-sync ruff format --check . — clean on all changed files
  • uv run --no-sync pyrefly check — 0 errors

Mutation-check evidence

For every new/changed test, reverting just the two source files to their
pre-fix state (git checkout bb1db0233 -- omnigent/server/auth.py omnigent/server/routes/_host_launch.py, keeping the tests as committed)
makes the entire new/changed test modules fail collection — ImportError
for the exact new symbols (K8sServiceAccountAuthConfig,
resolve_host_launch_allowlist) the fix introduces, since these are
brand-new code paths with no prior behavior to diff against:

$ git checkout bb1db0233 -- omnigent/server/auth.py omnigent/server/routes/_host_launch.py
$ uv run --no-sync pytest tests/server/test_k8s_sa_auth.py::TestCheckK8sServiceAccount::test_wrong_audience_rejected \
    tests/server/test_k8s_sa_auth.py::TestCheckK8sServiceAccount::test_wrong_issuer_rejected \
    tests/server/test_k8s_sa_auth.py::TestCheckK8sServiceAccount::test_other_namespace_subject_rejected \
    tests/server/test_k8s_sa_auth.py::TestCheckK8sServiceAccount::test_disabled_returns_none \
    tests/server/routes/test_host_launch.py::TestResolveHostOwnerLaunchAllowlist::test_allowlisted_identity_permitted_though_not_owner \
    -v
# → 2 collection errors: ImportError: cannot import name 'K8sServiceAccountAuthConfig' ...
#                          ImportError: cannot import name 'resolve_host_launch_allowlist' ...

Restoring the fix and re-running the identical command turns every one of
those into a pass:

$ git checkout 1d6353054 -- omnigent/server/auth.py omnigent/server/routes/_host_launch.py
$ uv run --no-sync pytest <same 5 node ids> -v
# → 5 passed in 0.29s

The same revert/restore was also run against the full new/changed test
modules (44 tests total) before narrowing to the 5-test excerpt above:
collection-error RED with the fix reverted, 44 passed GREEN with it
restored.

Demo

N/A — server-only auth/authz change, no UI.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Changelog

In-cluster workloads (e.g. a webhook receiver) can now authenticate with a Kubernetes ServiceAccount token instead of a human-minted session JWT (opt-in, default-off)

btli and others added 9 commits August 6, 2026 21:45
…e sub-agents

Named sub-agent workers on the kimi-native and antigravity-native
harnesses launched with no autonomy flag, so every risky tool call
parked on a web approval card no headless pane can answer.
_derive_terminal_launch_args_from_spec only knew claude/codex/cursor
and fell through to None for both harnesses.

- kimi-native: executor.config yolo: true -> ["--yolo"] (kimi's
  auto-approve-tools flag, matching codex/cursor semantics; --auto full
  autonomy deliberately not mapped). Opt-in: absent/false unchanged.
- antigravity-native: executor.config permission_mode:
  bypassPermissions -> ["--dangerously-skip-permissions"], agy's only
  pre-emptive permission control. Other/absent modes unchanged. The
  runner spawn path already forwards snapshot terminal_launch_args
  verbatim into the agy argv (build_agy_launch extra_args), now pinned
  by a spawn-path test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Tribunal round-1 findings on the kimi-native / antigravity-native
launch-arg derivation:

- Verified the spec parser stringifies scalar executor.config values
  (spec/parser.py str(v) coercion), so the bool arm serves
  programmatically built specs (config is dict[str, Any]); kept it,
  aligned the comment, and added bool True/False test rows.
- Documented the value-matching policy: flag keys (yolo) accept bool or
  case-insensitive true/false strings (mirroring
  _spec_config_flag_explicitly_disabled); mode keys (permission_mode)
  match exactly, mirroring the runner's should_skip_permissions
  comparison.
- Debug-log a present-but-unrecognized yolo / permission_mode value
  instead of silently no-opping.
- Parametrized boundary tests pinning accepted-vs-rejected spellings
  for both branches.
- Pinned build_agy_launch's existing skip-flag dedup for the
  double-source case (permission_mode=bypassPermissions + the flag
  already in extra_args -> exactly one flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
fix(server): derive launch args for kimi-native and antigravity-native sub-agents
This reverts commit 2c1ae3a, reversing
changes made to 34eff2c.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ceAccount identity

Lets a workload authenticated as its own Kubernetes ServiceAccount (e.g.
the k3s-infra webhook receiver) call this server directly instead of via
a human `omnigent login` + manually-refreshed 30-day JWT.

Two additive, default-off changes:

- server/auth.py: UnifiedAuthProvider._check_cookie's Bearer fallback,
  on HS256 decode failure, now also tries verifying the token as a K8s
  SA JWT (reusing the existing PyJWKClient pattern from
  routes/auth.py's _resolve_oidc_email), pinning issuer + audience +
  an exact subject allowlist. Gated behind OMNIGENT_K8S_SA_AUTH_ENABLED;
  unconfigured, the branch is unreachable and a Bearer token that fails
  the primary decode is rejected exactly as before. A human/CLI session
  token always succeeds on the first HS256 decode, so it never reaches
  this path.

- server/routes/_host_launch.py: resolve_host_owner gains an exact-match
  (host_id, identity) carve-out (OMNIGENT_HOST_LAUNCH_ALLOWLIST) so an
  allowlisted non-owner identity may launch a runner on one specific
  host without taking over its ownership. Empty/unset allowlist
  reproduces today's owner-only 403 behavior exactly.

Env config introduced (all optional, all default-off):
OMNIGENT_K8S_SA_AUTH_ENABLED, OMNIGENT_K8S_SA_ISSUER,
OMNIGENT_K8S_SA_AUDIENCE, OMNIGENT_K8S_SA_SUBJECTS,
OMNIGENT_K8S_SA_JWKS_URI, OMNIGENT_K8S_SA_CA_BUNDLE,
OMNIGENT_HOST_LAUNCH_ALLOWLIST.

Server-side only — no receiver/k8s/deployment/manifest changes (PR-B).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant