mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
62d2a0fe6a
* 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
227 lines
7.5 KiB
Python
227 lines
7.5 KiB
Python
"""Tests for TLSManager — console CA and ACME server."""
|
|
|
|
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 a TLSManager backed by test storage."""
|
|
from turnstone.console.tls import TLSManager
|
|
|
|
return TLSManager(get_storage())
|
|
|
|
|
|
# ── CA initialization ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_init_ca(tls_manager):
|
|
await tls_manager.init_ca()
|
|
assert tls_manager.ca_initialized
|
|
root_pem = tls_manager.get_root_cert_pem()
|
|
assert b"BEGIN CERTIFICATE" in root_pem
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_init_ca_persists(tls_manager):
|
|
"""CA root survives re-initialization (loaded from storage)."""
|
|
await tls_manager.init_ca()
|
|
pem1 = tls_manager.get_root_cert_pem()
|
|
|
|
# Create a new manager on the same storage
|
|
from turnstone.console.tls import TLSManager
|
|
|
|
mgr2 = TLSManager(get_storage())
|
|
await mgr2.init_ca()
|
|
pem2 = mgr2.get_root_cert_pem()
|
|
|
|
assert pem1 == pem2 # Same CA loaded from DB
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_responder_before_init(tls_manager):
|
|
with pytest.raises(RuntimeError, match="CA not initialized"):
|
|
tls_manager.get_responder()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_responder(tls_manager):
|
|
await tls_manager.init_ca()
|
|
responder = tls_manager.get_responder()
|
|
assert responder is not None
|
|
# Should be an ASGI app (callable)
|
|
assert callable(responder)
|
|
|
|
|
|
# ── Cert issuance ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_issue_console_certs_internal(tls_manager):
|
|
"""Console certs issued from internal CA when no external directory."""
|
|
await tls_manager.init_ca()
|
|
await tls_manager.issue_console_certs(["console.internal", "localhost"])
|
|
assert tls_manager.internal_bundle is not None
|
|
assert tls_manager.frontend_bundle is not None
|
|
assert tls_manager.internal_bundle.domain == "console.internal"
|
|
assert b"BEGIN CERTIFICATE" in tls_manager.internal_bundle.cert_pem
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_issue_console_certs_persists(tls_manager):
|
|
"""Certs loaded from storage on re-issue."""
|
|
await tls_manager.init_ca()
|
|
await tls_manager.issue_console_certs(["console.internal"])
|
|
bundle1 = tls_manager.internal_bundle
|
|
|
|
# New manager, same storage
|
|
from turnstone.console.tls import TLSManager
|
|
|
|
mgr2 = TLSManager(get_storage())
|
|
await mgr2.init_ca()
|
|
await mgr2.issue_console_certs(["console.internal"])
|
|
bundle2 = mgr2.internal_bundle
|
|
|
|
assert bundle1.cert_pem == bundle2.cert_pem
|
|
|
|
|
|
# ── SSL contexts ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ssl_contexts_none_before_certs(tls_manager):
|
|
await tls_manager.init_ca()
|
|
assert tls_manager.get_server_ssl_context() is None
|
|
assert tls_manager.get_client_ssl_context() is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ssl_contexts_after_certs(tls_manager):
|
|
await tls_manager.init_ca()
|
|
await tls_manager.issue_console_certs(["console.internal"])
|
|
server_ctx = tls_manager.get_server_ssl_context()
|
|
client_ctx = tls_manager.get_client_ssl_context()
|
|
assert server_ctx is not None
|
|
assert client_ctx is not None
|
|
import ssl
|
|
|
|
assert isinstance(server_ctx, ssl.SSLContext)
|
|
assert isinstance(client_ctx, ssl.SSLContext)
|
|
|
|
|
|
# ── Root cert endpoint ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tls_ca_cert_endpoint(tls_manager):
|
|
"""Test the CA cert download endpoint via test client."""
|
|
await tls_manager.init_ca()
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
|
|
|
# Middleware that grants full access (config-token style: no user_id)
|
|
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)
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Route("/ca.pem", tls_ca_cert),
|
|
Route("/ca", tls_ca_status),
|
|
],
|
|
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
|
)
|
|
app.state.tls_manager = tls_manager
|
|
|
|
client = TestClient(app)
|
|
|
|
# CA cert download
|
|
resp = client.get("/ca.pem")
|
|
assert resp.status_code == 200
|
|
assert b"BEGIN CERTIFICATE" in resp.content
|
|
assert resp.headers["content-type"] == "application/x-pem-file"
|
|
|
|
# CA status
|
|
resp = client.get("/ca")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["enabled"] is True
|
|
assert data["ca_cn"] == "Turnstone CA"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tls_endpoints_disabled():
|
|
"""Endpoints return 404/disabled when TLS not enabled."""
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
|
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.pem", tls_ca_cert),
|
|
Route("/ca", tls_ca_status),
|
|
],
|
|
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
|
)
|
|
# No tls_manager on state
|
|
|
|
client = TestClient(app)
|
|
|
|
resp = client.get("/ca.pem")
|
|
assert resp.status_code == 404
|
|
|
|
resp = client.get("/ca")
|
|
data = resp.json()
|
|
assert data["enabled"] is False
|
|
|
|
|
|
# ── Events ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_event_dispatcher_wired(tls_manager):
|
|
"""Verify the event dispatcher has subscribers."""
|
|
assert tls_manager._event_dispatcher is not None
|
|
# Should have at least 4 subscriptions (issued, renewed, expiring, failed)
|
|
# The exact check depends on lacme's EventDispatcher internals,
|
|
# so just verify the dispatcher exists and the manager initializes cleanly
|
|
await tls_manager.init_ca()
|