diff --git a/justfile b/justfile index 1deb31e75c..15637248b6 100644 --- a/justfile +++ b/justfile @@ -109,3 +109,54 @@ lint-ts: [group('lint')] normalize-locks: _ensure-uv uv run scripts/normalize_uv_lock_registry.py uv.lock || true + +# ─── homelab test env (omni-test.bryanli.net) ── NOT upstream ───────────── +# The `testing` branch on origin (btli/omnigent) IS the deployment: any push +# to it fires a GitHub webhook -> hooks.bryanli.net -> the omnigent-test pod +# redeploys from source (k3s-infra k8s/omnigent-test + k8s/webhooks). These +# recipes just compose and push that branch — no SSH needed to deploy. +# Spec: homelab docs/superpowers/specs/2026-08-04-*.md + +TEST_KUBECTL := "ssh bli@host.k3s.joyful.house kubectl -n omnigent-test" +TEST_BASE := env("OMNIGENT_TEST_BASE", "main") + +# Compose upstream {{ TEST_BASE }} + the given PR numbers (upstream GitHub +# PRs; none = plain upstream HEAD) and force-push it to `testing`, which +# auto-deploys. Aborts loudly on merge conflicts. +[group('test-env')] +test-branch *prs: + #!/usr/bin/env bash + set -euo pipefail + ids="$(echo "{{ prs }}" | tr ' ' '-')" + wt="$(git rev-parse --show-toplevel)/../omnigent-worktrees/testing${ids:+-$ids}" + git fetch upstream {{ TEST_BASE }} + rm -rf "$wt" && git worktree prune + git worktree add --detach "$wt" "upstream/{{ TEST_BASE }}" + cd "$wt" + for pr in {{ prs }}; do + echo "── merging upstream PR #$pr" + git fetch upstream "pull/${pr}/head" + git merge --no-edit FETCH_HEAD \ + || { echo "CONFLICT merging PR #$pr — resolve in $wt"; exit 1; } + done + git push -f origin HEAD:refs/heads/testing + echo "pushed $(git rev-parse --short HEAD) → testing; deploying → https://omni-test.bryanli.net" + +# Deploy the CURRENT tree, uncommitted changes included, via a throwaway +# snapshot commit (`git stash create` — leaves your working tree untouched; +# brand-new files must be `git add`ed to ride along). +[group('test-env')] +test-sync: + #!/usr/bin/env bash + set -euo pipefail + sha="$(git stash create || true)" + sha="${sha:-$(git rev-parse HEAD)}" + git push -f origin "$sha":refs/heads/testing + echo "pushed snapshot ${sha:0:8} → testing; deploying → https://omni-test.bryanli.net" + +# Park the test env (any later push to `testing` wakes it back up — the +# webhook patches replicas back to 1). +[group('test-env')] +test-down: + {{ TEST_KUBECTL }} scale deploy/omnigent-test --replicas=0 +# ─── end homelab test env ───────────────────────────────────────────────────── diff --git a/omnigent/server/auth.py b/omnigent/server/auth.py index e607ad083a..c31cf5b96c 100644 --- a/omnigent/server/auth.py +++ b/omnigent/server/auth.py @@ -23,15 +23,24 @@ both share :class:`AccountsConfig`/:class:`OIDCConfig`-shaped cookie parameters. The provider is instantiated once at server startup and closed over by route factories — no per-request import cost. + +In ``"oidc"``/``"accounts"`` mode, a Bearer token that fails the +self-minted-session check falls back to one more identity source: an +in-cluster Kubernetes ServiceAccount JWT, gated behind +``OMNIGENT_K8S_SA_AUTH_ENABLED`` and default-off — see +:func:`resolve_k8s_sa_auth_config` and +:meth:`UnifiedAuthProvider._check_k8s_service_account`. """ from __future__ import annotations import logging import os +import ssl import time from abc import ABC, abstractmethod from collections.abc import Callable +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING @@ -313,6 +322,137 @@ def resolve_auth_source() -> str: return "header" +# ── In-cluster Kubernetes ServiceAccount bearer auth ────────────── +# +# Lets a workload running as a Kubernetes ServiceAccount (e.g. an +# in-cluster webhook receiver) authenticate to this server with its +# projected SA token instead of a human-minted, manually-refreshed +# session JWT (``omnigent login``). Consulted only from +# :meth:`UnifiedAuthProvider._check_cookie`'s Bearer-token fallback, +# and only AFTER the primary self-minted HS256 decode has already +# failed — see :func:`resolve_k8s_sa_auth_config` for the default-off +# contract. + +_K8S_SA_AUTH_ENABLED_ENV = "OMNIGENT_K8S_SA_AUTH_ENABLED" +_K8S_SA_ISSUER_ENV = "OMNIGENT_K8S_SA_ISSUER" +_K8S_SA_AUDIENCE_ENV = "OMNIGENT_K8S_SA_AUDIENCE" +_K8S_SA_SUBJECTS_ENV = "OMNIGENT_K8S_SA_SUBJECTS" +_K8S_SA_JWKS_URI_ENV = "OMNIGENT_K8S_SA_JWKS_URI" +_K8S_SA_CA_BUNDLE_ENV = "OMNIGENT_K8S_SA_CA_BUNDLE" +# Where the kubelet projects a pod's trusted CA bundle inside every +# container — the standard in-cluster API-server trust anchor. Used to +# auto-detect in-cluster CA trust when no explicit override is given; +# harmless off-cluster, where the file is simply absent and this whole +# feature is inert anyway (see :func:`resolve_k8s_sa_auth_config`). +_DEFAULT_K8S_CA_BUNDLE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +# K8s API server's service-account-issuer-discovery convention. +_K8S_SA_JWKS_PATH = "/openid/v1/jwks" + + +@dataclass(frozen=True) +class K8sServiceAccountAuthConfig: + """Validated config for verifying an in-cluster Kubernetes + ServiceAccount projected JWT presented as a Bearer token. + + Built once at server startup by :func:`resolve_k8s_sa_auth_config` + and threaded into :class:`UnifiedAuthProvider`. Consulted only by + :meth:`UnifiedAuthProvider._check_k8s_service_account`. + + :param issuer: Expected ``iss`` claim, e.g. + ``"https://kubernetes.default.svc.cluster.local"``. + :param audience: Expected ``aud`` claim, e.g. + ``"omnigent-webhook-receiver"`` (a custom audience configured + on the projected-volume token, not the default API-server + audience). + :param subjects: Exact-match allowlist of accepted ``sub`` claims, + e.g. ``frozenset({"system:serviceaccount:webhooks:webhook"})``. + A signature- and claim-valid token whose subject is not in + this set is still rejected — the allowlist, not signature + validity alone, is what scopes access to one specific + workload identity out of every ServiceAccount in the cluster. + :param jwks_uri: The issuer's JWKS discovery endpoint. + :param ssl_context: TLS trust context for the JWKS fetch (trusting + the in-cluster CA), or ``None`` to use the interpreter's + default trust store. + """ + + issuer: str + audience: str + subjects: frozenset[str] + jwks_uri: str + ssl_context: ssl.SSLContext | None + + +def resolve_k8s_sa_auth_config() -> K8sServiceAccountAuthConfig | None: + """ + Build the K8s ServiceAccount auth config from the environment. + + Default-off: returns ``None`` unless ``OMNIGENT_K8S_SA_AUTH_ENABLED`` + is truthy. While ``None``, + :meth:`UnifiedAuthProvider._check_k8s_service_account` is a no-op + and a Bearer token that fails the primary session-cookie decode is + rejected exactly as it was before this feature existed — merging + this code changes nothing for a deployment that never sets the + env vars. + + Once enabled, ``OMNIGENT_K8S_SA_ISSUER``, ``OMNIGENT_K8S_SA_AUDIENCE``, + and ``OMNIGENT_K8S_SA_SUBJECTS`` (a comma-separated exact-subject + allowlist) are all required — the explicit opt-in gets the same + fail-loud validation :meth:`OIDCConfig.from_env` and + :meth:`AccountsConfig.from_env` give their own required vars, so a + half-configured deployment fails at startup rather than running + with an ambiguous verification scope. + + ``OMNIGENT_K8S_SA_JWKS_URI`` overrides the JWKS endpoint; it + otherwise defaults to the issuer's + ``/openid/v1/jwks`` (K8s's service-account-issuer-discovery + convention). ``OMNIGENT_K8S_SA_CA_BUNDLE`` overrides the CA bundle + trusted for that fetch; it otherwise defaults to the kubelet- + projected in-cluster bundle at + ``/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`` when that + file is present (auto-detected in-cluster trust), else the + interpreter's default trust store. + + :returns: The validated config, or ``None`` when the feature is + off. + :raises RuntimeError: When enabled but any required variable is + missing or empty. + """ + if not env_var_is_truthy(_K8S_SA_AUTH_ENABLED_ENV): + return None + + def _require(name: str) -> str: + val = os.environ.get(name, "").strip() + if not val: + raise RuntimeError( + f"Missing required environment variable {name} " + f"({_K8S_SA_AUTH_ENABLED_ENV}=1 requires it)" + ) + return val + + issuer = _require(_K8S_SA_ISSUER_ENV).rstrip("/") + audience = _require(_K8S_SA_AUDIENCE_ENV) + raw_subjects = _require(_K8S_SA_SUBJECTS_ENV) + subjects = frozenset(s.strip() for s in raw_subjects.split(",") if s.strip()) + if not subjects: + raise RuntimeError(f"{_K8S_SA_SUBJECTS_ENV} must list at least one exact subject") + + jwks_uri = os.environ.get(_K8S_SA_JWKS_URI_ENV, "").strip() or (issuer + _K8S_SA_JWKS_PATH) + + ca_bundle_path = os.environ.get(_K8S_SA_CA_BUNDLE_ENV, "").strip() + if not ca_bundle_path and os.path.exists(_DEFAULT_K8S_CA_BUNDLE_PATH): + ca_bundle_path = _DEFAULT_K8S_CA_BUNDLE_PATH + ssl_context = ssl.create_default_context(cafile=ca_bundle_path) if ca_bundle_path else None + + return K8sServiceAccountAuthConfig( + issuer=issuer, + audience=audience, + subjects=subjects, + jwks_uri=jwks_uri, + ssl_context=ssl_context, + ) + + class AuthProvider(ABC): """Extract a user ID from an incoming request. @@ -384,6 +524,17 @@ class UnifiedAuthProvider(AuthProvider): back to ``""`` (strip nothing; see :func:`resolve_auth_header_strip_prefix`). Only consulted in header mode. Tests pass an explicit prefix. + :param k8s_sa_config: Config for verifying an in-cluster Kubernetes + ServiceAccount Bearer token as a fallback identity source (see + :meth:`_check_k8s_service_account`). ``None`` (the default) + disables the fallback entirely — unlike the other optional + params, this is NOT auto-resolved from the environment here; + :func:`create_auth_provider` resolves it once via + :func:`resolve_k8s_sa_auth_config` and passes it in, so ``None`` + unambiguously means "off" rather than "resolve from env". Only + consulted in ``"oidc"``/``"accounts"`` mode, and only after the + primary HS256 cookie/token decode has already failed. Tests + pass an explicit :class:`K8sServiceAccountAuthConfig`. """ def __init__( @@ -394,10 +545,12 @@ def __init__( local_single_user: bool | None = None, header_name: str | None = None, header_strip_prefix: str | None = None, + k8s_sa_config: K8sServiceAccountAuthConfig | None = None, ) -> None: self._source = source self._oidc_config = oidc_config self._accounts_config = accounts_config + self._k8s_sa_config = k8s_sa_config self._local_single_user = ( local_single_user if local_single_user is not None else local_single_user_enabled() ) @@ -499,8 +652,13 @@ def _check_cookie(self, request: HTTPConnection) -> str | None: Checks the session cookie first (browser clients), then falls back to ``Authorization: Bearer `` (CLI clients - authenticated via ``omnigent login``). Both carry the same - HS256-signed JWT. + authenticated via ``omnigent login``, or an in-cluster + workload's projected Kubernetes ServiceAccount token — see + :meth:`_check_k8s_service_account`). Both the CLI and browser + paths carry the same HS256-signed JWT, checked FIRST; only a + Bearer token that fails that check is ever handed to the SA + verifier, so a human/CLI session token is always accepted on + this first decode and never reaches the SA path. Uses a TTL credential cache keyed by HMAC-SHA256 digest of the raw token to avoid repeated JWT decoding on every @@ -543,7 +701,11 @@ def _check_cookie(self, request: HTTPConnection) -> str | None: algorithms=["HS256"], ) except jwt.InvalidTokenError: - return None + # Not a self-minted session/CLI token. The only other bearer + # this server recognizes is an in-cluster ServiceAccount + # token — inert (returns None immediately) unless that + # fallback is explicitly configured. + return self._check_k8s_service_account(token) user_id = payload.get("sub") if not isinstance(user_id, str) or not user_id or user_id in _RESERVED_USERS: @@ -573,6 +735,63 @@ def _check_cookie(self, request: HTTPConnection) -> str | None: return user_id + def _check_k8s_service_account(self, token: str) -> str | None: + """Verify *token* as an in-cluster Kubernetes ServiceAccount JWT. + + Only reached from :meth:`_check_cookie` after the primary + HS256 session-cookie/CLI-token decode has already failed, so a + valid human/CLI session token never reaches this method. + + Reuses the same :class:`jwt.PyJWKClient` verification pattern + as the generic-OIDC ``id_token`` check in + ``omnigent.server.routes.auth._resolve_oidc_email``: fetch the + signing key for the token's ``kid`` from the issuer's JWKS, + then verify signature, issuer, and audience together via + ``jwt.decode``. The decoded ``sub`` must additionally match + the exact-match allowlist in :attr:`_k8s_sa_config` — a valid + signature only proves the token came from *some* ServiceAccount + in the cluster, not that it is the specific one this + deployment intends to trust. + + Fails closed on every error path (feature disabled, invalid + token, wrong issuer/audience, unlisted or reserved subject, or + a JWKS-fetch failure) by returning ``None`` — the caller then + falls through to the ordinary 401, exactly as an unrecognized + Bearer token always has. Deliberately catches ``jwt.PyJWTError`` + (broader than the ``InvalidTokenError`` the OIDC path catches) + so a transient JWKS-fetch failure also fails closed here rather + than raising a 500; no claim contents are logged either way. + + :param token: The raw bearer token that failed HS256 decoding. + :returns: The verified ``sub`` claim (used as the user ID), or + ``None`` if the feature is disabled or verification fails. + """ + config = self._k8s_sa_config + if config is None: + return None + + import jwt + + try: + jwks_client = jwt.PyJWKClient(config.jwks_uri, ssl_context=config.ssl_context) + signing_key = jwks_client.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=config.audience, + issuer=config.issuer, + ) + except jwt.PyJWTError: + return None + + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + return None + if subject in _RESERVED_USERS or subject not in config.subjects: + return None + return subject + def _check_header(self, request: HTTPConnection) -> str | None: """Read the trusted identity header and return the user ID. @@ -661,7 +880,11 @@ def create_auth_provider() -> AuthProvider: Validates the source's required env vars at startup (fail loud) — OIDC fetches the discovery document, accounts decodes - the cookie secret. + the cookie secret. The optional in-cluster ServiceAccount Bearer + fallback (see :func:`resolve_k8s_sa_auth_config`) is resolved and + attached here too, independent of *source* — it only ever activates + from within the ``"oidc"``/``"accounts"`` cookie-check path, so + attaching it under ``"header"`` mode is inert. :returns: Configured auth provider. :raises RuntimeError: On unknown source or invalid config. @@ -693,6 +916,7 @@ def create_auth_provider() -> AuthProvider: source=source, oidc_config=oidc_config, accounts_config=accounts_config, + k8s_sa_config=resolve_k8s_sa_auth_config(), ) diff --git a/omnigent/server/routes/_host_launch.py b/omnigent/server/routes/_host_launch.py index 2751a01426..cd86d4d697 100644 --- a/omnigent/server/routes/_host_launch.py +++ b/omnigent/server/routes/_host_launch.py @@ -14,10 +14,21 @@ Centralizing the checks here keeps the two call sites from drifting (the original bug was each site enforcing a different subset). + +The host-owner check also honors an optional, exact-match +service-identity carve-out (:func:`resolve_host_launch_allowlist`) so +a non-owner caller authenticated as a specific allowlisted identity — +e.g. an in-cluster webhook receiver authenticated via +:meth:`omnigent.server.auth.UnifiedAuthProvider._check_k8s_service_account` +— may launch on one specific host without becoming its owner. Default-off +(empty allowlist): unset, this reproduces today's owner-only behavior +exactly. """ from __future__ import annotations +import logging +import os from dataclasses import dataclass from fastapi import HTTPException @@ -30,6 +41,62 @@ from omnigent.stores.host_store import Host, HostStore from omnigent.stores.permission_store import PermissionStore +_logger = logging.getLogger(__name__) + +_HOST_LAUNCH_ALLOWLIST_ENV = "OMNIGENT_HOST_LAUNCH_ALLOWLIST" + + +def resolve_host_launch_allowlist() -> frozenset[tuple[str, str]]: + """ + Parse the service-identity host-launch allowlist from the environment. + + ``OMNIGENT_HOST_LAUNCH_ALLOWLIST`` is a comma-separated list of + ``host_id=identity`` pairs, e.g. + ``"server1=system:serviceaccount:webhooks:webhook"``. A listed pair + may launch on *host_id* authenticated as *identity* even though it + does not own the host (see :func:`resolve_host_owner`) — the + carve-out that lets an in-cluster workload authenticated as its own + Kubernetes ServiceAccount identity launch a runner on one specific + host without taking over that host's ownership, so the human owner + of that host is unaffected. + + Unset or empty (the default) yields an empty allowlist, so + :func:`resolve_host_owner` behaves exactly as it did before this + carve-out existed. Matching is EXACT on the ``(host_id, identity)`` + pair — never prefix, substring, or wildcard — so one listed entry + can never authorize a different host or a different identity. + + Fails closed on a malformed entry (missing ``=``, or an empty + ``host_id``/``identity`` half): the WHOLE variable is treated as + unset (empty allowlist — no carve-out) rather than applying only + the entries that did parse, so a typo can only ever narrow access + back to today's owner-only behavior, never widen it. + + :returns: The set of authorized ``(host_id, identity)`` pairs. + """ + raw = os.environ.get(_HOST_LAUNCH_ALLOWLIST_ENV, "").strip() + if not raw: + return frozenset() + + pairs: set[tuple[str, str]] = set() + for raw_entry in raw.split(","): + entry = raw_entry.strip() + if not entry: + continue + host_id, sep, identity = entry.partition("=") + host_id = host_id.strip() + identity = identity.strip() + if not sep or not host_id or not identity: + _logger.error( + "Ignoring %s: malformed entry %r (expected 'host_id=identity'); " + "the host-launch allowlist carve-out is disabled until this is fixed", + _HOST_LAUNCH_ALLOWLIST_ENV, + entry, + ) + return frozenset() + pairs.add((host_id, identity)) + return frozenset(pairs) + @dataclass class HostLaunchTarget: @@ -51,9 +118,11 @@ def resolve_host_owner( user_id: str | None, host_id: str, host_store: HostStore, + launch_allowlist: frozenset[tuple[str, str]] | None = None, ) -> Host: """ - Authorize that the caller owns a known host. + Authorize that the caller owns a known host, or is an allowlisted + non-owner service identity for it. Every route that reaches a host on the caller's behalf must pass this first so the owner check can't drift between them: the runner @@ -63,19 +132,38 @@ def resolve_host_owner( any ownership check. When ``user_id`` is ``None`` (auth disabled) the check is skipped, consistent with single-user/local behavior. - :param user_id: Authenticated caller, e.g. ``"alice@example.com"``, - or ``None`` when auth is disabled. + A non-owner caller is still authorized when ``(host_id, user_id)`` + exact-matches an entry in *launch_allowlist* — see + :func:`resolve_host_launch_allowlist`. This never weakens the + owner's own access (the owner still passes the first check above + and never consults the allowlist), and with the allowlist empty + (the default) this is exactly today's owner-only behavior. + + :param user_id: Authenticated caller, e.g. ``"alice@example.com"`` + or ``"system:serviceaccount:webhooks:webhook"``, or ``None`` + when auth is disabled. :param host_id: Target host id, e.g. ``"host_a1b2c3d4..."``. :param host_store: Persistent host registrations. - :returns: The host record owned by the caller. + :param launch_allowlist: Exact-match ``(host_id, identity)`` pairs + authorized to launch without owning the host. ``None`` (the + default) resolves it from ``OMNIGENT_HOST_LAUNCH_ALLOWLIST`` at + call time via :func:`resolve_host_launch_allowlist`; tests pass + an explicit set. + :returns: The host record — owned by the caller, or allowlisted for + them. :raises HTTPException: 404 if the host is unknown; 403 if it is - owned by a different user. + owned by a different user and the caller is not allowlisted for + it. """ host = host_store.get_host(host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") if user_id is not None and host.user_id != user_id: - raise HTTPException(status_code=403, detail="not your host") + allowlist = ( + launch_allowlist if launch_allowlist is not None else resolve_host_launch_allowlist() + ) + if (host_id, user_id) not in allowlist: + raise HTTPException(status_code=403, detail="not your host") return host diff --git a/tests/server/routes/test_host_launch.py b/tests/server/routes/test_host_launch.py index 160fb9aef9..88ce5fcd40 100644 --- a/tests/server/routes/test_host_launch.py +++ b/tests/server/routes/test_host_launch.py @@ -14,9 +14,13 @@ from omnigent.entities import Conversation from omnigent.server.routes._host_launch import ( resolve_host_launch, + resolve_host_launch_allowlist, resolve_host_owner, ) +_MAINTAINER = "alice" +_SA_IDENTITY = "system:serviceaccount:webhooks:webhook" + @dataclass class _FakeHost: @@ -79,6 +83,166 @@ def test_no_auth_skips_owner_check(self) -> None: assert result.host_id == "host_1" +# ── resolve_host_owner: service-identity launch allowlist carve-out ── + + +class TestResolveHostOwnerLaunchAllowlist: + """The (host_id, identity) carve-out lets a non-owner service identity + (e.g. an in-cluster webhook receiver authenticated as its own K8s + ServiceAccount) launch on one specific host without becoming its owner. + """ + + def test_allowlisted_identity_permitted_though_not_owner(self) -> None: + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + result = resolve_host_owner( + user_id=_SA_IDENTITY, + host_id="server1", + host_store=store, + launch_allowlist=frozenset({("server1", _SA_IDENTITY)}), + ) + assert result.host_id == "server1" + + def test_non_allowlisted_identity_still_403(self) -> None: + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + with pytest.raises(HTTPException) as exc_info: + resolve_host_owner( + user_id="system:serviceaccount:other-ns:other-sa", + host_id="server1", + host_store=store, + launch_allowlist=frozenset({("server1", _SA_IDENTITY)}), + ) + assert exc_info.value.status_code == 403 + + def test_near_miss_identity_still_403(self) -> None: + """A prefix/near-miss of the allowlisted identity must not match.""" + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + with pytest.raises(HTTPException) as exc_info: + resolve_host_owner( + user_id=_SA_IDENTITY + "-imposter", + host_id="server1", + host_store=store, + launch_allowlist=frozenset({("server1", _SA_IDENTITY)}), + ) + assert exc_info.value.status_code == 403 + + def test_wrong_host_id_still_403(self) -> None: + """The same identity, allowlisted for a DIFFERENT host, must not match here.""" + host = _FakeHost(host_id="server2", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server2": host}) + with pytest.raises(HTTPException) as exc_info: + resolve_host_owner( + user_id=_SA_IDENTITY, + host_id="server2", + host_store=store, + launch_allowlist=frozenset({("server1", _SA_IDENTITY)}), + ) + assert exc_info.value.status_code == 403 + + def test_maintainer_ownership_unaffected_by_allowlist(self) -> None: + """The real owner is still permitted even with an allowlist configured + (and even though they are not in it) — ownership is checked first.""" + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + result = resolve_host_owner( + user_id=_MAINTAINER, + host_id="server1", + host_store=store, + launch_allowlist=frozenset({("server1", _SA_IDENTITY)}), + ) + assert result.host_id == "server1" + assert result.user_id == _MAINTAINER + + def test_empty_allowlist_matches_current_403_behavior(self) -> None: + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + with pytest.raises(HTTPException) as exc_info: + resolve_host_owner( + user_id=_SA_IDENTITY, + host_id="server1", + host_store=store, + launch_allowlist=frozenset(), + ) + assert exc_info.value.status_code == 403 + + def test_default_env_resolution_with_no_var_set_matches_current_403( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With no explicit allowlist passed and the env var unset, behavior is + unchanged from before this carve-out existed.""" + monkeypatch.delenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", raising=False) + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + with pytest.raises(HTTPException) as exc_info: + resolve_host_owner(user_id=_SA_IDENTITY, host_id="server1", host_store=store) + assert exc_info.value.status_code == 403 + + def test_default_env_resolution_with_var_set_permits_launch( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With no explicit allowlist passed, the env var is consulted directly.""" + monkeypatch.setenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", f"server1={_SA_IDENTITY}") + host = _FakeHost(host_id="server1", user_id=_MAINTAINER) + store = _FakeHostStore(hosts={"server1": host}) + result = resolve_host_owner(user_id=_SA_IDENTITY, host_id="server1", host_store=store) + assert result.host_id == "server1" + + +# ── resolve_host_launch_allowlist ───────────────────────────────────── + + +class TestResolveHostLaunchAllowlist: + def test_unset_yields_empty_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", raising=False) + assert resolve_host_launch_allowlist() == frozenset() + + def test_blank_yields_empty_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", " ") + assert resolve_host_launch_allowlist() == frozenset() + + def test_single_entry_parsed(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", f"server1={_SA_IDENTITY}") + assert resolve_host_launch_allowlist() == frozenset({("server1", _SA_IDENTITY)}) + + def test_multiple_entries_parsed_with_whitespace_tolerance( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "OMNIGENT_HOST_LAUNCH_ALLOWLIST", + f" server1={_SA_IDENTITY} , server2=system:serviceaccount:ns2:sa2 ", + ) + assert resolve_host_launch_allowlist() == frozenset( + { + ("server1", _SA_IDENTITY), + ("server2", "system:serviceaccount:ns2:sa2"), + } + ) + + def test_malformed_entry_fails_closed_to_empty_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing '=' anywhere in the list disables the WHOLE allowlist, + rather than silently applying only the entries that did parse.""" + monkeypatch.setenv( + "OMNIGENT_HOST_LAUNCH_ALLOWLIST", f"server1={_SA_IDENTITY},server2-no-equals-sign" + ) + assert resolve_host_launch_allowlist() == frozenset() + + def test_empty_host_id_fails_closed_to_empty_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", f"={_SA_IDENTITY}") + assert resolve_host_launch_allowlist() == frozenset() + + def test_empty_identity_fails_closed_to_empty_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OMNIGENT_HOST_LAUNCH_ALLOWLIST", "server1=") + assert resolve_host_launch_allowlist() == frozenset() + + # ── resolve_host_launch ────────────────────────────────────────────── diff --git a/tests/server/test_k8s_sa_auth.py b/tests/server/test_k8s_sa_auth.py new file mode 100644 index 0000000000..b370fe6449 --- /dev/null +++ b/tests/server/test_k8s_sa_auth.py @@ -0,0 +1,346 @@ +"""Tests for the in-cluster Kubernetes ServiceAccount Bearer auth fallback. + +Covers both halves of :mod:`omnigent.server.auth`'s K8s SA support: + +- :func:`resolve_k8s_sa_auth_config` — env-driven config resolution, + default-off, fail-loud on a half-configured explicit opt-in. +- :meth:`UnifiedAuthProvider._check_k8s_service_account` (and its + wiring into :meth:`UnifiedAuthProvider._check_cookie` / + :meth:`UnifiedAuthProvider.get_user_id`) — signature/issuer/audience/ + subject verification, with the primary HS256 session-token decode + proven unchanged. + +Tokens are genuinely RS256-signed and verified via a real ``jwt.decode`` +call; only the network JWKS fetch is stubbed (the same boundary +``tests/server/test_oidc_callback.py`` stubs for the generic-OIDC +``id_token`` path), so this exercises the production verification logic +end to end, offline. +""" + +from __future__ import annotations + +import datetime +import json +import time +from dataclasses import dataclass, field +from pathlib import Path + +import jwt +import pytest +from jwt.algorithms import RSAAlgorithm + +from omnigent.server.accounts_config import AccountsConfig +from omnigent.server.auth import ( + RESERVED_USER_LOCAL, + K8sServiceAccountAuthConfig, + UnifiedAuthProvider, + resolve_k8s_sa_auth_config, +) +from omnigent.server.oidc import mint_session_token + +_ISSUER = "https://kubernetes.default.svc.cluster.local" +_AUDIENCE = "omnigent-webhook-receiver" +_SUBJECT = "system:serviceaccount:webhooks:webhook" + + +@dataclass +class _FakeRequest: + """Minimal stand-in for the ``HTTPConnection`` duck type ``_check_cookie`` reads.""" + + cookies: dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) + + +class _SaKeys: + """An RSA keypair plus the JWKS signing key derived from its public half. + + Mirrors ``tests/server/test_oidc_callback.py``'s ``_IdpKeys`` helper. + """ + + def __init__(self) -> None: + from cryptography.hazmat.primitives.asymmetric import rsa + + self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk_dict = json.loads(RSAAlgorithm.to_jwk(self.private_key.public_key())) + jwk_dict["alg"] = "RS256" + self.signing_key = jwt.PyJWK.from_dict(jwk_dict) + + def sign(self, claims: dict[str, object]) -> str: + """Sign *claims* into an RS256 JWT, filling iss/aud/sub/exp if absent. + + :param claims: Claim overrides, e.g. ``{"aud": "wrong-audience"}``. + :returns: A compact-serialized signed JWT string. + """ + now = int(time.time()) + payload: dict[str, object] = { + "iss": _ISSUER, + "aud": _AUDIENCE, + "sub": _SUBJECT, + "iat": now, + "exp": now + 300, + } + payload.update(claims) + return jwt.encode(payload, self.private_key, algorithm="RS256") + + +def _sa_config(**overrides: object) -> K8sServiceAccountAuthConfig: + """Build a :class:`K8sServiceAccountAuthConfig` with test defaults.""" + defaults: dict[str, object] = { + "issuer": _ISSUER, + "audience": _AUDIENCE, + "subjects": frozenset({_SUBJECT}), + "jwks_uri": f"{_ISSUER}/openid/v1/jwks", + "ssl_context": None, + } + defaults.update(overrides) + return K8sServiceAccountAuthConfig(**defaults) # type: ignore[arg-type] + + +def _accounts_config() -> AccountsConfig: + """Build a minimal, valid :class:`AccountsConfig` for cookie-check plumbing.""" + return AccountsConfig( + cookie_secret=bytes.fromhex("bb" * 32), + session_ttl_hours=8, + base_url="http://localhost:6767", + init_admin_password=None, + invite_ttl_seconds=72 * 3600, + magic_ttl_seconds=600, + ) + + +@pytest.fixture +def sa_keys(monkeypatch: pytest.MonkeyPatch) -> _SaKeys: + """SA keypair with the JWKS signing-key lookup stubbed to return it. + + Stubs only ``PyJWKClient.get_signing_key_from_jwt`` (no network JWKS + fetch); the returned key still goes through a real ``jwt.decode`` + signature/issuer/audience check. + """ + keys = _SaKeys() + monkeypatch.setattr( + jwt.PyJWKClient, + "get_signing_key_from_jwt", + lambda self, token: keys.signing_key, + ) + return keys + + +# ── _check_k8s_service_account (direct) ──────────────────────────── + + +class TestCheckK8sServiceAccount: + def test_valid_token_accepted(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + token = sa_keys.sign({}) + assert provider._check_k8s_service_account(token) == _SUBJECT + + def test_wrong_audience_rejected(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + token = sa_keys.sign({"aud": "some-other-audience"}) + assert provider._check_k8s_service_account(token) is None + + def test_wrong_issuer_rejected(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + token = sa_keys.sign({"iss": "https://not-kubernetes.example.com"}) + assert provider._check_k8s_service_account(token) is None + + def test_other_namespace_subject_rejected(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + token = sa_keys.sign({"sub": "system:serviceaccount:attacker-ns:attacker-sa"}) + assert provider._check_k8s_service_account(token) is None + + def test_near_miss_subject_rejected(self, sa_keys: _SaKeys) -> None: + """A subject that merely starts with the allowlisted one is not a match.""" + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + token = sa_keys.sign({"sub": _SUBJECT + "-imposter"}) + assert provider._check_k8s_service_account(token) is None + + def test_reserved_subject_rejected_even_if_allowlisted(self, sa_keys: _SaKeys) -> None: + """A reserved name can never authenticate, even via a misconfigured allowlist.""" + provider = UnifiedAuthProvider( + source="accounts", + k8s_sa_config=_sa_config(subjects=frozenset({RESERVED_USER_LOCAL})), + ) + token = sa_keys.sign({"sub": RESERVED_USER_LOCAL}) + assert provider._check_k8s_service_account(token) is None + + def test_expired_rejected(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + now = int(time.time()) + token = sa_keys.sign({"iat": now - 3600, "exp": now - 1}) + assert provider._check_k8s_service_account(token) is None + + def test_malformed_token_rejected(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=_sa_config()) + assert provider._check_k8s_service_account("not-a-jwt-at-all") is None + + def test_disabled_returns_none(self, sa_keys: _SaKeys) -> None: + """No config attached (feature off) rejects immediately, no JWKS lookup needed.""" + provider = UnifiedAuthProvider(source="accounts", k8s_sa_config=None) + token = sa_keys.sign({}) + assert provider._check_k8s_service_account(token) is None + + +# ── _check_cookie / get_user_id (end to end) ──────────────────────── + + +class TestCheckCookieK8sFallback: + def test_sa_bearer_accepted_via_get_user_id(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider( + source="accounts", + accounts_config=_accounts_config(), + k8s_sa_config=_sa_config(), + ) + token = sa_keys.sign({}) + request = _FakeRequest(headers={"Authorization": f"Bearer {token}"}) + assert provider.get_user_id(request) == _SUBJECT + + def test_sa_bearer_rejected_when_feature_disabled(self, sa_keys: _SaKeys) -> None: + provider = UnifiedAuthProvider( + source="accounts", + accounts_config=_accounts_config(), + k8s_sa_config=None, + ) + token = sa_keys.sign({}) + request = _FakeRequest(headers={"Authorization": f"Bearer {token}"}) + assert provider.get_user_id(request) is None + + def test_human_session_token_unaffected_by_k8s_branch( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A valid human/CLI HS256 token succeeds on the first decode and never + reaches the K8s verification path — proven by making that path explode + if it is ever invoked.""" + + def _must_not_be_called(self: object, token: object) -> None: + raise AssertionError( + "K8s SA verification must not run for a valid HS256 session token" + ) + + monkeypatch.setattr(jwt.PyJWKClient, "get_signing_key_from_jwt", _must_not_be_called) + + accounts_config = _accounts_config() + provider = UnifiedAuthProvider( + source="accounts", + accounts_config=accounts_config, + k8s_sa_config=_sa_config(), + ) + session_token = mint_session_token( + "alice@example.com", accounts_config.cookie_secret, 3600, "accounts" + ) + request = _FakeRequest(cookies={accounts_config.session_cookie_name: session_token}) + assert provider.get_user_id(request) == "alice@example.com" + + def test_garbage_bearer_rejected_when_feature_disabled(self) -> None: + """The pre-existing 401 path for an unrecognized Bearer token is unchanged.""" + accounts_config = _accounts_config() + provider = UnifiedAuthProvider( + source="accounts", accounts_config=accounts_config, k8s_sa_config=None + ) + request = _FakeRequest(headers={"Authorization": "Bearer garbage-token"}) + assert provider.get_user_id(request) is None + + +# ── resolve_k8s_sa_auth_config ────────────────────────────────────── + + +class TestResolveK8sSaAuthConfig: + def test_disabled_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OMNIGENT_K8S_SA_AUTH_ENABLED", raising=False) + assert resolve_k8s_sa_auth_config() is None + + def test_explicitly_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OMNIGENT_K8S_SA_AUTH_ENABLED", "0") + assert resolve_k8s_sa_auth_config() is None + + def _set_full_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OMNIGENT_K8S_SA_AUTH_ENABLED", "1") + monkeypatch.setenv("OMNIGENT_K8S_SA_ISSUER", _ISSUER) + monkeypatch.setenv("OMNIGENT_K8S_SA_AUDIENCE", _AUDIENCE) + monkeypatch.setenv("OMNIGENT_K8S_SA_SUBJECTS", _SUBJECT) + monkeypatch.delenv("OMNIGENT_K8S_SA_JWKS_URI", raising=False) + monkeypatch.delenv("OMNIGENT_K8S_SA_CA_BUNDLE", raising=False) + + def test_enabled_missing_issuer_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + monkeypatch.delenv("OMNIGENT_K8S_SA_ISSUER", raising=False) + with pytest.raises(RuntimeError, match="OMNIGENT_K8S_SA_ISSUER"): + resolve_k8s_sa_auth_config() + + def test_enabled_missing_audience_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + monkeypatch.delenv("OMNIGENT_K8S_SA_AUDIENCE", raising=False) + with pytest.raises(RuntimeError, match="OMNIGENT_K8S_SA_AUDIENCE"): + resolve_k8s_sa_auth_config() + + def test_enabled_missing_subjects_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + monkeypatch.delenv("OMNIGENT_K8S_SA_SUBJECTS", raising=False) + with pytest.raises(RuntimeError, match="OMNIGENT_K8S_SA_SUBJECTS"): + resolve_k8s_sa_auth_config() + + def test_enabled_full_config_builds(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + config = resolve_k8s_sa_auth_config() + assert config is not None + assert config.issuer == _ISSUER + assert config.audience == _AUDIENCE + assert config.subjects == frozenset({_SUBJECT}) + assert config.jwks_uri == f"{_ISSUER}/openid/v1/jwks" + # No in-cluster CA bundle on this test machine, no explicit override: + # falls back to the interpreter's default trust store. + assert config.ssl_context is None + + def test_jwks_uri_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + monkeypatch.setenv("OMNIGENT_K8S_SA_JWKS_URI", "https://example.com/custom/jwks") + config = resolve_k8s_sa_auth_config() + assert config is not None + assert config.jwks_uri == "https://example.com/custom/jwks" + + def test_ca_bundle_override_builds_ssl_context( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # A syntactically valid (self-signed) PEM cert is enough for + # ssl.create_default_context to accept the file as a CA bundle. + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test-ca")]) + now = datetime.datetime.now(tz=datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(hours=1)) + .not_valid_after(now + datetime.timedelta(hours=1)) + .sign(key, hashes.SHA256()) + ) + ca_path = tmp_path / "ca.crt" + ca_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + self._set_full_env(monkeypatch) + monkeypatch.setenv("OMNIGENT_K8S_SA_CA_BUNDLE", str(ca_path)) + config = resolve_k8s_sa_auth_config() + assert config is not None + assert config.ssl_context is not None + + def test_subjects_supports_comma_separated_list(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._set_full_env(monkeypatch) + monkeypatch.setenv( + "OMNIGENT_K8S_SA_SUBJECTS", + "system:serviceaccount:webhooks:webhook, system:serviceaccount:webhooks:other", + ) + config = resolve_k8s_sa_auth_config() + assert config is not None + assert config.subjects == frozenset( + { + "system:serviceaccount:webhooks:webhook", + "system:serviceaccount:webhooks:other", + } + )