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
329 lines
10 KiB
Python
329 lines
10 KiB
Python
"""Tests for the channel gateway HTTP notify endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.channels._http import create_channel_app
|
|
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
_JWT_SECRET = "a" * 32
|
|
|
|
|
|
def _make_jwt() -> str:
|
|
"""Create a valid JWT for channel auth."""
|
|
return create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret=_JWT_SECRET,
|
|
audience=JWT_AUD_CHANNEL,
|
|
)
|
|
|
|
|
|
def _auth_headers() -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {_make_jwt()}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_adapter():
|
|
adapter = AsyncMock()
|
|
adapter.channel_type = "discord"
|
|
adapter.send = AsyncMock(return_value="msg_001")
|
|
return adapter
|
|
|
|
|
|
@pytest.fixture
|
|
def no_auth_client(storage, mock_adapter):
|
|
"""Client with no auth configured (for fail-closed tests)."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage, mock_adapter):
|
|
"""Default client with JWT auth configured."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def authed_client(storage, mock_adapter):
|
|
"""Alias -- same as client, for auth-specific test clarity."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def jwt_client(storage, mock_adapter):
|
|
"""Client with JWT auth configured."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
class TestNotifyEndpoint:
|
|
def test_health(self, client):
|
|
resp = client.get("/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
def test_direct_discord_target(self, client, mock_adapter):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert len(results) == 1
|
|
assert results[0]["status"] == "sent"
|
|
assert results[0]["message_id"] == "msg_001"
|
|
mock_adapter.send.assert_called_once_with("123456", "Hello!")
|
|
|
|
def test_with_title(self, client, mock_adapter):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
"title": "Alert",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
|
|
|
|
def test_username_resolution(self, client, storage, mock_adapter):
|
|
# Create a user and link a channel
|
|
storage.create_user("u1", "testuser", "Test User", "hash")
|
|
storage.create_channel_user("discord", "disc_123", "u1")
|
|
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "testuser"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert len(results) == 1
|
|
assert results[0]["status"] == "sent"
|
|
mock_adapter.send.assert_called_once_with("disc_123", "Hello!")
|
|
|
|
def test_unknown_username(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "nobody"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 404
|
|
error = resp.json()["error"]
|
|
assert "nobody" not in error
|
|
assert "not found or has no linked channels" in error
|
|
|
|
def test_user_no_channels(self, authed_client, storage):
|
|
storage.create_user("u1", "testuser", "Test User", "hash")
|
|
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "testuser"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 404
|
|
# Generic message -- must not differentiate "not found" vs "no channels"
|
|
error = resp.json()["error"]
|
|
assert "testuser" not in error
|
|
assert "not found or has no linked channels" in error
|
|
|
|
def test_missing_fields(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={"target": {"username": "x"}},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_missing_target(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={"message": "Hello!"},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_invalid_target(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"invalid": "field"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_no_adapter(self, client, storage):
|
|
# App has discord adapter, try email target
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "email", "channel_id": "test@example.com"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert results[0]["status"] == "no_adapter"
|
|
|
|
def test_adapter_failure(self, client, mock_adapter):
|
|
mock_adapter.send.side_effect = RuntimeError("Discord API error")
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert results[0]["status"] == "failed"
|
|
|
|
def test_invalid_json(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
content=b"not json",
|
|
headers={
|
|
"content-type": "application/json",
|
|
"Authorization": f"Bearer {_make_jwt()}",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_whitespace_only_message(self, client):
|
|
"""Whitespace-only messages should be rejected."""
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": " ",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestNotifyAuth:
|
|
"""Tests for authentication on the /v1/api/notify endpoint."""
|
|
|
|
def test_reject_when_unconfigured(self, no_auth_client):
|
|
"""Requests are rejected (fail closed) when no auth is configured."""
|
|
resp = no_auth_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_without_token(self, authed_client):
|
|
"""Requests without Authorization header are rejected when auth is configured."""
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_wrong_token(self, authed_client):
|
|
"""Requests with wrong token are rejected."""
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": "Bearer wrong-token"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_accept_valid_jwt(self, jwt_client, mock_adapter):
|
|
"""Requests with a valid JWT for the channel audience are accepted."""
|
|
token = _make_jwt()
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_reject_jwt_wrong_audience(self, jwt_client):
|
|
"""JWTs with wrong audience are rejected."""
|
|
token = create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret=_JWT_SECRET,
|
|
audience="turnstone-server", # wrong audience
|
|
)
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_jwt_wrong_secret(self, jwt_client):
|
|
"""JWTs signed with wrong secret are rejected."""
|
|
token = create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret="b" * 32, # wrong secret
|
|
audience=JWT_AUD_CHANNEL,
|
|
)
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_health_bypasses_auth(self, authed_client):
|
|
"""Health endpoint is always accessible regardless of auth config."""
|
|
resp = authed_client.get("/health")
|
|
assert resp.status_code == 200
|