mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f89d0cc13 | |||
| 62d2a0fe6a | |||
| 5df37f83a7 | |||
| 651c4d98cd | |||
| e901e859c7 | |||
| 200dcfeac5 | |||
| 8c414feba2 | |||
| d7cea053b6 | |||
| c45e98462b | |||
| e17cbe35a5 |
@@ -17,3 +17,8 @@ CVE-2026-27135
|
||||
# Affects libsystemd0, libudev1
|
||||
# https://avd.aquasec.com/nvd/cve-2026-29111
|
||||
CVE-2026-29111
|
||||
|
||||
# glibc iconv() DoS — fix_deferred, no patched libc in Debian 13 yet
|
||||
# Affects libc-bin, libc6
|
||||
# https://avd.aquasec.com/nvd/cve-2026-4046
|
||||
CVE-2026-4046
|
||||
|
||||
@@ -18,6 +18,12 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
|
||||
+9
-54
@@ -6,7 +6,6 @@
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# Cluster + DDG: docker compose --profile ddgCluster up
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -28,7 +27,6 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
@@ -82,15 +80,13 @@ services:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
@@ -105,9 +101,6 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
ddg-search:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
@@ -130,9 +123,8 @@ services:
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
@@ -157,7 +149,6 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -168,8 +159,8 @@ services:
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
@@ -181,39 +172,6 @@ services:
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# ddg-search — DuckDuckGo Search MCP server (HTTP transport)
|
||||
# Provides web search + content fetch tools to turnstone via MCP.
|
||||
# No API key required.
|
||||
#
|
||||
# Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \
|
||||
# docker compose --profile ddgCluster up
|
||||
# -------------------------------------------------------------------
|
||||
ddg-search:
|
||||
image: python:3.14-slim
|
||||
profiles:
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
pip install --no-cache-dir duckduckgo-mcp-server &&
|
||||
python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')"
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
restart: unless-stopped
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
@@ -228,7 +186,7 @@ services:
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster, ddgCluster]
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -243,15 +201,13 @@ services:
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
@@ -262,7 +218,6 @@ services:
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
ddg-search: { condition: service_healthy, required: false }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
|
||||
@@ -36,13 +36,13 @@ spec:
|
||||
- secretRef:
|
||||
name: {{ include "turnstone.llm.secretName" . }}
|
||||
optional: true
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
env:
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -41,12 +41,12 @@ spec:
|
||||
env:
|
||||
- name: TURNSTONE_DB_URL
|
||||
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -15,7 +15,7 @@ data:
|
||||
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
|
||||
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
|
||||
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -59,10 +59,9 @@ llm:
|
||||
apiKey: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Authentication
|
||||
# -- Authentication (always enabled, JWT secret required)
|
||||
auth:
|
||||
enabled: false
|
||||
token: ""
|
||||
jwtSecret: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Ingress configuration
|
||||
|
||||
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
|
||||
[
|
||||
aws_secretsmanager_secret.openai_api_key.arn,
|
||||
aws_secretsmanager_secret.db_password.arn,
|
||||
aws_secretsmanager_secret.jwt_secret.arn,
|
||||
],
|
||||
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
@@ -41,20 +41,26 @@ locals {
|
||||
},
|
||||
]
|
||||
|
||||
auth_env = var.auth_token != "" ? [
|
||||
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
|
||||
] : []
|
||||
|
||||
auth_secrets = var.auth_token != "" ? [
|
||||
auth_secrets = [
|
||||
{
|
||||
name = "TURNSTONE_AUTH_TOKEN"
|
||||
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
|
||||
name = "TURNSTONE_JWT_SECRET"
|
||||
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
|
||||
},
|
||||
] : []
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- Secrets Manager ----------
|
||||
|
||||
resource "aws_secretsmanager_secret" "jwt_secret" {
|
||||
name = "${var.name_prefix}-${var.environment}-jwt-secret"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "jwt_secret" {
|
||||
secret_id = aws_secretsmanager_secret.jwt_secret.id
|
||||
secret_string = var.jwt_secret
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "openai_api_key" {
|
||||
name = "${var.name_prefix}-${var.environment}-openai-api-key"
|
||||
tags = local.common_tags
|
||||
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
|
||||
secret_string = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
name = "${var.name_prefix}-${var.environment}-auth-token"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
secret_id = aws_secretsmanager_secret.auth_token[0].id
|
||||
secret_string = var.auth_token
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_password" {
|
||||
name = "${var.name_prefix}-${var.environment}-db-password"
|
||||
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
|
||||
{ containerPort = 8080, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
|
||||
{ containerPort = 8090, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
|
||||
@@ -90,11 +90,10 @@ variable "name_prefix" {
|
||||
default = "turnstone"
|
||||
}
|
||||
|
||||
variable "auth_token" {
|
||||
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
|
||||
variable "jwt_secret" {
|
||||
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ console.log(result.content);
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
|
||||
Auth is always enabled. All API endpoints except public paths require a valid token.
|
||||
|
||||
### Sending Credentials
|
||||
|
||||
@@ -65,15 +65,14 @@ Include a token in one of two ways:
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
|
||||
The server accepts three token types:
|
||||
The server accepts two token types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| JWT | Base64 segments separated by dots | `eyJhbG...` |
|
||||
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
|
||||
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
|
||||
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
|
||||
|
||||
### `POST /v1/api/auth/login`
|
||||
|
||||
|
||||
@@ -1016,13 +1016,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||||
Turnstone supports three authentication mechanisms, unified behind an
|
||||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||||
|
||||
1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]`
|
||||
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
|
||||
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
|
||||
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
1. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||||
`POST /v1/api/auth/login`.
|
||||
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||||
`src` (origin) in claims.
|
||||
|
||||
@@ -1046,9 +1043,8 @@ Three hierarchical scopes control endpoint access:
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token; otherwise config-file token.
|
||||
4. **Validation** — JWT signature check, API token hash lookup in storage, or
|
||||
config-token hmac comparison.
|
||||
indicates API token.
|
||||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||||
scope; the request is rejected with 403 if the token lacks it.
|
||||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||||
|
||||
+5
-6
@@ -193,7 +193,6 @@ Plan review requests are displayed as a blue embed with:
|
||||
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
||||
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
||||
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
||||
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
|
||||
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
@@ -321,11 +320,11 @@ The `services` table schema:
|
||||
### Security
|
||||
|
||||
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
||||
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
|
||||
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
||||
or a static token via `--auth-token`. If neither is set, the
|
||||
gateway fails closed and rejects all requests with 401. Server JWTs
|
||||
(`aud: turnstone-server`) are rejected.
|
||||
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
||||
server can mint JWTs with `aud: turnstone-channel` automatically.
|
||||
If the secret is not set, the gateway fails closed and rejects all
|
||||
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
||||
rejected.
|
||||
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
||||
increments on successful delivery, so failures don't consume the
|
||||
budget.
|
||||
|
||||
+1
-2
@@ -628,7 +628,6 @@ CLI flags for `turnstone-console`:
|
||||
|------|---------|-------------|
|
||||
| `--host` | `0.0.0.0` | Bind host |
|
||||
| `--port` | `8090` | HTTP port |
|
||||
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
|
||||
| `--log-level` | `INFO` | Log level |
|
||||
|
||||
Config file (`~/.config/turnstone/config.toml`):
|
||||
@@ -649,7 +648,7 @@ url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
turnstone-server --port 8080
|
||||
|
||||
# Start cluster console (one instance)
|
||||
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
|
||||
|
||||
+3
-3
@@ -72,11 +72,11 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|
||||
### Auth
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
### Database
|
||||
|
||||
|
||||
+5
-5
@@ -38,7 +38,7 @@ are set.
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
|
||||
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
@@ -246,10 +246,10 @@ password) before OIDC is enabled. The setup wizard always works
|
||||
regardless of this setting because it is only available when zero users
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token)
|
||||
continues to work regardless of this setting. JWTs and API tokens are
|
||||
the supported authentication methods. OIDC-only mode affects
|
||||
password-based authentication only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -332,6 +332,6 @@ client.login(token="ts_abc123...")
|
||||
- `client.logout()` clears the stored JWT from the client.
|
||||
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
|
||||
|
||||
### Backward Compatibility
|
||||
### Token Types
|
||||
|
||||
The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.
|
||||
The SDK accepts any Bearer token — JWTs (from `ServiceTokenManager` or login) and API tokens (`ts_` prefix) are both supported. Use `token_factory` for auto-rotating JWTs or a static `token` for API tokens.
|
||||
|
||||
+11
-54
@@ -8,23 +8,6 @@ credentials while individual server nodes validate JWTs locally.
|
||||
|
||||
## Token Types
|
||||
|
||||
### Config-file tokens
|
||||
|
||||
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
||||
environment variable. Validated in-memory using `hmac.compare_digest`
|
||||
(timing-safe). Each token maps to a role that determines its scopes.
|
||||
|
||||
```toml
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full" # full → {read, write, approve}
|
||||
```
|
||||
|
||||
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
||||
|
||||
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
||||
on every request. No JWT exchange is needed.
|
||||
|
||||
### API tokens
|
||||
|
||||
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
||||
@@ -149,15 +132,6 @@ The API token is hashed, looked up in the database, and exchanged for a
|
||||
JWT with the token's scopes. This is the recommended flow for SDKs and
|
||||
automated clients that need cookie-based sessions.
|
||||
|
||||
### Config-file tokens (direct)
|
||||
|
||||
Config tokens are validated per-request via `hmac.compare_digest`. No
|
||||
login exchange is needed — include the token as a `Bearer` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer tok_legacy
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
When no users exist in the database:
|
||||
@@ -276,7 +250,7 @@ Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
|
||||
form on the login page and blocks password-based login at the API
|
||||
level. The setup wizard always works regardless of this setting — the
|
||||
first admin user is created with a password before OIDC is relevant.
|
||||
API tokens and config-file tokens are unaffected by this setting.
|
||||
API tokens are unaffected by this setting.
|
||||
|
||||
#### Known limitations
|
||||
|
||||
@@ -297,8 +271,6 @@ and classifies the token:
|
||||
|
||||
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
||||
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
||||
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
||||
each configured token
|
||||
|
||||
If a session cookie is present and no `Authorization` header is sent,
|
||||
the cookie value is treated as a JWT (step 1).
|
||||
@@ -332,16 +304,10 @@ deployments.
|
||||
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
||||
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
||||
| Algorithm | — | — | HS256 (not configurable) |
|
||||
| Minimum secret length | — | — | 32 characters (warning if shorter) |
|
||||
| Minimum secret length | — | — | 32 characters (exits if shorter) |
|
||||
|
||||
All service nodes that need to validate JWTs must share the same signing
|
||||
secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
|
||||
is provided. It exits with an error if the secret is missing, since
|
||||
ephemeral secrets would silently break inter-service communication.
|
||||
All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
|
||||
missing or shorter than 32 characters.
|
||||
|
||||
---
|
||||
|
||||
@@ -442,16 +408,15 @@ Console (cluster-wide) Server (per-node)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ User/Token CRUD (DB) │ │ JWT validation only │
|
||||
│ Login: creds → JWT │ │ (shared signing key) │
|
||||
│ Admin API endpoints │ │ Config tokens: hmac │
|
||||
│ Storage: users, │ │ No auth DB needed │
|
||||
│ Admin API endpoints │ │ No auth DB needed │
|
||||
│ Storage: users, │ │ │
|
||||
│ api_tokens tables │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The console owns the credential database and handles all user/token
|
||||
CRUD. Individual server nodes only need the JWT signing secret to
|
||||
validate session tokens. Config-file tokens are validated locally
|
||||
without any database.
|
||||
validate session tokens.
|
||||
|
||||
### Proxy auth forwarding
|
||||
|
||||
@@ -478,8 +443,7 @@ distinguish proxied requests from direct logins in audit logs.
|
||||
|
||||
When no user context is available (auth disabled, or internal requests),
|
||||
the proxy falls back to a `ServiceTokenManager` with service identity
|
||||
`console-proxy` and full scopes. If `--auth-token` is provided, that
|
||||
static token is used as a final fallback.
|
||||
`console-proxy` and full scopes.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
@@ -518,22 +482,17 @@ channel gateway endpoint, and vice versa.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
jwt_secret = "your-secret-key-here"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
|
||||
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
|
||||
|
||||
---
|
||||
@@ -571,8 +530,6 @@ and browsers enforce same-origin policy.
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **Timing-safe comparison** for config-file tokens via
|
||||
`hmac.compare_digest` — no timing side-channel.
|
||||
- **Hash-based lookup** for API tokens — the database stores only
|
||||
SHA-256 hashes, eliminating timing attacks on token comparison.
|
||||
- **Local JWT validation** — no network call or database query needed
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
|
||||
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
|
||||
|
||||
# List issued certs
|
||||
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
|
||||
turnstone-admin tls-list --console-url http://console:8080
|
||||
```
|
||||
|
||||
### Console URL Discovery
|
||||
|
||||
+1
-1
@@ -593,7 +593,7 @@ current turn and letting it search for them on demand.
|
||||
Tool search uses the best available mechanism for each provider:
|
||||
|
||||
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25` server-side
|
||||
search tool. Anthropic's API handles search and expansion transparently.
|
||||
|
||||
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.8"
|
||||
version = "0.9.10"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* const client = new TurnstoneServer({
|
||||
* baseUrl: "http://localhost:8080",
|
||||
* token: "tok_xxx",
|
||||
* token: "ts_your_api_token",
|
||||
* });
|
||||
*
|
||||
* const ws = await client.createWorkstream({ name: "demo" });
|
||||
|
||||
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _server_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
|
||||
|
||||
def _console_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
|
||||
_CONSOLE_AUTH_HEADERS = {"Authorization": f"Bearer {_console_jwt()}"}
|
||||
|
||||
|
||||
class TestServerVersioning:
|
||||
"""Test /v1/ routes and OpenAPI endpoints on the server."""
|
||||
@@ -14,7 +45,6 @@ class TestServerVersioning:
|
||||
def client(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.server import create_app
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
@@ -26,19 +56,19 @@ class TestServerVersioning:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_workstreams(self, client):
|
||||
resp = client.get("/v1/api/workstreams")
|
||||
resp = client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert "workstreams" in resp.json()
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/workstreams")
|
||||
resp = client.get("/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_cluster_overview(self, client):
|
||||
resp = client.get("/v1/api/cluster/overview")
|
||||
resp = client.get("/v1/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/cluster/overview")
|
||||
resp = client.get("/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
|
||||
+263
-332
@@ -9,12 +9,11 @@ import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
WRITE_PATHS,
|
||||
AuthConfig,
|
||||
_extract_bearer,
|
||||
_extract_cookie,
|
||||
check_request,
|
||||
create_jwt,
|
||||
is_public_path,
|
||||
load_auth_config,
|
||||
make_clear_cookie,
|
||||
make_set_cookie,
|
||||
required_scope,
|
||||
@@ -199,37 +198,6 @@ class TestRequiredScope:
|
||||
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAuthConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthConfig:
|
||||
def test_check_valid_full_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
|
||||
assert cfg.check("tok_full") == "full"
|
||||
|
||||
def test_check_valid_read_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
|
||||
assert cfg.check("tok_read") == "read"
|
||||
|
||||
def test_check_invalid_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check("wrong") is None
|
||||
|
||||
def test_check_none_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check(None) is None
|
||||
|
||||
def test_check_empty_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check("") is None
|
||||
|
||||
def test_check_no_tokens(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={})
|
||||
assert cfg.check("anything") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtractBearer
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -353,167 +321,157 @@ class TestMakeClearCookie:
|
||||
class TestCheckRequest:
|
||||
"""Tests for the main check_request() entry point."""
|
||||
|
||||
@pytest.fixture()
|
||||
def disabled(self):
|
||||
return AuthConfig(enabled=False)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
def read_jwt(self):
|
||||
return f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', self._SECRET)}"
|
||||
|
||||
def test_disabled_allows_all(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None)
|
||||
@pytest.fixture()
|
||||
def full_jwt(self):
|
||||
return f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', self._SECRET)}"
|
||||
|
||||
def test_public_path_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_disabled_allows_no_header(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None)
|
||||
def test_public_root_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_path_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_public_root_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
|
||||
def test_public_static_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_static_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_api_no_token_401(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
|
||||
def test_api_no_token_401(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
assert "Unauthorized" in msg
|
||||
|
||||
def test_api_invalid_token_401(self, enabled):
|
||||
def test_api_invalid_token_401(self):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer wrong_token"
|
||||
"GET", "/api/workstreams", "Bearer wrong_token"
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_api_read_token_ok(self, enabled):
|
||||
def test_api_read_token_ok(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_read"
|
||||
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_api_full_token_ok(self, enabled):
|
||||
def test_api_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_full"
|
||||
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_write_read_token_403(self, enabled):
|
||||
def test_write_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_read"
|
||||
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
assert "Forbidden" in msg
|
||||
|
||||
def test_write_full_token_ok(self, enabled):
|
||||
def test_write_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_full"
|
||||
"POST", "/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_approve_read_token_403(self, enabled):
|
||||
def test_approve_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_read"
|
||||
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_read_token_403(self, enabled):
|
||||
def test_proxy_write_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot escalate to write ops via proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
|
||||
"POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_trailing_slash_read_token_403(self, enabled):
|
||||
def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
|
||||
"""Trailing slash must not bypass write-role check on proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
|
||||
"POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_direct_write_trailing_slash_read_token_403(self, enabled):
|
||||
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
|
||||
"""Trailing slash must not bypass write-role check on direct routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send/", "Bearer tok_read"
|
||||
"POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_full_token_ok(self, enabled):
|
||||
def test_proxy_write_full_token_ok(self, full_jwt):
|
||||
"""Full tokens pass through proxy write routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full"
|
||||
"POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_proxy_v1_write_read_token_403(self, enabled):
|
||||
def test_proxy_v1_write_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read"
|
||||
"POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_v1_write_full_token_ok(self, enabled):
|
||||
def test_proxy_v1_write_full_token_ok(self, full_jwt):
|
||||
"""Full tokens pass through v1 proxy write routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full"
|
||||
"POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_proxy_v1_cluster_ws_new_read_403(self, enabled):
|
||||
def test_proxy_v1_cluster_ws_new_read_403(self, read_jwt):
|
||||
"""Read tokens cannot create workstreams via v1 proxy."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/node/node-a/v1/api/cluster/workstreams/new",
|
||||
"Bearer tok_read",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_read_endpoint_read_token_ok(self, enabled):
|
||||
def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
|
||||
"""Read tokens can access proxy read endpoints."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read"
|
||||
"GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_console_create_ws_read_token_403(self, enabled):
|
||||
def test_console_create_ws_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot create workstreams."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read"
|
||||
"POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_approve_full_token_ok(self, enabled):
|
||||
def test_approve_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_full"
|
||||
"POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_auth_header_string(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "")
|
||||
def test_no_auth_header_string(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
@@ -526,70 +484,71 @@ class TestCheckRequest:
|
||||
class TestCheckRequestWithCookie:
|
||||
"""Tests for cookie-based auth fallback in check_request."""
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
def test_cookie_fallback_when_no_bearer(self, enabled):
|
||||
@pytest.fixture()
|
||||
def read_jwt(self):
|
||||
return create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
|
||||
@pytest.fixture()
|
||||
def full_jwt(self):
|
||||
return create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
||||
|
||||
def test_cookie_fallback_when_no_bearer(self, read_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_bearer_takes_precedence_over_cookie(self, enabled):
|
||||
# Bearer is full, cookie is read — Bearer should win
|
||||
def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
"Bearer tok_full",
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
f"Bearer {full_jwt}",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_invalid_cookie_401(self, enabled):
|
||||
def test_invalid_cookie_401(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=wrong_token",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_cookie_read_on_write_403(self, enabled):
|
||||
def test_cookie_read_on_write_403(self, read_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_cookie_full_on_write_ok(self, enabled):
|
||||
def test_cookie_full_on_write_ok(self, full_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_full",
|
||||
cookie_header=f"turnstone_auth={full_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_cookie_no_bearer_401(self, enabled):
|
||||
def test_no_cookie_no_bearer_401(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
@@ -598,18 +557,16 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_login_path_public(self, enabled):
|
||||
def test_login_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
None,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_logout_path_public(self, enabled):
|
||||
def test_logout_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
None,
|
||||
@@ -617,139 +574,6 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLoadAuthConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
"""Tests for load_auth_config with mocked config + env vars."""
|
||||
|
||||
def test_default_enabled(self):
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.tokens == {}
|
||||
|
||||
def test_explicit_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={"enabled": False}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_env_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "0"}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_config_file_tokens(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [
|
||||
{"value": "tok_a", "role": "full"},
|
||||
{"value": "tok_b", "role": "read"},
|
||||
],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.tokens == {"tok_a": "full", "tok_b": "read"}
|
||||
|
||||
def test_env_var_enabled(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "1"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_env_var_token(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert "tok_env" in cfg.tokens
|
||||
assert cfg.tokens["tok_env"] == "full"
|
||||
|
||||
def test_config_plus_env_merge(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [{"value": "tok_cfg", "role": "read"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.tokens["tok_cfg"] == "read"
|
||||
assert cfg.tokens["tok_env"] == "full"
|
||||
|
||||
def test_invalid_role_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [
|
||||
{"value": "tok_ok", "role": "full"},
|
||||
{"value": "tok_bad", "role": "admin"},
|
||||
],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert "tok_ok" in cfg.tokens
|
||||
assert "tok_bad" not in cfg.tokens
|
||||
|
||||
def test_empty_value_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [{"value": "", "role": "full"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert len(cfg.tokens) == 0
|
||||
|
||||
def test_non_dict_token_entry_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": ["not_a_dict", {"value": "tok_ok", "role": "full"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.tokens == {"tok_ok": "full"}
|
||||
|
||||
def test_env_enabled_true(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "true"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_env_enabled_yes(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "yes"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — actual HTTP server with auth enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -785,16 +609,22 @@ class TestServerAuth:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_SERVER
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
cls._read_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
|
||||
}
|
||||
cls._full_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
|
||||
}
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
cors_origins=["*"],
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -809,7 +639,6 @@ class TestServerAuth:
|
||||
|
||||
def test_metrics_no_token_passes_auth(self):
|
||||
resp = self.client.get("/metrics")
|
||||
# Public path — should never be 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_root_no_token_200(self):
|
||||
@@ -826,23 +655,17 @@ class TestServerAuth:
|
||||
assert "Unauthorized" in resp.json().get("error", "")
|
||||
|
||||
def test_api_workstreams_read_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_workstreams_full_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_send_read_token_403(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
headers=self._read_hdr,
|
||||
json={"message": "hello", "ws_id": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -851,10 +674,9 @@ class TestServerAuth:
|
||||
def test_api_send_full_token_passes_auth(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
headers=self._full_hdr,
|
||||
json={"message": "hello", "ws_id": "nonexistent"},
|
||||
)
|
||||
# Should get 404 (unknown workstream), not 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_api_send_no_token_401(self):
|
||||
@@ -921,12 +743,18 @@ class TestConsoleAuth:
|
||||
"aggregate": {"total_tokens": 100},
|
||||
}
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
cls._read_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
|
||||
}
|
||||
cls._full_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
|
||||
}
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -947,17 +775,11 @@ class TestConsoleAuth:
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_api_overview_read_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_overview_full_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_token_401(self):
|
||||
@@ -1003,16 +825,33 @@ class TestServerLogin:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
# Mock storage with a test user for password login
|
||||
from turnstone.core.auth import hash_password
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_user_by_username.side_effect = lambda u: (
|
||||
{
|
||||
"user_id": "uid_test",
|
||||
"username": "testuser",
|
||||
"password_hash": hash_password("testpass"),
|
||||
"display_name": "Test",
|
||||
}
|
||||
if u == "testuser"
|
||||
else None
|
||||
)
|
||||
mock_storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
|
||||
]
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1020,36 +859,36 @@ class TestServerLogin:
|
||||
def teardown_class(cls):
|
||||
cls.test_client.close()
|
||||
|
||||
def test_login_valid_token_sets_cookie(self):
|
||||
def test_login_config_token_rejected(self):
|
||||
"""Config token exchange is no longer allowed."""
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_full"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "full"
|
||||
cookie = resp.headers.get("set-cookie", "")
|
||||
assert "turnstone_auth=tok_full" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_invalid_token_401(self):
|
||||
def test_login_invalid_credentials_401(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "wrong"},
|
||||
json={"username": "testuser", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_no_auth_required(self):
|
||||
# /v1/api/auth/login is public — shouldn't require auth itself
|
||||
def test_login_password_ok(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_read"},
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "jwt" in data
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
# Login to get cookie (TestClient tracks cookies automatically)
|
||||
login_resp = self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
login_resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
# Use cookie to access API — TestClient forwards cookies
|
||||
@@ -1057,7 +896,10 @@ class TestServerLogin:
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_logout_clears_cookie(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
|
||||
# Logout
|
||||
logout_resp = self.test_client.post("/v1/api/auth/logout")
|
||||
@@ -1081,6 +923,7 @@ class TestConsoleLogin:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import hash_password
|
||||
|
||||
_load_static()
|
||||
|
||||
@@ -1092,12 +935,26 @@ class TestConsoleLogin:
|
||||
"aggregate": {"total_tokens": 100},
|
||||
}
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_user_by_username.side_effect = lambda u: (
|
||||
{
|
||||
"user_id": "uid_test",
|
||||
"username": "testuser",
|
||||
"password_hash": hash_password("testpass"),
|
||||
"display_name": "Test",
|
||||
}
|
||||
if u == "testuser"
|
||||
else None
|
||||
)
|
||||
mock_storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
|
||||
]
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1105,28 +962,34 @@ class TestConsoleLogin:
|
||||
def teardown_class(cls):
|
||||
cls.test_client.close()
|
||||
|
||||
def test_login_valid_token(self):
|
||||
def test_login_config_token_rejected(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_read"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_password_ok(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
|
||||
|
||||
def test_login_invalid_token(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_logout_then_api_fails(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
self.test_client.post("/v1/api/auth/logout")
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 401
|
||||
@@ -1385,25 +1248,29 @@ class TestIsSecureRequest:
|
||||
|
||||
|
||||
class TestSecretStrength:
|
||||
def test_short_secret_warns(self, caplog):
|
||||
import logging
|
||||
def test_short_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
from turnstone.core.auth import _MIN_SECRET_LENGTH
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
auth_mod.load_jwt_secret()
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.auth"):
|
||||
import turnstone.core.auth as auth_mod
|
||||
def test_missing_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
secret = auth_mod.load_jwt_secret()
|
||||
assert secret == "short"
|
||||
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records)
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
auth_mod.load_jwt_secret()
|
||||
|
||||
|
||||
class TestCorsConfigurable:
|
||||
@@ -1424,7 +1291,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health", headers={"Origin": "http://evil.com"})
|
||||
@@ -1446,7 +1312,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
cors_origins=["http://example.com"],
|
||||
)
|
||||
client = TestClient(app)
|
||||
@@ -1502,3 +1367,69 @@ class TestOIDCPublicPaths:
|
||||
def test_oidc_callback_is_public(self):
|
||||
assert is_public_path("/api/auth/oidc/callback") is True
|
||||
assert is_public_path("/v1/api/auth/oidc/callback") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRequirePermissionServiceScope — service scope bypasses permission checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequirePermissionServiceScope:
|
||||
"""Verify require_permission() behaviour with the service scope."""
|
||||
|
||||
def _make_request(self, auth_result):
|
||||
"""Build a mock Starlette request with the given AuthResult on state."""
|
||||
request = MagicMock()
|
||||
request.state.auth_result = auth_result
|
||||
return request
|
||||
|
||||
def test_service_scope_bypasses_permission(self):
|
||||
"""Service-scoped tokens bypass all permission checks (returns None)."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="svc-agent",
|
||||
scopes=frozenset({"service"}),
|
||||
token_source="jwt",
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is None # bypass — no 403
|
||||
|
||||
def test_without_service_scope_and_without_permission_returns_403(self):
|
||||
"""Non-service tokens without the required permission get 403."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="regular-user",
|
||||
scopes=frozenset({"read", "write"}),
|
||||
token_source="jwt",
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is not None
|
||||
assert result.status_code == 403
|
||||
|
||||
def test_without_service_scope_with_permission_returns_none(self):
|
||||
"""Non-service tokens with the required permission pass."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="admin-user",
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
token_source="jwt",
|
||||
permissions=frozenset({"admin.users"}),
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is None # granted — no 403
|
||||
|
||||
def test_no_auth_result_returns_401(self):
|
||||
"""Missing auth_result on request state returns 401."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
request = MagicMock()
|
||||
del request.state.auth_result # ensure attribute is absent
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is not None
|
||||
assert result.status_code == 401
|
||||
|
||||
+37
-57
@@ -7,7 +7,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AuthConfig,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
@@ -203,24 +202,10 @@ class TestRequiredScope:
|
||||
|
||||
|
||||
class TestAuthenticateToken:
|
||||
def test_config_token_read(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
result = _authenticate_token("tok_read", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read"})
|
||||
assert result.token_source == "config"
|
||||
|
||||
def test_config_token_full(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
result = _authenticate_token("tok_full", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_source == "db"
|
||||
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
|
||||
}
|
||||
return None
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.has_scope("write")
|
||||
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
|
||||
"expires": "2020-01-02T00:00:00",
|
||||
}
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is None
|
||||
|
||||
def test_unknown_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
||||
result = _authenticate_token("unknown", cfg)
|
||||
result = _authenticate_token("unknown")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
|
||||
|
||||
|
||||
class TestCheckRequestScopes:
|
||||
def test_config_read_on_write_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
||||
_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_config_read_on_approve_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
||||
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_config_full_on_approve_ok(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
||||
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):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_admin_path_requires_approve(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_read",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_backward_compat_role_full(self):
|
||||
"""Config tokens with role='full' get all scopes."""
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, _, _, result = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_full",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
+34
-23
@@ -9,6 +9,24 @@ import pytest
|
||||
|
||||
from turnstone.console.collector import ClusterCollector, NodeSnapshot
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-console",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock storage for collector tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -711,13 +729,11 @@ class TestConsoleHTTPEndpoints:
|
||||
|
||||
_load_static()
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -953,12 +969,11 @@ class TestConsoleWorkstreamCreation:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
|
||||
# Set up a mock proxy_client (lifespan doesn't run in TestClient)
|
||||
@@ -974,7 +989,7 @@ class TestConsoleWorkstreamCreation:
|
||||
mock_proxy.post = mock_post
|
||||
app.state.proxy_client = mock_proxy
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client, mock_post
|
||||
client.close()
|
||||
|
||||
@@ -1151,14 +1166,13 @@ class TestConsoleProxy:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1322,14 +1336,13 @@ class TestConsoleVersionEndpoints:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1364,7 +1377,6 @@ class TestSharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1376,9 +1388,9 @@ class TestSharedStatic:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1481,7 +1493,6 @@ class TestProxySharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1494,9 +1505,9 @@ class TestProxySharedStatic:
|
||||
collector.get_node_detail.return_value = None
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
@@ -1815,14 +1826,14 @@ class TestProxyAuthHeaders:
|
||||
# Should use ServiceTokenManager, not mint a user JWT
|
||||
assert headers["Authorization"] == f"Bearer {mgr.token}"
|
||||
|
||||
def test_fallback_static_token(self):
|
||||
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token."""
|
||||
def test_no_mgr_no_user_returns_empty(self):
|
||||
"""No auth_result, no ServiceTokenManager → empty headers."""
|
||||
from turnstone.console.server import _proxy_auth_headers
|
||||
|
||||
req = self._make_request(proxy_auth_token="static-tok-123")
|
||||
req = self._make_request()
|
||||
headers = _proxy_auth_headers(req)
|
||||
|
||||
assert headers == {"Authorization": "Bearer static-tok-123"}
|
||||
assert headers == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,24 @@ from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
from turnstone.core.hash_ring import NoAvailableNodeError
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-routing",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -42,12 +60,11 @@ def _make_app(
|
||||
router: Any = None,
|
||||
) -> Any:
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
return create_app(
|
||||
collector=collector or _make_mock_collector(),
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
router=router,
|
||||
)
|
||||
|
||||
@@ -100,6 +117,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -109,6 +127,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -126,6 +145,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"resume_ws": "old_ws_id"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -150,6 +170,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"target_node": "node-c"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -203,6 +224,7 @@ class TestRouteCreate503Retry:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -233,6 +255,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc123", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
|
||||
@@ -245,6 +268,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/approve",
|
||||
json={"ws_id": "abc123", "approved": True},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -252,6 +276,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/cancel",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -259,6 +284,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/command",
|
||||
json={"ws_id": "abc123", "command": "status"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -266,6 +292,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/close",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -288,14 +315,14 @@ class TestRouteLookup:
|
||||
client.close()
|
||||
|
||||
def test_route_lookup(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc123")
|
||||
resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["node_url"] == "http://a:8080"
|
||||
assert data["node_id"] == "node-a"
|
||||
|
||||
def test_route_lookup_missing_ws_id(self, client):
|
||||
resp = client.get("/v1/api/route")
|
||||
resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 400
|
||||
assert "ws_id" in resp.json()["error"]
|
||||
|
||||
@@ -329,6 +356,7 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -336,6 +364,7 @@ class TestRouteNotReady:
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -343,22 +372,24 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_router_503(self, client_no_router):
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_proxy_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
@@ -384,6 +415,7 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "No available node" in resp.json()["error"]
|
||||
@@ -392,9 +424,10 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_node_503(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc")
|
||||
resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
+38
-48
@@ -8,8 +8,26 @@ 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):
|
||||
@@ -33,22 +51,22 @@ def no_auth_client(storage, mock_adapter):
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage, mock_adapter):
|
||||
"""Default client with static auth token configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
|
||||
"""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, auth_token="test-secret-token")
|
||||
"""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="a" * 32)
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -58,9 +76,6 @@ class TestNotifyEndpoint:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer test-secret-token"}
|
||||
|
||||
def test_direct_discord_target(self, client, mock_adapter):
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
@@ -68,7 +83,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -85,7 +100,7 @@ class TestNotifyEndpoint:
|
||||
"message": "Hello!",
|
||||
"title": "Alert",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
|
||||
@@ -101,7 +116,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -116,7 +131,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "nobody"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
error = resp.json()["error"]
|
||||
@@ -132,10 +147,10 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-secret-token"},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
# Generic message — must not differentiate "not found" vs "no channels"
|
||||
# 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
|
||||
@@ -144,7 +159,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"target": {"username": "x"}},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -152,7 +167,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"message": "Hello!"},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -163,7 +178,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"invalid": "field"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -175,7 +190,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "email", "channel_id": "test@example.com"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -189,7 +204,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -201,7 +216,7 @@ class TestNotifyEndpoint:
|
||||
content=b"not json",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"Authorization": "Bearer test-secret-token",
|
||||
"Authorization": f"Bearer {_make_jwt()}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -214,7 +229,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123"},
|
||||
"message": " ",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -256,30 +271,9 @@ class TestNotifyAuth:
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_accept_valid_static_token(self, authed_client, mock_adapter):
|
||||
"""Requests with correct static token are accepted."""
|
||||
resp = authed_client.post(
|
||||
"/v1/api/notify",
|
||||
json={
|
||||
"target": {"channel_type": "discord", "channel_id": "123"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-secret-token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["status"] == "sent"
|
||||
|
||||
def test_accept_valid_jwt(self, jwt_client, mock_adapter):
|
||||
"""Requests with a valid JWT for the channel audience are accepted."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
audience=JWT_AUD_CHANNEL,
|
||||
)
|
||||
token = _make_jwt()
|
||||
resp = jwt_client.post(
|
||||
"/v1/api/notify",
|
||||
json={
|
||||
@@ -292,13 +286,11 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_audience(self, jwt_client):
|
||||
"""JWTs with wrong audience are rejected."""
|
||||
from turnstone.core.auth import create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
secret=_JWT_SECRET,
|
||||
audience="turnstone-server", # wrong audience
|
||||
)
|
||||
resp = jwt_client.post(
|
||||
@@ -313,8 +305,6 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_secret(self, jwt_client):
|
||||
"""JWTs signed with wrong secret are rejected."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
|
||||
@@ -2303,8 +2303,8 @@ class TestAnthropicToolSearch:
|
||||
# MCP tool should be deferred
|
||||
assert result[1]["defer_loading"] is True
|
||||
# Search tool should be appended
|
||||
assert result[-1]["type"] == "tool_search_tool_bm25_20251119"
|
||||
assert result[-1]["name"] == "tool_search"
|
||||
assert result[-1]["type"] == "tool_search_tool_bm25"
|
||||
assert result[-1]["name"] == "tool_search_tool_bm25"
|
||||
|
||||
def test_inject_tool_search_no_op_without_deferred(self, provider):
|
||||
caps = provider.get_capabilities("claude-opus-4-6-20260101")
|
||||
|
||||
+30
-11
@@ -580,6 +580,24 @@ class TestSessionConfig:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _server_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-server-live",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
|
||||
|
||||
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
|
||||
|
||||
|
||||
class TestServerHealthMetrics:
|
||||
"""Verify /health and /metrics endpoints using a Starlette TestClient.
|
||||
|
||||
@@ -596,7 +614,6 @@ class TestServerHealthMetrics:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
@@ -631,7 +648,7 @@ class TestServerHealthMetrics:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -727,8 +744,8 @@ class TestServerHealthMetrics:
|
||||
assert 'le="+Inf"' in body
|
||||
|
||||
def test_unknown_endpoint_returns_404(self):
|
||||
status, _, _ = self._get("/does-not-exist")
|
||||
assert status == 404
|
||||
resp = self.client.get("/does-not-exist", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_health_contains_backend_field(self):
|
||||
_, _, body = self._get("/health")
|
||||
@@ -772,7 +789,6 @@ class TestServerRateLimiting:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.ratelimit import RateLimiter
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
@@ -808,7 +824,7 @@ class TestServerRateLimiting:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3),
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -830,16 +846,19 @@ class TestServerRateLimiting:
|
||||
"""After exhausting burst on a non-exempt endpoint, get 429."""
|
||||
# Exhaust burst on a non-exempt endpoint
|
||||
for _ in range(5):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
# At least one should be 429
|
||||
statuses = [self._get("/v1/api/workstreams").status_code for _ in range(3)]
|
||||
statuses = [
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS).status_code
|
||||
for _ in range(3)
|
||||
]
|
||||
assert 429 in statuses
|
||||
|
||||
def test_429_includes_retry_after(self):
|
||||
"""429 response includes Retry-After header."""
|
||||
# Burn through burst
|
||||
for _ in range(10):
|
||||
resp = self._get("/v1/api/workstreams")
|
||||
resp = self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
if resp.status_code == 429:
|
||||
assert "retry-after" in resp.headers
|
||||
data = resp.json()
|
||||
@@ -851,7 +870,7 @@ class TestServerRateLimiting:
|
||||
"""Health endpoint is always accessible regardless of rate limit."""
|
||||
# Burn through bucket on non-exempt path
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
# Health should still work
|
||||
resp = self._get("/health")
|
||||
assert resp.status_code == 200
|
||||
@@ -859,6 +878,6 @@ class TestServerRateLimiting:
|
||||
def test_metrics_exempt_from_ratelimit(self):
|
||||
"""Metrics endpoint is always accessible regardless of rate limit."""
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
resp = self._get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Tests for skill resource materialization to disk.
|
||||
|
||||
Verifies that skill-bundled resources (scripts, references, assets) stored
|
||||
in the ``skill_resources`` table are written to a temp directory when a
|
||||
skill is loaded, exposed via ``SKILL_RESOURCES_DIR`` env var and ``PATH``,
|
||||
and cleaned up on skill change or session close.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (mirrors test_skills.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that discards all output."""
|
||||
|
||||
def on_thinking_start(self):
|
||||
pass
|
||||
|
||||
def on_thinking_stop(self):
|
||||
pass
|
||||
|
||||
def on_reasoning_token(self, text):
|
||||
pass
|
||||
|
||||
def on_content_token(self, text):
|
||||
pass
|
||||
|
||||
def on_stream_end(self):
|
||||
pass
|
||||
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
pass
|
||||
|
||||
def on_status(self, usage, context_window, effort):
|
||||
pass
|
||||
|
||||
def on_plan_review(self, content):
|
||||
return ""
|
||||
|
||||
def on_info(self, message):
|
||||
pass
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
pass
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
def on_output_warning(self, call_id, assessment):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(**kwargs: Any) -> ChatSession:
|
||||
defaults: dict[str, Any] = dict(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=NullUI(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
def _create_skill(db: Any, skill_id: str, name: str, content: str, **kw: Any) -> None:
|
||||
db.create_prompt_template(
|
||||
template_id=skill_id,
|
||||
name=name,
|
||||
category=kw.get("category", "general"),
|
||||
content=content,
|
||||
variables=kw.get("variables", "[]"),
|
||||
is_default=kw.get("is_default", False),
|
||||
org_id="",
|
||||
created_by="test",
|
||||
origin="manual",
|
||||
mcp_server="",
|
||||
readonly=False,
|
||||
description="",
|
||||
tags="[]",
|
||||
source_url="",
|
||||
version="1.0.0",
|
||||
author="",
|
||||
activation=kw.get("activation", "named"),
|
||||
token_estimate=0,
|
||||
model="",
|
||||
auto_approve=False,
|
||||
temperature=None,
|
||||
reasoning_effort="",
|
||||
max_tokens=None,
|
||||
token_budget=0,
|
||||
agent_max_turns=None,
|
||||
notify_on_complete="{}",
|
||||
enabled=True,
|
||||
allowed_tools="[]",
|
||||
priority=0,
|
||||
)
|
||||
|
||||
|
||||
def _sys_content(session: ChatSession) -> str:
|
||||
msgs = [m for m in session.system_messages if m["role"] == "system"]
|
||||
assert msgs
|
||||
return msgs[0]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaterializeResources:
|
||||
def test_materialize_creates_files(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "test-skill", "Use the scripts.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hello')")
|
||||
db.create_skill_resource("r2", "s1", "references/api.md", "# API")
|
||||
|
||||
session = _make_session(skill="test-skill")
|
||||
assert session._skill_resources_dir is not None
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
helper = os.path.join(base, "scripts", "helper.py")
|
||||
assert os.path.isfile(helper)
|
||||
with open(helper) as f:
|
||||
assert f.read() == "print('hello')"
|
||||
|
||||
api_md = os.path.join(base, "references", "api.md")
|
||||
assert os.path.isfile(api_md)
|
||||
with open(api_md) as f:
|
||||
assert f.read() == "# API"
|
||||
|
||||
session.close()
|
||||
|
||||
def test_scripts_executable(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "exec-skill", "Run scripts/run.sh")
|
||||
db.create_skill_resource("r1", "s1", "scripts/run.sh", "#!/bin/bash\necho hi")
|
||||
|
||||
session = _make_session(skill="exec-skill")
|
||||
base = session._skill_resources_dir
|
||||
run_sh = os.path.join(base, "scripts", "run.sh")
|
||||
mode = os.stat(run_sh).st_mode
|
||||
assert mode & stat.S_IXUSR # owner execute
|
||||
session.close()
|
||||
|
||||
def test_non_scripts_not_executable(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "ref-skill", "Read references/guide.md")
|
||||
db.create_skill_resource("r1", "s1", "references/guide.md", "# Guide")
|
||||
|
||||
session = _make_session(skill="ref-skill")
|
||||
base = session._skill_resources_dir
|
||||
guide = os.path.join(base, "references", "guide.md")
|
||||
mode = os.stat(guide).st_mode
|
||||
assert not (mode & stat.S_IXUSR) # not executable
|
||||
session.close()
|
||||
|
||||
def test_cleanup_on_close(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "cleanup-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/a.py", "code")
|
||||
|
||||
session = _make_session(skill="cleanup-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
session.close()
|
||||
assert not os.path.exists(base)
|
||||
assert session._skill_resources_dir is None
|
||||
|
||||
def test_cleanup_on_skill_switch(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "skill-a", "Skill A")
|
||||
db.create_skill_resource("r1", "s1", "scripts/a.py", "code_a")
|
||||
_create_skill(db, "s2", "skill-b", "Skill B")
|
||||
db.create_skill_resource("r2", "s2", "scripts/b.py", "code_b")
|
||||
|
||||
session = _make_session(skill="skill-a")
|
||||
dir_a = session._skill_resources_dir
|
||||
assert os.path.isfile(os.path.join(dir_a, "scripts", "a.py"))
|
||||
|
||||
session.set_skill("skill-b")
|
||||
dir_b = session._skill_resources_dir
|
||||
assert dir_b != dir_a
|
||||
assert not os.path.exists(dir_a)
|
||||
assert os.path.isfile(os.path.join(dir_b, "scripts", "b.py"))
|
||||
|
||||
session.close()
|
||||
|
||||
def test_cleanup_on_skill_clear(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "clear-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
|
||||
|
||||
session = _make_session(skill="clear-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
session.set_skill(None)
|
||||
assert not os.path.exists(base)
|
||||
assert session._skill_resources_dir is None
|
||||
|
||||
session.close()
|
||||
|
||||
def test_empty_resources_no_dir(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "no-res-skill", "content")
|
||||
# No resources added
|
||||
|
||||
session = _make_session(skill="no-res-skill")
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_no_skill_no_dir(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_path_traversal_rejected(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "traversal-skill", "content")
|
||||
# Inject a malicious path directly into storage
|
||||
db.create_skill_resource("r1", "s1", "../etc/passwd", "bad content")
|
||||
db.create_skill_resource("r2", "s1", "scripts/good.py", "good content")
|
||||
|
||||
session = _make_session(skill="traversal-skill")
|
||||
base = session._skill_resources_dir
|
||||
# The traversal path must not be written inside the resources dir
|
||||
assert not os.path.exists(os.path.join(base, "etc"))
|
||||
# The good resource should still be materialized
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "good.py"))
|
||||
session.close()
|
||||
|
||||
|
||||
class TestSkillResourceEnv:
|
||||
def test_env_with_resources(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "env-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/tool.py", "code")
|
||||
|
||||
session = _make_session(skill="env-skill")
|
||||
env = session._skill_resource_env()
|
||||
assert env["SKILL_RESOURCES_DIR"] == session._skill_resources_dir
|
||||
assert "PATH" in env
|
||||
scripts_dir = os.path.join(session._skill_resources_dir, "scripts")
|
||||
assert env["PATH"].startswith(scripts_dir + ":")
|
||||
session.close()
|
||||
|
||||
def test_env_without_scripts_dir(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "no-scripts-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "references/doc.md", "# Doc")
|
||||
|
||||
session = _make_session(skill="no-scripts-skill")
|
||||
env = session._skill_resource_env()
|
||||
assert "SKILL_RESOURCES_DIR" in env
|
||||
# No scripts/ subdir so PATH should not be overridden
|
||||
assert "PATH" not in env
|
||||
session.close()
|
||||
|
||||
def test_env_empty_when_no_resources(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert session._skill_resource_env() == {}
|
||||
session.close()
|
||||
|
||||
|
||||
class TestSystemMessageHint:
|
||||
def test_hint_present_when_resources_exist(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "hint-skill", "Use the bundled scripts.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/run.py", "code")
|
||||
|
||||
session = _make_session(skill="hint-skill")
|
||||
content = _sys_content(session)
|
||||
assert "$SKILL_RESOURCES_DIR" in content
|
||||
assert "scripts/ are on PATH" in content
|
||||
session.close()
|
||||
|
||||
def test_no_hint_when_no_resources(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "plain-skill", "No resources here.")
|
||||
|
||||
session = _make_session(skill="plain-skill")
|
||||
content = _sys_content(session)
|
||||
assert "SKILL_RESOURCES_DIR" not in content
|
||||
session.close()
|
||||
|
||||
|
||||
class TestMaterializeEdgeCases:
|
||||
def test_all_resources_rejected_no_dir(self, tmp_db):
|
||||
"""When every resource fails path validation, no temp dir is left."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "all-bad", "content")
|
||||
db.create_skill_resource("r1", "s1", "../escape", "bad")
|
||||
db.create_skill_resource("r2", "s1", "/absolute", "bad")
|
||||
|
||||
session = _make_session(skill="all-bad")
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_dot_path_rejected(self, tmp_db):
|
||||
"""A bare '.' path is rejected rather than crashing."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "dot-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", ".", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="dot-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
|
||||
session.close()
|
||||
|
||||
def test_empty_path_rejected(self, tmp_db):
|
||||
"""An empty string path is rejected."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "empty-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="empty-skill")
|
||||
assert session._skill_resources_dir is not None
|
||||
session.close()
|
||||
|
||||
def test_nested_traversal_rejected(self, tmp_db):
|
||||
"""Traversal hidden inside a valid prefix is still caught."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "nested-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/../../../etc/passwd", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="nested-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert not os.path.exists(os.path.join(base, "etc"))
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
|
||||
session.close()
|
||||
|
||||
def test_double_close_idempotent(self, tmp_db):
|
||||
"""Calling close() twice does not raise."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "double-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
|
||||
|
||||
session = _make_session(skill="double-skill")
|
||||
session.close()
|
||||
session.close() # must not raise
|
||||
|
||||
|
||||
class TestPreflightValidation:
|
||||
def test_missing_resource_warns(self, tmp_db):
|
||||
"""Skill content references a script not in resources."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "warn-skill", "Run scripts/missing.py to start.")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="warn-skill")
|
||||
ui.on_info.assert_called_once()
|
||||
msg = ui.on_info.call_args[0][0]
|
||||
assert "scripts/missing.py" in msg
|
||||
assert "warn-skill" in msg
|
||||
session.close()
|
||||
|
||||
def test_all_resources_present_no_warn(self, tmp_db):
|
||||
"""No warning when all referenced paths are bundled."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "ok-skill", "Run scripts/helper.py for help.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hi')")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="ok-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_no_references_no_warn(self, tmp_db):
|
||||
"""Skill content with no resource paths triggers no validation warning."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "plain-skill", "Just a plain skill with no paths.")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="plain-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_multiple_missing_warns_once(self, tmp_db):
|
||||
"""Multiple missing resources produce a single warning listing all."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"multi-skill",
|
||||
"Use scripts/a.py and scripts/b.sh to process references/guide.md",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="multi-skill")
|
||||
ui.on_info.assert_called_once()
|
||||
msg = ui.on_info.call_args[0][0]
|
||||
assert "3 resource(s)" in msg
|
||||
assert "scripts/a.py" in msg
|
||||
assert "scripts/b.sh" in msg
|
||||
assert "references/guide.md" in msg
|
||||
session.close()
|
||||
|
||||
def test_validation_skipped_no_skill(self, tmp_db):
|
||||
"""No crash or warning when no skill is active."""
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui)
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_json_extension_not_truncated(self, tmp_db):
|
||||
"""assets/config.json should match as .json, not .js."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "json-skill", "Load assets/config.json for settings.")
|
||||
db.create_skill_resource("r1", "s1", "assets/config.json", "{}")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="json-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_compound_prefix_not_matched(self, tmp_db):
|
||||
"""'myscripts/tool.py' should not match as 'scripts/tool.py'."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"compound-skill",
|
||||
"The myscripts/tool.py file is unrelated.",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="compound-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_extension_suffix_not_matched(self, tmp_db):
|
||||
"""'scripts/tool.python' should not match as 'scripts/tool.py'."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"suffix-skill",
|
||||
"Run scripts/tool.python to start.",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="suffix-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
+109
-2
@@ -55,8 +55,8 @@ def _make_app(tls_manager):
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
scopes=frozenset({"approve", "service"}),
|
||||
token_source="test",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -124,6 +124,113 @@ def test_delete_cert_not_found(tls_manager):
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ async def test_tls_ca_cert_endpoint(tls_manager):
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -190,7 +190,7 @@ async def test_tls_endpoints_disabled():
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
+19
-10
@@ -730,13 +730,23 @@ class TestWebUIFanOut:
|
||||
ui._enqueue({"type": "content", "text": "hello"}) # should not raise
|
||||
|
||||
def test_enqueue_single_listener(self):
|
||||
"""Single listener receives the event."""
|
||||
"""Single listener receives the event with ws_id stamped."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
q = ui._register_listener()
|
||||
ui._enqueue({"type": "content", "text": "hello"})
|
||||
assert q.get_nowait() == {"type": "content", "text": "hello"}
|
||||
assert q.get_nowait() == {"type": "content", "text": "hello", "ws_id": "test"}
|
||||
|
||||
def test_enqueue_does_not_mutate_input(self):
|
||||
"""_enqueue must not mutate the caller's dict."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
ui._register_listener()
|
||||
original = {"type": "content", "text": "hello"}
|
||||
ui._enqueue(original)
|
||||
assert "ws_id" not in original
|
||||
|
||||
def test_enqueue_multiple_listeners(self):
|
||||
"""All registered listeners receive an identical copy."""
|
||||
@@ -747,12 +757,12 @@ class TestWebUIFanOut:
|
||||
q2 = ui._register_listener()
|
||||
q3 = ui._register_listener()
|
||||
|
||||
event = {"type": "content", "text": "world"}
|
||||
ui._enqueue(event)
|
||||
ui._enqueue({"type": "content", "text": "world"})
|
||||
|
||||
assert q1.get_nowait() == event
|
||||
assert q2.get_nowait() == event
|
||||
assert q3.get_nowait() == event
|
||||
expected = {"type": "content", "text": "world", "ws_id": "test"}
|
||||
assert q1.get_nowait() == expected
|
||||
assert q2.get_nowait() == expected
|
||||
assert q3.get_nowait() == expected
|
||||
|
||||
def test_unregister_stops_delivery(self):
|
||||
"""After unregister, the queue receives no further events."""
|
||||
@@ -784,11 +794,10 @@ class TestWebUIFanOut:
|
||||
assert fast.qsize() == 0
|
||||
|
||||
# Enqueue via fan-out — slow drops (full), fast receives
|
||||
event = {"type": "content", "text": "overflow"}
|
||||
ui._enqueue(event)
|
||||
ui._enqueue({"type": "content", "text": "overflow"})
|
||||
assert slow.qsize() == 500 # still full, overflow dropped
|
||||
assert fast.qsize() == 1
|
||||
assert fast.get_nowait() == event
|
||||
assert fast.get_nowait() == {"type": "content", "text": "overflow", "ws_id": "test"}
|
||||
|
||||
def test_unregister_idempotent(self):
|
||||
"""Double unregister does not raise."""
|
||||
|
||||
@@ -56,11 +56,9 @@
|
||||
# --- Auth (node, console) ---
|
||||
|
||||
[auth]
|
||||
# enabled = true # env: TURNSTONE_AUTH_ENABLED
|
||||
# Auth is always enabled. JWT secret is required.
|
||||
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
|
||||
# env: TURNSTONE_JWT_SECRET
|
||||
# token = "" # Static config token for full access
|
||||
# env: TURNSTONE_AUTH_TOKEN
|
||||
|
||||
# --- Logging (turnstone, node, console) ---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.9.8"
|
||||
__version__ = "0.9.10"
|
||||
|
||||
+13
-18
@@ -265,9 +265,19 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
|
||||
url = f"{console_url}/v1/api/admin/tls/certs"
|
||||
headers = {}
|
||||
token = getattr(args, "auth_token", "") or _get_config_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
# Prefer JWT via ServiceTokenManager when JWT secret is available
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="admin-cli",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="cli",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {mgr.token}"
|
||||
resp = httpx.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -283,20 +293,6 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
|
||||
|
||||
|
||||
def _get_config_token() -> str:
|
||||
"""Try to load auth token from config.toml or environment."""
|
||||
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
cfg = load_config("auth")
|
||||
return str(cfg.get("token", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _discover_console_url() -> str:
|
||||
"""Discover console URL from the services table."""
|
||||
from turnstone.core.storage import get_storage
|
||||
@@ -381,7 +377,6 @@ def main() -> None:
|
||||
|
||||
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
|
||||
p_tlslist.add_argument("--console-url", default="", help="Console URL")
|
||||
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
|
||||
@@ -82,10 +82,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
|
||||
- `POSTGRES_USER` — PostgreSQL username (default: turnstone)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (required for production/cluster)
|
||||
|
||||
### Authentication
|
||||
- `TURNSTONE_AUTH_ENABLED` — Enable auth (`true`/empty)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled)
|
||||
- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth
|
||||
### Authentication (always enabled)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required). All services must share the same secret. \
|
||||
Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
|
||||
### OIDC SSO (optional)
|
||||
- `TURNSTONE_OIDC_ISSUER` — OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
|
||||
@@ -162,8 +161,9 @@ Walk the user through setting up their deployment step by step:
|
||||
(may differ from this wizard's model). Ask for base URL, API key, model name.
|
||||
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
|
||||
PostgreSQL is required for cluster mode.
|
||||
5. **Security**: Recommend enabling auth for any non-local deployment. \
|
||||
Use `generate_secret` for JWT secret, auth token, and Postgres password. \
|
||||
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
|
||||
Use `generate_secret` for JWT secret and Postgres password. \
|
||||
Always set `TURNSTONE_JWT_SECRET` in the .env. \
|
||||
Ask for initial admin username and password. \
|
||||
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
|
||||
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
|
||||
|
||||
@@ -37,10 +37,9 @@ async def _handle_health(request: Request) -> JSONResponse:
|
||||
|
||||
def _check_auth(request: Request) -> JSONResponse | None:
|
||||
"""Validate the request's Authorization header. Returns an error response or None."""
|
||||
auth_token: str = getattr(request.app.state, "auth_token", "")
|
||||
jwt_secret: str = getattr(request.app.state, "jwt_secret", "")
|
||||
|
||||
if not auth_token and not jwt_secret:
|
||||
if not jwt_secret:
|
||||
log.warning("notify.auth_not_configured")
|
||||
return JSONResponse({"error": "authentication not configured"}, status_code=401)
|
||||
|
||||
@@ -50,15 +49,8 @@ def _check_auth(request: Request) -> JSONResponse | None:
|
||||
|
||||
token = header[7:]
|
||||
|
||||
# Static token check
|
||||
if auth_token:
|
||||
import hmac
|
||||
|
||||
if hmac.compare_digest(token, auth_token):
|
||||
return None
|
||||
|
||||
# JWT check
|
||||
if jwt_secret and "." in token:
|
||||
# JWT validation
|
||||
if "." in token:
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt
|
||||
|
||||
result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL)
|
||||
@@ -178,7 +170,6 @@ def create_channel_app(
|
||||
adapters: dict[str, ChannelAdapter],
|
||||
storage: StorageBackend,
|
||||
*,
|
||||
auth_token: str = "",
|
||||
jwt_secret: str = "",
|
||||
) -> Starlette:
|
||||
"""Create the channel gateway HTTP application."""
|
||||
@@ -195,7 +186,6 @@ def create_channel_app(
|
||||
)
|
||||
app.state.adapters = adapters
|
||||
app.state.storage = storage
|
||||
app.state.auth_token = auth_token
|
||||
app.state.jwt_secret = jwt_secret
|
||||
return app
|
||||
|
||||
|
||||
@@ -72,13 +72,6 @@ def main() -> None:
|
||||
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
|
||||
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
|
||||
|
||||
# -- Auth ----------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_CHANNEL_AUTH_TOKEN", ""),
|
||||
help="Static auth token for /v1/api/notify (default: $TURNSTONE_CHANNEL_AUTH_TOKEN)",
|
||||
)
|
||||
|
||||
# -- Workstream defaults -------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
@@ -121,7 +114,6 @@ def main() -> None:
|
||||
)
|
||||
|
||||
# -- Auth config ---------------------------------------------------------
|
||||
auth_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "") or args.auth_token
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
|
||||
# Prefer auto-rotating service JWTs when jwt_secret is available.
|
||||
@@ -132,7 +124,7 @@ def main() -> None:
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
_scopes = frozenset({"read", "write", "approve"})
|
||||
_scopes = frozenset({"read", "write", "approve", "service"})
|
||||
_console_mgr = ServiceTokenManager(
|
||||
user_id="channel-gateway",
|
||||
scopes=_scopes,
|
||||
@@ -151,7 +143,6 @@ def main() -> None:
|
||||
)
|
||||
_console_token_factory = lambda: _console_mgr.token # noqa: E731
|
||||
_server_token_factory = lambda: _server_mgr.token # noqa: E731
|
||||
auth_token = "" # don't also pass static token
|
||||
|
||||
server_url: str = args.server_url
|
||||
console_url: str = args.console_url
|
||||
@@ -242,7 +233,6 @@ def main() -> None:
|
||||
config,
|
||||
server_url,
|
||||
storage,
|
||||
api_token=auth_token,
|
||||
console_url=console_url,
|
||||
console_token_factory=_console_token_factory,
|
||||
server_token_factory=_server_token_factory,
|
||||
@@ -253,7 +243,6 @@ def main() -> None:
|
||||
channel_app = create_channel_app(
|
||||
adapters, # type: ignore[arg-type]
|
||||
storage,
|
||||
auth_token=auth_token,
|
||||
jwt_secret=jwt_secret,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ class TurnstoneBot:
|
||||
server_token_factory=server_token_factory,
|
||||
)
|
||||
|
||||
self._commands_synced: bool = False
|
||||
self._subscribed_ws: set[str] = set()
|
||||
self._sse_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._streaming: dict[str, StreamingMessage] = {}
|
||||
@@ -204,12 +205,17 @@ class TurnstoneBot:
|
||||
# response message can be re-tracked for multi-turn DM conversations.
|
||||
self._notify_reply_channels: dict[str, tuple[discord.abc.Messageable, str]] = {}
|
||||
|
||||
# Shared HTTP client for SSE connections (long-lived, no timeout).
|
||||
# Shared HTTP client for SSE connections.
|
||||
# Read timeout detects half-open connections (server sends ping=5s
|
||||
# keepalives, so 90s is very conservative).
|
||||
# Token factory provides auto-rotating JWTs; static token is fallback.
|
||||
headers: dict[str, str] = {}
|
||||
if api_token and not server_token_factory:
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
self._http_client = httpx.AsyncClient(headers=headers, timeout=None)
|
||||
self._http_client = httpx.AsyncClient(
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(connect=10.0, read=90.0, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
@@ -230,6 +236,10 @@ class TurnstoneBot:
|
||||
async def on_ready() -> None:
|
||||
await self._on_ready()
|
||||
|
||||
@self._bot.event
|
||||
async def on_resumed() -> None:
|
||||
await self._on_resumed()
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _setup_hook(self) -> None:
|
||||
@@ -247,23 +257,57 @@ class TurnstoneBot:
|
||||
log.info("discord.setup_hook_complete")
|
||||
|
||||
async def _on_ready(self) -> None:
|
||||
"""Sync slash commands and recover existing routes."""
|
||||
"""Sync slash commands (once) and recover existing routes."""
|
||||
import discord
|
||||
|
||||
bot = self._bot
|
||||
log.info("discord.ready", user=str(bot.user), guild_count=len(bot.guilds))
|
||||
|
||||
if self.config.guild_id:
|
||||
guild = discord.Object(id=self.config.guild_id)
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
await bot.tree.sync(guild=guild)
|
||||
log.info("discord.commands_synced", guild_id=self.config.guild_id)
|
||||
else:
|
||||
await bot.tree.sync()
|
||||
log.info("discord.commands_synced_global")
|
||||
if not self._commands_synced:
|
||||
if self.config.guild_id:
|
||||
guild = discord.Object(id=self.config.guild_id)
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
await bot.tree.sync(guild=guild)
|
||||
log.info("discord.commands_synced", guild_id=self.config.guild_id)
|
||||
else:
|
||||
await bot.tree.sync()
|
||||
log.info("discord.commands_synced_global")
|
||||
self._commands_synced = True
|
||||
|
||||
self._purge_dead_sse_tasks("ready")
|
||||
await self._recover_routes()
|
||||
|
||||
async def _on_resumed(self) -> None:
|
||||
"""Recover dead SSE tasks after a gateway session resume.
|
||||
|
||||
Unlike ``on_ready``, ``on_resumed`` fires when discord.py resumes
|
||||
an existing session after a brief disconnect — ``on_ready`` is NOT
|
||||
called in that case. Any SSE listener tasks that died during the
|
||||
blip need to be cleaned up and re-subscribed.
|
||||
"""
|
||||
self._purge_dead_sse_tasks("resumed")
|
||||
await self._recover_routes()
|
||||
|
||||
def _purge_dead_sse_tasks(self, trigger: str) -> None:
|
||||
"""Remove completed/failed SSE tasks so they can be re-subscribed."""
|
||||
dead = [ws_id for ws_id, task in self._sse_tasks.items() if task.done()]
|
||||
for ws_id in dead:
|
||||
task = self._sse_tasks.pop(ws_id)
|
||||
self._subscribed_ws.discard(ws_id)
|
||||
# Retrieve exception to suppress "Task exception was never
|
||||
# retrieved" warnings and log the underlying failure.
|
||||
if not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
log.warning(
|
||||
"discord.sse_task_failed",
|
||||
trigger=trigger,
|
||||
ws_id=ws_id,
|
||||
error=str(exc),
|
||||
)
|
||||
if dead:
|
||||
log.info("discord.purged_dead_tasks", trigger=trigger, count=len(dead), ws_ids=dead)
|
||||
|
||||
async def _recover_routes(self) -> None:
|
||||
"""Re-subscribe to event channels for existing discord routes.
|
||||
|
||||
@@ -361,14 +405,15 @@ class TurnstoneBot:
|
||||
"""
|
||||
import httpx_sse
|
||||
|
||||
# When routing through the console, connect SSE directly to the
|
||||
# assigned server node (node_url from the create response).
|
||||
node_base = await self.router.get_node_url(ws_id)
|
||||
url = f"{node_base}/v1/api/events"
|
||||
delay = _SSE_RECONNECT_DELAY
|
||||
url = "" # set before loop so exception handlers can reference it
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Re-resolve node URL on each attempt so reconnects pick up
|
||||
# changes after bot restarts or router cache expiry.
|
||||
node_base = await self.router.get_node_url(ws_id)
|
||||
url = f"{node_base}/v1/api/events"
|
||||
# Refresh auth header per-connection (token may have rotated)
|
||||
sse_headers: dict[str, str] | None = None
|
||||
if self._token_factory is not None:
|
||||
@@ -392,7 +437,13 @@ class TurnstoneBot:
|
||||
ws_id=ws_id,
|
||||
status=status,
|
||||
)
|
||||
# Fall through to backoff/retry for transient errors.
|
||||
# Don't try to parse a non-SSE error body —
|
||||
# fall through to backoff/retry below.
|
||||
raise httpx.HTTPStatusError(
|
||||
f"SSE upstream {status}",
|
||||
request=event_source.response.request,
|
||||
response=event_source.response,
|
||||
)
|
||||
delay = _SSE_RECONNECT_DELAY # reset on successful connect
|
||||
async for sse in event_source.aiter_sse():
|
||||
if sse.event == "message" or not sse.event:
|
||||
@@ -406,12 +457,27 @@ class TurnstoneBot:
|
||||
)
|
||||
continue
|
||||
event = ServerEvent.from_dict(data)
|
||||
await self._on_ws_event(ws_id, thread, event)
|
||||
try:
|
||||
await self._on_ws_event(ws_id, thread, event)
|
||||
except Exception:
|
||||
# Discord API failures (rate limits, outages)
|
||||
# must not kill the SSE connection.
|
||||
log.warning(
|
||||
"discord.event_dispatch_failed",
|
||||
ws_id=ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
pass # already logged above; fall through to backoff
|
||||
except httpx.RemoteProtocolError:
|
||||
# Server closed connection (normal on stream_end or shutdown).
|
||||
log.debug("discord.sse_remote_closed", ws_id=ws_id)
|
||||
except asyncio.CancelledError:
|
||||
return # unsubscribe or shutdown
|
||||
except httpx.ReadTimeout:
|
||||
# No data received within read timeout — likely a half-open
|
||||
# connection. Reconnect to recover.
|
||||
log.info("discord.sse_read_timeout", ws_id=ws_id)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
||||
log.warning(
|
||||
"discord.sse_connect_failed",
|
||||
|
||||
+14
-9
@@ -633,7 +633,7 @@ def _handle_ws_command(
|
||||
# ─── Cluster commands ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token: str = "") -> None:
|
||||
def _handle_cluster_command(cmd_line: str, console_url: str | None) -> None:
|
||||
"""Handle /cluster subcommands querying the turnstone-console API."""
|
||||
import httpx
|
||||
|
||||
@@ -642,8 +642,18 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
|
||||
return
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
|
||||
|
||||
_cluster_token_mgr = ServiceTokenManager(
|
||||
user_id="cli",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="cli",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {_cluster_token_mgr.token}"
|
||||
|
||||
parts = cmd_line.strip().split()
|
||||
sub = parts[1] if len(parts) > 1 else "status"
|
||||
@@ -968,11 +978,6 @@ def main() -> None:
|
||||
default=None,
|
||||
help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
|
||||
help="Bearer token for authenticating to turnstone services (default: $TURNSTONE_AUTH_TOKEN)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mcp-config",
|
||||
default=None,
|
||||
@@ -1247,7 +1252,7 @@ def main() -> None:
|
||||
continue
|
||||
|
||||
if user_input.startswith("/cluster"):
|
||||
_handle_cluster_command(user_input, args.console_url, args.auth_token)
|
||||
_handle_cluster_command(user_input, args.console_url)
|
||||
continue
|
||||
|
||||
active = manager.get_active()
|
||||
|
||||
@@ -59,7 +59,6 @@ class ClusterCollector:
|
||||
storage: StorageBackend,
|
||||
discovery_interval: float = 60.0,
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
@@ -74,10 +73,6 @@ class ClusterCollector:
|
||||
self._console_metrics = console_metrics
|
||||
self._tls_verify = tls_verify
|
||||
self._tls_cert = tls_cert
|
||||
# Static auth header — only used when no token_manager is present.
|
||||
self._static_auth: dict[str, str] | None = None
|
||||
if auth_token and token_manager is None:
|
||||
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._nodes: dict[str, NodeSnapshot] = {}
|
||||
@@ -165,7 +160,7 @@ class ClusterCollector:
|
||||
"""Build auth headers for the current SSE connection."""
|
||||
if self._token_manager is not None:
|
||||
return {"Authorization": f"Bearer {self._token_manager.token}"}
|
||||
return dict(self._static_auth) if self._static_auth else {}
|
||||
return {}
|
||||
|
||||
# -- SSE manager ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class Rebalancer:
|
||||
lock_ttl: int = 120,
|
||||
eager_migrate: bool = False,
|
||||
api_token: str = "",
|
||||
token_manager: Any = None,
|
||||
) -> None:
|
||||
self._storage = storage
|
||||
self._router = router
|
||||
@@ -75,6 +76,7 @@ class Rebalancer:
|
||||
self._lock_ttl = lock_ttl
|
||||
self._eager_migrate = eager_migrate
|
||||
self._api_token = api_token
|
||||
self._token_manager = token_manager
|
||||
self._stop_event = threading.Event()
|
||||
self._trigger_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
@@ -530,7 +532,9 @@ class Rebalancer:
|
||||
return 0
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if self._api_token:
|
||||
if self._token_manager is not None:
|
||||
headers["Authorization"] = f"Bearer {self._token_manager.token}"
|
||||
elif self._api_token:
|
||||
headers["Authorization"] = f"Bearer {self._api_token}"
|
||||
|
||||
migrated = 0
|
||||
|
||||
+31
-74
@@ -162,19 +162,11 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]:
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Fallback: service identity (no user context).
|
||||
# When auth is disabled on the console, auth_result is None, so all proxied
|
||||
# requests use the full-privilege service identity. This is safe only when
|
||||
# the upstream server also has auth disabled.
|
||||
# Fallback: service identity via ServiceTokenManager.
|
||||
mgr = getattr(request.app.state, "proxy_token_mgr", None)
|
||||
if mgr is not None:
|
||||
return dict(mgr.bearer_header)
|
||||
|
||||
# Fall back to static proxy_auth_token (e.g. from --auth-token)
|
||||
static_token = getattr(request.app.state, "proxy_auth_token", "")
|
||||
if static_token:
|
||||
return {"Authorization": f"Bearer {static_token}"}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@@ -5580,7 +5572,7 @@ async def admin_list_prompt_policies(request: Request) -> JSONResponse:
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.prompt_policies")
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -5599,7 +5591,7 @@ async def admin_create_prompt_policy(request: Request) -> JSONResponse:
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.prompt_policies")
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -5656,7 +5648,7 @@ async def admin_get_prompt_policy(request: Request) -> JSONResponse:
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.prompt_policies")
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -5676,7 +5668,7 @@ async def admin_update_prompt_policy(request: Request) -> JSONResponse:
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.prompt_policies")
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -5729,7 +5721,7 @@ async def admin_delete_prompt_policy(request: Request) -> JSONResponse:
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.prompt_policies")
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -5924,10 +5916,8 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None:
|
||||
def create_app(
|
||||
*,
|
||||
collector: ClusterCollector,
|
||||
auth_config: Any,
|
||||
jwt_secret: str = "",
|
||||
auth_storage: Any = None,
|
||||
proxy_auth_token: str = "",
|
||||
proxy_token_mgr: Any = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
tls_manager: Any = None,
|
||||
@@ -6265,10 +6255,8 @@ def create_app(
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
app.state.collector = collector
|
||||
app.state.auth_config = auth_config
|
||||
app.state.jwt_secret = jwt_secret
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.proxy_auth_token = proxy_auth_token
|
||||
app.state.proxy_token_mgr = proxy_token_mgr
|
||||
app.state.console_url = console_url
|
||||
app.state.tls_manager = tls_manager
|
||||
@@ -6301,7 +6289,7 @@ def create_app(
|
||||
scheduler = TaskScheduler(
|
||||
collector=collector,
|
||||
storage=auth_storage,
|
||||
api_token=proxy_auth_token,
|
||||
api_token="",
|
||||
token_manager=proxy_token_mgr,
|
||||
)
|
||||
app.state.scheduler = scheduler
|
||||
@@ -6351,12 +6339,6 @@ def main() -> None:
|
||||
from turnstone.core.log import add_log_args
|
||||
|
||||
add_log_args(parser)
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
|
||||
help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)",
|
||||
)
|
||||
|
||||
from turnstone.core.config import add_config_arg, apply_config
|
||||
|
||||
add_config_arg(parser)
|
||||
@@ -6367,10 +6349,9 @@ def main() -> None:
|
||||
|
||||
configure_logging_from_args(args, "console")
|
||||
|
||||
from turnstone.core.auth import load_auth_config, load_jwt_secret
|
||||
from turnstone.core.auth import load_jwt_secret
|
||||
|
||||
auth_config = load_auth_config()
|
||||
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
|
||||
jwt_secret = load_jwt_secret()
|
||||
|
||||
# Initialize storage early — the collector needs it for service discovery.
|
||||
auth_storage = None
|
||||
@@ -6399,38 +6380,23 @@ def main() -> None:
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
# If no explicit auth token is provided, use a ServiceTokenManager
|
||||
# so collector JWTs auto-rotate. A shared JWT secret is required for
|
||||
# multi-service deployments — ephemeral secrets differ per process.
|
||||
collector_token = args.auth_token
|
||||
collector_token_mgr = None
|
||||
if not collector_token:
|
||||
_jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
if not _jwt_secret:
|
||||
log.error(
|
||||
"TURNSTONE_JWT_SECRET is not set and no --auth-token provided. "
|
||||
"The console cannot authenticate to server nodes. Set TURNSTONE_JWT_SECRET "
|
||||
"to a shared secret (at least 32 characters) or pass --auth-token."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=_jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
|
||||
router = ConsoleRouter(storage=auth_storage)
|
||||
console_metrics = ConsoleMetrics()
|
||||
|
||||
collector = ClusterCollector(
|
||||
storage=auth_storage,
|
||||
auth_token=collector_token if collector_token_mgr is None else "",
|
||||
token_manager=collector_token_mgr,
|
||||
router=router,
|
||||
console_metrics=console_metrics,
|
||||
@@ -6439,22 +6405,15 @@ def main() -> None:
|
||||
|
||||
_load_static()
|
||||
|
||||
# If no explicit auth token is provided, use a ServiceTokenManager
|
||||
# so proxy JWTs auto-rotate.
|
||||
proxy_token = args.auth_token
|
||||
proxy_token_mgr = None
|
||||
if not proxy_token and jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.proxy_token_manager_created")
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.proxy_token_manager_created")
|
||||
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
|
||||
@@ -6541,7 +6500,8 @@ def main() -> None:
|
||||
threshold=_rcs.get("rebalancer.threshold", 0.10),
|
||||
vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150),
|
||||
eager_migrate=_rcs.get("rebalancer.eager_migrate", False),
|
||||
api_token=proxy_token if proxy_token_mgr is None else "",
|
||||
api_token="",
|
||||
token_manager=proxy_token_mgr,
|
||||
)
|
||||
log.info("rebalancer.configured")
|
||||
except Exception:
|
||||
@@ -6549,10 +6509,8 @@ def main() -> None:
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=auth_storage,
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
tls_manager=tls_mgr,
|
||||
@@ -6563,8 +6521,7 @@ def main() -> None:
|
||||
)
|
||||
|
||||
log.info("Console starting on %s", console_url)
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
log.info("Auth: enabled (JWT)")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
import uvicorn
|
||||
|
||||
+31
-130
@@ -1,15 +1,12 @@
|
||||
"""Bearer token authentication and authorization for turnstone HTTP servers.
|
||||
|
||||
Supports three token types:
|
||||
Supports two token types:
|
||||
|
||||
1. **Config-file tokens** — static tokens in ``config.toml`` or the
|
||||
``TURNSTONE_AUTH_TOKEN`` env var. Validated in-memory via
|
||||
``hmac.compare_digest``. Map to scopes via their role.
|
||||
2. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256
|
||||
1. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256
|
||||
hashes. Exchanged for JWTs via ``/api/auth/login``.
|
||||
3. **JWTs** — short-lived session tokens issued after API token validation.
|
||||
Validated locally via shared HMAC-SHA256 secret. Contain user_id and
|
||||
scopes in claims.
|
||||
2. **JWTs** — short-lived session tokens issued after login or by
|
||||
:class:`ServiceTokenManager`. Validated locally via shared HMAC-SHA256
|
||||
secret. Contain user_id and scopes in claims.
|
||||
|
||||
Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``,
|
||||
``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are
|
||||
@@ -19,7 +16,6 @@ always accessible without authentication.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -28,7 +24,7 @@ import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -56,7 +52,7 @@ JWT_AUD_CONSOLE = "turnstone-console"
|
||||
JWT_AUD_CHANNEL = "turnstone-channel"
|
||||
_MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
|
||||
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve"})
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
USERNAME_MAX_LEN = 64
|
||||
@@ -72,16 +68,12 @@ def is_valid_username(username: str) -> bool:
|
||||
|
||||
|
||||
# Hierarchical: each scope implies all lower scopes.
|
||||
# "service" is a superset that grants full access + bypasses RBAC permission checks.
|
||||
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"write": frozenset({"read", "write"}),
|
||||
"approve": frozenset({"read", "write", "approve"}),
|
||||
}
|
||||
|
||||
# Map old role names to scope sets.
|
||||
_ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"full": frozenset({"read", "write", "approve"}),
|
||||
"service": frozenset({"read", "write", "approve", "service"}),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,7 +98,7 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
for perm in permissions:
|
||||
if perm in VALID_SCOPES:
|
||||
if perm in VALID_SCOPES and perm != "service":
|
||||
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
|
||||
# Any admin.* permission requires access to admin endpoints → approve scope
|
||||
if any(p.startswith("admin.") for p in permissions):
|
||||
@@ -120,15 +112,14 @@ def require_permission(request: Request, permission: str) -> JSONResponse | None
|
||||
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
|
||||
|
||||
Call from admin handlers after the middleware scope check passes.
|
||||
Config-file tokens (no user_id) are treated as full-access.
|
||||
Service tokens (scope ``service``) bypass permission checks.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
if auth_result is None:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
# Config-file tokens (no user_id) are treated as full-access
|
||||
if not auth_result.user_id:
|
||||
if auth_result.has_scope("service"):
|
||||
return None
|
||||
if auth_result.has_permission(permission):
|
||||
return None
|
||||
@@ -200,9 +191,9 @@ def _strip_version_prefix(path: str) -> str:
|
||||
class AuthResult:
|
||||
"""Result of successful authentication."""
|
||||
|
||||
user_id: str # empty string for config-file tokens
|
||||
user_id: str
|
||||
scopes: frozenset[str]
|
||||
token_source: str # "config", "jwt", "database"
|
||||
token_source: str # "jwt", "database", "password", or service origin (e.g. "console", "cli")
|
||||
permissions: frozenset[str] = frozenset()
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
@@ -214,28 +205,6 @@ class AuthResult:
|
||||
return permission in self.permissions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthConfig (unchanged from before — static config-file tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Auth configuration loaded once at startup (not modified after creation)."""
|
||||
|
||||
enabled: bool = False
|
||||
tokens: dict[str, str] = field(default_factory=dict) # token_value → role
|
||||
|
||||
def check(self, token: str | None) -> str | None:
|
||||
"""Return the role for a valid config token, or *None*."""
|
||||
if not token:
|
||||
return None
|
||||
for known_token, role in self.tokens.items():
|
||||
if hmac.compare_digest(token, known_token):
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token generation and hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -303,7 +272,11 @@ def parse_scopes(scopes_str: str) -> frozenset[str]:
|
||||
|
||||
|
||||
def load_jwt_secret() -> str:
|
||||
"""Load JWT signing secret from env or config, or auto-generate."""
|
||||
"""Load JWT signing secret from env or config.
|
||||
|
||||
Raises :class:`SystemExit` if no secret is configured. A JWT secret
|
||||
is required for auth, inter-service communication, and session tokens.
|
||||
"""
|
||||
secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if not secret:
|
||||
from turnstone.core.config import load_config
|
||||
@@ -312,18 +285,19 @@ def load_jwt_secret() -> str:
|
||||
secret = str(auth_cfg.get("jwt_secret", "")).strip()
|
||||
|
||||
if not secret:
|
||||
# Auto-generate an ephemeral secret
|
||||
secret = secrets.token_hex(32)
|
||||
log.warning(
|
||||
"No JWT secret configured — using ephemeral secret (tokens will not survive restart)"
|
||||
log.error(
|
||||
"TURNSTONE_JWT_SECRET is required but not set. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||
)
|
||||
return secret
|
||||
raise SystemExit(1)
|
||||
|
||||
if len(secret) < _MIN_SECRET_LENGTH:
|
||||
log.warning(
|
||||
"JWT secret is shorter than %d characters — consider using a stronger secret",
|
||||
log.error(
|
||||
"JWT secret must be at least %d characters. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"',
|
||||
_MIN_SECRET_LENGTH,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return secret
|
||||
|
||||
|
||||
@@ -397,62 +371,6 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_auth_config() -> AuthConfig:
|
||||
"""Build :class:`AuthConfig` from ``config.toml`` ``[auth]`` + env vars.
|
||||
|
||||
Auth is **enabled by default**. Set ``[auth] enabled = false`` or
|
||||
``TURNSTONE_AUTH_ENABLED=0`` to disable.
|
||||
|
||||
Config format::
|
||||
|
||||
[auth]
|
||||
enabled = false # opt out
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_abc123"
|
||||
role = "full"
|
||||
|
||||
Environment variables:
|
||||
|
||||
- ``TURNSTONE_AUTH_ENABLED=0`` — disables auth
|
||||
- ``TURNSTONE_AUTH_ENABLED=1`` — enables auth (default)
|
||||
- ``TURNSTONE_AUTH_TOKEN=<token>`` — registers a single full-access token
|
||||
"""
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
auth_cfg = load_config("auth")
|
||||
enabled = bool(auth_cfg.get("enabled", True))
|
||||
tokens: dict[str, str] = {}
|
||||
|
||||
# Tokens from config file (TOML array-of-tables)
|
||||
for entry in auth_cfg.get("tokens", []):
|
||||
value = entry.get("value", "") if isinstance(entry, dict) else ""
|
||||
role = entry.get("role", "read") if isinstance(entry, dict) else ""
|
||||
if value and role in ("read", "full"):
|
||||
tokens[value] = role
|
||||
|
||||
# Environment variable overrides
|
||||
env_enabled = os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip().lower()
|
||||
if env_enabled in ("1", "true", "yes"):
|
||||
enabled = True
|
||||
elif env_enabled in ("0", "false", "no"):
|
||||
enabled = False
|
||||
|
||||
env_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "").strip()
|
||||
if env_token:
|
||||
tokens[env_token] = "full"
|
||||
|
||||
if enabled and not tokens:
|
||||
log.info("Auth enabled (no config tokens — use /api/auth/setup or turnstone-admin)")
|
||||
|
||||
return AuthConfig(enabled=enabled, tokens=tokens)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -529,7 +447,6 @@ def _extract_proxied_path(normalized: str) -> str | None:
|
||||
|
||||
|
||||
def check_request(
|
||||
auth_config: AuthConfig,
|
||||
method: str,
|
||||
path: str,
|
||||
auth_header: str | None,
|
||||
@@ -539,20 +456,16 @@ def check_request(
|
||||
jwt_audience: str = "",
|
||||
storage: Any = None,
|
||||
) -> tuple[bool, int, str, AuthResult | None]:
|
||||
"""Validate a request against the auth config.
|
||||
"""Validate a request.
|
||||
|
||||
Checks ``Authorization: Bearer <token>`` first, then falls back to the
|
||||
``turnstone_auth`` cookie. Token types are auto-detected:
|
||||
|
||||
- Contains ``.`` → JWT (validated with *jwt_secret*)
|
||||
- Starts with ``ts_`` → API token (looked up in *storage* by hash)
|
||||
- Otherwise → config-file token (hmac check)
|
||||
|
||||
Returns ``(allowed, status_code, message, auth_result)``.
|
||||
"""
|
||||
if not auth_config.enabled:
|
||||
return True, 200, "", None
|
||||
|
||||
if is_public_path(path):
|
||||
return True, 200, "", None
|
||||
|
||||
@@ -566,7 +479,7 @@ def check_request(
|
||||
|
||||
# Authenticate
|
||||
result = _authenticate_token(
|
||||
raw_token, auth_config, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
|
||||
raw_token, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
|
||||
)
|
||||
if result is None:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
@@ -581,7 +494,6 @@ def check_request(
|
||||
|
||||
def _authenticate_token(
|
||||
token: str,
|
||||
auth_config: AuthConfig,
|
||||
*,
|
||||
jwt_secret: str = "",
|
||||
jwt_audience: str = "",
|
||||
@@ -601,12 +513,6 @@ def _authenticate_token(
|
||||
if token.startswith(TOKEN_PREFIX) and storage is not None:
|
||||
return _authenticate_api_token(token, storage)
|
||||
|
||||
# 3. Config-file token (hmac comparison)
|
||||
role = auth_config.check(token)
|
||||
if role is not None:
|
||||
scopes = _ROLE_TO_SCOPES.get(role, frozenset({"read"}))
|
||||
return AuthResult(user_id="", scopes=scopes, token_source="config")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -852,7 +758,6 @@ class AuthMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
method = request.method
|
||||
@@ -860,7 +765,6 @@ class AuthMiddleware:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
cookie_header = request.headers.get("Cookie")
|
||||
allowed, status, msg, auth_result = check_request(
|
||||
auth_config,
|
||||
method,
|
||||
path,
|
||||
auth_header,
|
||||
@@ -904,7 +808,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
|
||||
@@ -955,7 +858,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
elif body.get("token"):
|
||||
result = _authenticate_token(
|
||||
body["token"],
|
||||
auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
jwt_audience=audience,
|
||||
storage=storage,
|
||||
@@ -1011,7 +913,6 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
"""Shared ``GET /api/auth/status`` handler — login UI state detection."""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
|
||||
has_users = False
|
||||
@@ -1027,9 +928,9 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
oidc_enabled = bool(oidc_config and oidc_config.enabled)
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"auth_enabled": auth_config.enabled,
|
||||
"auth_enabled": True,
|
||||
"has_users": has_users,
|
||||
"setup_required": auth_config.enabled and not has_users,
|
||||
"setup_required": not has_users,
|
||||
}
|
||||
if oidc_enabled and oidc_config is not None:
|
||||
resp["oidc_enabled"] = True
|
||||
|
||||
@@ -66,7 +66,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
|
||||
"ANTHROPIC_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"TURNSTONE_JWT_SECRET",
|
||||
"TURNSTONE_AUTH_TOKEN",
|
||||
"TURNSTONE_DISCORD_TOKEN",
|
||||
"TURNSTONE_GITHUB_TOKEN",
|
||||
"TURNSTONE_OIDC_CLIENT_SECRET",
|
||||
|
||||
@@ -69,7 +69,7 @@ def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
_WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
|
||||
|
||||
# Tool search: server-side BM25 tool discovery for deferred tools
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25_20251119"
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25"
|
||||
|
||||
# -- model capabilities -------------------------------------------------------
|
||||
|
||||
@@ -205,7 +205,7 @@ class AnthropicProvider:
|
||||
result.append({**tool, "defer_loading": True})
|
||||
else:
|
||||
result.append(tool)
|
||||
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": "tool_search"})
|
||||
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": _TOOL_SEARCH_TOOL_TYPE})
|
||||
return result
|
||||
|
||||
# -- shared param logic --------------------------------------------------
|
||||
|
||||
+154
-2
@@ -19,6 +19,7 @@ import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -161,6 +162,13 @@ _IMAGE_SIZE_CAP: int = 4 * 1024 * 1024
|
||||
# Upper bound on total skill content injected into system messages
|
||||
_MAX_SKILL_CONTENT: int = 32768
|
||||
|
||||
# Matches resource paths referenced in skill content (scripts/foo.py, etc.)
|
||||
_RESOURCE_PATH_RE = re.compile(
|
||||
r"(?<![/\w-])(?:scripts|references|assets)/[\w./-]+\."
|
||||
r"(?:json|yaml|yml|toml|cfg|ini|py|sh|js|ts|md|txt)"
|
||||
r"(?=[\s)\]}'\"`,;:\x60]|$)"
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
|
||||
|
||||
@@ -417,6 +425,7 @@ class ChatSession:
|
||||
self._skill_name: str | None = skill
|
||||
self._skill_content: str | None = None
|
||||
self._skill_resources: dict[str, str] = {}
|
||||
self._skill_resources_dir: str | None = None
|
||||
self._load_skills()
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
@@ -583,6 +592,8 @@ class ChatSession:
|
||||
else:
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
self._materialize_skill_resources()
|
||||
self._validate_skill_resources()
|
||||
|
||||
def set_skill(self, name: str | None) -> None:
|
||||
"""Set or clear the active skill."""
|
||||
@@ -613,6 +624,81 @@ class ChatSession:
|
||||
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
def _cleanup_skill_resources(self) -> None:
|
||||
"""Remove materialized skill resources from disk."""
|
||||
d = self._skill_resources_dir
|
||||
if d is not None:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
self._skill_resources_dir = None
|
||||
|
||||
def _materialize_skill_resources(self) -> None:
|
||||
"""Write skill resources to a temp directory for subprocess access."""
|
||||
self._cleanup_skill_resources()
|
||||
if not self._skill_resources:
|
||||
return
|
||||
base = tempfile.mkdtemp(prefix=f"skill-{self._ws_id[:8]}-")
|
||||
written = 0
|
||||
for rel_path, content in self._skill_resources.items():
|
||||
normed = os.path.normpath(rel_path)
|
||||
if not normed or normed == "." or normed.startswith(("..", "/")):
|
||||
log.warning("skill_resources.bad_path", path=rel_path)
|
||||
continue
|
||||
if ".." in normed.split(os.sep):
|
||||
log.warning("skill_resources.bad_path", path=rel_path)
|
||||
continue
|
||||
full = os.path.join(base, normed)
|
||||
if not os.path.realpath(full).startswith(os.path.realpath(base)):
|
||||
log.warning("skill_resources.path_escape", path=rel_path)
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
if normed.startswith("scripts/"):
|
||||
os.chmod(full, 0o755)
|
||||
written += 1
|
||||
except Exception:
|
||||
log.warning("skill_resources.write_failed", path=rel_path, exc_info=True)
|
||||
if written == 0:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
return
|
||||
self._skill_resources_dir = base
|
||||
log.info(
|
||||
"skill_resources.materialized",
|
||||
dir=base,
|
||||
count=written,
|
||||
)
|
||||
|
||||
def _skill_resource_env(self) -> dict[str, str]:
|
||||
"""Return extra env vars for bash when skill resources are materialized."""
|
||||
if not self._skill_resources_dir:
|
||||
return {}
|
||||
env: dict[str, str] = {"SKILL_RESOURCES_DIR": self._skill_resources_dir}
|
||||
scripts_dir = os.path.join(self._skill_resources_dir, "scripts")
|
||||
if os.path.isdir(scripts_dir):
|
||||
current_path = os.environ.get("PATH")
|
||||
if current_path:
|
||||
env["PATH"] = scripts_dir + os.pathsep + current_path
|
||||
else:
|
||||
env["PATH"] = scripts_dir
|
||||
return env
|
||||
|
||||
def _validate_skill_resources(self) -> None:
|
||||
"""Warn if skill content references resource paths not in skill_resources."""
|
||||
if not self._skill_content or not self._skill_name:
|
||||
return
|
||||
referenced = {os.path.normpath(p) for p in _RESOURCE_PATH_RE.findall(self._skill_content)}
|
||||
if not referenced:
|
||||
return
|
||||
available = {os.path.normpath(p) for p in self._skill_resources}
|
||||
missing = sorted(referenced - available)
|
||||
if missing:
|
||||
log.warning("skill_resources.missing", skill=self._skill_name, paths=missing)
|
||||
self.ui.on_info(
|
||||
f"Skill '{self._skill_name}' references {len(missing)} resource(s) "
|
||||
f"not bundled: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# -- MCP tool refresh ----------------------------------------------------
|
||||
|
||||
def _on_mcp_tools_changed(self) -> None:
|
||||
@@ -747,6 +833,7 @@ class ChatSession:
|
||||
self._mcp_prompt_cb = None
|
||||
if self._watch_runner:
|
||||
self._watch_runner.remove_dispatch_fn(self._ws_id)
|
||||
self._cleanup_skill_resources()
|
||||
|
||||
def _handle_mcp_refresh(self, arg: str) -> None:
|
||||
"""Handle ``/mcp refresh [server]``."""
|
||||
@@ -893,6 +980,13 @@ class ChatSession:
|
||||
self._msg_tokens = [
|
||||
max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages
|
||||
]
|
||||
log.info(
|
||||
"Resuming ws=%s: %d messages, provider=%s, model=%s",
|
||||
ws_id,
|
||||
len(messages),
|
||||
type(self._provider).__name__,
|
||||
self.model,
|
||||
)
|
||||
# Restore persisted config
|
||||
config = load_workstream_config(ws_id)
|
||||
if config:
|
||||
@@ -910,11 +1004,24 @@ class ChatSession:
|
||||
self.context_window = cfg.context_window
|
||||
if not self._manual_tool_truncation:
|
||||
self.tool_truncation = int(cfg.context_window * self._chars_per_token * 0.5)
|
||||
log.info(
|
||||
"Resume: resolved alias=%s → provider=%s, model=%s, ctx=%d",
|
||||
saved_alias,
|
||||
type(self._provider).__name__,
|
||||
model_name,
|
||||
cfg.context_window,
|
||||
)
|
||||
elif saved_model and saved_model != self.model:
|
||||
# No alias or alias no longer in registry — at least set the model name
|
||||
self.model = saved_model
|
||||
self._model_alias = None
|
||||
self._cached_capabilities = None
|
||||
log.warning(
|
||||
"Resume: alias %r not in registry, keeping default provider=%s for model=%s",
|
||||
saved_alias,
|
||||
type(self._provider).__name__,
|
||||
saved_model,
|
||||
)
|
||||
if "temperature" in config:
|
||||
self.temperature = float(config["temperature"])
|
||||
if "reasoning_effort" in config:
|
||||
@@ -1091,6 +1198,12 @@ class ChatSession:
|
||||
"Resource content omitted (total exceeds 8KB). "
|
||||
"Resource files are listed above by path and size."
|
||||
)
|
||||
if self._skill_resources_dir:
|
||||
lines.append(
|
||||
"\nResource files are materialized on disk. "
|
||||
"Scripts in scripts/ are on PATH and can be run by name. "
|
||||
"All files are under $SKILL_RESOURCES_DIR."
|
||||
)
|
||||
lines.append("</skill-resources>")
|
||||
dev_parts.append("\n".join(lines))
|
||||
# Skill catalog: disclose search-activated skills so the model
|
||||
@@ -1275,6 +1388,21 @@ class ChatSession:
|
||||
) -> Iterator[StreamChunk]:
|
||||
"""Attempt a streaming API call with retries on transient errors."""
|
||||
prov = provider or self._provider
|
||||
raw_url = str(getattr(client, "base_url", getattr(client, "_base_url", "?")))
|
||||
safe_url = raw_url.split("?")[0] # strip query params (may contain keys)
|
||||
msg_count = len(msgs)
|
||||
role_counts: dict[str, int] = {}
|
||||
for m in msgs:
|
||||
r = m.get("role", "?")
|
||||
role_counts[r] = role_counts.get(r, 0) + 1
|
||||
log.debug(
|
||||
"API call: provider=%s model=%s base_url=%s msgs=%d roles=%s",
|
||||
type(prov).__name__,
|
||||
model,
|
||||
safe_url,
|
||||
msg_count,
|
||||
role_counts,
|
||||
)
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
self._check_cancelled()
|
||||
@@ -1294,6 +1422,29 @@ class ChatSession:
|
||||
)
|
||||
except Exception as e:
|
||||
ename = type(e).__name__
|
||||
cause_name = (
|
||||
type(e.__cause__).__name__
|
||||
if e.__cause__
|
||||
else (type(e.__context__).__name__ if e.__context__ else "None")
|
||||
)
|
||||
log.warning(
|
||||
"API error (attempt %d/%d): %s (cause=%s) "
|
||||
"provider=%s model=%s base_url=%s msgs=%d",
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES + 1,
|
||||
ename,
|
||||
cause_name,
|
||||
type(prov).__name__,
|
||||
model,
|
||||
safe_url,
|
||||
msg_count,
|
||||
)
|
||||
log.debug(
|
||||
"API error details (attempt %d/%d)",
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES + 1,
|
||||
exc_info=True,
|
||||
)
|
||||
if ename not in prov.retryable_error_names or attempt == self._MAX_RETRIES:
|
||||
raise
|
||||
last_err = e
|
||||
@@ -2184,7 +2335,8 @@ class ChatSession:
|
||||
"""Emit status info via the UI."""
|
||||
if not self._last_usage:
|
||||
return
|
||||
self.ui.on_status(self._last_usage, self.context_window, self.reasoning_effort)
|
||||
usage: dict[str, Any] = {**self._last_usage, "model": self.model}
|
||||
self.ui.on_status(usage, self.context_window, self.reasoning_effort)
|
||||
|
||||
# -- Conversation compaction ------------------------------------------------
|
||||
|
||||
@@ -4338,7 +4490,7 @@ class ChatSession:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
env=scrubbed_env(),
|
||||
env=scrubbed_env(extra=self._skill_resource_env()),
|
||||
)
|
||||
with self._procs_lock:
|
||||
self._active_procs.add(proc)
|
||||
|
||||
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
from turnstone.sdk import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
|
||||
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
|
||||
overview = client.overview()
|
||||
print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}")
|
||||
"""
|
||||
@@ -73,7 +73,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
@@ -961,14 +961,14 @@ class TurnstoneConsole:
|
||||
|
||||
Usage::
|
||||
|
||||
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
|
||||
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
|
||||
overview = client.overview()
|
||||
print(f"Nodes: {overview.nodes}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
|
||||
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
|
||||
ws = client.create_workstream(name="Analysis")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
@@ -432,7 +432,7 @@ class TurnstoneServer:
|
||||
|
||||
Usage::
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
|
||||
ws = client.create_workstream(name="Analysis")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
|
||||
+23
-9
@@ -119,6 +119,11 @@ class WebUI:
|
||||
self._ws_turn_content_size: int = 0
|
||||
|
||||
def _enqueue(self, data: dict[str, Any]) -> None:
|
||||
# Stamp ws_id on every per-workstream event so the client can
|
||||
# validate it belongs to the pane's current workstream.
|
||||
# Shallow copy to avoid mutating caller's dict (e.g. _pending_approval).
|
||||
if "ws_id" not in data:
|
||||
data = {**data, "ws_id": self.ws_id}
|
||||
with self._listeners_lock:
|
||||
snapshot = list(self._listeners)
|
||||
for lq in snapshot:
|
||||
@@ -2096,7 +2101,12 @@ def internal_mcp_reload(request: Request) -> JSONResponse:
|
||||
|
||||
mcp_mgr = MCPClientManager({})
|
||||
mcp_mgr.start()
|
||||
mcp_mgr.set_storage(storage)
|
||||
request.app.state.mcp_client = mcp_mgr
|
||||
# Update shared ref so session_factory sees the new client
|
||||
mcp_ref = getattr(request.app.state, "mcp_ref", None)
|
||||
if mcp_ref is not None:
|
||||
mcp_ref[0] = mcp_mgr
|
||||
|
||||
result = mcp_mgr.reconcile_sync(storage)
|
||||
return JSONResponse({"status": "ok", **result})
|
||||
@@ -2439,12 +2449,12 @@ def create_app(
|
||||
global_listeners: list[queue.Queue[dict[str, Any]]],
|
||||
global_listeners_lock: threading.Lock,
|
||||
skip_permissions: bool,
|
||||
auth_config: Any,
|
||||
jwt_secret: str = "",
|
||||
auth_storage: Any = None,
|
||||
health_monitor: Any = None,
|
||||
rate_limiter: Any = None,
|
||||
mcp_client: Any = None,
|
||||
mcp_ref: list[Any] | None = None,
|
||||
registry: Any = None,
|
||||
idle_timeout: int = 0,
|
||||
node_id: str = "",
|
||||
@@ -2519,12 +2529,12 @@ def create_app(
|
||||
app.state.global_listeners = global_listeners
|
||||
app.state.global_listeners_lock = global_listeners_lock
|
||||
app.state.skip_permissions = skip_permissions
|
||||
app.state.auth_config = auth_config
|
||||
app.state.jwt_secret = jwt_secret
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.health_monitor = health_monitor
|
||||
app.state.rate_limiter = rate_limiter
|
||||
app.state.mcp_client = mcp_client
|
||||
app.state.mcp_ref = mcp_ref
|
||||
app.state.registry = registry
|
||||
app.state.idle_timeout = idle_timeout
|
||||
app.state.node_id = node_id
|
||||
@@ -2751,6 +2761,9 @@ def main() -> None:
|
||||
refresh_interval=config_store.get("mcp.refresh_interval"),
|
||||
storage=_get_storage(),
|
||||
)
|
||||
# Mutable ref so session_factory always sees the latest MCP client,
|
||||
# including ones created by internal_mcp_reload after startup.
|
||||
_mcp_ref: list[Any] = [mcp_client]
|
||||
|
||||
# Backend health monitor with circuit breaker
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
@@ -2861,6 +2874,9 @@ def main() -> None:
|
||||
) -> ChatSession:
|
||||
assert ui is not None
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
# Read MCP client from shared ref — may have been replaced after startup
|
||||
# by internal_mcp_reload (Sync to Nodes) when no --mcp-config was passed.
|
||||
live_mcp_client = _mcp_ref[0]
|
||||
uid = getattr(ui, "_user_id", "") or ""
|
||||
|
||||
# Resolve username from user_id for system message context
|
||||
@@ -2895,7 +2911,7 @@ def main() -> None:
|
||||
auto_compact_pct=config_store.get("session.auto_compact_pct"),
|
||||
agent_max_turns=config_store.get("tools.agent_max_turns"),
|
||||
tool_truncation=config_store.get("tools.truncation"),
|
||||
mcp_client=mcp_client,
|
||||
mcp_client=live_mcp_client,
|
||||
registry=registry,
|
||||
model_alias=model_alias or registry.default,
|
||||
health_monitor=health_monitor,
|
||||
@@ -2989,13 +3005,11 @@ def main() -> None:
|
||||
_metrics.set_judge_enabled(judge_config.enabled if judge_config else False)
|
||||
|
||||
# Auth config
|
||||
from turnstone.core.auth import load_auth_config, load_jwt_secret
|
||||
from turnstone.core.auth import load_jwt_secret
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
auth_config = load_auth_config()
|
||||
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
jwt_secret = load_jwt_secret()
|
||||
log.info("Auth: enabled (JWT)")
|
||||
|
||||
# Build the ASGI app
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
@@ -3020,12 +3034,12 @@ def main() -> None:
|
||||
global_listeners=global_listeners,
|
||||
global_listeners_lock=global_listeners_lock,
|
||||
skip_permissions=_skip_perms,
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=get_storage(),
|
||||
health_monitor=health_monitor,
|
||||
rate_limiter=rate_limiter,
|
||||
mcp_client=mcp_client,
|
||||
mcp_ref=_mcp_ref,
|
||||
registry=registry,
|
||||
idle_timeout=config_store.get("server.workstream_idle_timeout"),
|
||||
node_id=_node_id,
|
||||
|
||||
+62
-17
@@ -310,29 +310,53 @@ Pane.prototype.connectSSE = function (wsId) {
|
||||
workstreams[ws.id] = { name: ws.name, state: ws.state };
|
||||
});
|
||||
renderTabBar();
|
||||
// Reconnect all disconnected panes, reassigning stale ws_ids
|
||||
// Reconnect all disconnected panes, reassigning stale ws_ids.
|
||||
// Two passes: (1) reassign stale panes, (2) reconnect all.
|
||||
// Track assigned ws_ids to avoid multiple panes on the same ws.
|
||||
var remaining = Object.keys(workstreams);
|
||||
if (!remaining.length) {
|
||||
showDashboard();
|
||||
return;
|
||||
}
|
||||
var usedWsIds = {};
|
||||
for (var pid in panes) {
|
||||
var p = panes[pid];
|
||||
if (p.wsId && !workstreams[p.wsId]) {
|
||||
var ids = Object.keys(workstreams);
|
||||
if (ids.length) {
|
||||
p.wsId = ids[0];
|
||||
p.messagesEl.innerHTML = "";
|
||||
p.showEmptyState();
|
||||
p.updateWsName();
|
||||
} else {
|
||||
showDashboard();
|
||||
return;
|
||||
if (panes[pid].wsId && workstreams[panes[pid].wsId])
|
||||
usedWsIds[panes[pid].wsId] = true;
|
||||
}
|
||||
for (var pid2 in panes) {
|
||||
var p2 = panes[pid2];
|
||||
if (p2.wsId && !workstreams[p2.wsId]) {
|
||||
var newWsId = null;
|
||||
for (var ri = 0; ri < remaining.length; ri++) {
|
||||
if (!usedWsIds[remaining[ri]]) {
|
||||
newWsId = remaining[ri];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newWsId) {
|
||||
p2.disconnectSSE();
|
||||
p2.wsId = newWsId;
|
||||
usedWsIds[newWsId] = true;
|
||||
while (p2.messagesEl.firstChild)
|
||||
p2.messagesEl.removeChild(p2.messagesEl.firstChild);
|
||||
p2.showEmptyState();
|
||||
p2.updateWsName();
|
||||
}
|
||||
// else: more panes than workstreams — leave pane stale,
|
||||
// connectSSE below will pick it up or it stays disconnected.
|
||||
}
|
||||
if (pid === focusedPaneId) currentWsId = p.wsId;
|
||||
if (!p.evtSource) {
|
||||
}
|
||||
// Pass 2: reconnect all panes and sync focused pane
|
||||
for (var pid3 in panes) {
|
||||
var p3 = panes[pid3];
|
||||
if (pid3 === focusedPaneId) currentWsId = p3.wsId;
|
||||
if (!p3.evtSource && p3.wsId && workstreams[p3.wsId]) {
|
||||
setTimeout(
|
||||
(function (pp) {
|
||||
return function () {
|
||||
pp.connectSSE(pp.wsId);
|
||||
};
|
||||
})(p),
|
||||
})(p3),
|
||||
self.retryDelay,
|
||||
);
|
||||
}
|
||||
@@ -357,6 +381,9 @@ Pane.prototype.connectSSE = function (wsId) {
|
||||
};
|
||||
|
||||
Pane.prototype.handleEvent = function (evt) {
|
||||
// Guard: drop events that belong to a different workstream.
|
||||
// This prevents cross-contamination during tab switches and reconnects.
|
||||
if (evt.ws_id && evt.ws_id !== this.wsId) return;
|
||||
var self = this;
|
||||
switch (evt.type) {
|
||||
case "thinking_start":
|
||||
@@ -2304,10 +2331,12 @@ function switchTab(wsId) {
|
||||
}
|
||||
}
|
||||
|
||||
pane.disconnectSSE();
|
||||
pane.reset();
|
||||
pane.wsId = wsId;
|
||||
currentWsId = wsId;
|
||||
pane.messagesEl.innerHTML = "";
|
||||
while (pane.messagesEl.firstChild)
|
||||
pane.messagesEl.removeChild(pane.messagesEl.firstChild);
|
||||
pane.showEmptyState();
|
||||
pane.updateWsName();
|
||||
renderTabBar();
|
||||
@@ -2957,8 +2986,18 @@ function connectGlobalSSE() {
|
||||
for (var id in panes) {
|
||||
if (panes[id].wsId === data.ws_id) panes[id].updateWsName();
|
||||
}
|
||||
} else if (data.type === "ws_created") {
|
||||
workstreams[data.ws_id] = workstreams[data.ws_id] || {};
|
||||
workstreams[data.ws_id].name = data.name || data.ws_id.slice(0, 6);
|
||||
workstreams[data.ws_id].state = "idle";
|
||||
renderTabBar();
|
||||
} else if (data.type === "ws_closed") {
|
||||
var wsId = data.ws_id;
|
||||
// Disconnect per-ws SSE on affected panes immediately so stale
|
||||
// events from the dying workstream don't leak into reassigned panes.
|
||||
for (var cid in panes) {
|
||||
if (panes[cid].wsId === wsId) panes[cid].disconnectSSE();
|
||||
}
|
||||
delete workstreams[wsId];
|
||||
renderTabBar();
|
||||
if (data.reason === "evicted") {
|
||||
@@ -3137,10 +3176,13 @@ function makeCollapsible(el) {
|
||||
|
||||
var _planContent = "";
|
||||
var _planPaneId = null;
|
||||
var _planWsId = null;
|
||||
|
||||
function showPlanDialog(content) {
|
||||
_planContent = content;
|
||||
_planPaneId = focusedPaneId;
|
||||
var paneNow = panes[_planPaneId];
|
||||
_planWsId = paneNow ? paneNow.wsId : currentWsId;
|
||||
document.getElementById("plan-content").textContent = content;
|
||||
var feedbackEl = document.getElementById("plan-feedback");
|
||||
feedbackEl.value = "";
|
||||
@@ -3186,7 +3228,10 @@ function resolvePlan(defaultFeedback) {
|
||||
}
|
||||
|
||||
// Critical: fire the API call first — this unblocks the server.
|
||||
var wsId = pane ? pane.wsId : currentWsId;
|
||||
// Use the ws_id captured when the dialog opened, not the current pane
|
||||
// (user may have switched tabs while the dialog was open).
|
||||
var wsId = _planWsId || (pane ? pane.wsId : currentWsId);
|
||||
_planWsId = null;
|
||||
authFetch("/v1/api/plan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
Reference in New Issue
Block a user