From 95d51f2d8aafc48645b5412d85b59571a08efe34 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 4 Jun 2026 07:37:40 +0000 Subject: [PATCH] feat: define auth-gating lane for exposed homelab service UIs (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the auth-gating validation lane under the access/TLS epic (#53), layering credential enforcement on top of the HTTPS transport security proven by #58. Changes: - Add §6 to homelab-contracts.md defining the auth-gating lane with minimum evidence requirements (unauthenticated rejection, challenge headers, bad-credential rejection, valid-credential acceptance, TLS-only auth, no credential leakage in 401 body) - Add test_auth_probe.py with six pytest checks that produce the required evidence artifacts - Add argo/homelab-auth-probe.yaml convenience workflow for submitting the auth lane (auth-mode=true) - Add run-homelab-auth Justfile recipe - Update workload matrix table and blockers to reflect #61 implemented - Explicitly defer SSO/OIDC, OAuth2 proxy, per-service auth, and credential rotation as follow-up work Signed-off-by: Andy Anderson Signed-off-by: unknown --- Justfile | 6 ++ argo/homelab-auth-probe.yaml | 15 +++ docs/homelab-contracts.md | 113 +++++++++++++++++++++- tests/homelab_access/test_auth_probe.py | 121 ++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 argo/homelab-auth-probe.yaml create mode 100644 tests/homelab_access/test_auth_probe.py diff --git a/Justfile b/Justfile index 37cb4e689..cb5d8aee0 100644 --- a/Justfile +++ b/Justfile @@ -287,6 +287,12 @@ run-homelab-access: argo submit --from workflowtemplate/homelab-access-probe \ -n {{ argo_ns }} --wait --log +# Run in-cluster homelab auth-gating probe (#61) +run-homelab-auth: + argo submit --from workflowtemplate/homelab-access-probe \ + -p auth-mode=true \ + -n {{ argo_ns }} --wait --log + # Run first PVC/local-path restore drill (#60 #74 #84) run-homelab-restore: argo submit --from workflowtemplate/homelab-restore-drill \ diff --git a/argo/homelab-auth-probe.yaml b/argo/homelab-auth-probe.yaml new file mode 100644 index 000000000..88e4a7606 --- /dev/null +++ b/argo/homelab-auth-probe.yaml @@ -0,0 +1,15 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + generateName: homelab-auth- + namespace: argo +spec: + serviceAccountName: argo + workflowTemplateRef: + name: homelab-access-probe + arguments: + parameters: + - name: auth-mode + value: "true" + - name: branch + value: "main" diff --git a/docs/homelab-contracts.md b/docs/homelab-contracts.md index 98ab4f45a..cba040f92 100644 --- a/docs/homelab-contracts.md +++ b/docs/homelab-contracts.md @@ -2,8 +2,9 @@ This document defines the in-cluster workload validation contracts for the testing-lab QA factory. It covers the workload matrix (#57), shared-storage -and RWX limits (#62), storage observability surface (#70, #78), and the -fleet-client vs. cluster-node boundary (#72). +and RWX limits (#62), storage observability surface (#70, #78), the +fleet-client vs. cluster-node boundary (#72), and the auth-gating lane for +exposed homelab service UIs (#61). --- @@ -18,6 +19,7 @@ access guarantees it must prove. | **General-purpose** | `homelab-substrate` | `tests/homelab_substrate/` | k3s scheduling, pod lifecycle, in-cluster HTTP/TCP reachability, `local-path` PVC allocation | | **NAS / storage** | `homelab-storage` | `tests/homelab_storage/` | PVC bound on `local-path`, data survives `rollout restart`, `findmnt`/`df`/`lsblk`/ZFS artifacts captured | | **Service access** | `homelab-access-probe` | `tests/homelab_access/` | Cluster-DNS resolution, TLS handshake, expected-host routing via SNI | +| **Auth-gating** | `homelab-access-probe` (`auth-mode=true`) | `tests/homelab_access/test_auth_probe.py` | Unauthenticated rejection, credential validation, challenge headers, no credential leakage (§6) | ### Minimum persistence contract per class @@ -42,7 +44,8 @@ access guarantees it must prove. - Media streaming / transcoding lane: not yet defined. GPU passthrough is a known blocker; filed as a follow-up under the service-catalog epic. - ReadWriteMany / shared-media access: blocked by #62. -- Service-to-service auth: deferred to service-catalog auth-gating lane (#61). +- Service-to-service auth: basic auth validation defined in §6 (#61); + SSO/OIDC identity-provider integration deferred to follow-up. --- @@ -187,13 +190,113 @@ The lab validates the following hostname/routing pattern for exposed in-cluster --- -## 6. Known Blockers and Deferred Work +## 6. Auth-Gating Lane for Exposed Service UIs (#61) + +This section defines the auth-gating validation lane under the access/TLS +epic (#53). It layers credential enforcement on top of the HTTPS transport +proven by the service-access lane and the hostname/routing contract in §5. + +### Representative endpoint + +The lane reuses the `homelab-access` fixture (same as the service-access +class) with `auth-mode=true`. The fixture's Python HTTPS server enforces +HTTP Basic authentication when this mode is enabled. + +| Property | Value | +|---|---| +| **Service** | `homelab-access..svc.cluster.local:8443` | +| **Expected hostname** | `homelab-access.local` | +| **Auth scheme** | HTTP Basic (`Authorization: Basic `) | +| **Credentials source** | `homelab-access-auth` Secret (username + password) | +| **Authenticated response** | `access-ok` on `GET /healthz` with valid credentials | +| **Unauthenticated response** | HTTP 401 with `WWW-Authenticate: Basic realm="homelab"` | + +### What counts as acceptable authenticated vs. unauthenticated exposure + +#### Authenticated (acceptable) +- Request includes a valid `Authorization: Basic` header with credentials + matching the `homelab-access-auth` Secret. +- Server returns HTTP 200 with body `access-ok`. +- The exchange occurs over TLS (HTTPS) — credentials must never traverse + the network in cleartext. + +#### Unauthenticated (must be rejected) +- Request with no `Authorization` header → HTTP 401 with `WWW-Authenticate` + challenge. +- Request with invalid credentials → HTTP 401. +- The 401 response body must not leak valid credentials, usernames, or + internal service details. + +### Minimum evidence the lane must capture + +Every run of this lane must produce the following evidence artifacts: + +| Check | Evidence artifact | Pass criteria | +|---|---|---| +| **Unauthenticated rejection** | `auth-unauth-status.txt` | HTTP status code is 401 | +| **Challenge header present** | `auth-challenge-headers.txt` | Response includes `WWW-Authenticate: Basic realm="homelab"` | +| **Bad credentials rejected** | `auth-bad-creds-status.txt` | HTTP status code is 401 for wrong user/pass | +| **Valid credentials accepted** | `auth-valid-creds.txt` | HTTP 200 with body `access-ok` | +| **Auth over TLS** | `auth-tls-evidence.txt` | Request used HTTPS scheme | +| **No credential leakage** | `auth-failure-body.txt` | 401 body is `auth-required`, contains no credentials | + +### Relationship to the HTTPS exposure lane (#58) + +The auth-gating lane **depends on** the HTTPS exposure lane: +- Transport security (TLS handshake, certificate, protocol version) is + validated by #58, not re-tested here. +- This lane assumes the service is reachable over HTTPS and focuses + exclusively on the authentication layer. +- Both lanes use the same fixture deployment; only the `auth-mode` + parameter differs. + +### What this lane validates vs. what it defers + +| Concern | This lane (#61) | Deferred to | +|---|---|---| +| HTTP Basic auth enforcement | ✅ | — | +| 401 challenge with correct scheme and realm | ✅ | — | +| Bad-credential rejection | ✅ | — | +| Credential confidentiality (no leakage in 401 body) | ✅ | — | +| Auth credentials transit over TLS only | ✅ | — | +| SSO / OIDC identity-provider integration | ❌ | Follow-up issue under #53 | +| OAuth2 proxy or reverse-proxy auth | ❌ | Follow-up once IdP is chosen | +| Session management / token refresh | ❌ | Follow-up with SSO | +| RBAC / role-based access within the service | ❌ | Service-specific lanes | +| Multi-factor authentication | ❌ | Not in scope for homelab baseline | +| NetworkPolicy restricting auth bypass | ❌ | Follow-up under #53 | + +### Follow-up work called out explicitly + +1. **SSO / OIDC identity provider**: The current lane validates HTTP Basic + auth as a baseline. Real homelab service UIs (Grafana, Home Assistant, + Argo Server) use OAuth2/OIDC. A follow-up issue should define what + identity provider the lab uses and how to validate redirect-based login + flows. This is a product-stack decision that belongs in bluespeed, not + in this first lane. + +2. **OAuth2 proxy pattern**: Many homelab services delegate auth to an + oauth2-proxy sidecar or ingress annotation. Validating this pattern + requires choosing an IdP first (see above) and is explicitly deferred. + +3. **Per-service auth expectations**: Different services have different auth + models (API keys, bearer tokens, cookie sessions). This lane validates + the generic pattern; service-specific auth validation belongs in the + service-catalog lanes under #51. + +4. **Credential rotation**: The fixture uses static credentials from a + Kubernetes Secret. Validating Secret rotation, credential expiry, or + vault integration is out of scope for this baseline lane. + +--- + +## 7. Known Blockers and Deferred Work | Issue | Status | Dependency | |---|---|---| | #62 RWX / shared-storage | ❌ blocked | NFS CSI or Longhorn installation on ghost | | #63 GPU transcoding lane | ❌ deferred | GPU passthrough KubeVirt feature gate | -| #61 auth-gated service UI | ❌ deferred | service-catalog baseline lane first | +| #61 auth-gated service UI | ✅ implemented | `homelab-access-probe` (auth-mode=true) + `tests/homelab_access/test_auth_probe.py` | | #60 first restore drill | ✅ implemented | `homelab-restore-drill` WorkflowTemplate + `tests/homelab_backup/` | | #84 PVC restore drill with backup artifact | ✅ implemented | `homelab-restore-drill` WorkflowTemplate + `tests/homelab_backup/` | | Media service lane | ❌ deferred | #62 (shared mount) + #63 (GPU) | diff --git a/tests/homelab_access/test_auth_probe.py b/tests/homelab_access/test_auth_probe.py new file mode 100644 index 000000000..ec22d8171 --- /dev/null +++ b/tests/homelab_access/test_auth_probe.py @@ -0,0 +1,121 @@ +""" +Auth-gating lane checks for exposed homelab service UIs (#61). + +Validates that an auth-gated homelab service correctly enforces +credentials over HTTPS: rejects unauthenticated requests, rejects +bad credentials, accepts valid credentials, and returns the expected +WWW-Authenticate challenge header. + +Depends on the HTTPS exposure lane (#58) for transport security. +Identity-provider / SSO integration is out of scope — see follow-ups. +""" + +from __future__ import annotations + +import os +import subprocess + +from tests.service_catalog.shared.kube import RESULTS_DIR, write_artifact + + +NAMESPACE = os.environ["TEST_NAMESPACE"] +SERVICE_NAME = os.environ.get("TEST_SERVICE_NAME", "homelab-access") +HOSTNAME = "homelab-access.local" +SERVICE_FQDN = f"{SERVICE_NAME}.{NAMESPACE}.svc.cluster.local" +HTTPS_PORT = 8443 + +TIMEOUT_SECONDS = 30 + +VALID_USER = "homelab" +VALID_PASS = "controlnode" + +EXPECTED_REALM = "homelab" +EXPECTED_AUTH_SCHEME = "Basic" + + +def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT_SECONDS) + + +def curl_auth(user: str | None = None, password: str | None = None, extra_flags: list[str] | None = None) -> subprocess.CompletedProcess[str]: + cmd = [ + "curl", "-sk", + "-H", f"Host: {HOSTNAME}", + ] + if extra_flags: + cmd.extend(extra_flags) + if user is not None and password is not None: + cmd.extend(["-u", f"{user}:{password}"]) + cmd.append(f"https://{SERVICE_FQDN}:{HTTPS_PORT}/healthz") + return run(*cmd) + + +class TestAuthGating: + """Evidence suite for the auth-gating lane (#61).""" + + def test_unauthenticated_request_returns_401(self): + result = curl_auth(extra_flags=["-o", "/dev/null", "-w", "%{http_code}"]) + write_artifact("auth-unauth-status.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert result.stdout.strip() == "401", ( + f"Expected HTTP 401 for unauthenticated request, got: {result.stdout.strip()}" + ) + + def test_unauthenticated_response_contains_challenge(self): + result = curl_auth(extra_flags=["-i"]) + combined = result.stdout + result.stderr + write_artifact("auth-challenge-headers.txt", combined) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert "WWW-Authenticate" in result.stdout, ( + f"Missing WWW-Authenticate header in 401 response: {result.stdout}" + ) + assert EXPECTED_AUTH_SCHEME in result.stdout, ( + f"Expected {EXPECTED_AUTH_SCHEME} auth scheme in challenge: {result.stdout}" + ) + assert EXPECTED_REALM in result.stdout, ( + f"Expected realm '{EXPECTED_REALM}' in challenge: {result.stdout}" + ) + + def test_wrong_credentials_returns_401(self): + result = curl_auth( + user="wrong-user", + password="wrong-pass", + extra_flags=["-o", "/dev/null", "-w", "%{http_code}"], + ) + write_artifact("auth-bad-creds-status.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert result.stdout.strip() == "401", ( + f"Expected HTTP 401 for bad credentials, got: {result.stdout.strip()}" + ) + + def test_valid_credentials_returns_200(self): + result = curl_auth(user=VALID_USER, password=VALID_PASS) + write_artifact("auth-valid-creds.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert result.stdout.strip() == "access-ok", ( + f"Expected 'access-ok' with valid credentials, got: {result.stdout.strip()}" + ) + + def test_valid_credentials_over_tls(self): + result = curl_auth( + user=VALID_USER, + password=VALID_PASS, + extra_flags=["-w", "\n%{ssl_verify_result}\n%{scheme}"], + ) + combined = result.stdout + result.stderr + write_artifact("auth-tls-evidence.txt", combined) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert "HTTPS" in combined, ( + f"Expected HTTPS scheme for auth request: {combined}" + ) + + def test_auth_failure_body_is_not_sensitive(self): + result = curl_auth() + write_artifact("auth-failure-body.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"curl failed: {result.stderr}" + body = result.stdout.strip() + assert body == "auth-required", ( + f"Expected 'auth-required' body on 401, got: {body}" + ) + assert VALID_PASS not in body, "401 response body must not leak credentials" + assert VALID_USER not in body, "401 response body must not leak usernames"