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
338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""Tests for user identity, API tokens, JWT, and scoped auth."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.auth import (
|
|
AuthResult,
|
|
_authenticate_token,
|
|
check_request,
|
|
create_jwt,
|
|
generate_token,
|
|
hash_password,
|
|
hash_token,
|
|
parse_scopes,
|
|
required_scope,
|
|
token_prefix,
|
|
validate_jwt,
|
|
verify_password,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AuthResult
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthResult:
|
|
def test_frozen(self):
|
|
r = AuthResult(user_id="u1", scopes=frozenset({"read"}), token_source="config")
|
|
with pytest.raises(AttributeError):
|
|
r.user_id = "u2" # type: ignore[misc]
|
|
|
|
def test_has_scope(self):
|
|
r = AuthResult(user_id="", scopes=frozenset({"read", "write"}), token_source="config")
|
|
assert r.has_scope("read")
|
|
assert r.has_scope("write")
|
|
assert not r.has_scope("approve")
|
|
|
|
def test_empty_scopes(self):
|
|
r = AuthResult(user_id="", scopes=frozenset(), token_source="config")
|
|
assert not r.has_scope("read")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token generation and hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTokenHelpers:
|
|
def test_generate_token_format(self):
|
|
tok = generate_token()
|
|
assert tok.startswith("ts_")
|
|
assert len(tok) == 3 + 64 # ts_ + 64 hex chars
|
|
|
|
def test_generate_token_unique(self):
|
|
tokens = {generate_token() for _ in range(10)}
|
|
assert len(tokens) == 10
|
|
|
|
def test_hash_token_deterministic(self):
|
|
assert hash_token("ts_abc") == hash_token("ts_abc")
|
|
|
|
def test_hash_token_hex(self):
|
|
h = hash_token("test")
|
|
assert len(h) == 64 # SHA-256 hex
|
|
int(h, 16) # valid hex
|
|
|
|
def test_token_prefix(self):
|
|
assert token_prefix("ts_abcdefgh1234") == "ts_abcde"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Password hashing (bcrypt)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPasswordHashing:
|
|
def test_hash_and_verify(self):
|
|
pw = "hunter2"
|
|
hashed = hash_password(pw)
|
|
assert verify_password(pw, hashed)
|
|
|
|
def test_wrong_password(self):
|
|
hashed = hash_password("correct")
|
|
assert not verify_password("wrong", hashed)
|
|
|
|
def test_hash_is_different_each_time(self):
|
|
h1 = hash_password("same")
|
|
h2 = hash_password("same")
|
|
assert h1 != h2 # different salts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseScopes:
|
|
def test_single_scope(self):
|
|
assert parse_scopes("read") == frozenset({"read"})
|
|
|
|
def test_hierarchy_write(self):
|
|
assert parse_scopes("write") == frozenset({"read", "write"})
|
|
|
|
def test_hierarchy_approve(self):
|
|
assert parse_scopes("approve") == frozenset({"read", "write", "approve"})
|
|
|
|
def test_comma_separated(self):
|
|
assert parse_scopes("read,write") == frozenset({"read", "write"})
|
|
|
|
def test_redundant_scopes(self):
|
|
# approve already includes read,write
|
|
assert parse_scopes("read,approve") == frozenset({"read", "write", "approve"})
|
|
|
|
def test_empty_string(self):
|
|
assert parse_scopes("") == frozenset()
|
|
|
|
def test_invalid_scope_filtered(self):
|
|
assert parse_scopes("bogus") == frozenset()
|
|
|
|
def test_mixed_valid_invalid(self):
|
|
assert parse_scopes("read,bogus,approve") == frozenset({"read", "write", "approve"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JWT create / validate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJWT:
|
|
SECRET = "test-secret-key-for-jwt-min-32b!"
|
|
|
|
def test_round_trip(self):
|
|
scopes = frozenset({"read", "write"})
|
|
token = create_jwt("user123", scopes, "database", self.SECRET, expiry_hours=1)
|
|
result = validate_jwt(token, self.SECRET)
|
|
assert result is not None
|
|
assert result.user_id == "user123"
|
|
assert result.scopes == frozenset({"read", "write"})
|
|
|
|
def test_expired_token(self):
|
|
import jwt
|
|
|
|
payload = {
|
|
"sub": "user1",
|
|
"scopes": "read",
|
|
"src": "database",
|
|
"iat": int(time.time()) - 7200,
|
|
"exp": int(time.time()) - 3600,
|
|
}
|
|
token = jwt.encode(payload, self.SECRET, algorithm="HS256")
|
|
assert validate_jwt(token, self.SECRET) is None
|
|
|
|
def test_invalid_signature(self):
|
|
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
|
|
assert validate_jwt(token, "wrong-secret-key-for-jwt-min-32b") is None
|
|
|
|
def test_malformed_token(self):
|
|
assert validate_jwt("not.a.jwt", self.SECRET) is None
|
|
|
|
def test_contains_dots(self):
|
|
"""JWTs contain dots, used for detection."""
|
|
token = create_jwt("u1", frozenset({"read"}), "db", self.SECRET)
|
|
assert "." in token
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# required_scope
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRequiredScope:
|
|
def test_get_read(self):
|
|
assert required_scope("GET", "/api/workstreams") == "read"
|
|
|
|
def test_post_write(self):
|
|
assert required_scope("POST", "/api/send") == "write"
|
|
|
|
def test_post_approve(self):
|
|
assert required_scope("POST", "/api/approve") == "approve"
|
|
|
|
def test_admin_prefix(self):
|
|
assert required_scope("GET", "/api/admin/users") == "approve"
|
|
assert required_scope("POST", "/api/admin/users") == "approve"
|
|
assert required_scope("DELETE", "/api/admin/users/abc") == "approve"
|
|
|
|
def test_versioned_path(self):
|
|
assert required_scope("POST", "/v1/api/send") == "write"
|
|
assert required_scope("POST", "/v1/api/approve") == "approve"
|
|
|
|
def test_proxy_write(self):
|
|
assert required_scope("POST", "/node/n1/api/send") == "write"
|
|
|
|
def test_proxy_approve(self):
|
|
assert required_scope("POST", "/node/n1/api/approve") == "approve"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _authenticate_token
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthenticateToken:
|
|
def test_jwt_token(self):
|
|
secret = "test-secret-key-for-jwt-min-32b!"
|
|
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
|
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
|
assert result is not None
|
|
assert result.user_id == "user1"
|
|
assert result.token_source == "db"
|
|
|
|
def test_api_token_with_storage(self):
|
|
"""API tokens are looked up by hash in storage."""
|
|
raw = generate_token()
|
|
|
|
class MockStorage:
|
|
def get_api_token_by_hash(self, token_hash):
|
|
expected = hash_token(raw)
|
|
if token_hash == expected:
|
|
return {
|
|
"token_id": "tid",
|
|
"token_prefix": "ts_abcde",
|
|
"user_id": "user1",
|
|
"name": "test",
|
|
"scopes": "read,write",
|
|
"created": "2026-01-01T00:00:00",
|
|
}
|
|
return None
|
|
|
|
result = _authenticate_token(raw, storage=MockStorage())
|
|
assert result is not None
|
|
assert result.user_id == "user1"
|
|
assert result.has_scope("write")
|
|
assert result.token_source == "database"
|
|
|
|
def test_api_token_expired(self):
|
|
"""Expired API tokens are rejected."""
|
|
raw = generate_token()
|
|
|
|
class MockStorage:
|
|
def get_api_token_by_hash(self, token_hash):
|
|
return {
|
|
"token_id": "tid",
|
|
"token_prefix": "ts_abcde",
|
|
"user_id": "user1",
|
|
"name": "test",
|
|
"scopes": "read",
|
|
"created": "2020-01-01T00:00:00",
|
|
"expires": "2020-01-02T00:00:00",
|
|
}
|
|
|
|
result = _authenticate_token(raw, storage=MockStorage())
|
|
assert result is None
|
|
|
|
def test_unknown_token(self):
|
|
result = _authenticate_token("unknown")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_request with scopes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckRequestScopes:
|
|
_SECRET = "test-secret-key-for-jwt-min-32b!"
|
|
|
|
def test_jwt_read_on_write_403(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "write" in msg
|
|
|
|
def test_jwt_read_on_approve_403(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/approve",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "approve" in msg
|
|
|
|
def test_jwt_full_on_approve_ok(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
|
allowed, status, msg, result = check_request(
|
|
"POST",
|
|
"/api/approve",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.has_scope("approve")
|
|
|
|
def test_jwt_with_scopes(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
|
allowed, status, msg, result = check_request(
|
|
"POST",
|
|
"/api/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.user_id == "u1"
|
|
|
|
def test_jwt_insufficient_scope(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
|
|
def test_admin_path_requires_approve(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"GET",
|
|
"/v1/api/admin/users",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|