Files
turnstone/tests/test_tls_admin.py
Patrick Buckley 62d2a0fe6a fix: remove non-auth support from bootstrap wizard (#274)
* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
2026-04-01 19:38:24 -07:00

331 lines
10 KiB
Python

"""Tests for TLS admin API endpoints and CLI commands."""
from __future__ import annotations
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
lacme = pytest.importorskip("lacme")
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
@pytest.fixture
def tls_manager():
"""Create an initialized TLSManager."""
import asyncio
from turnstone.console.tls import TLSManager
mgr = TLSManager(get_storage())
asyncio.run(mgr.init_ca())
# Issue a test cert
asyncio.run(mgr.issue_console_certs(["test.internal", "localhost"]))
return mgr
# ── Admin API endpoints ───────────────────────────────────────────────────────
def _make_app(tls_manager):
"""Create a minimal Starlette app with TLS endpoints."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
from turnstone.core.auth import AuthResult
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="",
scopes=frozenset({"approve", "service"}),
token_source="test",
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
)
app.state.tls_manager = tls_manager
return app
def test_list_certs(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.get("/certs")
assert resp.status_code == 200
data = resp.json()
assert len(data["certs"]) >= 1
assert data["certs"][0]["domain"] == "test.internal"
def test_renew_cert(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 200
data = resp.json()
assert data["domain"] == "test.internal"
def test_renew_cert_not_found(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.post("/certs/nonexistent.internal/renew")
assert resp.status_code == 404
def test_delete_cert(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 200
assert resp.json()["deleted"] == "test.internal"
# Verify it's gone
resp = client.get("/certs")
domains = [c["domain"] for c in resp.json()["certs"]]
assert "test.internal" not in domains
def test_delete_cert_not_found(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.delete("/certs/nonexistent.internal")
assert resp.status_code == 404
# ── Auth enforcement ──────────────────────────────────────────────────────────
def _make_app_no_auth(tls_manager):
"""Create app without auth middleware — simulates unauthenticated requests."""
from starlette.applications import Starlette
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
)
app.state.tls_manager = tls_manager
return app
def _make_app_read_only(tls_manager):
"""Create app with read-only auth — should be rejected by admin endpoints."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
from turnstone.core.auth import AuthResult
async def _grant_read(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="viewer",
scopes=frozenset({"read"}),
token_source="jwt",
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_read)],
)
app.state.tls_manager = tls_manager
return app
def test_unauthenticated_list_certs_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.get("/certs")
assert resp.status_code == 401
def test_unauthenticated_renew_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 401
def test_unauthenticated_delete_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 401
def test_read_only_renew_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 403
def test_read_only_delete_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 403
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
def test_cli_bootstrap(tmp_path):
"""Test offline CA bootstrap."""
import argparse
from turnstone.admin import _cmd_tls_bootstrap
out = tmp_path / "certs"
args = argparse.Namespace(out=str(out), issue=["app.internal", "pg.internal"])
_cmd_tls_bootstrap(args)
assert (out / "ca.pem").exists()
assert b"BEGIN CERTIFICATE" in (out / "ca.pem").read_bytes()
# Check certs were issued
assert (out / "certs" / "app.internal").exists()
assert (out / "certs" / "pg.internal").exists()
def test_cli_bootstrap_no_issue(tmp_path):
"""Bootstrap with no --issue creates CA only."""
import argparse
from turnstone.admin import _cmd_tls_bootstrap
out = tmp_path / "certs"
args = argparse.Namespace(out=str(out), issue=[])
_cmd_tls_bootstrap(args)
assert (out / "ca.pem").exists()
# No certs dir
certs_dir = out / "certs"
if certs_dir.exists():
assert len(list(certs_dir.iterdir())) == 0
# ── Config parsing ────────────────────────────────────────────────────────────
def test_database_ssl_config_map():
"""Database SSL keys are in the config map."""
from turnstone.core.config import _CONFIG_MAP
db_map = _CONFIG_MAP["database"]
assert "sslmode" in db_map
assert "sslrootcert" in db_map
assert "sslcert" in db_map
assert "sslkey" in db_map
# ── Auth enforcement ──────────────────────────────────────────────────────────
def test_tls_endpoints_require_auth(tls_manager):
"""TLS admin endpoints return 401 without auth."""
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.server import tls_ca_status, tls_list_certs
# No auth middleware — request.state.auth_result will be missing
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/certs", tls_list_certs),
]
)
app.state.tls_manager = tls_manager
client = TestClient(app)
resp = client.get("/ca")
assert resp.status_code == 401
resp = client.get("/certs")
assert resp.status_code == 401
# ── SDK TLS params ────────────────────────────────────────────────────────────
def test_sdk_client_cert_requires_both():
"""SDK raises ValueError if only one of client_cert/client_key provided."""
from turnstone.sdk._base import _BaseClient
with pytest.raises(ValueError, match="Both client_cert and client_key"):
_BaseClient(
base_url="http://localhost:8080",
client_cert="/path/to/cert.pem",
)
with pytest.raises(ValueError, match="Both client_cert and client_key"):
_BaseClient(
base_url="http://localhost:8080",
client_key="/path/to/key.pem",
)