mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2c6689a8e | |||
| f5af4875ba | |||
| 0d77d65266 | |||
| c11991819e | |||
| fc948d711d | |||
| d9722f3578 | |||
| f494553020 | |||
| c766c81f25 | |||
| 3b746fb28d | |||
| b82fa4923c | |||
| 4ac316dc0e | |||
| 387ef06da7 | |||
| e4e2200c33 | |||
| 8b94d553e4 | |||
| cf16724137 | |||
| 22402e89de | |||
| e7743fd079 | |||
| 27349e1c13 | |||
| 37a48bb30d | |||
| 730f5704ff | |||
| 9b3b1c1ddd | |||
| a9ed8a954b | |||
| 1efcbcf2ba | |||
| 1d36d80fe5 | |||
| e603a6a7d1 | |||
| 2e95f2ac73 | |||
| 5f27ed9fca | |||
| 20df7b3034 | |||
| 68c991fbdd | |||
| 376da3d084 | |||
| e2a199c9c3 | |||
| 4152ea2352 | |||
| 44cc14b46f | |||
| 83b0cde32f | |||
| 2ef8a8711b | |||
| 3658b77de8 |
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":separateMajorReleases"
|
||||
],
|
||||
"labels": [
|
||||
"dependencies"
|
||||
],
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored KaTeX version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"katex-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"depNameTemplate": "katex",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Highlight.js version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"hljs-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"depNameTemplate": "highlight.js",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Mermaid version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"mermaid-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"depNameTemplate": "mermaid",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "LLM SDKs — always review manually",
|
||||
"groupName": "LLM SDKs",
|
||||
"matchPackageNames": [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"mcp"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Web framework stack",
|
||||
"groupName": "Web Framework",
|
||||
"matchPackageNames": [
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
"sse-starlette",
|
||||
"httpx",
|
||||
"httpx-sse",
|
||||
"pydantic"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Database layer",
|
||||
"groupName": "Database",
|
||||
"matchPackageNames": [
|
||||
"sqlalchemy",
|
||||
"alembic",
|
||||
"psycopg"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Security-critical — always review manually",
|
||||
"groupName": "Security",
|
||||
"matchPackageNames": [
|
||||
"PyJWT",
|
||||
"pyjwt",
|
||||
"bcrypt"
|
||||
],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": [
|
||||
"structlog",
|
||||
"redis",
|
||||
"croniter",
|
||||
"discord.py"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Vendored JS — requires manual file download after merge",
|
||||
"groupName": "Vendored JS",
|
||||
"matchPackageNames": [
|
||||
"katex",
|
||||
"highlight.js",
|
||||
"mermaid"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": false,
|
||||
"prBodyNotes": [
|
||||
"This PR updates version references only.",
|
||||
"After merging, run `scripts/update-vendored-js.sh <lib> <version>` to download the actual files."
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Dev/test tooling",
|
||||
"groupName": "Tooling",
|
||||
"matchPackageNames": [
|
||||
"ruff",
|
||||
"mypy",
|
||||
"types-redis",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Docker base images",
|
||||
"groupName": "Docker Images",
|
||||
"matchManagers": [
|
||||
"dockerfile",
|
||||
"docker-compose"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "TypeScript SDK dev dependencies",
|
||||
"groupName": "TypeScript SDK",
|
||||
"matchFileNames": [
|
||||
"sdk/typescript/**"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions — group all action updates",
|
||||
"groupName": "GitHub Actions",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+48
-12
@@ -10,21 +10,21 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
- run: ruff check turnstone/ tests/
|
||||
- run: ruff format --check turnstone/ tests/
|
||||
python-version: "3.14"
|
||||
- run: pip install pre-commit
|
||||
# mypy runs separately in typecheck job with full project deps
|
||||
- run: SKIP=mypy pre-commit run --all-files
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
- run: pip install mypy types-redis
|
||||
- run: pip install -e ".[mq]"
|
||||
- run: mypy turnstone/
|
||||
@@ -35,14 +35,50 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[test,mq]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: uv sync --frozen --all-extras
|
||||
- run: uv pip install pip-audit
|
||||
- run: uv run pip-audit --strict --desc
|
||||
|
||||
security-ts:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: npm ci
|
||||
- run: npm audit --audit-level=moderate
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- run: docker build -t turnstone:scan .
|
||||
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: "turnstone:scan"
|
||||
severity: "HIGH,CRITICAL"
|
||||
exit-code: "1"
|
||||
@@ -13,16 +13,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@b25b93d384199fc0fc8c2e126b2d937a0cbeb2ae # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.10
|
||||
rev: v0.15.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.1
|
||||
rev: v1.19.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-redis>=4.6, redis>=7.2]
|
||||
|
||||
+22
-24
@@ -1,41 +1,39 @@
|
||||
# =============================================================================
|
||||
# Turnstone — multi-stage Docker build
|
||||
# Turnstone — Docker build with uv for reproducible, locked installs
|
||||
# Single image for all services: server, bridge, console, sim, eval
|
||||
# =============================================================================
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 1: Builder — build the wheel
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN pip install --no-cache-dir hatchling
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY turnstone/ turnstone/
|
||||
|
||||
RUN pip wheel --no-deps --wheel-dir /build/wheels .
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 2: Runtime — slim image with the installed package
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.10 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
# Install the wheel with all optional extras
|
||||
COPY --from=builder /build/wheels/*.whl /tmp/wheels/
|
||||
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim,postgres,discord]" \
|
||||
&& rm -rf /tmp/wheels
|
||||
WORKDIR /app
|
||||
|
||||
# Compile bytecode for faster startup
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Health check script (stdlib only, no pip deps needed)
|
||||
COPY docker/healthcheck.py /usr/local/bin/healthcheck.py
|
||||
|
||||
@@ -18,7 +18,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
|
||||
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams, reverse proxy for server UIs
|
||||
- **Intent validation** — an LLM judge evaluates every tool call before approval, presenting risk assessments and evidence-based recommendations so users can make informed decisions instead of blindly approving raw tool calls
|
||||
- **Governance & compliance** — RBAC, tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs
|
||||
- **Governance & compliance** — RBAC, OIDC SSO (Okta, Azure AD, Google, Keycloak), tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
|
||||
Works with any OpenAI-compatible API (vLLM, llama.cpp, NVIDIA NIM) or Anthropic's native Messages API. Supports [MCP](https://modelcontextprotocol.io/) for external tool servers with native deferred tool loading on Anthropic and OpenAI APIs (BM25 fallback for local models).
|
||||
@@ -136,12 +136,14 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|
||||
| [Governance Architecture](docs/diagrams/png/19-governance-architecture.png) | RBAC, policies, audit, usage enforcement flow |
|
||||
| [WS Template Architecture](docs/diagrams/png/21-ws-template-architecture.png) | Workstream template application and lifecycle |
|
||||
| [Judge Architecture](docs/diagrams/png/22-judge-architecture.png) | Intent validation two-tier evaluation pipeline |
|
||||
| [OIDC Architecture](docs/diagrams/png/25-oidc-architecture.png) | OIDC SSO authorization code flow with PKCE |
|
||||
|
||||
### Governance
|
||||
|
||||
Turnstone includes a built-in governance layer for enterprise deployments — manage who can do what, which tools run unattended, and where every token goes.
|
||||
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
Turnstone — Third-Party Notices
|
||||
|
||||
This file contains the licenses and notices for third-party software bundled
|
||||
with Turnstone. Each bundled dependency retains its original license; the
|
||||
Turnstone BUSL-1.1 license does not apply to these components.
|
||||
|
||||
================================================================================
|
||||
|
||||
KaTeX 0.16.38
|
||||
https://katex.org/
|
||||
https://github.com/KaTeX/KaTeX
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
highlight.js 11.11.1
|
||||
https://highlightjs.org/
|
||||
https://github.com/highlightjs/highlight.js
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
================================================================================
|
||||
|
||||
Mermaid 11.13.0
|
||||
https://mermaid.js.org/
|
||||
https://github.com/mermaid-js/mermaid
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+5
-4
@@ -26,7 +26,7 @@ services:
|
||||
# PostgreSQL — production database (profile: production)
|
||||
# -------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: pgautoupgrade/pgautoupgrade:18-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -44,7 +45,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -56,7 +57,7 @@ services:
|
||||
# Redis — message broker, pub/sub, node registry
|
||||
# -------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
image: redis:8.6-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -257,7 +258,7 @@ services:
|
||||
# docker compose --profile ddgCluster up
|
||||
# -------------------------------------------------------------------
|
||||
ddg-search:
|
||||
image: python:3.13-slim
|
||||
image: python:3.14-slim
|
||||
profiles:
|
||||
- ddgCluster
|
||||
command:
|
||||
|
||||
@@ -7,10 +7,10 @@ appVersion: "0.3.0"
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~16.0
|
||||
version: ~18.5.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
- name: redis
|
||||
version: ~20.0
|
||||
version: ~25.3.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: redis.enabled
|
||||
|
||||
@@ -618,6 +618,67 @@ Each saved workstream object:
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/templates`
|
||||
|
||||
Returns a summary list of all available prompt templates. This is a read-only
|
||||
endpoint (requires `read` scope) that exposes template names and categories
|
||||
without revealing template content. Useful for populating template selectors
|
||||
in UIs or discovering available templates before creating a workstream.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"templates": [
|
||||
{"name": "safety-guidelines", "category": "safety", "is_default": true, "origin": "manual"},
|
||||
{"name": "mcp__server__code", "category": "", "is_default": false, "origin": "mcp"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each template summary:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------------|
|
||||
| `name` | string | Template name (used in `template` field on creation) |
|
||||
| `category` | string | Template category |
|
||||
| `is_default` | bool | Whether template is auto-applied to all sessions |
|
||||
| `origin` | string | Template origin: `manual` or `mcp` |
|
||||
|
||||
> **Note:** For full template management (create, update, delete, view content),
|
||||
> use the admin endpoints at `GET /v1/api/admin/templates` (requires `admin.templates` permission).
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/ws-templates`
|
||||
|
||||
Returns a summary list of enabled workstream templates. This is a read-only
|
||||
endpoint (requires `read` scope) for populating template selectors in UIs.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ws_templates": [
|
||||
{"name": "code-review", "description": "Code review profile", "model": "gpt-5"},
|
||||
{"name": "ops-triage", "description": "On-call triage", "model": ""}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each workstream template summary:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|--------|-------------------------------------------------|
|
||||
| `name` | string | Template name (used in `ws_template` on creation)|
|
||||
| `description` | string | Human-readable description |
|
||||
| `model` | string | Model alias override (empty = use default) |
|
||||
|
||||
> **Note:** For full workstream template management, use the admin endpoints at
|
||||
> `GET /v1/api/admin/ws-templates` (requires `admin.templates` permission).
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/send`
|
||||
|
||||
Sends a user message to a workstream. Spawns a daemon worker thread that calls
|
||||
|
||||
+27
-8
@@ -91,14 +91,16 @@ turnstone/
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.38/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
spinner.py Braille character spinner (daemon thread)
|
||||
static/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css Page-specific UI styles (dashboard layout, approval blocks)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
|
||||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
|
||||
tools/
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
@@ -1200,8 +1202,13 @@ The bridge dispatches it to `POST /v1/api/cancel` on the server owning the works
|
||||
which sets the cooperative cancel flag and unblocks any pending approval/plan waits.
|
||||
|
||||
**Completion detection:** The bridge tracks which `correlation_id` maps to which
|
||||
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
|
||||
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
|
||||
`ws_id` for active sends. The server accumulates content tokens in the WebUI and
|
||||
piggybacks the full response text onto the `ws_state → idle` global SSE event.
|
||||
When the bridge receives this event, it emits a synthetic `TurnCompleteEvent`
|
||||
carrying the correlation ID and the server-provided `content`. This lets downstream
|
||||
consumers (e.g. the Discord bot) recover the full response when individual
|
||||
`ContentEvent`s were missed, and serves as the primary delivery path for
|
||||
bidirectional notification DM forwarding.
|
||||
|
||||
**Multi-node routing:** Each bridge retrieves its `node_id` from the server's
|
||||
`/health` endpoint on startup (with exponential backoff retry). The server
|
||||
@@ -1365,11 +1372,23 @@ directly over HTTP for lower latency: `_exec_notify()` queries the
|
||||
`services` database table for healthy channel gateways (heartbeat within
|
||||
120 seconds), authenticates with a service JWT (`aud: turnstone-channel`),
|
||||
and POSTs to `POST /v1/api/notify` on the first healthy gateway. The
|
||||
gateway validates the JWT, resolves the target (username lookup via
|
||||
payload includes the originating `ws_id` for reply routing. The gateway
|
||||
validates the JWT, resolves the target (username lookup via
|
||||
`channel_users` or direct `channel_type`+`channel_id`), and delegates to
|
||||
the appropriate `ChannelAdapter.send()`. Delivery retries up to 3 times
|
||||
with backoff, re-querying the service registry on each attempt. See
|
||||
[Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
`ChannelAdapter.send_notification()` which sends the message and tracks
|
||||
the outgoing message ID → `(ws_id, target_user_id)` mapping. Delivery
|
||||
retries up to 3 times with backoff, re-querying the service registry on
|
||||
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
**Bidirectional replies:** When a user replies to a notification DM, the
|
||||
Discord bot looks up the originating `ws_id` from the tracked message ID,
|
||||
verifies the replying user matches the notification recipient, and routes
|
||||
the reply to the workstream via `router.send_message()`. The workstream's
|
||||
response is forwarded back to the DM via a temporary entry in
|
||||
`_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
|
||||
itself tracked for further replies, enabling multi-turn DM conversations
|
||||
without requiring the user to open the web UI. Tracking entries are capped
|
||||
at 100 (FIFO eviction) and cleaned up on workstream close.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+40
-4
@@ -30,8 +30,8 @@ Key components:
|
||||
|
||||
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
||||
interface for any messaging platform. Defines `start()`, `stop()`,
|
||||
`send()`, `edit_message()`, `send_approval_request()`,
|
||||
`send_plan_review()`, and `create_thread()`.
|
||||
`send()`, `send_notification()`, `edit_message()`,
|
||||
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
|
||||
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
||||
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
||||
creation via MQ, stale route detection, and user identity resolution.
|
||||
@@ -271,13 +271,43 @@ gateway directly over HTTP:
|
||||
2. `_exec_notify()` queries the `services` table for healthy channel
|
||||
gateways (heartbeat within the last 120 seconds)
|
||||
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
||||
`ServiceTokenManager` and POSTs to the first healthy gateway
|
||||
`ServiceTokenManager` and POSTs to the first healthy gateway. The
|
||||
payload includes the originating `ws_id` for reply routing.
|
||||
4. The gateway validates the JWT, resolves the target, and calls
|
||||
`adapter.send()` on the appropriate platform adapter
|
||||
`adapter.send_notification()` which sends the message and tracks
|
||||
the outgoing message ID for reply routing
|
||||
5. On failure, the server tries the next gateway. If all fail, it
|
||||
retries up to 2 more times (delays: 1s, 3s), re-querying the
|
||||
service registry on each attempt
|
||||
|
||||
### Bidirectional Replies
|
||||
|
||||
Notifications support multi-turn DM conversations. When a user replies
|
||||
to a notification DM:
|
||||
|
||||
1. The bot looks up the originating `ws_id` from the tracked message ID
|
||||
(`_notify_ws_map`)
|
||||
2. Verifies the replying user matches the original notification
|
||||
recipient (defence in depth — Discord DMs are already private)
|
||||
3. Routes the reply to the workstream via `router.send_message()`
|
||||
4. Registers the DM channel for response forwarding
|
||||
(`_notify_reply_channels`)
|
||||
5. When the workstream responds (`TurnCompleteEvent`), the response is
|
||||
forwarded to the DM
|
||||
6. The response message is itself tracked, so the user can reply again
|
||||
for another turn
|
||||
|
||||
This enables scenarios like an oncall engineer responding to a CI/CD
|
||||
failure notification from their phone before opening a laptop.
|
||||
|
||||
**Limits:**
|
||||
|
||||
- Tracking map capped at 100 entries (FIFO eviction of oldest)
|
||||
- Entries cleaned up on workstream close/unsubscribe
|
||||
- Replying to an expired notification sends
|
||||
*"This notification is no longer active."*
|
||||
- DM reply content capped at 4096 characters
|
||||
|
||||
### Service Registry
|
||||
|
||||
The channel gateway registers itself in the `services` database table
|
||||
@@ -328,12 +358,18 @@ class ChannelAdapter(Protocol):
|
||||
async def start(self) -> None: ...
|
||||
async def stop(self) -> None: ...
|
||||
async def send(self, channel_id: str, content: str) -> str: ...
|
||||
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
|
||||
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
||||
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
||||
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
||||
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
|
||||
```
|
||||
|
||||
`send_notification()` is like `send()` but associates the outgoing
|
||||
message with a `ws_id` so that user replies can be routed back to the
|
||||
originating workstream. Adapters must track the mapping from outgoing
|
||||
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
||||
|
||||
To add a new platform:
|
||||
|
||||
1. Create `turnstone/channels/<platform>/` package
|
||||
|
||||
@@ -75,6 +75,7 @@ package "turnstone/ui/" <<Rectangle>> {
|
||||
component [colors.py\nANSI colors] as colors <<ui>>
|
||||
component [markdown.py\nMD rendering] as markdown <<ui>>
|
||||
component [spinner.py\nTerminal spinner] as spinner <<ui>>
|
||||
component [renderer.js\nBrowser MD + LaTeX] as renderer <<ui>>
|
||||
}
|
||||
|
||||
' API schemas
|
||||
|
||||
@@ -69,8 +69,8 @@ partition "Phase 2: Approve" #FFF3E0 {
|
||||
**TerminalUI**: Print headers/previews,
|
||||
prompt [y/n/a, optional message]
|
||||
If user chose "always":
|
||||
Set ui.auto_approve = True
|
||||
(auto-approve all future tools in this session)
|
||||
Add pending tool names to auto_approve_tools
|
||||
(auto-approve these tool types going forward)
|
||||
**WebUI**: Enqueue approve_request,
|
||||
block on _approval_event.wait()
|
||||
**NullUI**: Return (True, None)
|
||||
|
||||
@@ -174,6 +174,7 @@ package "Outbound Events (Bridge → Client)" #E3F2FD {
|
||||
}
|
||||
class TurnCompleteEvent {
|
||||
type = "turn_complete"
|
||||
+ content: str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nAckEvent(status:"ok")
|
||||
... SSE events flow: content, tool_output_chunk, tool_result, status, state_change ...
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nContentEvent, ToolResultEvent, ...
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:global\nStateChangeEvent(state:"idle")
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nTurnCompleteEvent
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:global\nStateChangeEvent(state:"idle", content:"...")
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nTurnCompleteEvent(content:"...")
|
||||
|
||||
== Scenario B: Directed Message to Specific Node ==
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class "DiscordBot" as Bot <<service>> {
|
||||
+on_message(msg)
|
||||
+on_interaction(interaction)
|
||||
+send(channel_id, content)
|
||||
+send_notification(channel_id, content, ws_id)
|
||||
+run(token)
|
||||
--
|
||||
discord.py Client
|
||||
@@ -61,6 +62,9 @@ class "DiscordBot" as Bot <<service>> {
|
||||
Creates threads for workstreams
|
||||
Renders approval buttons
|
||||
escape_mentions() on send
|
||||
--
|
||||
_notify_ws_map: msg_id → (ws_id, user_id)
|
||||
_notify_reply_channels: ws_id → (dm, user_id)
|
||||
}
|
||||
|
||||
class "ChannelRouter" as Router <<service>> {
|
||||
@@ -240,11 +244,20 @@ note bottom of SVC
|
||||
3. Queries services table for healthy gateways
|
||||
4. Mints JWT (aud: turnstone-channel) via
|
||||
ServiceTokenManager
|
||||
5. POSTs to first healthy gateway
|
||||
5. POSTs to first healthy gateway (incl. ws_id)
|
||||
6. Gateway validates JWT, resolves target
|
||||
7. adapter.send() → Discord API
|
||||
7. adapter.send_notification() → Discord API
|
||||
(tracks msg_id → ws_id for reply routing)
|
||||
8. On failure: retry up to 3× (1s, 3s backoff)
|
||||
9. SSRF: only http(s) URLs allowed
|
||||
|
||||
**Bidirectional DM Replies**
|
||||
1. User replies to notification DM
|
||||
2. Bot looks up ws_id from _notify_ws_map
|
||||
3. Verifies author == notification recipient
|
||||
4. Routes reply via router.send_message()
|
||||
5. Response forwarded to DM on TurnCompleteEvent
|
||||
6. Response tracked for multi-turn conversation
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -103,6 +103,41 @@ alt all retries exhausted
|
||||
Session --> Session : "Error: notification delivery failed"
|
||||
end
|
||||
|
||||
== Bidirectional Reply (User responds to notification DM) ==
|
||||
|
||||
Discord -> Adapter : user replies to\nnotification message
|
||||
Adapter -> Adapter : lookup message_id\nin _notify_ws_map
|
||||
note right
|
||||
Maps message_id →
|
||||
(ws_id, target_user_id)
|
||||
Atomic pop prevents TOCTOU
|
||||
end note
|
||||
|
||||
alt message not tracked
|
||||
Adapter -> Discord : "This notification\nis no longer active."
|
||||
else tracked
|
||||
Adapter -> Adapter : verify author ==\ntarget_user_id
|
||||
Adapter -> Adapter : resolve_user()\n(unlinked → drop)
|
||||
Adapter -> Adapter : router.send_message(ws_id, content)
|
||||
note right
|
||||
Routes reply via MQ to
|
||||
the originating workstream.
|
||||
Registers DM channel in
|
||||
_notify_reply_channels[ws_id]
|
||||
end note
|
||||
|
||||
... workstream processes reply ...
|
||||
|
||||
Adapter <- Adapter : TurnCompleteEvent\n(with content)
|
||||
Adapter -> Discord : forward response to DM
|
||||
Adapter -> Adapter : track response message\nfor multi-turn replies
|
||||
note right
|
||||
Response message_id added
|
||||
to _notify_ws_map — user can
|
||||
reply again indefinitely
|
||||
end note
|
||||
end
|
||||
|
||||
== Service Registry (Background) ==
|
||||
|
||||
note over Gateway, Storage
|
||||
|
||||
@@ -85,6 +85,12 @@ tload --> trender : template content
|
||||
trender --> tsys : rendered content
|
||||
tset --> tload : name or None
|
||||
|
||||
note right of pt_db
|
||||
Read-only listing:
|
||||
GET /v1/api/templates
|
||||
(read scope, summary only)
|
||||
end note
|
||||
|
||||
govjs --> wt_db : /v1/api/admin/ws-templates
|
||||
wtr --> wt_db : get_ws_template_by_name()
|
||||
wtr --> wta : template settings
|
||||
|
||||
@@ -48,6 +48,14 @@ end note
|
||||
Admin -> Server : GET /v1/api/admin/ws-templates
|
||||
Server -> Storage : list_ws_templates()
|
||||
|
||||
Server <-- Server : GET /v1/api/ws-templates\n(read scope, summary only)
|
||||
note right
|
||||
**Read-only listing:**
|
||||
name, description, model.
|
||||
Used by creation UI dropdowns.
|
||||
Available on both server + console.
|
||||
end note
|
||||
|
||||
Admin -> Server : DELETE /v1/api/admin/ws-templates/{id}
|
||||
Server -> Storage : delete_ws_template(id)
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — OIDC Authorization Code Flow with PKCE
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<browser>> #E8EAF6
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<idp>> #C8E6C9
|
||||
}
|
||||
|
||||
participant "Browser" as Browser <<browser>>
|
||||
participant "Turnstone\n(Server / Console)" as Server <<server>>
|
||||
database "SQLite /\nPostgreSQL" as DB <<storage>>
|
||||
participant "Identity Provider\n(IdP)" as IdP <<idp>>
|
||||
|
||||
== Page Load ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/status
|
||||
Server --> Browser : {oidc_enabled: true,\noidc_provider_name: "...",\npassword_enabled: true}
|
||||
|
||||
note right of Browser
|
||||
Login screen renders
|
||||
"Continue with {provider_name}"
|
||||
button alongside password form.
|
||||
If password_enabled=false,
|
||||
only the SSO button is shown.
|
||||
end note
|
||||
|
||||
== Authorization Request ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/authorize
|
||||
|
||||
Server -> Server : Generate state (random)\nnonce (random)\nPKCE code_verifier + code_challenge
|
||||
|
||||
Server -> DB : create_oidc_pending_state(\nstate, nonce, code_verifier, audience)
|
||||
note right of DB
|
||||
Stored with created_at timestamp.
|
||||
Expires after 5 minutes.
|
||||
end note
|
||||
|
||||
Server --> Browser : 302 Redirect to IdP\nauthorization_endpoint
|
||||
|
||||
Browser -> IdP : GET /authorize?\nresponse_type=code&\nclient_id=...&\nredirect_uri=...&\nscope=openid email profile&\nstate=...&nonce=...&\ncode_challenge=...&\ncode_challenge_method=S256
|
||||
|
||||
== User Authentication (at IdP) ==
|
||||
|
||||
IdP -> Browser : Login page (if no\nexisting IdP session)
|
||||
Browser -> IdP : User authenticates\n(username/password, MFA, etc.)
|
||||
|
||||
IdP --> Browser : 302 Redirect to callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
== Callback Processing ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
Server -> Server : Rate limit check\n(5 per 5min per IP)
|
||||
|
||||
Server -> DB : cleanup_expired_oidc_states(300)
|
||||
note right of DB
|
||||
Lazy cleanup of states
|
||||
older than 5 minutes.
|
||||
end note
|
||||
|
||||
Server -> DB : pop_oidc_pending_state(state)
|
||||
DB --> Server : {nonce, code_verifier, audience}
|
||||
note right of Server
|
||||
Atomic fetch-and-delete.
|
||||
Returns None if state is
|
||||
expired or unknown.
|
||||
end note
|
||||
|
||||
== Token Exchange ==
|
||||
|
||||
Server -> IdP : POST /token\ngrant_type=authorization_code&\ncode=AUTH_CODE&\nclient_id=...&\nclient_secret=...&\ncode_verifier=...&\nredirect_uri=...
|
||||
note right of Server
|
||||
Client secret + PKCE verifier
|
||||
sent server-side only.
|
||||
Never exposed to browser.
|
||||
end note
|
||||
|
||||
IdP --> Server : {id_token: "eyJ...",\naccess_token: "..."}
|
||||
|
||||
== ID Token Validation ==
|
||||
|
||||
Server -> IdP : Fetch JWKS public keys\n(cached at startup, refreshed\non-demand when unknown kid\nencountered — key rotation)
|
||||
|
||||
Server -> Server : Validate ID token:\n1. Verify signature (RS256/ES256)\n2. Check iss == configured issuer\n3. Check aud == client_id\n4. Check exp (not expired)\n5. Verify nonce matches
|
||||
|
||||
== User Provisioning ==
|
||||
|
||||
Server -> DB : get_oidc_identity(issuer, sub)
|
||||
|
||||
alt Existing identity found
|
||||
DB --> Server : {user_id, ...}
|
||||
Server -> DB : update_oidc_identity_login()\nupdate last_login timestamp
|
||||
Server -> DB : get_user(user_id)
|
||||
DB --> Server : user record
|
||||
else New user (first login)
|
||||
Server -> Server : Derive username from\npreferred_username / email
|
||||
Server -> DB : create_user(user_id, username,\ndisplay_name, "!oidc")
|
||||
note right of DB
|
||||
Password hash set to sentinel
|
||||
value "!oidc" — not a valid
|
||||
bcrypt hash, so password login
|
||||
is always rejected.
|
||||
end note
|
||||
Server -> DB : create_oidc_identity(\nissuer, sub, user_id, email)
|
||||
end
|
||||
|
||||
opt Role mapping configured
|
||||
Server -> Server : Read role_claim from ID token\nMap values via role_map
|
||||
Server -> DB : Sync roles: add new,\nrevoke stale OIDC-assigned,\npreserve manually assigned
|
||||
end
|
||||
|
||||
== Issue Turnstone JWT ==
|
||||
|
||||
Server -> Server : Load user permissions\nDerive scopes from permissions
|
||||
Server -> Server : Create JWT (HS256)\nsub: user_id\nscopes: read,write,...\nsrc: "oidc"\naud: turnstone-server\nexp: +24h
|
||||
|
||||
Server --> Browser : 302 Redirect to /?oidc_success=1\nSet-Cookie: session=JWT\n(HttpOnly, SameSite=Lax, Secure)
|
||||
|
||||
== Browser Success Detection ==
|
||||
|
||||
Browser -> Browser : Detect ?oidc_success=1\nStrip param from URL\n(history.replaceState)
|
||||
Browser -> Browser : Hide login overlay\nCall onLoginSuccess()
|
||||
|
||||
note right of Browser
|
||||
Browser is now authenticated.
|
||||
JWT cookie sent on all
|
||||
subsequent requests.
|
||||
end note
|
||||
|
||||
== Error Paths ==
|
||||
|
||||
note over Browser, IdP
|
||||
**Error handling:**
|
||||
- IdP returns error param → redirect to /?oidc_error=...
|
||||
- State missing/expired → redirect to /?oidc_error=Login+session+expired
|
||||
- Token exchange fails → redirect to /?oidc_error=...
|
||||
- ID token validation fails → redirect to /?oidc_error=...
|
||||
- No admin user exists → redirect to /?oidc_error=Initial+setup+required
|
||||
- Rate limit exceeded → redirect to /?oidc_error=Too+many+login+attempts
|
||||
All errors are shown as toast messages on the login screen.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9daca81971ba7a8ed6736d23d5373c69435158fa6240b9880d14fc4759ab580
|
||||
size 329673
|
||||
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
|
||||
size 310079
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6dd3c923d1e1c49b5f91d8d342fb4b0d49a46d432460379ad146a9e3b075a05a
|
||||
size 277234
|
||||
oid sha256:43844b07d36beb04db871f6795a3f3be17852a6a484fdc0ea207403bd7f512a6
|
||||
size 274286
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2229801220548e4794baa67e27a0a39dc7968c826a28fa8144763e678c8ed733
|
||||
size 192556
|
||||
oid sha256:6e94a10f039a7f69517e84d0946e0c649035c15b38ebc2314e7b9cd501eb244d
|
||||
size 192559
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1380065cbb5f95b5ea7dc6b2a00986c455b82888af60784980dffbd936460dcf
|
||||
size 431129
|
||||
oid sha256:6fc99bb8d84d6e9f3dac9d5c12ac7f569a041b29431c57612c24b50f332982ed
|
||||
size 462992
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0f6097840fccdbfe16cd5e4c9f5d063b2c36942460a944df68b8ec947e63ea3
|
||||
size 221452
|
||||
oid sha256:cc4c511c34a2e5d286fd128c3509405a5b240ca02a4bafb395d2e94d002a5b8b
|
||||
size 293203
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4dac4948d928b4705936d73b4d159aa1e89315ec0397616ca914bbf19e7a1ce
|
||||
size 206479
|
||||
oid sha256:3aaca1ae4c6c255dc9569f59e3ccc24f8b3bab0ac2a9b08c85e2af72d6a400c7
|
||||
size 218575
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c06d7086d7965eb9fe333396f027133d42507cf120bfe8dc851c009a8768ec48
|
||||
size 284926
|
||||
oid sha256:fadf5b07f8230ecf97805a86b308eaa9eb30516dd26900e5c9f70e6fb7562bab
|
||||
size 296339
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259
|
||||
size 309584
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
# OpenID Connect (OIDC) Single Sign-On
|
||||
|
||||
Turnstone supports OpenID Connect for federated authentication, allowing
|
||||
users to log in with their existing corporate identity provider instead of
|
||||
managing a separate password. OIDC is opt-in: when configured, the login
|
||||
screen shows a "Continue with SSO" button alongside the existing
|
||||
username/password form. When not configured, the login experience is
|
||||
unchanged.
|
||||
|
||||
Any OIDC-compliant provider works: Google, Okta, Azure AD, Keycloak,
|
||||
Auth0, OneLogin, and others that publish a
|
||||
`.well-known/openid-configuration` discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A registered **confidential** OIDC client at your identity provider
|
||||
2. The client's redirect URI must include:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. A local admin user must exist in Turnstone (complete the initial setup
|
||||
wizard before enabling OIDC)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
OIDC is configured via environment variables (preferred) or the `[oidc]`
|
||||
section of `config.toml`. Environment variables take precedence when both
|
||||
are set.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `TURNSTONE_OIDC_ISSUER` | Yes | — | Issuer URL (e.g. `https://accounts.google.com`). Must serve `/.well-known/openid-configuration`. |
|
||||
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | — | OAuth 2.0 client ID from your provider |
|
||||
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | — | OAuth 2.0 client secret (confidential client) |
|
||||
| `TURNSTONE_OIDC_SCOPES` | No | `openid email profile` | Space-separated OAuth scopes to request |
|
||||
| `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_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
|
||||
secret) are non-empty. If any is missing, OIDC is silently disabled and
|
||||
the login screen shows only the password form.
|
||||
|
||||
### Reverse Proxy / Load Balancer
|
||||
|
||||
When Turnstone runs behind a reverse proxy, the internal `Host` header may
|
||||
not match the externally-reachable URL. Set `TURNSTONE_OIDC_REDIRECT_BASE`
|
||||
to the public origin so the redirect URI sent to the identity provider is
|
||||
correct:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_REDIRECT_BASE=https://app.example.com
|
||||
```
|
||||
|
||||
The resulting callback URL will be
|
||||
`https://app.example.com/v1/api/auth/oidc/callback` — register this as the
|
||||
authorized redirect URI in your identity provider.
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
issuer = "https://accounts.google.com"
|
||||
client_id = "your-client-id"
|
||||
client_secret = "your-client-secret"
|
||||
scopes = "openid email profile"
|
||||
provider_name = "Google"
|
||||
role_claim = "groups"
|
||||
password_enabled = true
|
||||
redirect_base = "https://app.example.com"
|
||||
|
||||
[oidc.role_map]
|
||||
admin = "builtin-admin"
|
||||
engineering = "builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider-Specific Setup
|
||||
|
||||
### Google
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) >
|
||||
**APIs & Services** > **Credentials**
|
||||
2. Click **Create Credentials** > **OAuth 2.0 Client ID**
|
||||
3. Application type: **Web application**
|
||||
4. Add authorized redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Copy the **Client ID** and **Client secret**
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://accounts.google.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=GOCSPX-...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Google
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
1. In the Okta Admin Console, go to **Applications** > **Create App
|
||||
Integration**
|
||||
2. Sign-in method: **OIDC - OpenID Connect**
|
||||
3. Application type: **Web Application**
|
||||
4. Add sign-in redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Note the **Issuer** (your Okta domain, e.g.
|
||||
`https://dev-123456.okta.com`)
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://dev-123456.okta.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=0oaXXXXXXXXXXXXX
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Okta
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,everyone:builtin-operator"
|
||||
```
|
||||
|
||||
### Azure AD (Entra ID)
|
||||
|
||||
1. In the Azure Portal, go to **App registrations** > **New registration**
|
||||
2. Redirect URI: **Web** >
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. Under **Certificates & secrets**, create a new **Client secret** and
|
||||
copy the value immediately
|
||||
4. The issuer URL is
|
||||
`https://login.microsoftonline.com/{tenant-id}/v2.0`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0
|
||||
TURNSTONE_OIDC_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME="Azure AD"
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="Admin:builtin-admin,User:builtin-operator"
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In the Keycloak Admin Console, select your **Realm**
|
||||
2. Go to **Clients** > **Create client**
|
||||
3. Client type: **OpenID Connect**
|
||||
4. Set **Client authentication** to **On** (confidential)
|
||||
5. Add valid redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
6. The issuer URL is
|
||||
`https://keycloak.example.com/realms/your-realm`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm
|
||||
TURNSTONE_OIDC_CLIENT_ID=turnstone
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Keycloak
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=realm_access.roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,operator:builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role Mapping
|
||||
|
||||
OIDC role mapping assigns Turnstone roles to users based on claims in the
|
||||
ID token. This is optional — without it, OIDC users are provisioned with
|
||||
the `builtin-viewer` role (read-only access) by default.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `TURNSTONE_OIDC_ROLE_CLAIM` to the name of the claim in the ID token
|
||||
that contains the user's group or role memberships. Then set
|
||||
`TURNSTONE_OIDC_ROLE_MAP` to map claim values to Turnstone role IDs.
|
||||
|
||||
The role map is a comma-separated list of `claim_value:turnstone_role`
|
||||
pairs:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer:builtin-viewer"
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Synced on every login**: roles are added when new claim values appear,
|
||||
and OIDC-assigned roles are revoked when the corresponding claim value
|
||||
is no longer present. Roles assigned manually (or by other sources) are
|
||||
never touched — only roles with `assigned_by="oidc"` are subject to
|
||||
revocation.
|
||||
- **List or string**: the claim value can be a JSON array
|
||||
(`["admin", "engineering"]`) or a single string (`"admin"`). Both are
|
||||
handled correctly.
|
||||
- **Unknown values**: claim values not present in the role map are silently
|
||||
ignored.
|
||||
- **Missing roles**: if the role map references a Turnstone role ID that
|
||||
does not exist in the database, the assignment is skipped (no error).
|
||||
- **Evaluated on every login**: roles are checked and applied each time
|
||||
the user authenticates via OIDC, so new group memberships are picked
|
||||
up on the next login.
|
||||
|
||||
### Built-in Roles
|
||||
|
||||
| Role ID | Permissions |
|
||||
|---------|-------------|
|
||||
| `builtin-admin` | All permissions |
|
||||
| `builtin-operator` | read, write, workstreams.create, workstreams.close |
|
||||
| `builtin-viewer` | read |
|
||||
|
||||
---
|
||||
|
||||
## User Provisioning
|
||||
|
||||
When a user logs in via OIDC for the first time, Turnstone automatically
|
||||
creates a local user account:
|
||||
|
||||
1. The OIDC identity (`issuer` + `sub` claim) is stored in the
|
||||
`oidc_identities` table and linked to the new user
|
||||
2. The **username** is derived from the `preferred_username` claim,
|
||||
falling back to the email local part, with deduplication if needed
|
||||
3. The **display name** comes from the `name` claim, falling back to
|
||||
`preferred_username` or email
|
||||
4. The user's password hash is set to a sentinel value (`!oidc`) — OIDC
|
||||
users cannot log in with a password
|
||||
|
||||
On subsequent logins, the existing user is matched by `(issuer, sub)` and
|
||||
the `last_login` timestamp is updated. Role mapping is re-evaluated on
|
||||
every login.
|
||||
|
||||
---
|
||||
|
||||
## OIDC-Only Mode
|
||||
|
||||
To enforce OIDC for all logins and hide the password form, set:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_PASSWORD_ENABLED=false
|
||||
```
|
||||
|
||||
In this mode the login screen shows only the "Continue with SSO" button.
|
||||
The password form, token toggle, and sign-in button are all hidden.
|
||||
All username/password logins are blocked at the API level, including
|
||||
admin accounts.
|
||||
|
||||
The first admin account must be created via the setup wizard (with a
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow
|
||||
|
||||
Both the server and console support OIDC login. The flow is identical:
|
||||
|
||||
1. The browser fetches `GET /v1/api/auth/status` at page load
|
||||
2. If the response includes `oidc_enabled: true`, the login screen shows
|
||||
a "Continue with {provider_name}" button
|
||||
3. Clicking the button navigates to `GET /v1/api/auth/oidc/authorize`
|
||||
4. Turnstone generates a state token, nonce, and PKCE verifier, stores
|
||||
them in the database, and redirects the browser to the identity
|
||||
provider's authorization endpoint
|
||||
5. The user authenticates at the identity provider
|
||||
6. The IdP redirects back to
|
||||
`GET /v1/api/auth/oidc/callback?code=...&state=...`
|
||||
7. Turnstone validates the state, exchanges the authorization code for
|
||||
tokens using the PKCE verifier, validates the ID token against the
|
||||
provider's JWKS public keys, provisions or matches the user, and
|
||||
issues a Turnstone JWT
|
||||
8. The browser is redirected to `/?oidc_success=1` with the JWT set in
|
||||
an `HttpOnly` session cookie
|
||||
9. The browser JavaScript detects the `oidc_success` query parameter,
|
||||
strips it from the URL, hides the login overlay, and calls
|
||||
`onLoginSuccess()` to initialize the application
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/api/auth/oidc/authorize` | Public | Redirects to identity provider |
|
||||
| GET | `/v1/api/auth/oidc/callback` | Public | Handles IdP callback, issues JWT |
|
||||
|
||||
Both endpoints are public (no authentication required) because they are
|
||||
part of the login flow itself.
|
||||
|
||||
### Auth status response
|
||||
|
||||
When OIDC is enabled, `GET /v1/api/auth/status` includes additional
|
||||
fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_enabled": true,
|
||||
"has_users": true,
|
||||
"setup_required": false,
|
||||
"oidc_enabled": true,
|
||||
"oidc_provider_name": "Google",
|
||||
"password_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 018 creates two tables:
|
||||
|
||||
```sql
|
||||
CREATE TABLE oidc_identities (
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL,
|
||||
last_login TEXT NOT NULL,
|
||||
PRIMARY KEY (issuer, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_identities_user_id ON oidc_identities(user_id);
|
||||
|
||||
CREATE TABLE oidc_pending_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
audience TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
The `oidc_identities` table links an OIDC subject (identified by
|
||||
`issuer` + `subject`) to a Turnstone `user_id`. A single user can have
|
||||
multiple OIDC identities (e.g. from different providers).
|
||||
|
||||
The `oidc_pending_states` table stores authorization flow state for
|
||||
callback validation. Entries are automatically cleaned up after 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Authorization Code Flow with PKCE**: the recommended OAuth 2.0 flow
|
||||
for web applications. PKCE prevents authorization code interception
|
||||
attacks even without a client secret (though the client secret is still
|
||||
used for additional security).
|
||||
- **ID token validation**: all tokens are validated using the provider's
|
||||
JWKS public keys (RS256 or ES256). The signature, issuer, audience,
|
||||
and expiry are all checked.
|
||||
- **State parameter**: a cryptographically random state token prevents
|
||||
CSRF attacks on the callback endpoint. The state is stored server-side
|
||||
and verified on callback.
|
||||
- **Nonce**: a random nonce is included in the authorization request and
|
||||
verified in the ID token to prevent replay attacks.
|
||||
- **Client secret**: never leaves the server — it is only used in the
|
||||
server-to-IdP token exchange, not exposed to the browser.
|
||||
- **OIDC users cannot use password login**: the sentinel password hash
|
||||
(`!oidc`) ensures `verify_password()` always rejects password attempts
|
||||
for OIDC-provisioned users.
|
||||
- **Rate limiting**: the callback endpoint shares the login rate limiter
|
||||
(5 attempts per 5-minute window per IP).
|
||||
- **State TTL**: pending authorization states expire after 5 minutes.
|
||||
Expired states are lazily cleaned up on each callback.
|
||||
- **Setup guard**: OIDC login requires at least one local admin user to
|
||||
exist. This ensures the initial admin account is always created via the
|
||||
setup wizard with a password, not hijacked by an external identity.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OIDC not configured"
|
||||
|
||||
All three required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`, and
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`. Check that none are empty or
|
||||
whitespace-only.
|
||||
|
||||
### "Login session expired"
|
||||
|
||||
The authorization flow must complete within 5 minutes. If the user takes
|
||||
too long at the identity provider, the pending state expires. Try again.
|
||||
|
||||
### "Initial setup required"
|
||||
|
||||
OIDC login is blocked until at least one local admin user exists.
|
||||
Complete the setup wizard first (navigate to the Turnstone URL and follow
|
||||
the prompts to create an admin user with a password).
|
||||
|
||||
### Discovery fails at startup
|
||||
|
||||
Check that the issuer URL is reachable from the Turnstone server and
|
||||
serves a valid `/.well-known/openid-configuration` document. The server
|
||||
logs the discovery attempt at startup:
|
||||
|
||||
```
|
||||
OIDC discovery failed for https://your-issuer.example.com: ...
|
||||
```
|
||||
|
||||
OIDC is automatically disabled when discovery fails. Restart the server
|
||||
after fixing the connectivity issue.
|
||||
|
||||
### Redirect URI mismatch
|
||||
|
||||
The redirect URI configured at the identity provider must exactly match
|
||||
`https://your-host/v1/api/auth/oidc/callback`. Common issues:
|
||||
|
||||
- **Scheme mismatch**: the redirect uses `https://` — make sure TLS is
|
||||
configured or a reverse proxy sets the `X-Forwarded-Proto` header
|
||||
- **Port mismatch**: if running on a non-standard port, include it in
|
||||
the redirect URI
|
||||
- **Path mismatch**: the path must include the `/v1` API version prefix
|
||||
|
||||
### User not assigned expected roles
|
||||
|
||||
Check that:
|
||||
|
||||
1. `TURNSTONE_OIDC_ROLE_CLAIM` matches the exact claim name in the ID
|
||||
token (case-sensitive)
|
||||
2. `TURNSTONE_OIDC_ROLE_MAP` maps the correct claim values to valid
|
||||
Turnstone role IDs
|
||||
3. The roles referenced in the map exist in the database (check the
|
||||
admin panel > Roles tab)
|
||||
4. The identity provider is configured to include the claim in the ID
|
||||
token (some providers require explicit scope or claim configuration)
|
||||
+101
-2
@@ -54,7 +54,7 @@ Claims:
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
|
||||
| `iss` | Issuer — always `turnstone` |
|
||||
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
|
||||
| `iat` | Issued-at timestamp |
|
||||
@@ -90,7 +90,8 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
|
||||
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
||||
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`,
|
||||
`/api/auth/oidc/authorize`, `/api/auth/oidc/callback`.
|
||||
|
||||
### RBAC (Granular Permissions)
|
||||
|
||||
@@ -199,6 +200,94 @@ Response:
|
||||
The response also sets an `HttpOnly` session cookie containing the JWT,
|
||||
so the browser is immediately authenticated after setup completes.
|
||||
|
||||
### OIDC SSO (Single Sign-On)
|
||||
|
||||
Turnstone supports OIDC Authorization Code Flow with PKCE for
|
||||
single sign-on with external identity providers (Okta, Azure AD,
|
||||
Google, etc.). SSO is opt-in — enabled when the three required
|
||||
environment variables are set. Users are auto-provisioned on first
|
||||
login.
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TURNSTONE_OIDC_ISSUER` | Yes | OIDC issuer URL (e.g., `https://accounts.google.com`) |
|
||||
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | Client ID from the identity provider |
|
||||
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | Client secret (confidential client) |
|
||||
| `TURNSTONE_OIDC_SCOPES` | No | OIDC scopes (default: `openid email profile`) |
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | Display name for the SSO button (default: `SSO`) |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | Claim name in the ID token for role mapping (e.g., `groups`) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | Comma-separated `claim_value:role_id` pairs (e.g., `admin:builtin-admin,eng:builtin-operator`) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | Set to `false` to hide password login and force SSO-only |
|
||||
|
||||
OIDC is enabled when all three required variables (`ISSUER`,
|
||||
`CLIENT_ID`, `CLIENT_SECRET`) are set.
|
||||
|
||||
#### Login flow
|
||||
|
||||
1. User clicks "Continue with [Provider]" on the login page
|
||||
2. `GET /v1/api/auth/oidc/authorize` generates state, nonce, and PKCE
|
||||
challenge, stores them in the database, and redirects to the IdP
|
||||
3. User authenticates at the identity provider
|
||||
4. IdP redirects to `/v1/api/auth/oidc/callback` with `code` + `state`
|
||||
5. Server validates state, exchanges the authorization code (with PKCE
|
||||
verifier), and validates the ID token (JWKS signature, issuer,
|
||||
audience, nonce)
|
||||
6. Provisions or matches the user by `(issuer, sub)` — never by
|
||||
username or email
|
||||
7. Issues a JWT (`src: oidc`), sets a session cookie, and redirects to
|
||||
the application
|
||||
|
||||
#### Security measures
|
||||
|
||||
- **PKCE (S256)** — prevents authorization code interception
|
||||
- **State parameter** — one-time use, 5-minute TTL, database-backed
|
||||
(multi-node safe)
|
||||
- **Nonce** — prevents ID token replay
|
||||
- **JWKS validation** — asymmetric algorithm allowlist (RS/ES/PS
|
||||
256-512), HMAC excluded
|
||||
- **Algorithm allowlist enforced** — the signing key is resolved from
|
||||
the JWKS by ``kid``; PyJWK infers the key's algorithm from the JWKS
|
||||
``alg``/``kty`` fields; the token header's ``alg`` must be in the
|
||||
allowlist AND match the key type, preventing algorithm confusion
|
||||
- **Identity matching by (issuer, sub) only** — prevents account
|
||||
takeover via email or username reuse
|
||||
- **`password_enabled=false` enforced server-side** — not just a UI
|
||||
toggle
|
||||
- **Rate limiting** on both authorize and callback endpoints
|
||||
- **OIDC-provisioned users cannot password-login** — the password hash
|
||||
is set to the `!oidc` sentinel, which never matches bcrypt verify
|
||||
|
||||
#### Role mapping
|
||||
|
||||
When `TURNSTONE_OIDC_ROLE_CLAIM` is set (e.g., `groups`), the server
|
||||
reads that claim from the ID token and maps values to Turnstone roles
|
||||
via `TURNSTONE_OIDC_ROLE_MAP`. Roles are synced on every login:
|
||||
matching claim values are added, and stale OIDC-assigned roles are
|
||||
revoked. Roles assigned manually (not by OIDC) are never touched.
|
||||
|
||||
If no role mapping is configured, OIDC users are provisioned with the
|
||||
`builtin-viewer` role by default.
|
||||
|
||||
#### OIDC-only mode
|
||||
|
||||
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.
|
||||
|
||||
#### Known limitations
|
||||
|
||||
- **No session revocation** — deprovisioned IdP users retain their JWT
|
||||
until the 24-hour expiry
|
||||
- **Single IdP** — configuration supports one issuer (the database
|
||||
schema supports multiple for future expansion)
|
||||
- **Redirect URI** — defaults to request Host header; deployments behind
|
||||
reverse proxies should set `TURNSTONE_OIDC_REDIRECT_BASE` to the
|
||||
externally-reachable origin to pin the redirect URI
|
||||
|
||||
---
|
||||
|
||||
## Token Detection Order
|
||||
@@ -483,3 +572,13 @@ and browsers enforce same-origin policy.
|
||||
refresh, eliminating long-lived static tokens for inter-service auth.
|
||||
- **Secret strength validation** — warning logged when JWT secret is
|
||||
shorter than 32 characters.
|
||||
- **OIDC PKCE enforcement** — S256 code challenge on every
|
||||
authorization request prevents code interception in transit.
|
||||
- **OIDC state/nonce in database** — one-time-use, TTL-bounded tokens
|
||||
stored in the database, safe for multi-node deployments.
|
||||
- **OIDC JWKS-only validation** — ID tokens are verified using the
|
||||
provider's published JWKS keys with asymmetric algorithms only;
|
||||
HMAC-based algorithms are rejected to prevent algorithm confusion.
|
||||
- **OIDC identity binding by (issuer, sub)** — user matching uses the
|
||||
immutable subject identifier, not email or username, preventing
|
||||
account takeover via IdP attribute changes.
|
||||
|
||||
+10
-4
@@ -87,8 +87,11 @@ All prepared items are sent to the UI via `ui.approve_tools(items)`.
|
||||
but do not block execution.
|
||||
- Items where `needs_approval` is `True` require the user to accept or deny.
|
||||
- The user can provide feedback alongside their approval (e.g. "y, use full path").
|
||||
- If `auto_approve` is `True` on the session (headless mode), all tools are
|
||||
approved automatically.
|
||||
- Choosing "always" (key `a`) adds the pending tool names to `auto_approve_tools`,
|
||||
so that specific tool type is auto-approved going forward (other tool types still
|
||||
prompt). This is per-tool, not blanket.
|
||||
- If `auto_approve` is `True` on the session (via `--skip-permissions` or workstream
|
||||
template), all tools are approved automatically.
|
||||
|
||||
### Phase 3: Execute
|
||||
|
||||
@@ -620,8 +623,11 @@ MCP-compatible service.
|
||||
MCP tools **require user approval by default** (`needs_approval: True`). turnstone
|
||||
does not auto-approve MCP tools based on their schema, since it cannot guarantee
|
||||
that external tools are read-only. However, global overrides such as
|
||||
`--skip-permissions` or the UI's "always allow" setting will auto-approve all
|
||||
tools, including MCP tools.
|
||||
`--skip-permissions` will auto-approve all tools, including MCP tools. The
|
||||
interactive "Always" button adds specific tool types to the per-tool auto-approve
|
||||
set. The web UI and server use `approval_label` for MCP tools, giving
|
||||
per-prompt/per-resource granularity. The CLI and bridge use `func_name`, which
|
||||
gives per-tool-type granularity (e.g., all `use_prompt` calls).
|
||||
|
||||
### Sub-agent availability
|
||||
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -76,6 +76,9 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.38/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
]
|
||||
|
||||
@@ -90,6 +93,7 @@ line-length = 100
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
||||
ignore = ["E501"]
|
||||
per-file-ignores = { "turnstone/core/sandbox.py" = ["N802"] }
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update a vendored JavaScript library in turnstone/shared_static/.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/update-vendored-js.sh katex 0.16.39
|
||||
# scripts/update-vendored-js.sh hljs 11.12.0
|
||||
# scripts/update-vendored-js.sh mermaid 11.14.0
|
||||
#
|
||||
# This script:
|
||||
# 1. Downloads the new version from CDN
|
||||
# 2. Creates the new versioned directory
|
||||
# 3. Updates all version references in source files
|
||||
# 4. Removes the old versioned directory
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATIC_DIR="turnstone/shared_static"
|
||||
CDN="https://cdn.jsdelivr.net/npm"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <katex|hljs|mermaid> <version>"
|
||||
echo "Example: $0 katex 0.16.39"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -eq 2 ]] || usage
|
||||
|
||||
LIB="$1"
|
||||
VERSION="$2"
|
||||
|
||||
# Detect current version from pyproject.toml
|
||||
detect_old_version() {
|
||||
local pattern="$1"
|
||||
grep -oE "${pattern}-[0-9.]+" pyproject.toml | head -1 | sed "s/${pattern}-//"
|
||||
}
|
||||
|
||||
# Update version references across all source files
|
||||
update_refs() {
|
||||
local old_pattern="$1" # e.g. katex-0.16.38
|
||||
local new_pattern="$2" # e.g. katex-0.16.39
|
||||
|
||||
# Find all files with version references (excludes vendored JS and worktrees)
|
||||
local files
|
||||
files=$(grep -rl --include='*.toml' --include='*.html' --include='*.js' --include='*.md' \
|
||||
-F "$old_pattern" . \
|
||||
--exclude-dir='.claude' --exclude-dir='node_modules' --exclude-dir='shared_static' \
|
||||
2>/dev/null || true)
|
||||
for f in $files; do
|
||||
sed -i "s|${old_pattern}|${new_pattern}|g" "$f"
|
||||
echo " Updated $f"
|
||||
done
|
||||
}
|
||||
|
||||
case "$LIB" in
|
||||
katex)
|
||||
OLD_VERSION=$(detect_old_version "katex")
|
||||
OLD_DIR="${STATIC_DIR}/katex-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/katex-${VERSION}"
|
||||
|
||||
echo "Updating KaTeX ${OLD_VERSION} -> ${VERSION}"
|
||||
mkdir -p "${NEW_DIR}/fonts"
|
||||
|
||||
echo " Downloading katex.min.js..."
|
||||
curl -sSfL "${CDN}/katex@${VERSION}/dist/katex.min.js" -o "${NEW_DIR}/katex.min.js"
|
||||
echo " Downloading katex.min.css..."
|
||||
curl -sSfL "${CDN}/katex@${VERSION}/dist/katex.min.css" -o "${NEW_DIR}/katex.min.css"
|
||||
|
||||
echo " Downloading fonts..."
|
||||
# Extract font filenames from the CSS
|
||||
font_files=$(curl -sSfL "${CDN}/katex@${VERSION}/dist/katex.min.css" \
|
||||
| grep -oE 'fonts/[^")]+' | sort -u)
|
||||
for font in $font_files; do
|
||||
if ! curl -sSfL "${CDN}/katex@${VERSION}/dist/${font}" -o "${NEW_DIR}/${font}" 2>/dev/null; then
|
||||
echo " WARNING: Failed to download font: ${font}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy LICENSE from old dir if present
|
||||
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
|
||||
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
|
||||
fi
|
||||
|
||||
update_refs "katex-${OLD_VERSION}" "katex-${VERSION}"
|
||||
rm -rf "${OLD_DIR}"
|
||||
echo "Done. Old directory removed: ${OLD_DIR}"
|
||||
;;
|
||||
|
||||
hljs)
|
||||
OLD_VERSION=$(detect_old_version "hljs")
|
||||
OLD_DIR="${STATIC_DIR}/hljs-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/hljs-${VERSION}"
|
||||
|
||||
echo "Updating Highlight.js ${OLD_VERSION} -> ${VERSION}"
|
||||
mkdir -p "${NEW_DIR}"
|
||||
|
||||
echo " Downloading highlight.min.js..."
|
||||
curl -sSfL "${CDN}/@highlightjs/cdn-assets@${VERSION}/highlight.min.js" -o "${NEW_DIR}/highlight.min.js"
|
||||
|
||||
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
|
||||
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
|
||||
fi
|
||||
|
||||
update_refs "hljs-${OLD_VERSION}" "hljs-${VERSION}"
|
||||
rm -rf "${OLD_DIR}"
|
||||
echo "Done. Old directory removed: ${OLD_DIR}"
|
||||
;;
|
||||
|
||||
mermaid)
|
||||
OLD_VERSION=$(detect_old_version "mermaid")
|
||||
OLD_DIR="${STATIC_DIR}/mermaid-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/mermaid-${VERSION}"
|
||||
|
||||
echo "Updating Mermaid ${OLD_VERSION} -> ${VERSION}"
|
||||
mkdir -p "${NEW_DIR}"
|
||||
|
||||
echo " Downloading mermaid.min.js..."
|
||||
curl -sSfL "${CDN}/mermaid@${VERSION}/dist/mermaid.min.js" -o "${NEW_DIR}/mermaid.min.js"
|
||||
|
||||
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
|
||||
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
|
||||
fi
|
||||
|
||||
update_refs "mermaid-${OLD_VERSION}" "mermaid-${VERSION}"
|
||||
rm -rf "${OLD_DIR}"
|
||||
echo "Done. Old directory removed: ${OLD_DIR}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown library: ${LIB}"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "Verify the update:"
|
||||
echo " git diff --stat"
|
||||
echo " python -m turnstone.server # test locally"
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -460,6 +460,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/oidc/authorize": {
|
||||
"get": {
|
||||
"summary": "Redirect to OIDC provider for SSO login",
|
||||
"operationId": "v1_api_auth_oidc_authorize_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/oidc/callback": {
|
||||
"get": {
|
||||
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
|
||||
"operationId": "v1_api_auth_oidc_callback_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/whoami": {
|
||||
"get": {
|
||||
"summary": "Return authenticated user info and permissions",
|
||||
"operationId": "v1_api_auth_whoami_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthWhoamiResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Error 401",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/users": {
|
||||
"get": {
|
||||
"summary": "List all users",
|
||||
@@ -2052,6 +2131,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/templates": {
|
||||
"get": {
|
||||
"summary": "List available prompt templates (summary)",
|
||||
"operationId": "v1_api_templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListPromptTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/usage": {
|
||||
"get": {
|
||||
"summary": "Aggregated usage data",
|
||||
@@ -3158,6 +3258,21 @@
|
||||
"setup_required": {
|
||||
"title": "Setup Required",
|
||||
"type": "boolean"
|
||||
},
|
||||
"oidc_enabled": {
|
||||
"default": false,
|
||||
"title": "Oidc Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"oidc_provider_name": {
|
||||
"default": "",
|
||||
"title": "Oidc Provider Name",
|
||||
"type": "string"
|
||||
},
|
||||
"password_enabled": {
|
||||
"default": true,
|
||||
"title": "Password Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5947,6 +6062,54 @@
|
||||
},
|
||||
"title": "McpReloadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PromptTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Template name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"default": "",
|
||||
"description": "Template category",
|
||||
"title": "Category",
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"default": false,
|
||||
"description": "Whether this template is applied by default",
|
||||
"title": "Is Default",
|
||||
"type": "boolean"
|
||||
},
|
||||
"origin": {
|
||||
"default": "manual",
|
||||
"description": "Template origin: manual or mcp",
|
||||
"title": "Origin",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "PromptTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListPromptTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptTemplateSummary"
|
||||
},
|
||||
"title": "Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"templates"
|
||||
],
|
||||
"title": "ListPromptTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -437,6 +437,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/templates": {
|
||||
"get": {
|
||||
"summary": "List available prompt templates (summary)",
|
||||
"operationId": "v1_api_templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListPromptTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/ws-templates": {
|
||||
"get": {
|
||||
"summary": "List enabled workstream templates (summary)",
|
||||
"operationId": "v1_api_ws-templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListWsTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/login": {
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
@@ -581,6 +623,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/oidc/authorize": {
|
||||
"get": {
|
||||
"summary": "Redirect to OIDC provider for SSO login",
|
||||
"operationId": "v1_api_auth_oidc_authorize_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/oidc/callback": {
|
||||
"get": {
|
||||
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
|
||||
"operationId": "v1_api_auth_oidc_callback_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/whoami": {
|
||||
"get": {
|
||||
"summary": "Return authenticated user info and permissions",
|
||||
"operationId": "v1_api_auth_whoami_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthWhoamiResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Error 401",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/memories": {
|
||||
"get": {
|
||||
"summary": "List structured memories",
|
||||
@@ -975,6 +1096,21 @@
|
||||
"setup_required": {
|
||||
"title": "Setup Required",
|
||||
"type": "boolean"
|
||||
},
|
||||
"oidc_enabled": {
|
||||
"default": false,
|
||||
"title": "Oidc Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"oidc_provider_name": {
|
||||
"default": "",
|
||||
"title": "Oidc Provider Name",
|
||||
"type": "string"
|
||||
},
|
||||
"password_enabled": {
|
||||
"default": true,
|
||||
"title": "Password Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1777,6 +1913,93 @@
|
||||
],
|
||||
"title": "SearchMemoriesRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"PromptTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Template name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"default": "",
|
||||
"description": "Template category",
|
||||
"title": "Category",
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"default": false,
|
||||
"description": "Whether this template is applied by default",
|
||||
"title": "Is Default",
|
||||
"type": "boolean"
|
||||
},
|
||||
"origin": {
|
||||
"default": "manual",
|
||||
"description": "Template origin: manual or mcp",
|
||||
"title": "Origin",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "PromptTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListPromptTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptTemplateSummary"
|
||||
},
|
||||
"title": "Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"templates"
|
||||
],
|
||||
"title": "ListPromptTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"WsTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"model"
|
||||
],
|
||||
"title": "WsTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListWsTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"ws_templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WsTemplateSummary"
|
||||
},
|
||||
"title": "Ws Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_templates"
|
||||
],
|
||||
"title": "ListWsTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+773
-843
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,6 @@
|
||||
"license": "BUSL-1.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4",
|
||||
"vitest": "^2.0"
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface WsStateEvent {
|
||||
context_ratio: number;
|
||||
activity: string;
|
||||
activity_state: string;
|
||||
/** Full assistant response text — populated on idle transitions only. */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface WsActivityEvent {
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
HealthResponse,
|
||||
ListMemoriesOptions,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWsTemplateSummaryResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
SaveMemoryRequest,
|
||||
@@ -196,6 +198,16 @@ export class TurnstoneServer extends BaseClient {
|
||||
return this.request("GET", "/v1/api/workstreams/saved");
|
||||
}
|
||||
|
||||
// -- Templates --------------------------------------------------------------
|
||||
|
||||
async listTemplates(): Promise<ListPromptTemplateSummaryResponse> {
|
||||
return this.request("GET", "/v1/api/templates");
|
||||
}
|
||||
|
||||
async listWsTemplates(): Promise<ListWsTemplateSummaryResponse> {
|
||||
return this.request("GET", "/v1/api/ws-templates");
|
||||
}
|
||||
|
||||
// -- Memories -------------------------------------------------------------
|
||||
|
||||
async listMemories(
|
||||
|
||||
@@ -143,6 +143,35 @@ export interface ListSavedWorkstreamsResponse {
|
||||
workstreams: SavedWorkstreamInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Prompt templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptTemplateSummary {
|
||||
name: string;
|
||||
category: string;
|
||||
is_default: boolean;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface ListPromptTemplateSummaryResponse {
|
||||
templates: PromptTemplateSummary[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Workstream templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WsTemplateSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ListWsTemplateSummaryResponse {
|
||||
ws_templates: WsTemplateSummary[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -169,6 +169,35 @@ class TestRequiredScope:
|
||||
def test_admin_memory_delete_needs_approve(self):
|
||||
assert required_scope("DELETE", "/api/admin/memories/some-id") == "approve"
|
||||
|
||||
# Internal endpoints
|
||||
def test_internal_mcp_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/api/_internal/mcp-reload") == "approve"
|
||||
|
||||
def test_v1_internal_mcp_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/v1/api/_internal/mcp-reload") == "approve"
|
||||
|
||||
def test_internal_config_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/api/_internal/config-reload") == "approve"
|
||||
|
||||
def test_v1_internal_config_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/v1/api/_internal/config-reload") == "approve"
|
||||
|
||||
def test_proxy_internal_config_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/node/n1/v1/api/_internal/config-reload") == "approve"
|
||||
|
||||
def test_proxy_no_v1_internal_config_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/node/n1/api/_internal/config-reload") == "approve"
|
||||
|
||||
def test_proxy_internal_mcp_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/node/n1/v1/api/_internal/mcp-reload") == "approve"
|
||||
|
||||
def test_proxy_no_v1_internal_mcp_reload_needs_approve(self):
|
||||
assert required_scope("POST", "/node/n1/api/_internal/mcp-reload") == "approve"
|
||||
|
||||
def test_get_internal_mcp_reload_needs_read(self):
|
||||
"""Only POST is elevated — GET falls through to read."""
|
||||
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAuthConfig
|
||||
@@ -1375,3 +1404,49 @@ class TestCorsConfigurable:
|
||||
)
|
||||
assert resp.headers.get("Access-Control-Allow-Origin") == "http://example.com"
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestVerifyPassword — OIDC sentinel handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifyPassword:
|
||||
def test_valid_bcrypt_hash(self):
|
||||
from turnstone.core.auth import hash_password, verify_password
|
||||
|
||||
hashed = hash_password("mypassword")
|
||||
assert verify_password("mypassword", hashed) is True
|
||||
assert verify_password("wrongpassword", hashed) is False
|
||||
|
||||
def test_oidc_sentinel_rejected(self):
|
||||
from turnstone.core.auth import verify_password
|
||||
|
||||
# OIDC sentinel must return False, not crash with ValueError
|
||||
assert verify_password("anypassword", "!oidc") is False
|
||||
|
||||
def test_non_bcrypt_hash_rejected(self):
|
||||
from turnstone.core.auth import verify_password
|
||||
|
||||
assert verify_password("password", "not_a_hash") is False
|
||||
assert verify_password("password", "") is False
|
||||
|
||||
def test_empty_password_against_oidc_sentinel(self):
|
||||
from turnstone.core.auth import verify_password
|
||||
|
||||
assert verify_password("", "!oidc") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestOIDCPublicPaths — OIDC endpoints are public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCPublicPaths:
|
||||
def test_oidc_authorize_is_public(self):
|
||||
assert is_public_path("/api/auth/oidc/authorize") is True
|
||||
assert is_public_path("/v1/api/auth/oidc/authorize") is True
|
||||
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.mq.bridge import Bridge
|
||||
from turnstone.mq.protocol import StateChangeEvent, TurnCompleteEvent
|
||||
from turnstone.mq.protocol import ContentEvent, StateChangeEvent, TurnCompleteEvent
|
||||
|
||||
|
||||
def _make_bridge():
|
||||
@@ -67,3 +67,54 @@ class TestIdleTurnComplete:
|
||||
assert len(state_changes) == 1
|
||||
assert state_changes[0].state == "thinking"
|
||||
assert len(turn_completes) == 0
|
||||
|
||||
|
||||
class TestContentPassthrough:
|
||||
"""Bridge should pass through content from the server's idle SSE event."""
|
||||
|
||||
def test_content_passed_through_in_turn_complete(self):
|
||||
"""Content from idle event should be included in TurnCompleteEvent."""
|
||||
bridge = _make_bridge()
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_global_event(
|
||||
{"type": "ws_state", "ws_id": "ws-1", "state": "idle", "content": "Hello world"}
|
||||
)
|
||||
|
||||
turn_completes = [(ws, ev) for ws, ev in published if isinstance(ev, TurnCompleteEvent)]
|
||||
assert len(turn_completes) == 1
|
||||
_, ev = turn_completes[0]
|
||||
assert ev.content == "Hello world"
|
||||
|
||||
def test_content_empty_when_not_in_event(self):
|
||||
"""TurnCompleteEvent.content should be empty when idle event has no content."""
|
||||
bridge = _make_bridge()
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_global_event({"type": "ws_state", "ws_id": "ws-1", "state": "idle"})
|
||||
|
||||
turn_completes = [(ws, ev) for ws, ev in published if isinstance(ev, TurnCompleteEvent)]
|
||||
assert len(turn_completes) == 1
|
||||
_, ev = turn_completes[0]
|
||||
assert ev.content == ""
|
||||
|
||||
def test_content_event_still_published(self):
|
||||
"""Content events should still be published to per-ws channel."""
|
||||
bridge = _make_bridge()
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_ws_event("ws-1", {"type": "content", "text": "hello"})
|
||||
|
||||
content_events = [(ws, ev) for ws, ev in published if isinstance(ev, ContentEvent)]
|
||||
assert len(content_events) == 1
|
||||
_, ev = content_events[0]
|
||||
assert ev.text == "hello"
|
||||
|
||||
@@ -21,7 +21,7 @@ def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _make_message(*, bot=False, guild=True, content="hello", channel=None):
|
||||
def _make_message(*, bot=False, guild=True, content="hello", channel=None, reference=None):
|
||||
"""Build a mock ``discord.Message``."""
|
||||
msg = MagicMock(spec=discord.Message)
|
||||
msg.author = MagicMock()
|
||||
@@ -31,6 +31,7 @@ def _make_message(*, bot=False, guild=True, content="hello", channel=None):
|
||||
msg.guild = MagicMock() if guild else None
|
||||
msg.channel = channel or MagicMock()
|
||||
msg.mentions = []
|
||||
msg.reference = reference
|
||||
return msg
|
||||
|
||||
|
||||
@@ -204,6 +205,8 @@ class TestMessageCog:
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts.config = MagicMock()
|
||||
ts._ws_tasks = {}
|
||||
ts._notify_ws_map = {}
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
@@ -328,6 +331,7 @@ class TestWsEventFinalization:
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
|
||||
# Use the real _on_ws_event method
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
@@ -356,6 +360,7 @@ class TestWsEventFinalization:
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
@@ -387,6 +392,7 @@ class TestApprovalVerdictDisplay:
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
@@ -504,6 +510,7 @@ class TestApprovalVerdictDisplay:
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {"ws-1": MagicMock()}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
@@ -513,6 +520,315 @@ class TestApprovalVerdictDisplay:
|
||||
assert "ws-1" not in bot._pending_approval_msgs
|
||||
|
||||
|
||||
class TestContentCatchup:
|
||||
"""TurnCompleteEvent with content field provides catch-up for missed ContentEvents."""
|
||||
|
||||
def _make_bot(self):
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_catchup_sends_content_when_no_streaming(self):
|
||||
"""TurnCompleteEvent with content but no SM sends catch-up message."""
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
raw = TurnCompleteEvent(
|
||||
ws_id="ws-1", correlation_id="", content="Caught up response"
|
||||
).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
thread.send.assert_awaited_once_with("Caught up response")
|
||||
|
||||
def test_catchup_skipped_when_streaming_exists(self):
|
||||
"""TurnCompleteEvent with content and existing SM uses SM finalize, not catch-up."""
|
||||
from turnstone.mq.protocol import ContentEvent, TurnCompleteEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
# Feed content event to create SM
|
||||
content_raw = ContentEvent(ws_id="ws-1", text="Streamed").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, content_raw))
|
||||
assert "ws-1" in bot._streaming
|
||||
|
||||
# Now TurnCompleteEvent with content — SM should be finalized, not catch-up
|
||||
complete_raw = TurnCompleteEvent(
|
||||
ws_id="ws-1", correlation_id="", content="Streamed"
|
||||
).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, complete_raw))
|
||||
assert "ws-1" not in bot._streaming
|
||||
|
||||
def test_catchup_empty_content_no_message(self):
|
||||
"""TurnCompleteEvent with empty content and no SM sends nothing."""
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
raw = TurnCompleteEvent(ws_id="ws-1", correlation_id="", content="").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
thread.send.assert_not_awaited()
|
||||
|
||||
|
||||
class TestNotificationTracking:
|
||||
"""Tests for notification message tracking and DM reply routing."""
|
||||
|
||||
def test_send_notification_tracks_message(self):
|
||||
"""send_notification should store message_id -> (ws_id, target_user) mapping."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._notify_ws_map = {}
|
||||
bot._MAX_NOTIFY_TRACKING = 100
|
||||
bot.send = AsyncMock(return_value="12345")
|
||||
bot.send_notification = TurnstoneBot.send_notification.__get__(bot, TurnstoneBot)
|
||||
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
|
||||
|
||||
_run(bot.send_notification("chan-1", "Hello", "ws-abc"))
|
||||
|
||||
assert 12345 in bot._notify_ws_map
|
||||
assert bot._notify_ws_map[12345] == ("ws-abc", "chan-1")
|
||||
|
||||
def test_send_notification_evicts_old_entries(self):
|
||||
"""Oldest notification tracking entries are evicted when cap is reached."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._MAX_NOTIFY_TRACKING = 3
|
||||
bot._notify_ws_map = {
|
||||
1: ("ws-1", "u1"),
|
||||
2: ("ws-2", "u2"),
|
||||
3: ("ws-3", "u3"),
|
||||
}
|
||||
bot.send = AsyncMock(return_value="4")
|
||||
bot.send_notification = TurnstoneBot.send_notification.__get__(bot, TurnstoneBot)
|
||||
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
|
||||
|
||||
_run(bot.send_notification("chan-1", "Hello", "ws-4"))
|
||||
|
||||
assert 4 in bot._notify_ws_map
|
||||
assert 1 not in bot._notify_ws_map # oldest evicted
|
||||
assert len(bot._notify_ws_map) <= 3
|
||||
|
||||
def test_dm_reply_routes_to_workstream(self):
|
||||
"""DM reply to a tracked notification routes the message to the workstream."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts._is_allowed_channel = MagicMock(return_value=True)
|
||||
ts.storage = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.resolve_user = AsyncMock(return_value="u_abc")
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts.config = MagicMock()
|
||||
# Maps message_id -> (ws_id, target_discord_user_id)
|
||||
ts._notify_ws_map = {77777: ("ws-target", "12345")}
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
|
||||
# Build a DM reply to the tracked notification message
|
||||
ref = MagicMock()
|
||||
ref.message_id = 77777
|
||||
msg = _make_message(guild=False, content="additional context", reference=ref)
|
||||
# msg.author.id defaults to 12345 from _make_message
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_awaited_once_with("ws-target", "additional context")
|
||||
assert "ws-target" in ts._notify_reply_channels
|
||||
dm_chan, target_uid = ts._notify_reply_channels["ws-target"]
|
||||
assert target_uid == "12345"
|
||||
assert 77777 not in ts._notify_ws_map # cleaned up
|
||||
|
||||
def test_dm_reply_user_mismatch_rejected_and_preserved(self):
|
||||
"""DM reply from wrong user is rejected; entry re-inserted for legitimate user."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.resolve_user = AsyncMock(return_value="u_abc")
|
||||
ts.router.send_message = AsyncMock()
|
||||
# Target user is "99999" but replying user has author.id = 12345
|
||||
ts._notify_ws_map = {77777: ("ws-target", "99999")}
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
ref = MagicMock()
|
||||
ref.message_id = 77777
|
||||
msg = _make_message(guild=False, content="impostor", reference=ref)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
# Entry should be re-inserted so the legitimate user can still reply.
|
||||
assert 77777 in ts._notify_ws_map
|
||||
assert ts._notify_ws_map[77777] == ("ws-target", "99999")
|
||||
|
||||
def test_dm_reply_stale_notification_feedback(self):
|
||||
"""DM reply to an expired/unknown notification should inform the user."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts._notify_ws_map = {} # empty — no tracked notifications
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
|
||||
ref = MagicMock()
|
||||
ref.message_id = 99999 # not in map
|
||||
dm_channel = AsyncMock()
|
||||
msg = _make_message(guild=False, content="reply", reference=ref, channel=dm_channel)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
# Should NOT route to any workstream
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
# Should send feedback to the DM channel
|
||||
dm_channel.send.assert_awaited_once_with("*This notification is no longer active.*")
|
||||
|
||||
def test_dm_without_reference_ignored(self):
|
||||
"""DM without a message reference should be ignored."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts._notify_ws_map = {77777: ("ws-target", "12345")}
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
msg = _make_message(guild=False) # reference=None
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_dm_reply_unlinked_user_ignored(self):
|
||||
"""DM reply from an unlinked user should be ignored."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
|
||||
ts = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.resolve_user = AsyncMock(return_value=None)
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts._notify_ws_map = {77777: ("ws-target", "12345")}
|
||||
ts._notify_reply_channels = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
|
||||
ref = MagicMock()
|
||||
ref.message_id = 77777
|
||||
msg = _make_message(guild=False, content="reply", reference=ref)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_turn_complete_forwards_to_dm(self):
|
||||
"""TurnCompleteEvent should forward content to notification reply DM."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_ws_map = {}
|
||||
bot._MAX_NOTIFY_TRACKING = 100
|
||||
|
||||
dm_channel = AsyncMock()
|
||||
sent_msg = MagicMock()
|
||||
sent_msg.id = 88888
|
||||
dm_channel.send = AsyncMock(return_value=sent_msg)
|
||||
bot._notify_reply_channels = {"ws-1": (dm_channel, "u123")}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
|
||||
raw = TurnCompleteEvent(
|
||||
ws_id="ws-1", correlation_id="", content="Here's the response"
|
||||
).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
# Should send to DM channel
|
||||
dm_channel.send.assert_awaited_once_with("Here's the response")
|
||||
# Should clean up forwarding
|
||||
assert "ws-1" not in bot._notify_reply_channels
|
||||
# Response message should be tracked for multi-turn replies
|
||||
assert 88888 in bot._notify_ws_map
|
||||
assert bot._notify_ws_map[88888] == ("ws-1", "u123")
|
||||
|
||||
def test_turn_complete_cleans_up_dm_even_without_content(self):
|
||||
"""TurnCompleteEvent without content should still clean up DM tracking."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_ws_map = {}
|
||||
|
||||
dm_channel = AsyncMock()
|
||||
bot._notify_reply_channels = {"ws-1": (dm_channel, "u123")}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
|
||||
raw = TurnCompleteEvent(ws_id="ws-1", correlation_id="", content="").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
# DM should not be sent to (no content)
|
||||
dm_channel.send.assert_not_awaited()
|
||||
# But should still be cleaned up
|
||||
assert "ws-1" not in bot._notify_reply_channels
|
||||
# No response tracked (nothing was sent)
|
||||
assert len(bot._notify_ws_map) == 0
|
||||
|
||||
|
||||
class TestChannelCLI:
|
||||
"""Tests for the channel CLI entry point."""
|
||||
|
||||
|
||||
+157
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
@@ -19,6 +19,8 @@ if TYPE_CHECKING:
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
_collect_mcp_status,
|
||||
_notify_nodes_mcp_reload,
|
||||
admin_create_mcp_server,
|
||||
admin_delete_mcp_server,
|
||||
admin_get_mcp_server,
|
||||
@@ -537,3 +539,157 @@ class TestPermission:
|
||||
def test_delete_without_permission(self, client_no_perm):
|
||||
r = client_no_perm.delete(f"/v1/api/admin/mcp-servers/{uuid.uuid4().hex}")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for _collect_mcp_status / _notify_nodes_mcp_reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_request(*nodes: dict[str, Any], proxy_client: Any = None) -> MagicMock:
|
||||
"""Build a minimal mock request with collector and proxy_client."""
|
||||
collector = MagicMock()
|
||||
collector.get_nodes.return_value = (list(nodes), len(nodes))
|
||||
req = MagicMock()
|
||||
req.app.state.collector = collector
|
||||
req.app.state.proxy_client = proxy_client or AsyncMock()
|
||||
req.app.state.proxy_token_mgr = None
|
||||
req.app.state.proxy_auth_token = "tok"
|
||||
return req
|
||||
|
||||
|
||||
def _mock_resp(status_code: int = 200, json_data: Any = None) -> MagicMock:
|
||||
"""Build a mock httpx response (sync .json(), like the real thing)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {}
|
||||
return resp
|
||||
|
||||
|
||||
class TestCollectMcpStatus:
|
||||
@pytest.mark.anyio
|
||||
async def test_returns_servers_on_200(self):
|
||||
resp = _mock_resp(200, {"servers": {"s1": {"status": "ok"}}})
|
||||
client = AsyncMock()
|
||||
client.get.return_value = resp
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {"n1": {"s1": {"status": "ok"}}}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skips_non_200(self):
|
||||
client = AsyncMock()
|
||||
client.get.return_value = _mock_resp(503)
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skips_nodes_without_url(self):
|
||||
client = AsyncMock()
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": ""},
|
||||
{"node_id": "n2"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {}
|
||||
client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handles_exception(self):
|
||||
client = AsyncMock()
|
||||
client.get.side_effect = ConnectionError("refused")
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_cluster(self):
|
||||
req = _fake_request()
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_nodes_mixed(self):
|
||||
ok_resp = _mock_resp(200, {"servers": {"s1": {"status": "ok"}}})
|
||||
err_resp = _mock_resp(500)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get.side_effect = [ok_resp, ConnectionError("down"), err_resp]
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
{"node_id": "n2", "server_url": "http://n2:8000"},
|
||||
{"node_id": "n3", "server_url": "http://n3:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _collect_mcp_status(req)
|
||||
assert result == {"n1": {"s1": {"status": "ok"}}}
|
||||
|
||||
|
||||
class TestNotifyNodesMcpReload:
|
||||
@pytest.mark.anyio
|
||||
async def test_returns_json_on_success(self):
|
||||
client = AsyncMock()
|
||||
client.post.return_value = _mock_resp(200, {"reloaded": 3})
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert result == {"n1": {"reloaded": 3}}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skips_nodes_without_url(self):
|
||||
client = AsyncMock()
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": ""},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert result == {}
|
||||
client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_records_error_on_exception(self):
|
||||
client = AsyncMock()
|
||||
client.post.side_effect = ConnectionError("refused")
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert "n1" in result
|
||||
assert "error" in result["n1"]
|
||||
assert "refused" in result["n1"]["error"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_cluster(self):
|
||||
req = _fake_request()
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_nodes_mixed(self):
|
||||
client = AsyncMock()
|
||||
client.post.side_effect = [
|
||||
_mock_resp(200, {"reloaded": 2}),
|
||||
TimeoutError("timeout"),
|
||||
]
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
{"node_id": "n2", "server_url": "http://n2:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert result["n1"] == {"reloaded": 2}
|
||||
assert "error" in result["n2"]
|
||||
|
||||
@@ -235,6 +235,56 @@ class TestGetAllServerStatus:
|
||||
assert statuses["down"]["tools"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error tracking (_last_error)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorTracking:
|
||||
def test_get_server_status_returns_error(self) -> None:
|
||||
"""Error stored in _last_error flows through get_server_status."""
|
||||
mgr = MCPClientManager({"test": {"command": "echo"}})
|
||||
mgr._last_error["test"] = "Connection refused"
|
||||
status = mgr.get_server_status("test")
|
||||
assert status["error"] == "Connection refused"
|
||||
assert status["connected"] is False
|
||||
|
||||
def test_no_error_by_default(self) -> None:
|
||||
"""Default error is empty string."""
|
||||
mgr = MCPClientManager({"test": {"command": "echo"}})
|
||||
status = mgr.get_server_status("test")
|
||||
assert status["error"] == ""
|
||||
|
||||
def test_error_cleared_after_pop(self) -> None:
|
||||
"""Clearing _last_error makes get_server_status return empty."""
|
||||
mgr = MCPClientManager({"test": {"command": "echo"}})
|
||||
mgr._last_error["test"] = "Connection refused"
|
||||
mgr._last_error.pop("test", None)
|
||||
status = mgr.get_server_status("test")
|
||||
assert status["error"] == ""
|
||||
|
||||
def test_error_cleared_on_remove(self) -> None:
|
||||
"""remove_server_sync cleans up _last_error entry."""
|
||||
mgr = MCPClientManager({"test": {"command": "echo"}})
|
||||
mgr._last_error["test"] = "Connection refused"
|
||||
mgr.remove_server_sync("test")
|
||||
assert "test" not in mgr._last_error
|
||||
|
||||
def test_all_server_status_includes_errors(self) -> None:
|
||||
"""get_all_server_status propagates per-server errors."""
|
||||
mgr = MCPClientManager({"alpha": {}, "bravo": {}})
|
||||
mgr._last_error["alpha"] = "Timeout"
|
||||
statuses = mgr.get_all_server_status()
|
||||
assert statuses["alpha"]["error"] == "Timeout"
|
||||
assert statuses["bravo"]["error"] == ""
|
||||
|
||||
def test_error_does_not_leak_across_servers(self) -> None:
|
||||
"""Error on one server does not affect another."""
|
||||
mgr = MCPClientManager({"a": {}, "b": {}})
|
||||
mgr._last_error["a"] = "Failed"
|
||||
assert mgr.get_server_status("b")["error"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reconcile_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -292,6 +292,69 @@ class TestServerUserScopeSecurity:
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestServerScopeScopeIdValidation:
|
||||
"""scope_id requires scope; global scope rejects scope_id."""
|
||||
|
||||
def test_save_global_with_scope_id_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "global", "scope_id": "ws1"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
def test_save_workstream_without_scope_id_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "workstream"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope_id is required" in r.json()["error"]
|
||||
|
||||
def test_save_workstream_with_scope_id_ok(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "workstream", "scope_id": "ws1"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_list_scope_id_without_scope_rejected(self, server_client):
|
||||
r = server_client.get("/v1/api/memories?scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope is required" in r.json()["error"]
|
||||
|
||||
def test_list_global_with_scope_id_rejected(self, server_client):
|
||||
r = server_client.get("/v1/api/memories?scope=global&scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
def test_search_scope_id_without_scope_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories/search",
|
||||
json={"query": "test", "scope_id": "ws1"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope is required" in r.json()["error"]
|
||||
|
||||
def test_search_global_with_scope_id_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories/search",
|
||||
json={"query": "test", "scope": "global", "scope_id": "ws1"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
def test_delete_global_with_scope_id_rejected(self, server_client):
|
||||
r = server_client.delete("/v1/api/memories/k?scope=global&scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
def test_delete_workstream_without_scope_id_rejected(self, server_client):
|
||||
r = server_client.delete("/v1/api/memories/k?scope=workstream")
|
||||
assert r.status_code == 400
|
||||
assert "scope_id is required" in r.json()["error"]
|
||||
|
||||
|
||||
class TestServerSearchMemories:
|
||||
def test_search(self, server_client, storage):
|
||||
_seed_memory(storage, "db_config", "postgresql host", description="database")
|
||||
@@ -367,6 +430,30 @@ class TestAdminListMemories:
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
|
||||
class TestAdminScopeScopeIdValidation:
|
||||
"""Console admin: scope_id requires scope; global scope rejects scope_id."""
|
||||
|
||||
def test_list_scope_id_without_scope_rejected(self, admin_client):
|
||||
r = admin_client.get("/v1/api/admin/memories?scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope is required" in r.json()["error"]
|
||||
|
||||
def test_list_global_with_scope_id_rejected(self, admin_client):
|
||||
r = admin_client.get("/v1/api/admin/memories?scope=global&scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
def test_search_scope_id_without_scope_rejected(self, admin_client):
|
||||
r = admin_client.get("/v1/api/admin/memories/search?q=test&scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope is required" in r.json()["error"]
|
||||
|
||||
def test_search_global_with_scope_id_rejected(self, admin_client):
|
||||
r = admin_client.get("/v1/api/admin/memories/search?q=test&scope=global&scope_id=ws1")
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
|
||||
|
||||
class TestAdminSearchMemories:
|
||||
def test_search(self, admin_client, storage):
|
||||
_seed_memory(storage, "db_config", "pg host", description="database")
|
||||
|
||||
+116
-9
@@ -14,15 +14,16 @@ from turnstone.core.metacognition import (
|
||||
|
||||
|
||||
class TestDetectCorrection:
|
||||
"""Strong patterns always fire; weak 'no <word>' uses allowlist."""
|
||||
|
||||
# -- strong patterns (always fire) --
|
||||
|
||||
def test_no_comma(self):
|
||||
assert detect_correction("no, that's wrong") is True
|
||||
|
||||
def test_no_period(self):
|
||||
assert detect_correction("no. do it differently") is True
|
||||
|
||||
def test_no_space(self):
|
||||
assert detect_correction("no I meant the other one") is True
|
||||
|
||||
def test_dont(self):
|
||||
assert detect_correction("don't use tabs") is True
|
||||
|
||||
@@ -47,6 +48,57 @@ class TestDetectCorrection:
|
||||
def test_please_dont(self):
|
||||
assert detect_correction("please don't mock the database") is True
|
||||
|
||||
# -- weak pattern: "no" + allowlisted context word --
|
||||
|
||||
def test_no_space(self):
|
||||
assert detect_correction("no I meant the other one") is True
|
||||
|
||||
def test_no_that(self):
|
||||
assert detect_correction("no that's wrong") is True
|
||||
|
||||
def test_no_it(self):
|
||||
assert detect_correction("no it should be different") is True
|
||||
|
||||
def test_no_the(self):
|
||||
assert detect_correction("no the other one") is True
|
||||
|
||||
def test_no_not(self):
|
||||
assert detect_correction("no not that file") is True
|
||||
|
||||
def test_no_you(self):
|
||||
assert detect_correction("no you should use pytest") is True
|
||||
|
||||
# -- negatives: "no <word>" not in allowlist --
|
||||
|
||||
def test_negative_no_problem(self):
|
||||
assert detect_correction("no problem") is False
|
||||
|
||||
def test_negative_no_worries(self):
|
||||
assert detect_correction("no worries") is False
|
||||
|
||||
def test_negative_no_rush(self):
|
||||
assert detect_correction("no rush") is False
|
||||
|
||||
def test_negative_no_one(self):
|
||||
assert detect_correction("no one knows") is False
|
||||
|
||||
def test_negative_no_thanks(self):
|
||||
assert detect_correction("no thanks") is False
|
||||
|
||||
def test_negative_no_doubt(self):
|
||||
assert detect_correction("no doubt about it") is False
|
||||
|
||||
def test_negative_no_idea(self):
|
||||
assert detect_correction("no idea what you mean") is False
|
||||
|
||||
def test_negative_no_kidding(self):
|
||||
assert detect_correction("no kidding") is False
|
||||
|
||||
def test_negative_no_luck(self):
|
||||
assert detect_correction("no luck finding the bug") is False
|
||||
|
||||
# -- negatives: unrelated messages --
|
||||
|
||||
def test_negative_notice(self):
|
||||
assert detect_correction("I noticed the test passes") is False
|
||||
|
||||
@@ -70,27 +122,82 @@ class TestDetectCorrection:
|
||||
|
||||
|
||||
class TestDetectCompletion:
|
||||
def test_thanks(self):
|
||||
assert detect_completion("thanks, that's perfect") is True
|
||||
"""Strong patterns always fire; weak patterns gated by length + continuation."""
|
||||
|
||||
# -- strong patterns (always fire) --
|
||||
|
||||
def test_thats_all(self):
|
||||
assert detect_completion("that's all for now") is True
|
||||
|
||||
def test_lgtm(self):
|
||||
assert detect_completion("lgtm") is True
|
||||
|
||||
# -- weak patterns: short message, no continuation --
|
||||
|
||||
def test_thanks(self):
|
||||
assert detect_completion("thanks, that's perfect") is True
|
||||
|
||||
def test_thanks_standalone(self):
|
||||
assert detect_completion("thanks") is True
|
||||
|
||||
def test_thanks_exclaim(self):
|
||||
assert detect_completion("thanks!") is True
|
||||
|
||||
def test_looks_good(self):
|
||||
assert detect_completion("looks good to me") is True
|
||||
|
||||
def test_perfect(self):
|
||||
assert detect_completion("perfect") is True
|
||||
|
||||
def test_lgtm(self):
|
||||
assert detect_completion("lgtm") is True
|
||||
|
||||
def test_done(self):
|
||||
assert detect_completion("done") is True
|
||||
|
||||
def test_negative_normal(self):
|
||||
def test_great_job(self):
|
||||
assert detect_completion("great job") is True
|
||||
|
||||
def test_that_works(self):
|
||||
assert detect_completion("that works") is True
|
||||
|
||||
# -- negatives: "thanks for" is acknowledgment --
|
||||
|
||||
def test_negative_thanks_for(self):
|
||||
assert detect_completion("thanks for the update") is False
|
||||
|
||||
def test_negative_thanks_for_looking(self):
|
||||
assert detect_completion("thanks for looking into this") is False
|
||||
|
||||
# -- negatives: continuation markers suppress weak patterns --
|
||||
|
||||
def test_negative_thanks_but(self):
|
||||
assert detect_completion("thanks but can you also add tests") is False
|
||||
|
||||
def test_negative_thanks_though(self):
|
||||
assert detect_completion("thanks though I have one more question") is False
|
||||
|
||||
def test_negative_looks_good_but(self):
|
||||
assert detect_completion("looks good but can you also add validation") is False
|
||||
|
||||
def test_negative_perfect_now(self):
|
||||
assert detect_completion("perfect, now add error handling") is False
|
||||
|
||||
def test_negative_done_can_you(self):
|
||||
assert detect_completion("done with that, can you start on the tests?") is False
|
||||
|
||||
def test_negative_question_mark(self):
|
||||
assert detect_completion("can you add error handling?") is False
|
||||
|
||||
# -- negatives: long messages suppress weak patterns --
|
||||
|
||||
def test_negative_thanks_long(self):
|
||||
msg = "thanks, this is really helpful — I was also wondering about the deployment pipeline and whether we need to update the CI config"
|
||||
assert detect_completion(msg) is False
|
||||
|
||||
def test_negative_looks_good_long(self):
|
||||
msg = "looks good overall, there are a few things I'd like to tweak though — the error messages could be more descriptive and the retry logic needs a backoff"
|
||||
assert detect_completion(msg) is False
|
||||
|
||||
# -- negatives: unrelated --
|
||||
|
||||
def test_negative_empty(self):
|
||||
assert detect_completion("") is False
|
||||
|
||||
|
||||
@@ -530,7 +530,9 @@ class TestWorkstreamModelParam:
|
||||
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
@@ -544,7 +546,9 @@ class TestWorkstreamModelParam:
|
||||
def test_create_without_model(self) -> None:
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Tests for turnstone.core.oidc — OIDC authentication support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
|
||||
from turnstone.core.oidc import (
|
||||
OIDCConfig,
|
||||
OIDCError,
|
||||
apply_role_mapping,
|
||||
build_authorize_url,
|
||||
discover_oidc,
|
||||
generate_pkce_pair,
|
||||
load_oidc_config,
|
||||
provision_oidc_user,
|
||||
validate_id_token,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_config(**overrides) -> OIDCConfig:
|
||||
"""Build a test OIDCConfig with sensible defaults."""
|
||||
defaults = {
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "my-client",
|
||||
"client_secret": "my-secret",
|
||||
"scopes": "openid email profile",
|
||||
"provider_name": "TestIDP",
|
||||
"role_claim": "",
|
||||
"role_map": {},
|
||||
"password_enabled": True,
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OIDCConfig(**defaults)
|
||||
|
||||
|
||||
def _mock_storage(**overrides):
|
||||
"""Build a MagicMock with sensible storage defaults."""
|
||||
s = MagicMock()
|
||||
s.get_oidc_identity.return_value = overrides.get("identity")
|
||||
s.get_user.return_value = overrides.get("user")
|
||||
s.get_user_by_username.return_value = overrides.get("user_by_username")
|
||||
s.get_role.return_value = overrides.get("role")
|
||||
return s
|
||||
|
||||
|
||||
def _mock_async_client(mock_get):
|
||||
"""Build a patched httpx.AsyncClient context manager for async tests."""
|
||||
|
||||
class _AsyncCtx:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
async def get(self, url):
|
||||
return await mock_get(url)
|
||||
|
||||
return _AsyncCtx()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadOIDCConfig:
|
||||
def test_load_oidc_config_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_SCOPES", "openid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_PROVIDER_NAME", "Okta")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.enabled is True
|
||||
assert cfg.issuer == "https://auth.example.com"
|
||||
assert cfg.client_id == "cid"
|
||||
assert cfg.client_secret == "csecret"
|
||||
assert cfg.scopes == "openid"
|
||||
assert cfg.provider_name == "Okta"
|
||||
|
||||
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_load_oidc_config_partial_env(self, monkeypatch):
|
||||
"""Only issuer set, no client_id -> enabled=False."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.enabled is False
|
||||
assert cfg.issuer == "https://auth.example.com"
|
||||
assert cfg.client_id == ""
|
||||
|
||||
def test_load_oidc_config_role_map_parsing(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_CLAIM", "roles")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin,eng:builtin-operator")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.role_claim == "roles"
|
||||
assert cfg.role_map == {"admin": "builtin-admin", "eng": "builtin-operator"}
|
||||
|
||||
def test_load_oidc_config_password_enabled_false(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "false")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.enabled is True
|
||||
assert cfg.password_enabled is False
|
||||
|
||||
def test_load_oidc_config_password_enabled_true(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "true")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.password_enabled is True
|
||||
|
||||
def test_load_oidc_config_role_map_empty_entries(self, monkeypatch):
|
||||
"""Role map with empty/whitespace entries should be silently skipped."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin, , :, foo:")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.role_map == {"admin": "builtin-admin"}
|
||||
|
||||
def test_load_oidc_config_defaults(self, monkeypatch):
|
||||
"""Defaults for scopes and provider_name when not set."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_PROVIDER_NAME", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.scopes == "openid email profile"
|
||||
assert cfg.provider_name == "SSO"
|
||||
|
||||
def test_load_oidc_config_redirect_base_from_env(self, monkeypatch):
|
||||
"""TURNSTONE_OIDC_REDIRECT_BASE populates redirect_base."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://app.example.com")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == "https://app.example.com"
|
||||
|
||||
def test_load_oidc_config_redirect_base_strips_trailing_slash(self, monkeypatch):
|
||||
"""Trailing slashes are stripped from redirect_base."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://app.example.com/")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == "https://app.example.com"
|
||||
|
||||
def test_load_oidc_config_redirect_base_default_empty(self, monkeypatch):
|
||||
"""redirect_base defaults to empty string when not set."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_REDIRECT_BASE", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_rejects_path(self, monkeypatch):
|
||||
"""redirect_base with a path component is rejected (falls back to empty)."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://app.example.com/subpath")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_rejects_no_scheme(self, monkeypatch):
|
||||
"""redirect_base without a scheme is rejected."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "app.example.com")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_rejects_userinfo(self, monkeypatch):
|
||||
"""redirect_base with userinfo (user:pass@host) is rejected."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://user:pass@app.example.com")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_rejects_invalid_port(self, monkeypatch):
|
||||
"""redirect_base with non-numeric port is rejected."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://app.example.com:abc")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_rejects_missing_hostname(self, monkeypatch):
|
||||
"""redirect_base without a hostname is rejected."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "https://")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == ""
|
||||
|
||||
def test_load_oidc_config_redirect_base_allows_http(self, monkeypatch):
|
||||
"""http:// redirect_base is allowed (with warning) for local dev."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_REDIRECT_BASE", "http://localhost:8000")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.redirect_base == "http://localhost:8000"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect URI Builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildOIDCRedirectURI:
|
||||
"""Tests for ``_build_oidc_redirect_uri`` in auth.py."""
|
||||
|
||||
def _make_request(self, host="app.example.com", scheme="https", forwarded_proto=""):
|
||||
"""Build a minimal mock Starlette Request."""
|
||||
req = MagicMock()
|
||||
headers = {"host": host}
|
||||
if forwarded_proto:
|
||||
headers["x-forwarded-proto"] = forwarded_proto
|
||||
req.headers = headers
|
||||
req.url.scheme = scheme
|
||||
return req
|
||||
|
||||
def test_pinned_redirect_base(self):
|
||||
"""When redirect_base is set, Host header is ignored."""
|
||||
from turnstone.core.auth import _build_oidc_redirect_uri
|
||||
|
||||
config = _make_config(redirect_base="https://public.example.com")
|
||||
req = self._make_request(host="internal-host:8080", scheme="http")
|
||||
result = _build_oidc_redirect_uri(req, config)
|
||||
assert result == "https://public.example.com/v1/api/auth/oidc/callback"
|
||||
|
||||
def test_fallback_to_host_header(self):
|
||||
"""When redirect_base is empty, redirect URI uses Host header."""
|
||||
from turnstone.core.auth import _build_oidc_redirect_uri
|
||||
|
||||
config = _make_config(redirect_base="")
|
||||
req = self._make_request(host="app.example.com", scheme="https")
|
||||
result = _build_oidc_redirect_uri(req, config)
|
||||
assert result == "https://app.example.com/v1/api/auth/oidc/callback"
|
||||
|
||||
def test_fallback_x_forwarded_proto(self):
|
||||
"""When redirect_base is empty and X-Forwarded-Proto is https, scheme is https."""
|
||||
from turnstone.core.auth import _build_oidc_redirect_uri
|
||||
|
||||
config = _make_config(redirect_base="")
|
||||
req = self._make_request(host="app.example.com", scheme="http", forwarded_proto="https")
|
||||
result = _build_oidc_redirect_uri(req, config)
|
||||
assert result == "https://app.example.com/v1/api/auth/oidc/callback"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPKCE:
|
||||
def test_generate_pkce_pair(self):
|
||||
verifier, challenge = generate_pkce_pair()
|
||||
|
||||
# Verifier should be URL-safe base64
|
||||
assert isinstance(verifier, str)
|
||||
assert len(verifier) > 40 # 48 bytes -> ~64 chars
|
||||
|
||||
# Challenge should be base64url SHA-256 of verifier
|
||||
expected_digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
expected_challenge = base64.urlsafe_b64encode(expected_digest).rstrip(b"=").decode("ascii")
|
||||
assert challenge == expected_challenge
|
||||
|
||||
def test_pkce_challenge_matches_verifier(self):
|
||||
"""Manually compute challenge and verify it matches."""
|
||||
verifier, challenge = generate_pkce_pair()
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
manual_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
assert challenge == manual_challenge
|
||||
|
||||
def test_pkce_pair_uniqueness(self):
|
||||
"""Each call should produce a unique pair."""
|
||||
v1, c1 = generate_pkce_pair()
|
||||
v2, c2 = generate_pkce_pair()
|
||||
assert v1 != v2
|
||||
assert c1 != c2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildAuthorizeURL:
|
||||
def test_build_authorize_url_contains_required_params(self):
|
||||
config = _make_config()
|
||||
verifier, _ = generate_pkce_pair()
|
||||
url = build_authorize_url(
|
||||
config=config,
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
state="test-state",
|
||||
nonce="test-nonce",
|
||||
code_verifier=verifier,
|
||||
)
|
||||
|
||||
assert url.startswith("https://idp.example.com/authorize?")
|
||||
assert "response_type=code" in url
|
||||
assert "client_id=my-client" in url
|
||||
assert "redirect_uri=" in url
|
||||
assert "scope=openid" in url
|
||||
assert "state=test-state" in url
|
||||
assert "nonce=test-nonce" in url
|
||||
assert "code_challenge=" in url
|
||||
assert "code_challenge_method=S256" in url
|
||||
|
||||
def test_build_authorize_url_pkce(self):
|
||||
"""code_challenge in URL should be correct S256 of the verifier."""
|
||||
config = _make_config()
|
||||
verifier, _ = generate_pkce_pair()
|
||||
|
||||
url = build_authorize_url(
|
||||
config=config,
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
state="s",
|
||||
nonce="n",
|
||||
code_verifier=verifier,
|
||||
)
|
||||
|
||||
# Extract code_challenge from URL
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
actual_challenge = params["code_challenge"][0]
|
||||
|
||||
# Compute expected challenge
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
assert actual_challenge == expected
|
||||
|
||||
def test_build_authorize_url_redirect_uri_encoded(self):
|
||||
config = _make_config()
|
||||
verifier, _ = generate_pkce_pair()
|
||||
redirect = "https://app.example.com/callback?extra=1"
|
||||
|
||||
url = build_authorize_url(
|
||||
config=config,
|
||||
redirect_uri=redirect,
|
||||
state="s",
|
||||
nonce="n",
|
||||
code_verifier=verifier,
|
||||
)
|
||||
|
||||
# The redirect_uri should be URL-encoded
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
assert params["redirect_uri"][0] == redirect
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID Token Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateIDToken:
|
||||
_FAKE_JWKS = {"keys": [{"kid": "key1", "kty": "RSA", "n": "abc", "e": "AQAB"}]}
|
||||
|
||||
def test_validate_id_token_nonce_mismatch(self):
|
||||
"""Nonce mismatch should raise OIDCError."""
|
||||
config = _make_config()
|
||||
|
||||
mock_pyjwk = MagicMock()
|
||||
mock_pyjwk.return_value.key = "fake-key"
|
||||
|
||||
with (
|
||||
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
||||
patch("jwt.PyJWK", mock_pyjwk),
|
||||
patch("jwt.decode", return_value={"sub": "user1", "nonce": "wrong-nonce"}),
|
||||
pytest.raises(OIDCError, match="nonce mismatch"),
|
||||
):
|
||||
validate_id_token(
|
||||
raw_token="fake.jwt.token",
|
||||
jwks_data=self._FAKE_JWKS,
|
||||
config=config,
|
||||
nonce="expected-nonce",
|
||||
)
|
||||
|
||||
def test_validate_id_token_success(self):
|
||||
"""Successful validation returns decoded claims."""
|
||||
config = _make_config()
|
||||
|
||||
mock_pyjwk = MagicMock()
|
||||
mock_pyjwk.return_value.key = "fake-key"
|
||||
|
||||
expected_claims = {
|
||||
"sub": "user1",
|
||||
"email": "user@example.com",
|
||||
"nonce": "test-nonce",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
||||
patch("jwt.PyJWK", mock_pyjwk),
|
||||
patch("jwt.decode", return_value=expected_claims) as mock_decode,
|
||||
):
|
||||
claims = validate_id_token(
|
||||
raw_token="fake.jwt.token",
|
||||
jwks_data=self._FAKE_JWKS,
|
||||
config=config,
|
||||
nonce="test-nonce",
|
||||
)
|
||||
|
||||
assert claims == expected_claims
|
||||
mock_decode.assert_called_once_with(
|
||||
"fake.jwt.token",
|
||||
"fake-key",
|
||||
algorithms=[
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
],
|
||||
audience="my-client",
|
||||
issuer="https://idp.example.com",
|
||||
)
|
||||
|
||||
def test_validate_id_token_kid_not_found(self):
|
||||
"""Unknown kid raises OIDCError with descriptive message."""
|
||||
config = _make_config()
|
||||
jwks_data = {"keys": [{"kid": "other-key", "kty": "RSA"}]}
|
||||
|
||||
with (
|
||||
patch("jwt.get_unverified_header", return_value={"kid": "unknown", "alg": "RS256"}),
|
||||
pytest.raises(OIDCError, match="not found in JWKS"),
|
||||
):
|
||||
validate_id_token(
|
||||
raw_token="bad.token",
|
||||
jwks_data=jwks_data,
|
||||
config=config,
|
||||
nonce="n",
|
||||
)
|
||||
|
||||
def test_validate_id_token_invalid_jwt(self):
|
||||
"""Invalid JWT raises OIDCError."""
|
||||
config = _make_config()
|
||||
|
||||
mock_pyjwk = MagicMock()
|
||||
mock_pyjwk.return_value.key = "fake-key"
|
||||
|
||||
with (
|
||||
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
||||
patch("jwt.PyJWK", mock_pyjwk := MagicMock(return_value=MagicMock(key="fake-key"))),
|
||||
patch("jwt.decode", side_effect=pyjwt.InvalidTokenError("expired")),
|
||||
pytest.raises(OIDCError, match="ID token validation failed"),
|
||||
):
|
||||
validate_id_token(
|
||||
raw_token="expired.token",
|
||||
jwks_data=self._FAKE_JWKS,
|
||||
config=config,
|
||||
nonce="n",
|
||||
)
|
||||
|
||||
def test_validate_id_token_invalid_header(self):
|
||||
"""Malformed token header raises OIDCError."""
|
||||
config = _make_config()
|
||||
|
||||
with (
|
||||
patch("jwt.get_unverified_header", side_effect=pyjwt.DecodeError("bad header")),
|
||||
pytest.raises(OIDCError, match="Invalid ID token header"),
|
||||
):
|
||||
validate_id_token(
|
||||
raw_token="garbage",
|
||||
jwks_data=self._FAKE_JWKS,
|
||||
config=config,
|
||||
nonce="n",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User Provisioning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvisionOIDCUser:
|
||||
def test_provision_oidc_user_existing(self):
|
||||
"""Existing identity -> returns existing user, updates last_login."""
|
||||
config = _make_config()
|
||||
existing_user = {
|
||||
"user_id": "u1",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"password_hash": "!oidc",
|
||||
}
|
||||
existing_identity = {
|
||||
"issuer": "https://idp.example.com",
|
||||
"subject": "sub-123",
|
||||
"user_id": "u1",
|
||||
"email": "alice@example.com",
|
||||
"created": "2024-01-01T00:00:00",
|
||||
"last_login": "2024-01-01T00:00:00",
|
||||
}
|
||||
storage = _mock_storage(identity=existing_identity, user=existing_user)
|
||||
|
||||
claims = {"sub": "sub-123", "email": "alice@example.com", "name": "Alice"}
|
||||
user = provision_oidc_user(storage, config, claims)
|
||||
|
||||
assert user["user_id"] == "u1"
|
||||
assert user["username"] == "alice"
|
||||
storage.update_oidc_identity_login.assert_called_once()
|
||||
# Should not create a new user
|
||||
storage.create_user.assert_not_called()
|
||||
storage.create_oidc_identity.assert_not_called()
|
||||
|
||||
def test_provision_oidc_user_new(self):
|
||||
"""No identity -> creates user + identity."""
|
||||
config = _make_config()
|
||||
storage = _mock_storage()
|
||||
|
||||
# After create_user, get_user should return the new user
|
||||
new_user = {
|
||||
"user_id": "u-new",
|
||||
"username": "bob",
|
||||
"display_name": "Bob",
|
||||
"password_hash": "!oidc",
|
||||
}
|
||||
storage.get_user.return_value = new_user
|
||||
|
||||
claims = {"sub": "sub-456", "preferred_username": "bob", "email": "bob@example.com"}
|
||||
|
||||
with patch("turnstone.core.oidc.uuid") as mock_uuid:
|
||||
mock_uuid.uuid4.return_value = MagicMock(hex="u-new-hex-00000000000000000000")
|
||||
user = provision_oidc_user(storage, config, claims)
|
||||
|
||||
assert user["username"] == "bob"
|
||||
storage.create_user.assert_called_once()
|
||||
storage.create_oidc_identity.assert_called_once()
|
||||
# Verify create_oidc_identity was called with correct issuer and sub
|
||||
call_args = storage.create_oidc_identity.call_args
|
||||
assert call_args[0][0] == "https://idp.example.com" # issuer
|
||||
assert call_args[0][1] == "sub-456" # subject
|
||||
|
||||
def test_provision_oidc_user_username_dedup(self):
|
||||
"""First username taken -> appends suffix."""
|
||||
config = _make_config()
|
||||
storage = _mock_storage()
|
||||
|
||||
# First call: username "bob" exists; second call: "bob2" doesn't exist
|
||||
storage.get_user_by_username.side_effect = [
|
||||
{"user_id": "u-other", "username": "bob"}, # "bob" taken
|
||||
None, # "bob2" available
|
||||
]
|
||||
new_user = {
|
||||
"user_id": "u-new",
|
||||
"username": "bob2",
|
||||
"display_name": "Bob",
|
||||
"password_hash": "!oidc",
|
||||
}
|
||||
storage.get_user.return_value = new_user
|
||||
|
||||
claims = {"sub": "sub-789", "preferred_username": "bob", "email": "bob@example.com"}
|
||||
user = provision_oidc_user(storage, config, claims)
|
||||
|
||||
assert user["username"] == "bob2"
|
||||
# create_user should have been called with "bob2" as username
|
||||
call_args = storage.create_user.call_args
|
||||
assert call_args[0][1] == "bob2"
|
||||
|
||||
def test_provision_oidc_user_email_prefix(self):
|
||||
"""No preferred_username -> uses email prefix."""
|
||||
config = _make_config()
|
||||
storage = _mock_storage()
|
||||
|
||||
new_user = {
|
||||
"user_id": "u-new",
|
||||
"username": "charlie",
|
||||
"display_name": "charlie@example.com",
|
||||
"password_hash": "!oidc",
|
||||
}
|
||||
storage.get_user.return_value = new_user
|
||||
|
||||
claims = {"sub": "sub-abc", "email": "charlie@example.com"}
|
||||
provision_oidc_user(storage, config, claims)
|
||||
|
||||
# create_user should have been called with "charlie" (email prefix)
|
||||
call_args = storage.create_user.call_args
|
||||
assert call_args[0][1] == "charlie"
|
||||
|
||||
def test_provision_oidc_user_missing_user_raises(self):
|
||||
"""Identity references missing user -> raises OIDCError."""
|
||||
config = _make_config()
|
||||
existing_identity = {
|
||||
"issuer": "https://idp.example.com",
|
||||
"subject": "sub-orphan",
|
||||
"user_id": "u-gone",
|
||||
"email": "gone@example.com",
|
||||
"created": "2024-01-01T00:00:00",
|
||||
"last_login": "2024-01-01T00:00:00",
|
||||
}
|
||||
storage = _mock_storage(identity=existing_identity, user=None)
|
||||
|
||||
claims = {"sub": "sub-orphan", "email": "gone@example.com"}
|
||||
with pytest.raises(OIDCError, match="missing user"):
|
||||
provision_oidc_user(storage, config, claims)
|
||||
|
||||
def test_provision_oidc_user_fallback_username(self):
|
||||
"""No preferred_username and no email -> falls back to 'user'."""
|
||||
config = _make_config()
|
||||
storage = _mock_storage()
|
||||
new_user = {
|
||||
"user_id": "u-new",
|
||||
"username": "user",
|
||||
"display_name": "",
|
||||
"password_hash": "!oidc",
|
||||
}
|
||||
storage.get_user.return_value = new_user
|
||||
|
||||
claims = {"sub": "sub-noemail"}
|
||||
provision_oidc_user(storage, config, claims)
|
||||
|
||||
call_args = storage.create_user.call_args
|
||||
assert call_args[0][1] == "user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role Mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyRoleMapping:
|
||||
def test_apply_role_mapping_basic(self):
|
||||
"""Maps claim value to role."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin"},
|
||||
)
|
||||
storage = _mock_storage(role={"role_id": "builtin-admin", "name": "Admin"})
|
||||
|
||||
claims = {"sub": "u1", "groups": "admin"}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_called_once_with("u1", "builtin-admin", "oidc")
|
||||
|
||||
def test_apply_role_mapping_list_claim(self):
|
||||
"""Claim is a list of strings -> maps each."""
|
||||
config = _make_config(
|
||||
role_claim="roles",
|
||||
role_map={"admin": "builtin-admin", "editor": "builtin-operator"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
# get_role returns non-None for both roles
|
||||
storage.get_role.return_value = {"role_id": "some-role"}
|
||||
|
||||
claims = {"sub": "u1", "roles": ["admin", "editor"]}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
assert storage.assign_role.call_count == 2
|
||||
|
||||
def test_apply_role_mapping_no_config(self):
|
||||
"""No role_claim configured -> no-op."""
|
||||
config = _make_config(role_claim="", role_map={})
|
||||
storage = _mock_storage()
|
||||
|
||||
claims = {"sub": "u1", "roles": "admin"}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_unknown_role(self):
|
||||
"""Claim maps to nonexistent role -> skipped."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "nonexistent-role"},
|
||||
)
|
||||
storage = _mock_storage(role=None) # role doesn't exist
|
||||
|
||||
claims = {"sub": "u1", "groups": "admin"}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_no_matching_claim_value(self):
|
||||
"""Claim value not in role_map -> no assignment."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
|
||||
claims = {"sub": "u1", "groups": "viewer"} # "viewer" not in role_map
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_claim_missing(self):
|
||||
"""Claim key not present in claims -> no-op."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
|
||||
claims = {"sub": "u1"} # no "groups" key
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_no_role_map(self):
|
||||
"""role_claim set but role_map empty -> no-op (early return)."""
|
||||
config = _make_config(role_claim="groups", role_map={})
|
||||
storage = _mock_storage()
|
||||
|
||||
claims = {"sub": "u1", "groups": "admin"}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
storage.assign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_revokes_stale_oidc_roles(self):
|
||||
"""Roles previously assigned by OIDC but no longer in claims are revoked."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin", "eng": "builtin-operator"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
storage.get_role.return_value = {"role_id": "some-role"}
|
||||
# User currently has admin (via OIDC) and a manual role
|
||||
storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "assigned_by": "oidc"},
|
||||
{"role_id": "custom-role", "assigned_by": "admin-ui"},
|
||||
]
|
||||
|
||||
# IdP now only says "eng", not "admin"
|
||||
claims = {"sub": "u1", "groups": ["eng"]}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
# builtin-admin should be revoked (OIDC-assigned, no longer in claims)
|
||||
storage.unassign_role.assert_called_once_with("u1", "builtin-admin")
|
||||
# custom-role should NOT be revoked (not assigned by OIDC)
|
||||
|
||||
def test_apply_role_mapping_preserves_manual_roles(self):
|
||||
"""Manually assigned roles are never revoked by OIDC sync."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
storage.get_role.return_value = {"role_id": "some-role"}
|
||||
storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "assigned_by": "admin-ui"},
|
||||
]
|
||||
|
||||
# Claims have no groups at all
|
||||
claims = {"sub": "u1"}
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
# Manual admin role must NOT be revoked
|
||||
storage.unassign_role.assert_not_called()
|
||||
|
||||
def test_apply_role_mapping_revokes_all_oidc_roles_when_claim_absent(self):
|
||||
"""When the claim is absent from the token, all OIDC-assigned roles are revoked."""
|
||||
config = _make_config(
|
||||
role_claim="groups",
|
||||
role_map={"admin": "builtin-admin"},
|
||||
)
|
||||
storage = _mock_storage()
|
||||
storage.get_role.return_value = {"role_id": "some-role"}
|
||||
storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "assigned_by": "oidc"},
|
||||
{"role_id": "builtin-operator", "assigned_by": "oidc"},
|
||||
]
|
||||
|
||||
claims = {"sub": "u1"} # no "groups" key
|
||||
apply_role_mapping(storage, "u1", claims, config)
|
||||
|
||||
assert storage.unassign_role.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery (async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoverOIDC:
|
||||
def test_discover_oidc_success(self):
|
||||
"""Mock httpx response, verify endpoints populated."""
|
||||
config = _make_config(
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
discovery_doc = {
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.authorization_endpoint == "https://idp.example.com/authorize"
|
||||
assert result.token_endpoint == "https://idp.example.com/token"
|
||||
assert result.userinfo_endpoint == "https://idp.example.com/userinfo"
|
||||
assert result.jwks_uri == "https://idp.example.com/.well-known/jwks.json"
|
||||
assert result.enabled is True
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_failure(self):
|
||||
"""Mock httpx error -> enabled=False returned."""
|
||||
config = _make_config(
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
async def _failing_get(url):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(_failing_get)
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_no_issuer(self):
|
||||
"""Empty issuer -> enabled=False."""
|
||||
config = _make_config(issuer="")
|
||||
|
||||
async def _run():
|
||||
result = await discover_oidc(config)
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_missing_required_endpoints(self):
|
||||
"""Discovery doc missing authorization_endpoint -> enabled=False."""
|
||||
config = _make_config(
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
# Document missing authorization_endpoint
|
||||
discovery_doc = {
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
async def _async_return(value):
|
||||
"""Helper: return a value from an async function."""
|
||||
return value
|
||||
@@ -0,0 +1,581 @@
|
||||
"""Integration tests for OIDC HTTP handlers (authorize, callback, admin endpoints).
|
||||
|
||||
Uses Starlette TestClient with real SQLiteBackend storage. External OIDC
|
||||
functions (exchange_code, validate_id_token, etc.) are mocked — the focus is
|
||||
on the HTTP handler logic, request/response wiring, and storage side-effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_delete_oidc_identity,
|
||||
admin_list_oidc_identities,
|
||||
)
|
||||
from turnstone.core.auth import (
|
||||
AuthResult,
|
||||
LoginRateLimiter,
|
||||
handle_oidc_authorize,
|
||||
handle_oidc_callback,
|
||||
)
|
||||
from turnstone.core.oidc import OIDCConfig, OIDCError
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_oidc_config(**overrides: Any) -> OIDCConfig:
|
||||
"""Build a test OIDCConfig with sensible defaults."""
|
||||
defaults: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "my-client",
|
||||
"client_secret": "my-secret",
|
||||
"scopes": "openid email profile",
|
||||
"provider_name": "TestIDP",
|
||||
"role_claim": "",
|
||||
"role_map": {},
|
||||
"password_enabled": True,
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OIDCConfig(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thin handler wrappers — match the pattern used in server.py / console
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _oidc_authorize(request: Request) -> Response:
|
||||
return await handle_oidc_authorize(request, "test-audience")
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request) -> Response:
|
||||
return await handle_oidc_callback(request, "test-audience")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth bypass middleware for admin endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset(
|
||||
{
|
||||
"read",
|
||||
"write",
|
||||
"approve",
|
||||
"admin.users",
|
||||
}
|
||||
),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Any) -> SQLiteBackend:
|
||||
"""Fresh SQLite backend with a seeded admin user."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
backend.create_user("test-admin", "testadmin", "Test Admin", "hash")
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_config() -> OIDCConfig:
|
||||
return _make_oidc_config()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authorize_client(storage: SQLiteBackend, oidc_config: OIDCConfig) -> TestClient:
|
||||
"""TestClient wired to the OIDC authorize + callback handlers."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
Route("/api/auth/oidc/authorize", _oidc_authorize),
|
||||
Route("/api/auth/oidc/callback", _oidc_callback),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "test-jwt-secret"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(storage: SQLiteBackend) -> TestClient:
|
||||
"""TestClient wired to the admin OIDC identity endpoints."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/oidc-identities",
|
||||
admin_list_oidc_identities,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/oidc-identities",
|
||||
admin_delete_oidc_identity,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /authorize tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCAuthorize:
|
||||
"""Tests for GET /v1/api/auth/oidc/authorize."""
|
||||
|
||||
def test_happy_path_redirects_to_idp(self, authorize_client: TestClient) -> None:
|
||||
resp = authorize_client.get("/v1/api/auth/oidc/authorize", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers["location"]
|
||||
assert location.startswith("https://idp.example.com/authorize?")
|
||||
parsed = urllib.parse.urlparse(location)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["client_id"] == ["my-client"]
|
||||
assert params["scope"] == ["openid email profile"]
|
||||
assert "state" in params
|
||||
assert "nonce" in params
|
||||
assert "code_challenge" in params
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
|
||||
def test_oidc_not_configured_returns_404(
|
||||
self,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
# No oidc_config at all
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/authorize")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"] == "OIDC not configured"
|
||||
|
||||
def test_oidc_not_enabled_returns_404(
|
||||
self,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config(enabled=False)
|
||||
app.state.auth_storage = storage
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/authorize")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_no_storage_returns_503(self) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.login_limiter = None
|
||||
# No auth_storage
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/authorize")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_no_users_returns_403(self, tmp_path: Any) -> None:
|
||||
backend = SQLiteBackend(str(tmp_path / "empty.db"))
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = backend
|
||||
app.state.login_limiter = None
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/authorize")
|
||||
assert resp.status_code == 403
|
||||
assert "setup" in resp.json()["error"].lower()
|
||||
|
||||
def test_pending_state_persisted(
|
||||
self,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
resp = authorize_client.get("/v1/api/auth/oidc/authorize", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers["location"]
|
||||
parsed = urllib.parse.urlparse(location)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
state = params["state"][0]
|
||||
# The pending state should be retrievable from storage
|
||||
pending = storage.pop_oidc_pending_state(state, max_age_seconds=300)
|
||||
assert pending is not None
|
||||
assert pending["audience"] == "test-audience"
|
||||
assert pending["nonce"] != ""
|
||||
assert pending["code_verifier"] != ""
|
||||
|
||||
def test_rate_limited_redirects_with_error(self, storage: SQLiteBackend) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = storage
|
||||
limiter = LoginRateLimiter(max_attempts=1, window_seconds=300)
|
||||
# Exhaust the rate limit
|
||||
limiter.record("ip:testclient")
|
||||
app.state.login_limiter = limiter
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/authorize", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_error" in resp.headers["location"]
|
||||
assert "Too+many" in resp.headers["location"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /callback tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCCallback:
|
||||
"""Tests for GET /v1/api/auth/oidc/callback."""
|
||||
|
||||
def _seed_pending_state(
|
||||
self,
|
||||
storage: SQLiteBackend,
|
||||
state: str = "valid-state",
|
||||
nonce: str = "test-nonce",
|
||||
code_verifier: str = "test-verifier",
|
||||
audience: str = "test-audience",
|
||||
) -> None:
|
||||
storage.create_oidc_pending_state(state, nonce, code_verifier, audience)
|
||||
|
||||
@patch("turnstone.core.oidc.provision_oidc_user")
|
||||
@patch("turnstone.core.oidc.validate_id_token")
|
||||
@patch("turnstone.core.oidc.exchange_code", new_callable=AsyncMock)
|
||||
def test_happy_path(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
self._seed_pending_state(storage)
|
||||
mock_exchange.return_value = {"id_token": "fake.jwt.token", "access_token": "at"}
|
||||
mock_validate.return_value = {
|
||||
"sub": "user123",
|
||||
"email": "u@example.com",
|
||||
"nonce": "test-nonce",
|
||||
}
|
||||
mock_provision.return_value = {"user_id": "test-admin", "username": "testadmin"}
|
||||
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=valid-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
assert "set-cookie" in resp.headers
|
||||
assert "turnstone_auth=" in resp.headers["set-cookie"]
|
||||
|
||||
def test_oidc_not_configured_returns_404(self, storage: SQLiteBackend) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/callback", _oidc_callback)])]
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
# No oidc_config
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/v1/api/auth/oidc/callback?code=x&state=y")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_idp_error_param_redirects(
|
||||
self,
|
||||
authorize_client: TestClient,
|
||||
) -> None:
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?error=access_denied&error_description=User+cancelled",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers["location"]
|
||||
assert "oidc_error" in location
|
||||
assert "User" in urllib.parse.unquote(location)
|
||||
|
||||
def test_invalid_state_redirects_expired(
|
||||
self,
|
||||
authorize_client: TestClient,
|
||||
) -> None:
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=nonexistent",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "Login+session+expired" in resp.headers["location"]
|
||||
|
||||
def test_expired_state_redirects(
|
||||
self,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
# Insert state then backdate created_at via raw SQL so that
|
||||
# pop_oidc_pending_state's max_age_seconds=300 check rejects it.
|
||||
self._seed_pending_state(storage, state="old-state")
|
||||
import sqlalchemy as sa
|
||||
|
||||
with storage._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE oidc_pending_states SET created_at = '2020-01-01T00:00:00' "
|
||||
"WHERE state = 'old-state'"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=old-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "Login+session+expired" in resp.headers["location"]
|
||||
|
||||
@patch("turnstone.core.oidc.exchange_code", new_callable=AsyncMock)
|
||||
def test_code_exchange_failure(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
self._seed_pending_state(storage)
|
||||
mock_exchange.side_effect = OIDCError("Token endpoint error")
|
||||
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=badcode&state=valid-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "Authentication+failed" in resp.headers["location"]
|
||||
|
||||
@patch("turnstone.core.oidc.validate_id_token")
|
||||
@patch("turnstone.core.oidc.exchange_code", new_callable=AsyncMock)
|
||||
def test_token_validation_failure(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
self._seed_pending_state(storage)
|
||||
mock_exchange.return_value = {"id_token": "fake.jwt.token"}
|
||||
mock_validate.side_effect = OIDCError("Signature invalid")
|
||||
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=valid-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "Authentication+failed" in resp.headers["location"]
|
||||
|
||||
@patch("turnstone.core.oidc.provision_oidc_user")
|
||||
@patch("turnstone.core.oidc.validate_id_token")
|
||||
@patch("turnstone.core.oidc.fetch_jwks", new_callable=AsyncMock)
|
||||
@patch("turnstone.core.oidc.exchange_code", new_callable=AsyncMock)
|
||||
def test_jwks_key_rotation_retry(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_fetch_jwks: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
authorize_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
"""First validate raises 'kid not found in JWKS', fetch_jwks retried, second validate succeeds."""
|
||||
self._seed_pending_state(storage)
|
||||
mock_exchange.return_value = {"id_token": "fake.jwt.token"}
|
||||
|
||||
# First call raises kid-not-found; second call (after JWKS refresh) succeeds
|
||||
mock_validate.side_effect = [
|
||||
OIDCError("Signing key 'new-kid' not found in JWKS"),
|
||||
{"sub": "user123", "email": "u@example.com", "nonce": "test-nonce"},
|
||||
]
|
||||
mock_fetch_jwks.return_value = {"keys": [{"kid": "new-kid", "kty": "RSA"}]}
|
||||
mock_provision.return_value = {"user_id": "test-admin", "username": "testadmin"}
|
||||
|
||||
resp = authorize_client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=valid-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
mock_fetch_jwks.assert_called_once()
|
||||
assert mock_validate.call_count == 2
|
||||
|
||||
@patch("turnstone.core.oidc.provision_oidc_user")
|
||||
@patch("turnstone.core.oidc.validate_id_token")
|
||||
@patch("turnstone.core.oidc.exchange_code", new_callable=AsyncMock)
|
||||
def test_no_users_after_oidc_success_redirects_setup(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
"""When OIDC succeeds but no users exist (edge case), redirect with setup error."""
|
||||
# Use a fresh empty-user storage
|
||||
backend = SQLiteBackend(str(tmp_path / "empty.db"))
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/callback", _oidc_callback)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = backend
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
|
||||
# Seed a pending state in the empty database
|
||||
backend.create_oidc_pending_state("state1", "nonce1", "verifier1", "test-audience")
|
||||
|
||||
mock_exchange.return_value = {"id_token": "fake.jwt.token"}
|
||||
mock_validate.return_value = {"sub": "u1", "email": "u@example.com", "nonce": "nonce1"}
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=state1",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "Initial+setup+required" in resp.headers["location"]
|
||||
|
||||
def test_rate_limited_redirects_with_error(self, storage: SQLiteBackend) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/callback", _oidc_callback)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
limiter = LoginRateLimiter(max_attempts=1, window_seconds=300)
|
||||
limiter.record("ip:testclient")
|
||||
app.state.login_limiter = limiter
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get(
|
||||
"/v1/api/auth/oidc/callback?code=authcode&state=x",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_error" in resp.headers["location"]
|
||||
assert "Too+many" in resp.headers["location"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin OIDC identity endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdminOIDCIdentities:
|
||||
"""Tests for admin OIDC identity management endpoints."""
|
||||
|
||||
def test_list_identities(
|
||||
self,
|
||||
admin_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
storage.create_oidc_identity(
|
||||
"https://idp.example.com",
|
||||
"sub-123",
|
||||
"test-admin",
|
||||
"admin@example.com",
|
||||
)
|
||||
resp = admin_client.get("/v1/api/admin/users/test-admin/oidc-identities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["oidc_identities"]) == 1
|
||||
identity = data["oidc_identities"][0]
|
||||
assert identity["issuer"] == "https://idp.example.com"
|
||||
assert identity["subject"] == "sub-123"
|
||||
assert identity["user_id"] == "test-admin"
|
||||
assert identity["email"] == "admin@example.com"
|
||||
|
||||
def test_list_empty(self, admin_client: TestClient) -> None:
|
||||
resp = admin_client.get("/v1/api/admin/users/test-admin/oidc-identities")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["oidc_identities"] == []
|
||||
|
||||
def test_delete_identity(
|
||||
self,
|
||||
admin_client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
storage.create_oidc_identity(
|
||||
"https://idp.example.com",
|
||||
"sub-456",
|
||||
"test-admin",
|
||||
"admin@example.com",
|
||||
)
|
||||
resp = admin_client.delete(
|
||||
"/v1/api/admin/oidc-identities?issuer=https://idp.example.com&subject=sub-456",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
# Verify it's gone
|
||||
assert storage.get_oidc_identity("https://idp.example.com", "sub-456") is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, admin_client: TestClient) -> None:
|
||||
resp = admin_client.delete(
|
||||
"/v1/api/admin/oidc-identities?issuer=https://no.such&subject=nope",
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["error"].lower()
|
||||
|
||||
def test_delete_missing_params_returns_400(self, admin_client: TestClient) -> None:
|
||||
# Missing subject
|
||||
resp = admin_client.delete(
|
||||
"/v1/api/admin/oidc-identities?issuer=https://idp.example.com",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "required" in resp.json()["error"].lower()
|
||||
|
||||
# Missing both
|
||||
resp = admin_client.delete("/v1/api/admin/oidc-identities")
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests for OIDC identity and pending state storage CRUD (SQLite backend)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC Identity CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCIdentityCRUD:
|
||||
def test_create_and_get_oidc_identity(self, db):
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
|
||||
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
|
||||
assert identity is not None
|
||||
assert identity["issuer"] == "https://idp.example.com"
|
||||
assert identity["subject"] == "sub-123"
|
||||
assert identity["user_id"] == "u1"
|
||||
assert identity["email"] == "alice@example.com"
|
||||
assert identity["created"] != ""
|
||||
assert identity["last_login"] != ""
|
||||
|
||||
def test_get_oidc_identity_not_found(self, db):
|
||||
assert db.get_oidc_identity("https://unknown.example.com", "sub-999") is None
|
||||
|
||||
def test_create_oidc_identity_idempotent(self, db):
|
||||
"""Creating twice with same (issuer, subject) does not error (OR IGNORE)."""
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-123", "u2", "bob@example.com")
|
||||
|
||||
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
|
||||
assert identity is not None
|
||||
# OR IGNORE preserves the first insert
|
||||
assert identity["user_id"] == "u1"
|
||||
assert identity["email"] == "alice@example.com"
|
||||
|
||||
def test_update_oidc_identity_login(self, db):
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
|
||||
|
||||
before = db.get_oidc_identity("https://idp.example.com", "sub-123")
|
||||
assert before is not None
|
||||
original_login = before["last_login"]
|
||||
|
||||
# Small sleep to ensure timestamp differs
|
||||
time.sleep(0.05)
|
||||
|
||||
result = db.update_oidc_identity_login("https://idp.example.com", "sub-123")
|
||||
assert result is True
|
||||
|
||||
after = db.get_oidc_identity("https://idp.example.com", "sub-123")
|
||||
assert after is not None
|
||||
assert after["last_login"] >= original_login
|
||||
|
||||
def test_update_oidc_identity_login_nonexistent(self, db):
|
||||
result = db.update_oidc_identity_login("https://idp.example.com", "sub-999")
|
||||
assert result is False
|
||||
|
||||
def test_list_oidc_identities_for_user(self, db):
|
||||
"""Two identities for same user, list returns both."""
|
||||
db.create_oidc_identity("https://idp1.example.com", "sub-A", "u1", "alice@idp1.com")
|
||||
db.create_oidc_identity("https://idp2.example.com", "sub-B", "u1", "alice@idp2.com")
|
||||
|
||||
identities = db.list_oidc_identities_for_user("u1")
|
||||
assert len(identities) == 2
|
||||
issuers = {i["issuer"] for i in identities}
|
||||
assert issuers == {"https://idp1.example.com", "https://idp2.example.com"}
|
||||
|
||||
def test_list_oidc_identities_for_user_empty(self, db):
|
||||
assert db.list_oidc_identities_for_user("u-none") == []
|
||||
|
||||
def test_list_oidc_identities_excludes_other_users(self, db):
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "alice@example.com")
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-2", "u2", "bob@example.com")
|
||||
|
||||
identities = db.list_oidc_identities_for_user("u1")
|
||||
assert len(identities) == 1
|
||||
assert identities[0]["user_id"] == "u1"
|
||||
|
||||
def test_delete_oidc_identity(self, db):
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
|
||||
assert db.delete_oidc_identity("https://idp.example.com", "sub-123") is True
|
||||
assert db.get_oidc_identity("https://idp.example.com", "sub-123") is None
|
||||
|
||||
def test_delete_oidc_identity_nonexistent(self, db):
|
||||
assert db.delete_oidc_identity("https://idp.example.com", "sub-999") is False
|
||||
|
||||
def test_delete_oidc_identity_only_deletes_target(self, db):
|
||||
"""Deleting one identity does not affect others."""
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "a@example.com")
|
||||
db.create_oidc_identity("https://idp.example.com", "sub-2", "u1", "b@example.com")
|
||||
|
||||
db.delete_oidc_identity("https://idp.example.com", "sub-1")
|
||||
|
||||
assert db.get_oidc_identity("https://idp.example.com", "sub-1") is None
|
||||
assert db.get_oidc_identity("https://idp.example.com", "sub-2") is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC Pending State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCPendingState:
|
||||
def test_create_and_pop_pending_state(self, db):
|
||||
db.create_oidc_pending_state(
|
||||
state="state-abc",
|
||||
nonce="nonce-xyz",
|
||||
code_verifier="verifier-123",
|
||||
audience="server",
|
||||
)
|
||||
|
||||
result = db.pop_oidc_pending_state("state-abc")
|
||||
assert result is not None
|
||||
assert result["state"] == "state-abc"
|
||||
assert result["nonce"] == "nonce-xyz"
|
||||
assert result["code_verifier"] == "verifier-123"
|
||||
assert result["audience"] == "server"
|
||||
assert result["created_at"] != ""
|
||||
|
||||
def test_pop_pending_state_not_found(self, db):
|
||||
assert db.pop_oidc_pending_state("nonexistent-state") is None
|
||||
|
||||
def test_pop_pending_state_expired(self, db):
|
||||
"""Create with old timestamp, pop returns None."""
|
||||
# Insert a row with an old created_at timestamp directly
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": "state-old",
|
||||
"nonce": "nonce-old",
|
||||
"code_verifier": "verifier-old",
|
||||
"audience": "server",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Default max_age_seconds=300, so a 2020 timestamp is expired
|
||||
result = db.pop_oidc_pending_state("state-old")
|
||||
assert result is None
|
||||
|
||||
def test_pop_pending_state_consumed(self, db):
|
||||
"""Pop twice -> second returns None (one-time use)."""
|
||||
db.create_oidc_pending_state(
|
||||
state="state-once",
|
||||
nonce="nonce-1",
|
||||
code_verifier="verifier-1",
|
||||
audience="server",
|
||||
)
|
||||
|
||||
first = db.pop_oidc_pending_state("state-once")
|
||||
assert first is not None
|
||||
|
||||
second = db.pop_oidc_pending_state("state-once")
|
||||
assert second is None
|
||||
|
||||
def test_pop_pending_state_custom_max_age(self, db):
|
||||
"""Custom max_age_seconds allows longer-lived states."""
|
||||
db.create_oidc_pending_state(
|
||||
state="state-long",
|
||||
nonce="nonce-long",
|
||||
code_verifier="verifier-long",
|
||||
audience="server",
|
||||
)
|
||||
|
||||
# With very short max_age, it might still be valid since we just created it
|
||||
result = db.pop_oidc_pending_state("state-long", max_age_seconds=600)
|
||||
assert result is not None
|
||||
|
||||
def test_create_pending_state_duplicate_raises(self, db):
|
||||
"""Duplicate state insertion raises IntegrityError (no silent drop)."""
|
||||
import sqlalchemy.exc
|
||||
|
||||
db.create_oidc_pending_state("state-dup", "nonce-1", "verifier-1", "server")
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
db.create_oidc_pending_state("state-dup", "nonce-2", "verifier-2", "server")
|
||||
|
||||
def test_cleanup_expired_states(self, db):
|
||||
"""Create expired + fresh, cleanup removes only expired."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
# Insert an expired state directly with old timestamp
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": "state-expired",
|
||||
"nonce": "nonce-old",
|
||||
"code_verifier": "verifier-old",
|
||||
"audience": "server",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Insert a fresh state via normal API
|
||||
db.create_oidc_pending_state("state-fresh", "nonce-new", "verifier-new", "server")
|
||||
|
||||
# Cleanup with default 300s max age
|
||||
deleted = db.cleanup_expired_oidc_states()
|
||||
assert deleted == 1
|
||||
|
||||
# Fresh state should still exist
|
||||
result = db.pop_oidc_pending_state("state-fresh")
|
||||
assert result is not None
|
||||
|
||||
def test_cleanup_expired_states_none_expired(self, db):
|
||||
"""Cleanup with no expired states returns 0."""
|
||||
db.create_oidc_pending_state("state-1", "nonce-1", "verifier-1", "server")
|
||||
deleted = db.cleanup_expired_oidc_states()
|
||||
assert deleted == 0
|
||||
|
||||
def test_cleanup_expired_states_all_expired(self, db):
|
||||
"""Cleanup with all expired states removes all."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
for i in range(3):
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": f"state-{i}",
|
||||
"nonce": f"nonce-{i}",
|
||||
"code_verifier": f"verifier-{i}",
|
||||
"audience": "server",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
deleted = db.cleanup_expired_oidc_states()
|
||||
assert deleted == 3
|
||||
|
||||
def test_cleanup_expired_states_custom_max_age(self, db):
|
||||
"""Custom max_age_seconds affects what counts as expired."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
# Insert a state created 60 seconds ago
|
||||
old_ts = (datetime.now(UTC) - timedelta(seconds=60)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": "state-1",
|
||||
"nonce": "nonce-1",
|
||||
"code_verifier": "verifier-1",
|
||||
"audience": "server",
|
||||
"created_at": old_ts,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# With default max_age=300s the 60s-old state is NOT expired
|
||||
deleted = db.cleanup_expired_oidc_states(max_age_seconds=300)
|
||||
assert deleted == 0
|
||||
|
||||
# With max_age=30s the 60s-old state IS expired
|
||||
deleted = db.cleanup_expired_oidc_states(max_age_seconds=30)
|
||||
assert deleted == 1
|
||||
|
||||
def test_pop_expired_cleans_up_row(self, db):
|
||||
"""Popping an expired state should delete the row (not leave orphan)."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": "state-cleanup",
|
||||
"nonce": "nonce-c",
|
||||
"code_verifier": "verifier-c",
|
||||
"audience": "server",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Pop returns None (expired)
|
||||
assert db.pop_oidc_pending_state("state-cleanup") is None
|
||||
|
||||
# Row should be gone (cleaned up even though expired)
|
||||
with db._engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(oidc_pending_states)
|
||||
.where(oidc_pending_states.c.state == "state-cleanup")
|
||||
).scalar()
|
||||
assert count == 0
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession, _render_template
|
||||
@@ -391,3 +392,131 @@ class TestMCPTemplates:
|
||||
session = _make_session(template="mcp__server__code")
|
||||
content = _sys_content(session)
|
||||
assert "MCP_EXPLICIT" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resume with deleted template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeDeletedTemplate:
|
||||
def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, capsys):
|
||||
from turnstone.core.memory import save_message
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "ephemeral-tpl", "EPHEMERAL_CONTENT", is_default=False)
|
||||
|
||||
# Create session with template, save a message so resume has history
|
||||
session1 = _make_session(template="ephemeral-tpl")
|
||||
ws_id = session1.ws_id
|
||||
save_message(ws_id, "user", "hello")
|
||||
assert "EPHEMERAL_CONTENT" in _sys_content(session1)
|
||||
|
||||
# Delete the template from storage
|
||||
db.delete_prompt_template("t1")
|
||||
|
||||
# Resume into a new session
|
||||
session2 = _make_session()
|
||||
resumed = session2.resume(ws_id)
|
||||
|
||||
assert resumed
|
||||
assert session2._template_name == "ephemeral-tpl"
|
||||
assert session2._template_content is None
|
||||
# System message should not contain the deleted template content
|
||||
content = _sys_content(session2)
|
||||
assert "EPHEMERAL_CONTENT" not in content
|
||||
# Warning should be logged via structlog
|
||||
captured = capsys.readouterr()
|
||||
assert "not_found" in captured.out or "not_found" in captured.err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threading safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateFactoryPassthrough:
|
||||
def test_template_passed_through_workstream_create(self, tmp_db):
|
||||
"""WorkstreamManager.create(template=...) propagates to session factory."""
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "factory-tpl", "FACTORY_CONTENT", is_default=False)
|
||||
|
||||
captured_template = None
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
ws = mgr.create(name="test", template="factory-tpl")
|
||||
assert captured_template == "factory-tpl"
|
||||
assert ws.session is not None
|
||||
assert ws.session._template_name == "factory-tpl"
|
||||
assert "FACTORY_CONTENT" in _sys_content(ws.session)
|
||||
|
||||
def test_template_none_uses_defaults(self, tmp_db):
|
||||
"""WorkstreamManager.create() without template passes None."""
|
||||
captured_template = "sentinel"
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
mgr.create(name="test")
|
||||
assert captured_template is None
|
||||
|
||||
|
||||
class TestTemplateThreadSafety:
|
||||
def test_concurrent_template_and_system_message_init(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "thread-tpl", "THREAD_TEMPLATE", is_default=False)
|
||||
|
||||
session = _make_session(template="thread-tpl")
|
||||
errors: list[Exception] = []
|
||||
stop = threading.Event()
|
||||
iterations = 200
|
||||
|
||||
def init_loop():
|
||||
"""Simulate MCP callback repeatedly calling _init_system_messages."""
|
||||
try:
|
||||
for _ in range(iterations):
|
||||
if stop.is_set():
|
||||
break
|
||||
session._init_system_messages()
|
||||
# system_messages must always be a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t = threading.Thread(target=init_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Main thread toggles template on/off
|
||||
try:
|
||||
for i in range(iterations):
|
||||
if i % 2 == 0:
|
||||
session.set_template("thread-tpl")
|
||||
else:
|
||||
session.set_template(None)
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not errors, f"Thread raised: {errors}"
|
||||
# Final state: system_messages is a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
|
||||
@@ -338,6 +338,28 @@ class TestPlanExec:
|
||||
# Last user message in second call is the coaching message
|
||||
assert "did not follow" in captured_messages[1][-1]["content"]
|
||||
|
||||
def test_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Plan agent system message includes template guardrails."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
session._template_content = "SAFETY: Do not produce harmful plans."
|
||||
_, _, messages = self._run_plan(session, "build something")
|
||||
sys_content = messages[0]["content"]
|
||||
assert "SAFETY: Do not produce harmful plans." in sys_content
|
||||
assert ChatSession._PLAN_IDENTITY in sys_content
|
||||
# Template appears before plan identity
|
||||
tpl_pos = sys_content.index("SAFETY:")
|
||||
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
|
||||
assert tpl_pos < identity_pos
|
||||
|
||||
def test_plan_no_template_is_identity_only(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Without templates, plan system message is exactly _PLAN_IDENTITY."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
assert session._template_content is None
|
||||
_, _, messages = self._run_plan(session, "build something")
|
||||
assert messages[0]["content"] == ChatSession._PLAN_IDENTITY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan validation
|
||||
@@ -557,6 +579,27 @@ class TestPlanRefinement:
|
||||
assert msgs[3]["role"] == "user"
|
||||
assert "add tests too" in msgs[3]["content"]
|
||||
|
||||
def test_refine_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""_refine_plan system message includes template guardrails."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
session._template_content = "SAFETY: guardrails here"
|
||||
captured = {}
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
captured["messages"] = list(messages)
|
||||
return self.GOOD_PLAN
|
||||
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
session._refine_plan(self.GOOD_PLAN, "add auth", "add tests too")
|
||||
|
||||
sys_content = captured["messages"][0]["content"]
|
||||
assert "SAFETY: guardrails here" in sys_content
|
||||
assert ChatSession._PLAN_IDENTITY in sys_content
|
||||
tpl_pos = sys_content.index("SAFETY:")
|
||||
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
|
||||
assert tpl_pos < identity_pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vision / image support
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for WebUI content accumulation — server-side single source of truth."""
|
||||
|
||||
import queue
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.server import WebUI
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_global_queue():
|
||||
"""Ensure WebUI._global_queue is set for tests and cleaned up after."""
|
||||
WebUI._global_queue = queue.Queue()
|
||||
yield
|
||||
WebUI._global_queue = None
|
||||
|
||||
|
||||
def _make_ui() -> WebUI:
|
||||
"""Create a WebUI with a global queue for capturing broadcast events."""
|
||||
return WebUI(ws_id="ws-test")
|
||||
|
||||
|
||||
def _drain_global() -> list[dict]:
|
||||
"""Drain all events from the global queue."""
|
||||
events = []
|
||||
assert WebUI._global_queue is not None
|
||||
while not WebUI._global_queue.empty():
|
||||
events.append(WebUI._global_queue.get_nowait())
|
||||
return events
|
||||
|
||||
|
||||
class TestContentAccumulation:
|
||||
"""WebUI should accumulate content tokens and include in idle broadcast."""
|
||||
|
||||
def test_content_token_accumulates(self):
|
||||
"""on_content_token should append to _ws_turn_content."""
|
||||
ui = _make_ui()
|
||||
ui.on_content_token("Hello ")
|
||||
ui.on_content_token("world")
|
||||
assert ui._ws_turn_content == ["Hello ", "world"]
|
||||
|
||||
def test_idle_broadcast_includes_content(self):
|
||||
"""_broadcast_state('idle') should include joined content and reset."""
|
||||
ui = _make_ui()
|
||||
ui.on_content_token("Hello ")
|
||||
ui.on_content_token("world")
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
assert idle_events[0]["content"] == "Hello world"
|
||||
# Accumulator should be reset
|
||||
assert ui._ws_turn_content == []
|
||||
assert ui._ws_turn_content_size == 0
|
||||
|
||||
def test_error_broadcast_resets_without_content(self):
|
||||
"""_broadcast_state('error') should reset accumulator without content in event."""
|
||||
ui = _make_ui()
|
||||
ui.on_content_token("partial")
|
||||
ui._broadcast_state("error")
|
||||
|
||||
events = _drain_global()
|
||||
error_events = [e for e in events if e.get("state") == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert "content" not in error_events[0]
|
||||
assert ui._ws_turn_content == []
|
||||
assert ui._ws_turn_content_size == 0
|
||||
|
||||
def test_thinking_broadcast_does_not_touch_accumulator(self):
|
||||
"""_broadcast_state('thinking') should not affect the accumulator."""
|
||||
ui = _make_ui()
|
||||
ui.on_content_token("in progress")
|
||||
ui._broadcast_state("thinking")
|
||||
|
||||
assert ui._ws_turn_content == ["in progress"]
|
||||
events = _drain_global()
|
||||
thinking_events = [e for e in events if e.get("state") == "thinking"]
|
||||
assert len(thinking_events) == 1
|
||||
assert "content" not in thinking_events[0]
|
||||
|
||||
def test_multi_round_accumulation(self):
|
||||
"""Content from multiple streaming rounds accumulates before idle."""
|
||||
ui = _make_ui()
|
||||
# Round 1
|
||||
ui.on_content_token("I'll check ")
|
||||
ui.on_content_token("that. ")
|
||||
# Round 2 (after tool execution)
|
||||
ui.on_content_token("Here's ")
|
||||
ui.on_content_token("the result.")
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
assert idle_events[0]["content"] == "I'll check that. Here's the result."
|
||||
|
||||
def test_empty_content_on_idle_without_tokens(self):
|
||||
"""idle with no content tokens should include empty content string."""
|
||||
ui = _make_ui()
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
assert idle_events[0]["content"] == ""
|
||||
|
||||
def test_cancellation_preserves_partial_content(self):
|
||||
"""Partial content accumulated before cancel should appear in idle event."""
|
||||
ui = _make_ui()
|
||||
ui.on_content_token("I'll ")
|
||||
ui.on_content_token("start by...")
|
||||
# Cancellation triggers idle broadcast with partial content
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
assert idle_events[0]["content"] == "I'll start by..."
|
||||
|
||||
def test_consecutive_turns_isolated(self):
|
||||
"""Content from turn 1 should not leak into turn 2."""
|
||||
ui = _make_ui()
|
||||
# Turn 1
|
||||
ui.on_content_token("first response")
|
||||
ui._broadcast_state("idle")
|
||||
_drain_global()
|
||||
|
||||
# Turn 2
|
||||
ui.on_content_token("second response")
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
assert idle_events[0]["content"] == "second response"
|
||||
|
||||
def test_content_cap_prevents_unbounded_growth(self):
|
||||
"""Content exceeding the cap should stop accumulating."""
|
||||
from turnstone.server import _MAX_TURN_CONTENT_CHARS
|
||||
|
||||
ui = _make_ui()
|
||||
# Fill to capacity
|
||||
chunk = "x" * 1024
|
||||
for _ in range(_MAX_TURN_CONTENT_CHARS // 1024 + 10):
|
||||
ui.on_content_token(chunk)
|
||||
|
||||
assert ui._ws_turn_content_size <= _MAX_TURN_CONTENT_CHARS + 1024
|
||||
ui._broadcast_state("idle")
|
||||
|
||||
events = _drain_global()
|
||||
idle_events = [e for e in events if e.get("state") == "idle"]
|
||||
assert len(idle_events) == 1
|
||||
# Content should be capped, not contain everything
|
||||
assert len(idle_events[0]["content"]) <= _MAX_TURN_CONTENT_CHARS + 1024
|
||||
@@ -20,7 +20,7 @@ class FakeSession:
|
||||
self.messages = []
|
||||
|
||||
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None):
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None, **kwargs):
|
||||
return FakeSession()
|
||||
|
||||
|
||||
|
||||
@@ -333,6 +333,381 @@ def test_webui_auto_approve_tools_empty_no_effect():
|
||||
webui._approval_event.wait.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-tool "always approve" — interactive "Always" adds to auto_approve_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_server_always_approve_adds_tool_names():
|
||||
"""POST /approve with always=True adds pending tool names to auto_approve_tools."""
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
{"func_name": "read_file", "needs_approval": False, "preview": "/tmp"},
|
||||
],
|
||||
}
|
||||
items = webui._pending_approval.get("items", [])
|
||||
tool_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in items
|
||||
if it.get("needs_approval") and it.get("func_name")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
webui.auto_approve_tools.update(tool_names)
|
||||
|
||||
assert webui.auto_approve_tools == {"bash"}
|
||||
assert webui.auto_approve is False # blanket flag NOT set
|
||||
|
||||
|
||||
def test_server_always_approve_uses_approval_label():
|
||||
"""When approval_label differs from func_name, approval_label is stored."""
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{
|
||||
"func_name": "use_prompt",
|
||||
"approval_label": "mcp__git__commit_msg",
|
||||
"needs_approval": True,
|
||||
"preview": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
items = webui._pending_approval.get("items", [])
|
||||
tool_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in items
|
||||
if it.get("needs_approval") and it.get("func_name")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
webui.auto_approve_tools.update(tool_names)
|
||||
|
||||
assert "mcp__git__commit_msg" in webui.auto_approve_tools
|
||||
assert "use_prompt" not in webui.auto_approve_tools
|
||||
|
||||
|
||||
def test_server_always_approve_excludes_budget_override():
|
||||
"""__budget_override__ should never be added to auto_approve_tools."""
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "__budget_override__", "needs_approval": True, "preview": ""},
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
],
|
||||
}
|
||||
items = webui._pending_approval.get("items", [])
|
||||
tool_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in items
|
||||
if it.get("needs_approval") and it.get("func_name")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
webui.auto_approve_tools.update(tool_names)
|
||||
|
||||
assert "__budget_override__" not in webui.auto_approve_tools
|
||||
assert webui.auto_approve_tools == {"bash"}
|
||||
|
||||
|
||||
def test_server_always_approve_accumulates():
|
||||
"""Successive 'always' approvals accumulate tool names."""
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
|
||||
# First always-approve: bash
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [{"func_name": "bash", "needs_approval": True, "preview": "ls"}],
|
||||
}
|
||||
items = webui._pending_approval["items"]
|
||||
names = {
|
||||
it.get("approval_label", "") or it["func_name"] for it in items if it.get("needs_approval")
|
||||
}
|
||||
names.discard("__budget_override__")
|
||||
webui.auto_approve_tools.update(names)
|
||||
|
||||
# Second always-approve: write_file
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [{"func_name": "write_file", "needs_approval": True, "preview": ""}],
|
||||
}
|
||||
items = webui._pending_approval["items"]
|
||||
names = {
|
||||
it.get("approval_label", "") or it["func_name"] for it in items if it.get("needs_approval")
|
||||
}
|
||||
names.discard("__budget_override__")
|
||||
webui.auto_approve_tools.update(names)
|
||||
|
||||
assert webui.auto_approve_tools == {"bash", "write_file"}
|
||||
|
||||
|
||||
def test_server_always_approve_no_pending_is_noop():
|
||||
"""If _pending_approval is None, always=True does nothing."""
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = None
|
||||
# The guard `if always and approved and ui._pending_approval:` prevents action
|
||||
assert webui.auto_approve_tools == set()
|
||||
assert webui.auto_approve is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI per-tool "always approve"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_always_adds_tool_names():
|
||||
"""CLI 'a' adds pending tool names to auto_approve_tools, not blanket flag."""
|
||||
from turnstone.cli import TerminalUI
|
||||
|
||||
ui = TerminalUI()
|
||||
items = [
|
||||
{"func_name": "bash", "header": "bash: ls", "needs_approval": True, "preview": "ls"},
|
||||
]
|
||||
with patch("builtins.input", return_value="a"):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
assert approved is True
|
||||
assert ui.auto_approve is False
|
||||
assert "bash" in ui.auto_approve_tools
|
||||
|
||||
|
||||
def test_cli_per_tool_auto_approves_subsequent():
|
||||
"""After 'always' for bash, subsequent bash calls auto-approve silently."""
|
||||
from turnstone.cli import TerminalUI
|
||||
|
||||
ui = TerminalUI()
|
||||
ui.auto_approve_tools = {"bash"}
|
||||
items = [
|
||||
{"func_name": "bash", "header": "bash: ls", "needs_approval": True, "preview": "ls"},
|
||||
]
|
||||
# Should auto-approve without prompting
|
||||
approved, _ = ui.approve_tools(items)
|
||||
assert approved is True
|
||||
|
||||
|
||||
def test_cli_per_tool_does_not_approve_unknown():
|
||||
"""Per-tool set for bash does NOT auto-approve write_file."""
|
||||
from turnstone.cli import TerminalUI
|
||||
|
||||
ui = TerminalUI()
|
||||
ui.auto_approve_tools = {"bash"}
|
||||
items = [
|
||||
{
|
||||
"func_name": "write_file",
|
||||
"header": "write_file: /tmp/x",
|
||||
"needs_approval": True,
|
||||
"preview": "",
|
||||
},
|
||||
]
|
||||
with patch("builtins.input", return_value="n"):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
assert approved is False
|
||||
|
||||
|
||||
def test_cli_always_excludes_budget_override():
|
||||
"""CLI 'always' should not add __budget_override__ to auto_approve_tools."""
|
||||
from turnstone.cli import TerminalUI
|
||||
|
||||
ui = TerminalUI()
|
||||
items = [
|
||||
{
|
||||
"func_name": "__budget_override__",
|
||||
"header": "budget",
|
||||
"needs_approval": True,
|
||||
"preview": "",
|
||||
},
|
||||
]
|
||||
with patch("builtins.input", return_value="a"):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
assert approved is True
|
||||
assert "__budget_override__" not in ui.auto_approve_tools
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge per-tool "always approve"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bridge_always_adds_to_approve_tools():
|
||||
"""Bridge 'always' adds tool names to _ws_approve_tools, not _ws_auto_approve."""
|
||||
import threading
|
||||
|
||||
from turnstone.mq.bridge import DEFAULT_SAFE_TOOLS, Bridge
|
||||
|
||||
bridge = Bridge.__new__(Bridge)
|
||||
bridge._lock = threading.Lock()
|
||||
bridge._ws_auto_approve = {}
|
||||
bridge._ws_approve_tools = {}
|
||||
|
||||
ws_id = "ws-1"
|
||||
items = [
|
||||
{"func_name": "bash", "needs_approval": True},
|
||||
{"func_name": "read_file", "needs_approval": False},
|
||||
]
|
||||
|
||||
# Simulate the always-approve extraction logic from _wait_approval
|
||||
tool_names = {
|
||||
it.get("func_name", "") for it in items if it.get("needs_approval") and it.get("func_name")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
if tool_names:
|
||||
with bridge._lock:
|
||||
existing = bridge._ws_approve_tools.get(ws_id, set(DEFAULT_SAFE_TOOLS))
|
||||
bridge._ws_approve_tools[ws_id] = existing | tool_names
|
||||
|
||||
# bash added, and DEFAULT_SAFE_TOOLS preserved
|
||||
assert "bash" in bridge._ws_approve_tools[ws_id]
|
||||
for name in DEFAULT_SAFE_TOOLS:
|
||||
assert name in bridge._ws_approve_tools[ws_id]
|
||||
assert ws_id not in bridge._ws_auto_approve
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — POST /v1/api/approve with always=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApproveEndpointAlways:
|
||||
"""Integration tests for the approve handler's per-tool 'always' logic."""
|
||||
|
||||
@staticmethod
|
||||
def _make_client(webui):
|
||||
import queue
|
||||
import threading
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.server import create_app
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.ui = webui
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
mock_mgr.list_all.return_value = []
|
||||
|
||||
app = create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
)
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
def test_always_adds_tool_to_auto_approve_tools(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": True, "always": True, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "bash" in webui.auto_approve_tools
|
||||
assert webui.auto_approve is False
|
||||
|
||||
def test_always_uses_approval_label_over_func_name(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{
|
||||
"func_name": "use_prompt",
|
||||
"approval_label": "mcp__git__commit_msg",
|
||||
"needs_approval": True,
|
||||
"preview": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": True, "always": True, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "mcp__git__commit_msg" in webui.auto_approve_tools
|
||||
assert "use_prompt" not in webui.auto_approve_tools
|
||||
|
||||
def test_always_excludes_budget_override(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "__budget_override__", "needs_approval": True, "preview": ""},
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": True, "always": True, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "__budget_override__" not in webui.auto_approve_tools
|
||||
assert "bash" in webui.auto_approve_tools
|
||||
|
||||
def test_always_skips_non_pending_items(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
{"func_name": "read_file", "needs_approval": False, "preview": "/tmp"},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": True, "always": True, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert webui.auto_approve_tools == {"bash"}
|
||||
|
||||
def test_always_false_does_not_add_tools(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": True, "always": False, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert webui.auto_approve_tools == set()
|
||||
|
||||
def test_deny_with_always_does_not_add_tools(self):
|
||||
webui = WebUI(ws_id="ws-1")
|
||||
webui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{"func_name": "bash", "needs_approval": True, "preview": "ls"},
|
||||
],
|
||||
}
|
||||
client = self._make_client(webui)
|
||||
resp = client.post(
|
||||
"/v1/api/approve",
|
||||
json={"approved": False, "always": True, "ws_id": "ws-1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert webui.auto_approve_tools == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol round-trip — CreateWorkstreamMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.6.2"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -71,6 +71,7 @@ from turnstone.api.schemas import (
|
||||
AuthSetupRequest,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
AuthWhoamiResponse,
|
||||
CreateScheduleRequest,
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
@@ -85,6 +86,7 @@ from turnstone.api.schemas import (
|
||||
UpdateScheduleRequest,
|
||||
UserInfo,
|
||||
)
|
||||
from turnstone.api.server_schemas import ListPromptTemplateSummaryResponse, PromptTemplateSummary
|
||||
|
||||
CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
# --- Cluster ---
|
||||
@@ -197,6 +199,29 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=StatusResponse,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/authorize",
|
||||
"GET",
|
||||
"Redirect to OIDC provider for SSO login",
|
||||
response_code=302,
|
||||
error_codes=[404, 503],
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/callback",
|
||||
"GET",
|
||||
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
|
||||
response_code=302,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/whoami",
|
||||
"GET",
|
||||
"Return authenticated user info and permissions",
|
||||
response_model=AuthWhoamiResponse,
|
||||
error_codes=[401],
|
||||
tags=["Auth"],
|
||||
),
|
||||
# --- Admin ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users",
|
||||
@@ -269,6 +294,20 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- OIDC Identities ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/oidc-identities",
|
||||
"GET",
|
||||
"List OIDC identities linked to a user",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/oidc-identities",
|
||||
"DELETE",
|
||||
"Unlink an OIDC identity (issuer + subject as query params)",
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Schedules ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/schedules",
|
||||
@@ -532,6 +571,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListWsTemplateSummaryResponse,
|
||||
tags=["Workstreams"],
|
||||
),
|
||||
# --- Prompt templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/templates",
|
||||
"GET",
|
||||
"List available prompt templates (summary)",
|
||||
response_model=ListPromptTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Governance: Usage & Audit ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/usage",
|
||||
@@ -807,6 +854,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ImportMcpConfigRequest,
|
||||
ImportMcpConfigResponse,
|
||||
McpReloadResponse,
|
||||
PromptTemplateSummary,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,16 @@ class AuthStatusResponse(BaseModel):
|
||||
auth_enabled: bool
|
||||
has_users: bool
|
||||
setup_required: bool
|
||||
oidc_enabled: bool = False
|
||||
oidc_provider_name: str = ""
|
||||
password_enabled: bool = True
|
||||
|
||||
|
||||
class AuthWhoamiResponse(BaseModel):
|
||||
"""GET /v1/api/auth/whoami response."""
|
||||
|
||||
user_id: str
|
||||
permissions: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstream management
|
||||
@@ -23,7 +23,9 @@ class SendResponse(BaseModel):
|
||||
class ApproveRequest(BaseModel):
|
||||
approved: bool = Field(description="True to approve, false to deny")
|
||||
feedback: str | None = Field(default=None, description="Optional denial reason")
|
||||
always: bool = Field(default=False, description="Enable auto-approve for this tool")
|
||||
always: bool = Field(
|
||||
default=False, description="Auto-approve the tools in this batch going forward"
|
||||
)
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
|
||||
|
||||
@@ -186,6 +188,15 @@ class SaveMemoryRequest(BaseModel):
|
||||
description="Scope identifier (ws_id for workstream, user_id for user scope)",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_scope_scope_id(self) -> SaveMemoryRequest:
|
||||
scope_id = self.scope_id.strip()
|
||||
if self.scope == "global" and scope_id:
|
||||
raise ValueError("scope_id is not allowed with global scope")
|
||||
if self.scope == "workstream" and not scope_id:
|
||||
raise ValueError("scope_id is required for workstream scope")
|
||||
return self
|
||||
|
||||
|
||||
class MemoryInfo(BaseModel):
|
||||
memory_id: str
|
||||
@@ -214,3 +225,30 @@ class SearchMemoriesRequest(BaseModel):
|
||||
scope: MemoryScopeFilter = Field(default="", description="Filter by scope")
|
||||
scope_id: str = Field(default="", description="Filter by scope_id")
|
||||
limit: int = Field(default=20, description="Max results (1-50)", ge=1, le=50)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_scope_scope_id(self) -> SearchMemoriesRequest:
|
||||
scope_id = self.scope_id.strip()
|
||||
if self.scope == "global" and scope_id:
|
||||
raise ValueError("scope_id is not allowed with global scope")
|
||||
if scope_id and not self.scope:
|
||||
raise ValueError("scope is required when scope_id is provided")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt templates (read-only listing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PromptTemplateSummary(BaseModel):
|
||||
name: str = Field(description="Template name")
|
||||
category: str = Field(default="", description="Template category")
|
||||
is_default: bool = Field(
|
||||
default=False, description="Whether this template is applied by default"
|
||||
)
|
||||
origin: str = Field(default="manual", description="Template origin: manual or mcp")
|
||||
|
||||
|
||||
class ListPromptTemplateSummaryResponse(BaseModel):
|
||||
templates: list[PromptTemplateSummary]
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.api.console_schemas import ListWsTemplateSummaryResponse, WsTemplateSummary
|
||||
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -14,6 +15,7 @@ from turnstone.api.schemas import (
|
||||
AuthSetupRequest,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
AuthWhoamiResponse,
|
||||
ErrorResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
@@ -27,10 +29,12 @@ from turnstone.api.server_schemas import (
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
PlanFeedbackRequest,
|
||||
PromptTemplateSummary,
|
||||
SaveMemoryRequest,
|
||||
SearchMemoriesRequest,
|
||||
SendRequest,
|
||||
@@ -144,6 +148,22 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListSavedWorkstreamsResponse,
|
||||
tags=["Workstreams"],
|
||||
),
|
||||
# --- Prompt templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/templates",
|
||||
"GET",
|
||||
"List available prompt templates (summary)",
|
||||
response_model=ListPromptTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Workstream templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/ws-templates",
|
||||
"GET",
|
||||
"List enabled workstream templates (summary)",
|
||||
response_model=ListWsTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Auth ---
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/login",
|
||||
@@ -177,6 +197,29 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=StatusResponse,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/authorize",
|
||||
"GET",
|
||||
"Redirect to OIDC provider for SSO login",
|
||||
response_code=302,
|
||||
error_codes=[404, 503],
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/callback",
|
||||
"GET",
|
||||
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
|
||||
response_code=302,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/whoami",
|
||||
"GET",
|
||||
"Return authenticated user info and permissions",
|
||||
response_model=AuthWhoamiResponse,
|
||||
error_codes=[401],
|
||||
tags=["Auth"],
|
||||
),
|
||||
# --- Memories ---
|
||||
EndpointSpec(
|
||||
"/v1/api/memories",
|
||||
@@ -257,6 +300,10 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
MemoryInfo,
|
||||
ListMemoriesResponse,
|
||||
SearchMemoriesRequest,
|
||||
PromptTemplateSummary,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
WsTemplateSummary,
|
||||
ListWsTemplateSummaryResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+18
-1
@@ -93,6 +93,16 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled)
|
||||
- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth
|
||||
|
||||
### OIDC SSO (optional)
|
||||
- `TURNSTONE_OIDC_ISSUER` — OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
|
||||
- `TURNSTONE_OIDC_CLIENT_ID` — Client ID from the identity provider
|
||||
- `TURNSTONE_OIDC_CLIENT_SECRET` — Client secret (confidential client)
|
||||
- `TURNSTONE_OIDC_PROVIDER_NAME` — Display name for the SSO button (default: "SSO")
|
||||
- `TURNSTONE_OIDC_SCOPES` — OIDC scopes (default: "openid email profile")
|
||||
- `TURNSTONE_OIDC_ROLE_CLAIM` — Claim name for role mapping (e.g., "groups")
|
||||
- `TURNSTONE_OIDC_ROLE_MAP` — Comma-separated claim_value:role_id pairs (e.g., "admin:builtin-admin,eng:builtin-operator")
|
||||
- `TURNSTONE_OIDC_PASSWORD_ENABLED` — Set to "false" to hide password login and force SSO-only
|
||||
|
||||
### Ports
|
||||
- `SERVER_PORT` — Server port (default: 8080)
|
||||
- `CONSOLE_PORT` — Console port (default: 8090)
|
||||
@@ -122,6 +132,10 @@ This is a one-time endpoint that only works when zero users exist.
|
||||
Subsequent governance setup (roles, policies, templates) uses the console admin API \
|
||||
with the JWT returned from setup.
|
||||
|
||||
If OIDC is configured, users can also log in via the "Continue with [Provider]" button on the login page.
|
||||
The first admin user must still be created via the setup wizard (OIDC login requires at least one user to exist).
|
||||
OIDC users are auto-provisioned on first login with a default viewer role unless role mapping is configured.
|
||||
|
||||
## Runtime Settings (ConfigStore)
|
||||
After the stack is running, ~40 runtime settings (model, temperature, max_tokens, \
|
||||
reasoning_effort, tool timeout, rate limiting, health probes, judge config, memory \
|
||||
@@ -157,7 +171,10 @@ Walk the user through setting up their deployment step by step:
|
||||
PostgreSQL is required for cluster mode.
|
||||
5. **Security**: Recommend enabling auth for any non-local deployment. \
|
||||
Use `generate_secret` for JWT secret, Redis password, auth token, and Postgres password. \
|
||||
Ask for initial admin username and password.
|
||||
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. \
|
||||
Optionally configure role mapping and OIDC-only mode.
|
||||
6. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
|
||||
7. **Optional features**: Discord integration, web search (Tavily key), \
|
||||
DuckDuckGo Search MCP (for cluster — uses `ddgCluster` profile with \
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -26,6 +27,9 @@ if TYPE_CHECKING:
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# ws_id is a hex string (8–32 chars depending on entry point).
|
||||
_WS_ID_RE = re.compile(r"^[0-9a-f]{8,32}$")
|
||||
|
||||
|
||||
async def _handle_health(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "service": "channel"})
|
||||
@@ -81,6 +85,9 @@ async def _handle_notify(request: Request) -> JSONResponse:
|
||||
target = body.get("target")
|
||||
message = body.get("message", "").strip() if isinstance(body.get("message"), str) else ""
|
||||
title = body.get("title", "").strip() if isinstance(body.get("title"), str) else ""
|
||||
ws_id = body.get("ws_id", "").strip() if isinstance(body.get("ws_id"), str) else ""
|
||||
if ws_id and not _WS_ID_RE.match(ws_id):
|
||||
return JSONResponse({"error": "invalid ws_id format"}, status_code=400)
|
||||
|
||||
if not target or not message:
|
||||
return JSONResponse({"error": "target and message are required"}, status_code=400)
|
||||
@@ -132,7 +139,10 @@ async def _handle_notify(request: Request) -> JSONResponse:
|
||||
)
|
||||
continue
|
||||
try:
|
||||
msg_id = await adapter.send(channel_id, content)
|
||||
if ws_id:
|
||||
msg_id = await adapter.send_notification(channel_id, content, ws_id)
|
||||
else:
|
||||
msg_id = await adapter.send(channel_id, content)
|
||||
results.append(
|
||||
{
|
||||
"channel_type": channel_type,
|
||||
|
||||
@@ -41,6 +41,14 @@ class ChannelAdapter(Protocol):
|
||||
"""Send a message to a channel. Returns the platform message ID."""
|
||||
...
|
||||
|
||||
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str:
|
||||
"""Send a notification and track the reply mapping. Returns message ID.
|
||||
|
||||
Like :meth:`send` but associates the outgoing message with *ws_id*
|
||||
so that replies can be routed back to the originating workstream.
|
||||
"""
|
||||
...
|
||||
|
||||
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None:
|
||||
"""Edit an existing message in a channel."""
|
||||
...
|
||||
|
||||
@@ -124,6 +124,7 @@ class TurnstoneBot:
|
||||
"""
|
||||
|
||||
channel_type: str = "discord"
|
||||
_MAX_NOTIFY_TRACKING: int = 100
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -151,6 +152,16 @@ class TurnstoneBot:
|
||||
# workstream so that IntentVerdictEvent can update it with LLM judge
|
||||
# results.
|
||||
self._pending_approval_msgs: dict[str, discord.Message] = {}
|
||||
# Notification reply tracking: maps Discord message ID →
|
||||
# (ws_id, target_discord_user_id) so that DM replies can be routed
|
||||
# back to the originating workstream. The target user ID is checked
|
||||
# on reply to prevent cross-user message injection.
|
||||
self._notify_ws_map: dict[int, tuple[str, str]] = {}
|
||||
# Temporary DM forwarding: maps ws_id → (DM channel, target_user_id)
|
||||
# for forwarding the workstream's next response back to the
|
||||
# notification reply DM. The target_user_id is carried so the
|
||||
# response message can be re-tracked for multi-turn DM conversations.
|
||||
self._notify_reply_channels: dict[str, tuple[discord.abc.Messageable, str]] = {}
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
@@ -256,6 +267,11 @@ class TurnstoneBot:
|
||||
self._subscribed_ws.discard(ws_id)
|
||||
self._streaming.pop(ws_id, None)
|
||||
self._pending_approval_msgs.pop(ws_id, None)
|
||||
self._notify_reply_channels.pop(ws_id, None)
|
||||
# Purge stale notification tracking entries for this workstream.
|
||||
stale = [mid for mid, entry in self._notify_ws_map.items() if entry[0] == ws_id]
|
||||
for mid in stale:
|
||||
del self._notify_ws_map[mid]
|
||||
log.info("discord.unsubscribed", ws_id=ws_id)
|
||||
|
||||
# -- event dispatch ------------------------------------------------------
|
||||
@@ -360,6 +376,26 @@ class TurnstoneBot:
|
||||
sm = self._streaming.pop(ws_id, None)
|
||||
if sm is not None:
|
||||
await sm.finalize()
|
||||
elif event.content:
|
||||
# Catch-up: content events were missed (race between global
|
||||
# SSE and per-ws SSE) — send the full response directly.
|
||||
for chunk in chunk_message(event.content, self.config.max_message_length):
|
||||
await thread.send(chunk)
|
||||
# Forward response to notification reply DM if active.
|
||||
dm_entry = self._notify_reply_channels.pop(ws_id, None)
|
||||
if dm_entry is not None and event.content:
|
||||
dm_channel, target_user_id = dm_entry
|
||||
last_msg: discord.Message | None = None
|
||||
for chunk in chunk_message(event.content, self.config.max_message_length):
|
||||
try:
|
||||
last_msg = await dm_channel.send(chunk)
|
||||
except Exception:
|
||||
log.debug("discord.notify_reply_dm_failed", ws_id=ws_id)
|
||||
break
|
||||
# Track the response message so the user can reply again
|
||||
# for multi-turn DM conversations.
|
||||
if last_msg is not None:
|
||||
self._track_notification(last_msg.id, ws_id, target_user_id)
|
||||
# Clean up pending approval message tracking.
|
||||
self._pending_approval_msgs.pop(ws_id, None)
|
||||
|
||||
@@ -390,6 +426,18 @@ class TurnstoneBot:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _track_notification(self, message_id: int, ws_id: str, target_user_id: str) -> None:
|
||||
"""Record a notification message for reply routing.
|
||||
|
||||
Evicts the oldest entry when the map exceeds
|
||||
``_MAX_NOTIFY_TRACKING``. Relies on dict insertion order
|
||||
(Python 3.7+).
|
||||
"""
|
||||
while len(self._notify_ws_map) >= self._MAX_NOTIFY_TRACKING:
|
||||
oldest = next(iter(self._notify_ws_map))
|
||||
del self._notify_ws_map[oldest]
|
||||
self._notify_ws_map[message_id] = (ws_id, target_user_id)
|
||||
|
||||
def _is_allowed_channel(self, channel_id: int) -> bool:
|
||||
"""Return True if *channel_id* is in the allowed list (or list is empty)."""
|
||||
if not self.config.allowed_channels:
|
||||
@@ -430,6 +478,26 @@ class TurnstoneBot:
|
||||
|
||||
return str(msg.id) if msg else ""
|
||||
|
||||
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str:
|
||||
"""Send a notification DM and track the message for reply routing.
|
||||
|
||||
Like :meth:`send` but records a mapping from the outgoing Discord
|
||||
message ID to ``(ws_id, channel_id)`` so that a user reply can be
|
||||
routed back to the originating workstream. The *channel_id* is the
|
||||
Discord user ID the notification was sent to — verified on reply to
|
||||
prevent cross-user message injection.
|
||||
"""
|
||||
msg_id_str = await self.send(channel_id, content)
|
||||
if msg_id_str and ws_id:
|
||||
self._track_notification(int(msg_id_str), ws_id, channel_id)
|
||||
log.debug(
|
||||
"discord.notification_tracked",
|
||||
message_id=msg_id_str,
|
||||
ws_id=ws_id,
|
||||
target_user=channel_id,
|
||||
)
|
||||
return msg_id_str
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Disconnect the bot and clean up subscriptions."""
|
||||
for ws_id in list(self._subscribed_ws):
|
||||
|
||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
||||
log = get_logger(__name__)
|
||||
|
||||
_THREAD_NAME_MAX = 100
|
||||
_DM_REPLY_MAX_LENGTH = 4096 # Discord's own message limit
|
||||
|
||||
|
||||
class MessageCog:
|
||||
@@ -102,8 +103,9 @@ class MessageCog:
|
||||
if message.author == self.bot.user or message.author.bot:
|
||||
return
|
||||
|
||||
# Ignore DMs.
|
||||
# DM handling — route replies to tracked notifications.
|
||||
if message.guild is None:
|
||||
await self._handle_dm(message)
|
||||
return
|
||||
|
||||
channel = message.channel
|
||||
@@ -198,6 +200,63 @@ class MessageCog:
|
||||
author=str(message.author),
|
||||
)
|
||||
|
||||
# -- DM reply handling ---------------------------------------------------
|
||||
|
||||
async def _handle_dm(self, message: discord.Message) -> None:
|
||||
"""Route DM replies to tracked notification workstreams."""
|
||||
# Only handle explicit replies to a tracked notification message.
|
||||
ref = message.reference
|
||||
if ref is None or ref.message_id is None:
|
||||
return
|
||||
|
||||
# Atomic pop prevents TOCTOU race across await points.
|
||||
entry = self.ts._notify_ws_map.pop(ref.message_id, None)
|
||||
if entry is None:
|
||||
# NOTE: This also fires for replies to non-notification bot
|
||||
# messages in DMs (false positive). Acceptable because DM
|
||||
# interactions are almost exclusively notification-driven.
|
||||
await message.channel.send("*This notification is no longer active.*")
|
||||
return
|
||||
|
||||
ws_id, target_user_id = entry
|
||||
|
||||
# Defence in depth: verify the replying user is the notification
|
||||
# recipient. Discord enforces this (DMs are private), but a
|
||||
# server-side check prevents cross-user injection via compromised
|
||||
# accounts or API-level forgery.
|
||||
if str(message.author.id) != target_user_id:
|
||||
# Re-insert so the legitimate user can still reply.
|
||||
self.ts._notify_ws_map[ref.message_id] = entry
|
||||
log.warning(
|
||||
"discord.notification_reply_user_mismatch",
|
||||
expected=target_user_id,
|
||||
actual=str(message.author.id),
|
||||
)
|
||||
return
|
||||
|
||||
# Resolve user identity — unlinked users are silently ignored.
|
||||
# Re-insert the tracking entry so the user can retry after linking.
|
||||
user_id = await self.ts.router.resolve_user("discord", str(message.author.id))
|
||||
if user_id is None:
|
||||
self.ts._notify_ws_map[ref.message_id] = entry
|
||||
return
|
||||
|
||||
# Route the reply to the originating workstream.
|
||||
content = message.content[:_DM_REPLY_MAX_LENGTH]
|
||||
await self.ts.router.send_message(ws_id, content)
|
||||
|
||||
# Register the DM channel for response forwarding. The bot's
|
||||
# _on_ws_event handler will send the next turn's response here,
|
||||
# track the response for further replies, and clean up on
|
||||
# TurnCompleteEvent.
|
||||
self.ts._notify_reply_channels[ws_id] = (message.channel, target_user_id)
|
||||
|
||||
log.info(
|
||||
"discord.notification_reply_routed",
|
||||
ws_id=ws_id,
|
||||
author=str(message.author),
|
||||
)
|
||||
|
||||
# -- slash commands ------------------------------------------------------
|
||||
|
||||
async def _cmd_link(self, interaction: discord.Interaction, token: str) -> None:
|
||||
|
||||
+17
-3
@@ -98,6 +98,7 @@ class TerminalUI(SessionUI):
|
||||
self.spinner: Spinner | None = None
|
||||
self._print_lock = threading.Lock()
|
||||
self.auto_approve = False
|
||||
self.auto_approve_tools: set[str] = set()
|
||||
|
||||
def on_thinking_start(self) -> None:
|
||||
self.spinner = Spinner("Thinking")
|
||||
@@ -159,6 +160,12 @@ class TerminalUI(SessionUI):
|
||||
if not pending or self.auto_approve:
|
||||
return True, None
|
||||
|
||||
# Per-tool auto-approve check
|
||||
if self.auto_approve_tools:
|
||||
pending_names = {it.get("func_name", "") for it in pending if it.get("func_name")}
|
||||
if pending_names and pending_names.issubset(self.auto_approve_tools):
|
||||
return True, None
|
||||
|
||||
# Prompt
|
||||
try:
|
||||
if len(pending) == 1:
|
||||
@@ -188,7 +195,10 @@ class TerminalUI(SessionUI):
|
||||
break
|
||||
|
||||
if decision in ("a", "always"):
|
||||
self.auto_approve = True
|
||||
tool_names = {it.get("func_name", "") for it in pending if it.get("func_name")}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
self.auto_approve_tools.update(tool_names)
|
||||
return True, feedback
|
||||
elif decision in ("y", "yes"):
|
||||
return True, feedback
|
||||
@@ -1016,7 +1026,11 @@ def main() -> None:
|
||||
|
||||
# ChatSession factory — captures shared config for creating workstreams
|
||||
def session_factory(
|
||||
ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
template: str | None = None,
|
||||
) -> ChatSession:
|
||||
assert ui is not None, "session_factory requires a non-None UI"
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
@@ -1040,7 +1054,7 @@ def main() -> None:
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
template=args.template,
|
||||
template=template if template is not None else args.template,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
|
||||
+219
-32
@@ -323,6 +323,27 @@ async def auth_setup(request: Request) -> Response:
|
||||
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
async def auth_whoami(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/whoami — return authenticated user info."""
|
||||
from turnstone.core.auth import handle_auth_whoami
|
||||
|
||||
return await handle_auth_whoami(request)
|
||||
|
||||
|
||||
async def oidc_authorize(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
|
||||
from turnstone.core.auth import handle_oidc_authorize
|
||||
|
||||
return await handle_oidc_authorize(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
|
||||
from turnstone.core.auth import handle_oidc_callback
|
||||
|
||||
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — workstream creation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -674,6 +695,31 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
if scheduler is not None:
|
||||
scheduler.start()
|
||||
# OIDC discovery (if configured)
|
||||
oidc_config = app.state.oidc_config
|
||||
if oidc_config.enabled:
|
||||
from turnstone.core.oidc import discover_oidc
|
||||
|
||||
try:
|
||||
oidc_config = await discover_oidc(oidc_config)
|
||||
app.state.oidc_config = oidc_config
|
||||
except Exception:
|
||||
log.warning("OIDC discovery failed — OIDC login disabled", exc_info=True)
|
||||
if oidc_config.enabled and oidc_config.jwks_uri:
|
||||
try:
|
||||
from turnstone.core.oidc import fetch_jwks
|
||||
|
||||
app.state.jwks_data = await fetch_jwks(oidc_config.jwks_uri)
|
||||
log.info(
|
||||
"OIDC enabled: %s (%s)",
|
||||
oidc_config.provider_name,
|
||||
oidc_config.issuer,
|
||||
)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
# Shutdown
|
||||
if scheduler is not None:
|
||||
@@ -1052,6 +1098,67 @@ async def admin_delete_channel(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error": "Channel link not found"}, status_code=404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API endpoints — OIDC identities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def admin_list_oidc_identities(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/users/{user_id}/oidc-identities — list OIDC links for a user."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.users")
|
||||
if err:
|
||||
return err
|
||||
|
||||
user_id = request.path_params["user_id"]
|
||||
identities = storage.list_oidc_identities_for_user(user_id)
|
||||
return JSONResponse({"oidc_identities": identities})
|
||||
|
||||
|
||||
async def admin_delete_oidc_identity(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/oidc-identities?issuer=...&subject=... — unlink OIDC identity."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.users")
|
||||
if err:
|
||||
return err
|
||||
|
||||
issuer = request.query_params.get("issuer", "")
|
||||
subject = request.query_params.get("subject", "")
|
||||
if not issuer or not subject:
|
||||
return JSONResponse({"error": "issuer and subject required"}, status_code=400)
|
||||
|
||||
# Look up before delete so audit captures which user was affected
|
||||
identity = storage.get_oidc_identity(issuer, subject)
|
||||
if not identity:
|
||||
return JSONResponse({"error": "Identity not found"}, status_code=404)
|
||||
|
||||
storage.delete_oidc_identity(issuer, subject)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"oidc_identity.delete",
|
||||
"oidc_identity",
|
||||
f"{issuer}:{subject}",
|
||||
{"user_id": identity["user_id"]},
|
||||
ip,
|
||||
)
|
||||
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API endpoints — scheduled tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1385,23 +1492,29 @@ async def admin_list_watches(request: Request) -> JSONResponse:
|
||||
nodes, _ = collector.get_nodes(limit=500)
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
|
||||
async def _fetch_node(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
server_url = (node.get("server_url") or "").rstrip("/")
|
||||
if not server_url:
|
||||
return []
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/v1/api/watches", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
watches: list[dict[str, Any]] = data.get("watches", [])
|
||||
# Tag each watch with node_id in case the server omits it
|
||||
for w in watches:
|
||||
if not w.get("node_id"):
|
||||
w["node_id"] = node["node_id"]
|
||||
return watches
|
||||
except Exception:
|
||||
log.debug("Failed to fetch watches from node %s", node.get("node_id"))
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/v1/api/watches", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
watches: list[dict[str, Any]] = data.get("watches", [])
|
||||
# Tag each watch with node_id in case the server omits it
|
||||
for w in watches:
|
||||
if not w.get("node_id"):
|
||||
w["node_id"] = node["node_id"]
|
||||
return watches
|
||||
except Exception:
|
||||
log.debug(
|
||||
"Failed to fetch watches from node %s",
|
||||
node.get("node_id"),
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
tasks = [_fetch_node(n) for n in nodes]
|
||||
@@ -1417,6 +1530,11 @@ async def admin_list_watches(request: Request) -> JSONResponse:
|
||||
|
||||
_VALID_WATCH_ID = re.compile(r"^[a-fA-F0-9]+$")
|
||||
|
||||
# Max concurrent outbound requests when fanning out to cluster nodes.
|
||||
# Sized below the default httpx pool limit (100) to leave headroom for
|
||||
# other proxy traffic (UI proxying, SSE streams, etc.).
|
||||
_NODE_FAN_OUT_LIMIT = 50
|
||||
|
||||
|
||||
async def admin_cancel_watch(request: Request) -> Response:
|
||||
"""POST /v1/api/admin/watches/{watch_id}/cancel — proxy cancel to the owning node."""
|
||||
@@ -2480,6 +2598,26 @@ async def list_ws_templates_summary(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"ws_templates": summary})
|
||||
|
||||
|
||||
async def list_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/templates — list available prompt templates (read scope)."""
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
templates = storage.list_prompt_templates()
|
||||
summaries = [
|
||||
{
|
||||
"name": t["name"],
|
||||
"category": t.get("category", ""),
|
||||
"is_default": bool(t.get("is_default")),
|
||||
"origin": t.get("origin", "manual"),
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"templates": summaries})
|
||||
|
||||
|
||||
async def admin_usage(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/usage — query usage data."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -2615,6 +2753,19 @@ async def admin_list_verdicts(request: Request) -> JSONResponse:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_memory_scope_filter(scope: str, scope_id: str) -> JSONResponse | None:
|
||||
"""Validate scope/scope_id consistency for memory queries."""
|
||||
scope = scope.strip()
|
||||
scope_id = scope_id.strip()
|
||||
if scope == "global" and scope_id:
|
||||
return JSONResponse({"error": "scope_id is not allowed with global scope"}, status_code=400)
|
||||
if scope_id and not scope:
|
||||
return JSONResponse(
|
||||
{"error": "scope is required when scope_id is provided"}, status_code=400
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def admin_list_memories(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/memories — list structured memories with filters."""
|
||||
from turnstone.core.auth import require_permission
|
||||
@@ -2630,6 +2781,9 @@ async def admin_list_memories(request: Request) -> JSONResponse:
|
||||
mem_type = request.query_params.get("type", "")
|
||||
scope = request.query_params.get("scope", "")
|
||||
scope_id = request.query_params.get("scope_id", "")
|
||||
err = _validate_memory_scope_filter(scope, scope_id)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
limit = min(int(request.query_params.get("limit", "100")), 200)
|
||||
except (ValueError, TypeError):
|
||||
@@ -2660,6 +2814,9 @@ async def admin_search_memories(request: Request) -> JSONResponse:
|
||||
mem_type = request.query_params.get("type", "")
|
||||
scope = request.query_params.get("scope", "")
|
||||
scope_id = request.query_params.get("scope_id", "")
|
||||
err = _validate_memory_scope_filter(scope, scope_id)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
limit = min(int(request.query_params.get("limit", "20")), 50)
|
||||
except (ValueError, TypeError):
|
||||
@@ -3013,24 +3170,30 @@ async def _collect_mcp_status(
|
||||
"""Query all nodes for MCP status. Returns {node_id: {server_name: status}}."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
|
||||
result: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
for node in nodes:
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
|
||||
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
|
||||
node_id = node.get("node_id", "")
|
||||
url = node.get("server_url", "")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
headers = _proxy_auth_headers(request)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
|
||||
return node_id, None
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{url.rstrip('/')}/v1/api/_internal/mcp-status",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
result[node_id] = resp.json().get("servers", {})
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
return node_id, resp.json().get("servers", {})
|
||||
except Exception:
|
||||
log.debug("Failed to fetch MCP status from node %s", node_id, exc_info=True)
|
||||
return node_id, None
|
||||
|
||||
results = await asyncio.gather(*[_fetch(n) for n in nodes])
|
||||
return {nid: servers for nid, servers in results if servers is not None}
|
||||
|
||||
|
||||
async def admin_list_mcp_servers(request: Request) -> JSONResponse:
|
||||
@@ -3358,25 +3521,29 @@ async def _notify_nodes_mcp_reload(request: Request) -> dict[str, Any]:
|
||||
"""Tell all nodes to re-read the mcp_servers DB table and reconcile."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
|
||||
results: dict[str, Any] = {}
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
|
||||
for node in nodes:
|
||||
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
|
||||
node_id = node.get("node_id", "")
|
||||
url = node.get("server_url", "")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
headers = _proxy_auth_headers(request)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client:
|
||||
return node_id, None
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{url.rstrip('/')}/v1/api/_internal/mcp-reload",
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
results[node_id] = resp.json()
|
||||
except Exception as exc:
|
||||
results[node_id] = {"error": str(exc)}
|
||||
return node_id, resp.json()
|
||||
except Exception as exc:
|
||||
log.debug("Failed to notify node %s for MCP reload", node_id, exc_info=True)
|
||||
return node_id, {"error": str(exc)}
|
||||
|
||||
return results
|
||||
results = await asyncio.gather(*[_notify(n) for n in nodes])
|
||||
return {nid: data for nid, data in results if data is not None}
|
||||
|
||||
|
||||
async def admin_mcp_reload(request: Request) -> JSONResponse:
|
||||
@@ -3532,10 +3699,14 @@ def create_app(
|
||||
Route("/api/cluster/snapshot", cluster_snapshot),
|
||||
Route("/api/cluster/events", cluster_events_sse),
|
||||
Route("/api/ws-templates", list_ws_templates_summary),
|
||||
Route("/api/templates", list_templates_summary),
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Route("/api/auth/status", auth_status),
|
||||
Route("/api/auth/setup", auth_setup, methods=["POST"]),
|
||||
Route("/api/auth/whoami", auth_whoami),
|
||||
Route("/api/auth/oidc/authorize", oidc_authorize),
|
||||
Route("/api/auth/oidc/callback", oidc_callback),
|
||||
Route("/api/admin/users", admin_list_users),
|
||||
Route("/api/admin/users", admin_create_user, methods=["POST"]),
|
||||
Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]),
|
||||
@@ -3558,6 +3729,15 @@ def create_app(
|
||||
admin_delete_channel,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/oidc-identities",
|
||||
admin_list_oidc_identities,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/oidc-identities",
|
||||
admin_delete_oidc_identity,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route("/api/admin/schedules", admin_list_schedules),
|
||||
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
|
||||
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
|
||||
@@ -3726,6 +3906,13 @@ def create_app(
|
||||
|
||||
app.state.login_limiter = LoginRateLimiter()
|
||||
|
||||
# OIDC configuration (opt-in via env vars)
|
||||
from turnstone.core.oidc import load_oidc_config
|
||||
|
||||
oidc_config = load_oidc_config()
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.jwks_data = None # populated after async discovery
|
||||
|
||||
# Scheduler — start background thread if storage is available
|
||||
if auth_storage is not None:
|
||||
from turnstone.console.scheduler import TaskScheduler
|
||||
|
||||
@@ -267,8 +267,13 @@ function _renderUsers(users) {
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var u = users[i];
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
'<div class="admin-row" role="listitem" data-expandable data-user-id="' +
|
||||
escapeHtml(u.user_id) +
|
||||
'" data-username="' +
|
||||
escapeHtml(u.username) +
|
||||
'" tabindex="0" aria-expanded="false">' +
|
||||
'<span class="admin-col admin-col-username">' +
|
||||
'<span class="admin-expand-indicator" aria-hidden="true">\u25b8</span>' +
|
||||
escapeHtml(u.username) +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-name">' +
|
||||
@@ -307,6 +312,31 @@ function _renderUsers(users) {
|
||||
);
|
||||
});
|
||||
}
|
||||
// Bind expandable row click + keyboard handlers for OIDC detail panel
|
||||
var rows = container.querySelectorAll(".admin-row[data-expandable]");
|
||||
for (var k = 0; k < rows.length; k++) {
|
||||
(function (row) {
|
||||
var _expand = function () {
|
||||
var uid = row.getAttribute("data-user-id");
|
||||
var uname = row.getAttribute("data-username");
|
||||
_toggleOidcPanel(uid, uname, row);
|
||||
};
|
||||
row.addEventListener("click", function (e) {
|
||||
if (
|
||||
e.target.closest(".admin-btn-danger") ||
|
||||
e.target.closest(".admin-btn-action")
|
||||
)
|
||||
return;
|
||||
_expand();
|
||||
});
|
||||
row.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
_expand();
|
||||
}
|
||||
});
|
||||
})(rows[k]);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteUser(userId, username) {
|
||||
@@ -332,6 +362,253 @@ function confirmDeleteUser(userId, username) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIDC identity expansion in Users tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _toggleOidcPanel(userId, username, rowEl) {
|
||||
var existing = rowEl.nextElementSibling;
|
||||
if (existing && existing.classList.contains("oidc-detail-panel")) {
|
||||
// Collapse
|
||||
existing.style.maxHeight = "0";
|
||||
var indicator = rowEl.querySelector(".admin-expand-indicator");
|
||||
if (indicator) indicator.classList.remove("expanded");
|
||||
rowEl.setAttribute("aria-expanded", "false");
|
||||
setTimeout(function () {
|
||||
if (existing.parentNode) existing.remove();
|
||||
}, 160);
|
||||
return;
|
||||
}
|
||||
// Collapse any other open panel first
|
||||
var openPanels = document.querySelectorAll(
|
||||
"#admin-users-table .oidc-detail-panel",
|
||||
);
|
||||
for (var i = 0; i < openPanels.length; i++) {
|
||||
openPanels[i].style.maxHeight = "0";
|
||||
var prevRow = openPanels[i].previousElementSibling;
|
||||
if (prevRow) {
|
||||
var ind = prevRow.querySelector(".admin-expand-indicator");
|
||||
if (ind) ind.classList.remove("expanded");
|
||||
prevRow.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
(function (panel) {
|
||||
setTimeout(function () {
|
||||
if (panel.parentNode) panel.remove();
|
||||
}, 160);
|
||||
})(openPanels[i]);
|
||||
}
|
||||
// Mark expanded
|
||||
var indicator = rowEl.querySelector(".admin-expand-indicator");
|
||||
if (indicator) indicator.classList.add("expanded");
|
||||
rowEl.setAttribute("aria-expanded", "true");
|
||||
// Create panel (role="none" so it doesn't break the parent role="list")
|
||||
var panel = document.createElement("div");
|
||||
panel.className = "oidc-detail-panel";
|
||||
panel.setAttribute("role", "none");
|
||||
panel.innerHTML =
|
||||
'<div class="oidc-detail-inner">' +
|
||||
'<div class="oidc-detail-header">OIDC Identities</div>' +
|
||||
'<div class="oidc-detail-body"><span class="oidc-detail-empty">Loading\u2026</span></div>' +
|
||||
"</div>";
|
||||
rowEl.after(panel);
|
||||
// Animate open
|
||||
requestAnimationFrame(function () {
|
||||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||||
});
|
||||
// Fetch identities
|
||||
authFetch(
|
||||
"/v1/api/admin/users/" + encodeURIComponent(userId) + "/oidc-identities",
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
_renderOidcDetail(panel, data.oidc_identities || [], userId, username);
|
||||
})
|
||||
.catch(function () {
|
||||
var body = panel.querySelector(".oidc-detail-body");
|
||||
if (body)
|
||||
body.innerHTML =
|
||||
'<span class="oidc-detail-empty">Failed to load</span>';
|
||||
});
|
||||
}
|
||||
|
||||
function _renderOidcDetail(panel, identities, userId, username) {
|
||||
var body = panel.querySelector(".oidc-detail-body");
|
||||
if (!body) return;
|
||||
if (!identities.length) {
|
||||
body.innerHTML =
|
||||
'<span class="oidc-detail-empty">No OIDC identities linked</span>';
|
||||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
for (var i = 0; i < identities.length; i++) {
|
||||
var oid = identities[i];
|
||||
var shortIssuer = _issuerShortName(oid.issuer || "");
|
||||
var shortSubject =
|
||||
(oid.subject || "").length > 12
|
||||
? (oid.subject || "").slice(0, 12) + "\u2026"
|
||||
: oid.subject || "";
|
||||
var lastLogin = oid.last_login ? _relativeTime(oid.last_login) : "never";
|
||||
html +=
|
||||
'<div class="oidc-identity-row">' +
|
||||
'<span class="oidc-identity-issuer"><span class="scope-badge">' +
|
||||
escapeHtml(shortIssuer) +
|
||||
"</span></span>" +
|
||||
'<span class="oidc-identity-subject" title="' +
|
||||
escapeHtml(oid.subject || "") +
|
||||
'">' +
|
||||
escapeHtml(shortSubject) +
|
||||
"</span>" +
|
||||
'<span class="oidc-identity-email" title="' +
|
||||
escapeHtml(oid.email || "") +
|
||||
'">' +
|
||||
escapeHtml(oid.email || "\u2014") +
|
||||
"</span>" +
|
||||
'<span class="oidc-identity-time">' +
|
||||
escapeHtml(lastLogin) +
|
||||
"</span>" +
|
||||
'<span class="oidc-identity-actions">' +
|
||||
'<button class="admin-btn-danger" aria-label="Unlink ' +
|
||||
escapeHtml(shortIssuer) +
|
||||
" identity " +
|
||||
escapeHtml(shortSubject) +
|
||||
'" data-oidc-issuer="' +
|
||||
escapeHtml(oid.issuer || "") +
|
||||
'" data-oidc-subject="' +
|
||||
escapeHtml(oid.subject || "") +
|
||||
'" data-oidc-username="' +
|
||||
escapeHtml(username) +
|
||||
'" data-oidc-user-id="' +
|
||||
escapeHtml(userId) +
|
||||
'">unlink</button>' +
|
||||
"</span></div>";
|
||||
}
|
||||
body.innerHTML = html;
|
||||
// Update panel height for animation
|
||||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||||
// Bind unlink buttons
|
||||
var btns = body.querySelectorAll("[data-oidc-issuer]");
|
||||
for (var j = 0; j < btns.length; j++) {
|
||||
btns[j].addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
var issuer = this.getAttribute("data-oidc-issuer");
|
||||
var subject = this.getAttribute("data-oidc-subject");
|
||||
var uname = this.getAttribute("data-oidc-username");
|
||||
var uid = this.getAttribute("data-oidc-user-id");
|
||||
_confirmUnlinkOidc(issuer, subject, uname, uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _confirmUnlinkOidc(issuer, subject, username, userId) {
|
||||
var shortIssuer = _issuerShortName(issuer);
|
||||
var shortSubject =
|
||||
subject.length > 16 ? subject.slice(0, 16) + "\u2026" : subject;
|
||||
showConfirmModal(
|
||||
"Unlink OIDC Identity",
|
||||
"Unlink " +
|
||||
shortIssuer +
|
||||
" identity \u2018" +
|
||||
shortSubject +
|
||||
"\u2019 from user " +
|
||||
username +
|
||||
"?\n\nThe user will need to log in via OIDC again to re-link.",
|
||||
"Unlink",
|
||||
function () {
|
||||
authFetch(
|
||||
"/v1/api/admin/oidc-identities?issuer=" +
|
||||
encodeURIComponent(issuer) +
|
||||
"&subject=" +
|
||||
encodeURIComponent(subject),
|
||||
{ method: "DELETE" },
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Unlink failed");
|
||||
showToast("OIDC identity unlinked");
|
||||
// Refresh the panel content in place (no close/reopen flicker)
|
||||
var allRows = document.querySelectorAll(
|
||||
"#admin-users-table .admin-row[data-expandable]",
|
||||
);
|
||||
var targetRow = null;
|
||||
for (var ri = 0; ri < allRows.length; ri++) {
|
||||
if (allRows[ri].getAttribute("data-user-id") === userId) {
|
||||
targetRow = allRows[ri];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetRow) {
|
||||
var panel = targetRow.nextElementSibling;
|
||||
if (panel && panel.classList.contains("oidc-detail-panel")) {
|
||||
var body = panel.querySelector(".oidc-detail-body");
|
||||
if (body)
|
||||
body.innerHTML =
|
||||
'<span class="oidc-detail-empty">Loading\u2026</span>';
|
||||
authFetch(
|
||||
"/v1/api/admin/users/" +
|
||||
encodeURIComponent(userId) +
|
||||
"/oidc-identities",
|
||||
)
|
||||
.then(function (r2) {
|
||||
if (!r2.ok) throw new Error("Failed");
|
||||
return r2.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
_renderOidcDetail(
|
||||
panel,
|
||||
data.oidc_identities || [],
|
||||
userId,
|
||||
username,
|
||||
);
|
||||
})
|
||||
.catch(function () {
|
||||
if (body)
|
||||
body.innerHTML =
|
||||
'<span class="oidc-detail-empty">Failed to load</span>';
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to unlink OIDC identity");
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _issuerShortName(issuer) {
|
||||
try {
|
||||
var host = new URL(issuer).hostname;
|
||||
if (host.includes("google")) return "google";
|
||||
if (host.includes("microsoftonline") || host.includes("azure"))
|
||||
return "azure";
|
||||
if (host.includes("okta")) return "okta";
|
||||
if (host.includes("auth0")) return "auth0";
|
||||
if (host.includes("keycloak")) return "keycloak";
|
||||
return host.replace(/^(login|accounts|auth|id|sso)\./, "");
|
||||
} catch (e) {
|
||||
return issuer || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function _relativeTime(isoStr) {
|
||||
try {
|
||||
var then = new Date(
|
||||
isoStr + (isoStr.includes("Z") || isoStr.includes("+") ? "" : "Z"),
|
||||
);
|
||||
var diff = (Date.now() - then.getTime()) / 1000;
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
|
||||
if (diff < 2592000) return Math.floor(diff / 86400) + "d ago";
|
||||
return isoStr.slice(0, 10);
|
||||
} catch (e) {
|
||||
return isoStr || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2404,6 +2681,7 @@ function _renderMcpServers(items) {
|
||||
var nodeIds = Object.keys(statusEntries);
|
||||
var anyConnected = false;
|
||||
var anyError = false;
|
||||
var firstError = "";
|
||||
var totalTools = 0,
|
||||
totalRes = 0,
|
||||
totalPrompts = 0;
|
||||
@@ -2415,7 +2693,10 @@ function _renderMcpServers(items) {
|
||||
totalRes += ns.resources || 0;
|
||||
totalPrompts += ns.prompts || 0;
|
||||
}
|
||||
if (ns.error) anyError = true;
|
||||
if (ns.error) {
|
||||
anyError = true;
|
||||
if (!firstError) firstError = ns.error;
|
||||
}
|
||||
}
|
||||
|
||||
var dotClass = "mcp-status-dot disabled";
|
||||
@@ -2492,7 +2773,9 @@ function _renderMcpServers(items) {
|
||||
'<span class="admin-col admin-col-mprompts">' +
|
||||
promptsVal +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-mstatus"><span class="' +
|
||||
'<span class="admin-col admin-col-mstatus"' +
|
||||
(firstError ? ' title="' + escapeHtml(firstError) + '"' : "") +
|
||||
'><span class="' +
|
||||
dotClass +
|
||||
'" aria-hidden="true"></span>' +
|
||||
escapeHtml(statusText) +
|
||||
@@ -2825,9 +3108,7 @@ function _openMcpDetail(s) {
|
||||
var dot = ns.connected
|
||||
? '<span class="mcp-status-dot connected"></span>'
|
||||
: '<span class="mcp-status-dot error"></span>';
|
||||
html +=
|
||||
"<li>" +
|
||||
dot +
|
||||
var nodeInfo =
|
||||
escapeHtml(nodeIds[j]) +
|
||||
" — " +
|
||||
(ns.tools || 0) +
|
||||
@@ -2835,7 +3116,14 @@ function _openMcpDetail(s) {
|
||||
(ns.resources || 0) +
|
||||
" resources, " +
|
||||
(ns.prompts || 0) +
|
||||
" prompts</li>";
|
||||
" prompts";
|
||||
if (ns.error) {
|
||||
nodeInfo +=
|
||||
'<br><span style="color:var(--red);font-size:11px">' +
|
||||
escapeHtml(ns.error) +
|
||||
"</span>";
|
||||
}
|
||||
html += "<li>" + dot + nodeInfo + "</li>";
|
||||
}
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
@@ -1263,7 +1263,7 @@ function showNewWsModal() {
|
||||
// Populate template dropdown
|
||||
var tplSelect = document.getElementById("new-ws-template");
|
||||
tplSelect.innerHTML = '<option value="">Use defaults</option>';
|
||||
authFetch("/v1/api/admin/templates")
|
||||
authFetch("/v1/api/templates")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
@@ -1303,7 +1303,14 @@ function showNewWsModal() {
|
||||
});
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
document.getElementById("new-ws-model").value = "";
|
||||
document.getElementById("new-ws-task").value = "";
|
||||
var taskEl = document.getElementById("new-ws-task");
|
||||
taskEl.value = "";
|
||||
var mod =
|
||||
navigator.platform && navigator.platform.indexOf("Mac") > -1
|
||||
? "\u2318"
|
||||
: "Ctrl";
|
||||
taskEl.placeholder =
|
||||
"What should this workstream work on? (" + mod + "+Enter to create)";
|
||||
var errEl = document.getElementById("new-ws-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
@@ -1336,7 +1343,7 @@ function showNewWsModal() {
|
||||
document.addEventListener("keydown", _newWsTrapHandler);
|
||||
|
||||
setTimeout(function () {
|
||||
document.getElementById("new-ws-name").focus();
|
||||
document.getElementById("new-ws-task").focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@@ -1412,11 +1419,10 @@ document.addEventListener("keydown", function (e) {
|
||||
e.preventDefault();
|
||||
hideNewWsModal();
|
||||
}
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
e.target.tagName !== "SELECT" &&
|
||||
e.target.tagName !== "TEXTAREA"
|
||||
) {
|
||||
if (e.key === "Enter") {
|
||||
if (e.target.tagName === "SELECT") return;
|
||||
if (e.target.tagName === "BUTTON") return; // let native click fire
|
||||
if (e.target.tagName === "TEXTAREA" && !(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById("new-ws-submit");
|
||||
if (btn && !btn.disabled) submitNewWs();
|
||||
|
||||
@@ -437,6 +437,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{ desc: "Navigate rows", badge: '<span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span>' }
|
||||
]},
|
||||
{ title: "General", keys: [
|
||||
{ desc: "Submit form from textarea", badge: '<span class="kb-key">' + (navigator.platform && navigator.platform.indexOf("Mac") > -1 ? "\u2318" : "Ctrl") + '</span>+<span class="kb-key">Enter</span>' },
|
||||
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' },
|
||||
{ desc: "Close overlay", badge: '<span class="kb-key">Esc</span>' }
|
||||
]}
|
||||
@@ -452,6 +453,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<div id="new-ws-box">
|
||||
<h2 id="new-ws-title">New Workstream</h2>
|
||||
<div id="new-ws-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="new-ws-task">Task <span class="label-hint">optional — sent as first message</span></label>
|
||||
<textarea id="new-ws-task" rows="4" placeholder="What should this workstream work on?"></textarea>
|
||||
<label for="new-ws-node">Node</label>
|
||||
<select id="new-ws-node">
|
||||
<option value="">Auto (best available)</option>
|
||||
@@ -468,8 +471,6 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<select id="new-ws-profile">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
<label for="new-ws-task">Task <span class="label-hint">optional — sent as first message</span></label>
|
||||
<textarea id="new-ws-task" rows="3" placeholder="What should this workstream work on?"></textarea>
|
||||
<div id="new-ws-buttons">
|
||||
<button id="new-ws-cancel" onclick="hideNewWsModal()">Cancel</button>
|
||||
<button id="new-ws-submit" onclick="submitNewWs()">Create</button>
|
||||
|
||||
@@ -627,8 +627,9 @@
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
opacity: 0.75;
|
||||
}
|
||||
#new-ws-box label:first-of-type { margin-top: 0; }
|
||||
#new-ws-box select,
|
||||
#new-ws-box input[type="text"],
|
||||
#new-ws-box textarea {
|
||||
@@ -642,7 +643,7 @@
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#new-ws-box textarea { resize: vertical; min-height: 40px; }
|
||||
#new-ws-box textarea { resize: vertical; min-height: 60px; }
|
||||
#new-ws-box textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
#new-ws-box select:focus,
|
||||
#new-ws-box input:focus,
|
||||
@@ -688,6 +689,7 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#new-ws-cancel:hover { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||
#new-ws-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
#new-ws-submit {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
@@ -1891,6 +1893,104 @@
|
||||
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
|
||||
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
|
||||
|
||||
/* ==========================================================================
|
||||
OIDC detail panel (inline expansion below user row)
|
||||
========================================================================== */
|
||||
.oidc-detail-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 150ms ease;
|
||||
margin: 0 8px 0 24px;
|
||||
}
|
||||
.oidc-detail-inner {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--row-alt);
|
||||
}
|
||||
.oidc-detail-header {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oidc-detail-header::before {
|
||||
content: "\25c6 ";
|
||||
color: var(--accent);
|
||||
}
|
||||
.oidc-identity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 60px 50px;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.oidc-identity-row + .oidc-identity-row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.oidc-identity-issuer {
|
||||
overflow: hidden;
|
||||
}
|
||||
.oidc-identity-issuer .scope-badge {
|
||||
font-size: 10px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.oidc-identity-subject {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.oidc-identity-email {
|
||||
color: var(--fg-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.oidc-identity-time {
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.oidc-identity-actions .admin-btn-danger { font-size: 11px; }
|
||||
.oidc-detail-empty {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Expand indicator on user rows */
|
||||
.admin-row[data-expandable] { cursor: pointer; }
|
||||
.admin-row[data-expandable]:hover { background: var(--bg-highlight); }
|
||||
.admin-row[data-expandable]:hover .admin-expand-indicator { color: var(--fg); }
|
||||
.admin-row[data-expandable]:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.admin-expand-indicator {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
transition: transform 150ms ease;
|
||||
transform-origin: center;
|
||||
}
|
||||
.admin-expand-indicator.expanded { transform: rotate(90deg); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.oidc-identity-row { grid-template-columns: 70px 1fr 50px; }
|
||||
.oidc-identity-email, .oidc-identity-time { display: none; }
|
||||
.oidc-detail-panel { margin-left: 8px; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — console-specific
|
||||
========================================================================== */
|
||||
@@ -1909,4 +2009,5 @@
|
||||
.admin-action-btn, .modal-cancel, .modal-submit { transition: none; }
|
||||
.admin-modal input, .admin-modal select { transition: none; }
|
||||
.mcp-status-dot.connecting { animation: none; }
|
||||
.oidc-detail-panel, .admin-expand-indicator { transition: none; }
|
||||
}
|
||||
|
||||
+252
-9
@@ -28,6 +28,7 @@ import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -37,6 +38,8 @@ if TYPE_CHECKING:
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -150,6 +153,8 @@ PUBLIC_PATHS: frozenset[str] = frozenset(
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/oidc/authorize",
|
||||
"/api/auth/oidc/callback",
|
||||
}
|
||||
)
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
|
||||
@@ -258,10 +263,20 @@ def hash_password(password: str) -> str:
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a bcrypt hash."""
|
||||
"""Verify a password against a bcrypt hash.
|
||||
|
||||
Returns ``False`` immediately for non-bcrypt hashes (e.g. the ``!oidc``
|
||||
sentinel used for OIDC-provisioned users) to avoid ``ValueError`` from
|
||||
``bcrypt.checkpw``.
|
||||
"""
|
||||
import bcrypt
|
||||
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
if not password_hash.startswith("$2"):
|
||||
return False # Not a bcrypt hash (e.g. OIDC sentinel)
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def parse_scopes(scopes_str: str) -> frozenset[str]:
|
||||
@@ -909,6 +924,13 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
password = body.get("password", "")
|
||||
|
||||
if username and password and storage is not None:
|
||||
# Enforce OIDC-only mode: reject password login when disabled
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
if oidc_config and oidc_config.enabled and not oidc_config.password_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Password login is disabled — use SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
user = storage.get_user_by_username(username)
|
||||
if user and verify_password(password, user["password_hash"]):
|
||||
# Derive scopes and permissions from assigned roles
|
||||
@@ -990,13 +1012,21 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"auth_enabled": auth_config.enabled,
|
||||
"has_users": has_users,
|
||||
"setup_required": auth_config.enabled and not has_users,
|
||||
}
|
||||
)
|
||||
# OIDC configuration
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
oidc_enabled = bool(oidc_config and oidc_config.enabled)
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"auth_enabled": auth_config.enabled,
|
||||
"has_users": has_users,
|
||||
"setup_required": auth_config.enabled and not has_users,
|
||||
}
|
||||
if oidc_enabled and oidc_config is not None:
|
||||
resp["oidc_enabled"] = True
|
||||
resp["oidc_provider_name"] = oidc_config.provider_name
|
||||
resp["password_enabled"] = oidc_config.password_enabled
|
||||
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
@@ -1097,3 +1127,216 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
if jwt_token:
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
|
||||
return response
|
||||
|
||||
|
||||
async def handle_auth_whoami(request: Request) -> Response:
|
||||
"""Shared ``GET /api/auth/whoami`` handler — return authenticated user info."""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_result: AuthResult | None = getattr(request.state, "auth_result", None)
|
||||
if not auth_result or not auth_result.user_id:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
|
||||
resp: dict[str, str] = {
|
||||
"user_id": auth_result.user_id,
|
||||
}
|
||||
if auth_result.permissions:
|
||||
resp["permissions"] = ",".join(sorted(auth_result.permissions))
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
def _build_oidc_redirect_uri(request: Request, oidc_config: OIDCConfig) -> str:
|
||||
"""Build the OIDC callback redirect URI.
|
||||
|
||||
Uses ``redirect_base`` from OIDC config when set (recommended for
|
||||
reverse-proxy deployments), otherwise falls back to the request Host header.
|
||||
"""
|
||||
if oidc_config.redirect_base:
|
||||
return f"{oidc_config.redirect_base}/v1/api/auth/oidc/callback"
|
||||
scheme = "https" if is_secure_request(dict(request.headers), request.url.scheme) else "http"
|
||||
host = request.headers.get("host", "localhost")
|
||||
return f"{scheme}://{host}/v1/api/auth/oidc/callback"
|
||||
|
||||
|
||||
async def handle_oidc_authorize(request: Request, audience: str) -> Response:
|
||||
"""Shared ``GET /api/auth/oidc/authorize`` handler — redirect to IdP."""
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
if not oidc_config or not oidc_config.enabled:
|
||||
return JSONResponse({"error": "OIDC not configured"}, status_code=404)
|
||||
|
||||
# Rate limit — prevents flooding oidc_pending_states table
|
||||
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if login_limiter is not None:
|
||||
ip_ok, _ip_retry = login_limiter.check(f"ip:{client_ip}")
|
||||
if not ip_ok:
|
||||
return RedirectResponse("/?oidc_error=Too+many+login+attempts", status_code=302)
|
||||
login_limiter.record(f"ip:{client_ip}") # Count every authorize to bound pending states
|
||||
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage is None:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
|
||||
# Require setup to be complete before allowing OIDC login
|
||||
try:
|
||||
users = storage.list_users()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Storage unavailable"}, status_code=503)
|
||||
if not users:
|
||||
return JSONResponse(
|
||||
{"error": "Initial setup required before OIDC login"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
from turnstone.core.oidc import build_authorize_url, generate_pkce_pair
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
code_verifier, _code_challenge = generate_pkce_pair()
|
||||
|
||||
# Store pending state in database
|
||||
storage.create_oidc_pending_state(state, nonce, code_verifier, audience)
|
||||
|
||||
# Build redirect URI (pinned by TURNSTONE_OIDC_REDIRECT_BASE when set)
|
||||
redirect_uri = _build_oidc_redirect_uri(request, oidc_config)
|
||||
|
||||
url = build_authorize_url(oidc_config, redirect_uri, state, nonce, code_verifier)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
async def handle_oidc_callback(request: Request, audience: str) -> Response:
|
||||
"""Shared ``GET /api/auth/oidc/callback`` handler — exchange code, provision user, issue JWT."""
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
if not oidc_config or not oidc_config.enabled:
|
||||
return JSONResponse({"error": "OIDC not configured"}, status_code=404)
|
||||
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
|
||||
if storage is None:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
|
||||
# Rate limiting
|
||||
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if login_limiter is not None:
|
||||
ip_ok, ip_retry = login_limiter.check(f"ip:{client_ip}")
|
||||
if not ip_ok:
|
||||
return RedirectResponse("/?oidc_error=Too+many+login+attempts", status_code=302)
|
||||
|
||||
# Lazy cleanup of expired pending states
|
||||
with contextlib.suppress(Exception):
|
||||
storage.cleanup_expired_oidc_states(300)
|
||||
|
||||
def _record_oidc_failure() -> None:
|
||||
if login_limiter is not None:
|
||||
login_limiter.record(f"ip:{client_ip}")
|
||||
|
||||
# Check for IdP error
|
||||
error = request.query_params.get("error", "")
|
||||
if error:
|
||||
_record_oidc_failure()
|
||||
desc = request.query_params.get("error_description", error)
|
||||
return RedirectResponse(f"/?oidc_error={urllib.parse.quote(desc)}", status_code=302)
|
||||
|
||||
# Validate state
|
||||
state = request.query_params.get("state", "")
|
||||
pending = storage.pop_oidc_pending_state(state, max_age_seconds=300)
|
||||
if not pending:
|
||||
_record_oidc_failure()
|
||||
return RedirectResponse("/?oidc_error=Login+session+expired", status_code=302)
|
||||
|
||||
# Build redirect URI (must match what was sent in authorize)
|
||||
redirect_uri = _build_oidc_redirect_uri(request, oidc_config)
|
||||
|
||||
try:
|
||||
from turnstone.core.oidc import (
|
||||
OIDCError,
|
||||
exchange_code,
|
||||
fetch_jwks,
|
||||
provision_oidc_user,
|
||||
validate_id_token,
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
code = request.query_params.get("code", "")
|
||||
tokens = await exchange_code(oidc_config, code, redirect_uri, pending["code_verifier"])
|
||||
|
||||
# Validate ID token against cached JWKS keys (no I/O).
|
||||
# On unknown kid, refresh JWKS once (async) for key rotation.
|
||||
jwks_data: dict[str, Any] | None = getattr(request.app.state, "jwks_data", None)
|
||||
if jwks_data is None and oidc_config.jwks_uri:
|
||||
# Lazy fetch: JWKS may have failed at startup but IdP recovered
|
||||
try:
|
||||
jwks_data = await fetch_jwks(oidc_config.jwks_uri)
|
||||
request.app.state.jwks_data = jwks_data
|
||||
except OIDCError:
|
||||
pass
|
||||
if jwks_data is None:
|
||||
return RedirectResponse("/?oidc_error=OIDC+temporarily+unavailable", status_code=302)
|
||||
|
||||
try:
|
||||
id_claims = validate_id_token(
|
||||
tokens["id_token"],
|
||||
jwks_data,
|
||||
oidc_config,
|
||||
pending["nonce"],
|
||||
)
|
||||
except OIDCError as first_err:
|
||||
if "not found in JWKS" not in str(first_err):
|
||||
raise
|
||||
# Key rotation: re-fetch JWKS and retry once.
|
||||
log.info("JWKS key not found — refreshing for possible key rotation")
|
||||
jwks_data = await fetch_jwks(oidc_config.jwks_uri)
|
||||
request.app.state.jwks_data = jwks_data
|
||||
id_claims = validate_id_token(
|
||||
tokens["id_token"],
|
||||
jwks_data,
|
||||
oidc_config,
|
||||
pending["nonce"],
|
||||
)
|
||||
|
||||
# Verify setup is complete
|
||||
users = storage.list_users()
|
||||
if not users:
|
||||
return RedirectResponse("/?oidc_error=Initial+setup+required", status_code=302)
|
||||
|
||||
# Provision or match user
|
||||
user = provision_oidc_user(storage, oidc_config, id_claims)
|
||||
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC callback failed: %s", exc)
|
||||
_record_oidc_failure()
|
||||
return RedirectResponse("/?oidc_error=Authentication+failed", status_code=302)
|
||||
except Exception:
|
||||
log.exception("OIDC callback error")
|
||||
_record_oidc_failure()
|
||||
return RedirectResponse("/?oidc_error=Authentication+failed", status_code=302)
|
||||
|
||||
# Load permissions and issue Turnstone JWT
|
||||
perms = _load_user_permissions(storage, user["user_id"])
|
||||
scopes = _permissions_to_scopes(perms)
|
||||
jwt_token = ""
|
||||
if jwt_secret:
|
||||
# Use the audience stored during authorize (not the handler param)
|
||||
# to bind the JWT to the service that initiated the flow
|
||||
jwt_audience = pending.get("audience", audience)
|
||||
jwt_token = create_jwt(
|
||||
user_id=user["user_id"],
|
||||
scopes=scopes,
|
||||
source="oidc",
|
||||
secret=jwt_secret,
|
||||
audience=jwt_audience,
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
|
||||
# Set cookie and redirect to app
|
||||
response = RedirectResponse("/?oidc_success=1", status_code=302)
|
||||
if jwt_token:
|
||||
secure = is_secure_request(dict(request.headers), request.url.scheme)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
|
||||
return response
|
||||
|
||||
@@ -109,6 +109,9 @@ class MCPClientManager:
|
||||
# Config-file servers loaded at startup are NOT in this set and
|
||||
# will never be removed by reconcile_sync.
|
||||
self._db_managed: set[str] = set()
|
||||
# Per-server last-error tracking (set on failure, cleared on success)
|
||||
self._last_error: dict[str, str] = {}
|
||||
self._MAX_ERROR_LEN = 256
|
||||
|
||||
# Per-server tool storage for surgical refresh
|
||||
self._per_server_tools: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -171,8 +174,9 @@ class MCPClientManager:
|
||||
for name, cfg in self._server_configs.items():
|
||||
try:
|
||||
await self._connect_one(name, cfg)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.warning("Failed to connect MCP server '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"{type(exc).__name__}: {exc}")
|
||||
|
||||
self._connected.set()
|
||||
|
||||
@@ -244,8 +248,10 @@ class MCPClientManager:
|
||||
elif isinstance(root, mcp_types.PromptListChangedNotification):
|
||||
log.info("Received prompts/list_changed from '%s'", name)
|
||||
await self._refresh_server_prompts(name)
|
||||
except Exception:
|
||||
self._last_error.pop(name, None)
|
||||
except Exception as exc:
|
||||
log.warning("Refresh after notification failed for '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"Refresh failed: {exc}")
|
||||
|
||||
try:
|
||||
session = await stack.enter_async_context(
|
||||
@@ -372,6 +378,9 @@ class MCPClientManager:
|
||||
except Exception:
|
||||
log.warning("Prompt sync after connect failed for '%s'", name, exc_info=True)
|
||||
|
||||
# Connection succeeded — clear any previous error
|
||||
self._last_error.pop(name, None)
|
||||
|
||||
# -- tool refresh --------------------------------------------------------
|
||||
|
||||
def _rebuild_tools(self) -> None:
|
||||
@@ -428,6 +437,7 @@ class MCPClientManager:
|
||||
added, removed = await self._refresh_server_tools(name)
|
||||
await self._refresh_server_resources(name)
|
||||
await self._refresh_server_prompts(name)
|
||||
self._last_error.pop(name, None)
|
||||
return added, removed
|
||||
|
||||
async def _refresh_all(
|
||||
@@ -456,8 +466,9 @@ class MCPClientManager:
|
||||
continue
|
||||
added, removed = await self._refresh_server(name)
|
||||
results[name] = (added, removed)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"Refresh failed: {exc}")
|
||||
results[name] = ([], [])
|
||||
|
||||
# Final sync to clean up templates from servers that are no longer connected
|
||||
@@ -497,8 +508,10 @@ class MCPClientManager:
|
||||
await self._refresh_server_resources(name)
|
||||
if not self._supports_prompt_list_changed.get(name, False):
|
||||
await self._refresh_server_prompts(name)
|
||||
except Exception:
|
||||
self._last_error.pop(name, None)
|
||||
except Exception as exc:
|
||||
log.warning("Periodic refresh failed for '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"Periodic refresh failed: {exc}")
|
||||
await asyncio.sleep(self._refresh_interval)
|
||||
|
||||
# -- resource refresh ----------------------------------------------------
|
||||
@@ -958,6 +971,7 @@ class MCPClientManager:
|
||||
self._supports_resource_list_changed.pop(name, None)
|
||||
self._supports_prompts.pop(name, None)
|
||||
self._supports_prompt_list_changed.pop(name, None)
|
||||
self._last_error.pop(name, None)
|
||||
# Rebuild merged state (serialized with notification handlers)
|
||||
self._rebuild_tools()
|
||||
self._rebuild_resources()
|
||||
@@ -979,6 +993,7 @@ class MCPClientManager:
|
||||
self._supports_resource_list_changed.pop(name, None)
|
||||
self._supports_prompts.pop(name, None)
|
||||
self._supports_prompt_list_changed.pop(name, None)
|
||||
self._last_error.pop(name, None)
|
||||
self._rebuild_tools()
|
||||
self._rebuild_resources()
|
||||
self._rebuild_prompts()
|
||||
@@ -992,6 +1007,11 @@ class MCPClientManager:
|
||||
log.info("Removed MCP server '%s'", name)
|
||||
return was_connected
|
||||
|
||||
def _set_error(self, name: str, msg: str) -> None:
|
||||
"""Store a sanitized error string for a server."""
|
||||
clean = msg.replace("\n", " ").replace("\r", "")
|
||||
self._last_error[name] = clean[: self._MAX_ERROR_LEN]
|
||||
|
||||
def get_server_status(self, name: str) -> dict[str, Any]:
|
||||
"""Return live status for a single server, including config details."""
|
||||
connected = name in self._sessions
|
||||
@@ -1002,7 +1022,7 @@ class MCPClientManager:
|
||||
"tools": len(self._per_server_tools.get(name, [])) if connected else 0,
|
||||
"resources": len(self._per_server_resources.get(name, [])) if connected else 0,
|
||||
"prompts": len(self._per_server_prompts.get(name, [])) if connected else 0,
|
||||
"error": "",
|
||||
"error": self._last_error.get(name, ""),
|
||||
"transport": transport,
|
||||
"command": cfg.get("command", "") if transport == "stdio" else "",
|
||||
"url": cfg.get("url", "") if transport != "stdio" else "",
|
||||
@@ -1123,6 +1143,11 @@ class MCPClientManager:
|
||||
def server_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Number of servers currently in error state."""
|
||||
return len(self._last_error)
|
||||
|
||||
@property
|
||||
def server_names(self) -> list[str]:
|
||||
"""Return configured server names."""
|
||||
|
||||
@@ -53,11 +53,16 @@ _NUDGE_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection heuristics
|
||||
# Detection heuristics — strong/weak tiers
|
||||
#
|
||||
# Strong patterns fire unconditionally. Weak patterns carry inherent
|
||||
# ambiguity ("no …", "thanks …") and only fire when the surrounding
|
||||
# message looks like a genuine correction/completion rather than normal
|
||||
# conversation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORRECTION_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)^no[,.\s]"),
|
||||
_STRONG_CORRECTION: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)^no[,.]"), # "no," / "no." — clear rejection
|
||||
re.compile(r"(?i)\bdon'?t\b"),
|
||||
re.compile(r"(?i)^stop\b"),
|
||||
re.compile(r"(?i)^actually[,\s]"),
|
||||
@@ -71,30 +76,64 @@ _CORRECTION_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)^please don'?t\b"),
|
||||
]
|
||||
|
||||
_COMPLETION_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)^thanks\b"),
|
||||
# "no <word>" is ambiguous — only match when the next word is a pronoun,
|
||||
# demonstrative, article, or verb that signals the user is redirecting,
|
||||
# not a fixed phrase like "no problem" or "no worries". Allowlist >
|
||||
# blocklist: we don't need to enumerate every benign "no X" phrase.
|
||||
_WEAK_CORRECTION: list[re.Pattern[str]] = [
|
||||
re.compile(
|
||||
r"(?i)^no\s+(?:I\b|you\b|we\b|they\b|it\b|he\b|she\b"
|
||||
r"|that\b|this\b|those\b|these\b"
|
||||
r"|the\b|a\b|an\b"
|
||||
r"|not\b|do\b|did\b|but\b)"
|
||||
),
|
||||
]
|
||||
|
||||
_STRONG_COMPLETION: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)\bthat'?s all\b"),
|
||||
re.compile(r"(?i)^lgtm\b"),
|
||||
]
|
||||
|
||||
# These patterns are common in both completion AND mid-conversation
|
||||
# acknowledgment. Only fire when the message is short and has no
|
||||
# continuation markers (question marks, follow-up requests).
|
||||
_WEAK_COMPLETION: list[re.Pattern[str]] = [
|
||||
re.compile(r"(?i)^thanks\b(?!\s+for\b)"), # "thanks for X" = acknowledgment
|
||||
re.compile(r"(?i)\blooks good\b"),
|
||||
re.compile(r"(?i)^perfect\b"),
|
||||
re.compile(r"(?i)^great job\b"),
|
||||
re.compile(r"(?i)\bthat works\b"),
|
||||
re.compile(r"(?i)^done\b"),
|
||||
re.compile(r"(?i)^lgtm\b"),
|
||||
]
|
||||
|
||||
_WEAK_MSG_CAP = 80 # weak completion patterns suppressed above this length
|
||||
|
||||
_CONTINUATION = re.compile(
|
||||
r"(?i)(?:\?|(?:can you|could you|please\s|also\s|but\s|now\s|next\s"
|
||||
r"|and\s+then|after\s+that|one\s+more|however))"
|
||||
)
|
||||
|
||||
|
||||
def detect_correction(message: str) -> bool:
|
||||
"""Return True if the message looks like a user correction."""
|
||||
if not message:
|
||||
return False
|
||||
return any(p.search(message) for p in _CORRECTION_PATTERNS)
|
||||
if any(p.search(message) for p in _STRONG_CORRECTION):
|
||||
return True
|
||||
return any(p.search(message) for p in _WEAK_CORRECTION)
|
||||
|
||||
|
||||
def detect_completion(message: str) -> bool:
|
||||
"""Return True if the message signals session completion."""
|
||||
if not message:
|
||||
return False
|
||||
return any(p.search(message) for p in _COMPLETION_PATTERNS)
|
||||
if any(p.search(message) for p in _STRONG_COMPLETION):
|
||||
return True
|
||||
if len(message) > _WEAK_MSG_CAP:
|
||||
return False
|
||||
if _CONTINUATION.search(message):
|
||||
return False
|
||||
return any(p.search(message) for p in _WEAK_COMPLETION)
|
||||
|
||||
|
||||
def should_nudge(
|
||||
|
||||
@@ -402,6 +402,11 @@ class MetricsCollector:
|
||||
"Number of MCP prompts available",
|
||||
mcp_info.get("prompts", 0),
|
||||
)
|
||||
gauge(
|
||||
"turnstone_mcp_server_errors",
|
||||
"Number of MCP servers currently in error state",
|
||||
mcp_info.get("errors", 0),
|
||||
)
|
||||
|
||||
lines.append("") # trailing newline
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"""OpenID Connect (OIDC) authentication support for Turnstone.
|
||||
|
||||
Implements the Authorization Code Flow with PKCE for secure SSO login.
|
||||
All external HTTP calls use ``httpx.AsyncClient`` to avoid blocking the
|
||||
event loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel password hash for OIDC-provisioned users.
|
||||
# Not a valid bcrypt hash -- verify_password() always rejects it.
|
||||
OIDC_PASSWORD_SENTINEL = "!oidc"
|
||||
|
||||
# Sanitisation pattern: only keep safe username characters.
|
||||
_USERNAME_SAFE_RE = re.compile(r"[^a-zA-Z0-9._-]")
|
||||
|
||||
# Asymmetric algorithms accepted for ID token signatures.
|
||||
# Symmetric (HMAC) algorithms are deliberately excluded to prevent
|
||||
# algorithm confusion attacks where the IdP's public key is used as
|
||||
# an HMAC secret.
|
||||
_ALLOWED_ID_TOKEN_ALGS = [
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""Raised when an OIDC operation fails."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OIDCConfig:
|
||||
"""OIDC provider configuration -- immutable after startup."""
|
||||
|
||||
enabled: bool = False
|
||||
issuer: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
scopes: str = "openid email profile"
|
||||
provider_name: str = "SSO"
|
||||
role_claim: str = ""
|
||||
role_map: dict[str, str] = field(default_factory=dict)
|
||||
password_enabled: bool = True
|
||||
redirect_base: str = ""
|
||||
# Discovered from .well-known/openid-configuration
|
||||
authorization_endpoint: str = ""
|
||||
token_endpoint: str = ""
|
||||
userinfo_endpoint: str = ""
|
||||
jwks_uri: str = ""
|
||||
|
||||
|
||||
def _parse_role_map(raw: str) -> dict[str, str]:
|
||||
"""Parse ``"admin:builtin-admin,eng:builtin-operator"`` into a dict."""
|
||||
result: dict[str, str] = {}
|
||||
for pair in raw.split(","):
|
||||
pair = pair.strip()
|
||||
if ":" in pair:
|
||||
k, v = pair.split(":", 1)
|
||||
k, v = k.strip(), v.strip()
|
||||
if k and v:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
def load_oidc_config() -> OIDCConfig:
|
||||
"""Build :class:`OIDCConfig` from env vars with config.toml fallback.
|
||||
|
||||
Returns ``OIDCConfig(enabled=False)`` when the required fields
|
||||
(issuer, client_id, client_secret) are not all present.
|
||||
"""
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
cfg = load_config("oidc")
|
||||
|
||||
# Start with config.toml values, then override with env vars.
|
||||
issuer = os.environ.get("TURNSTONE_OIDC_ISSUER", "").strip()
|
||||
if not issuer:
|
||||
issuer = str(cfg.get("issuer", "")).strip()
|
||||
|
||||
client_id = os.environ.get("TURNSTONE_OIDC_CLIENT_ID", "").strip()
|
||||
if not client_id:
|
||||
client_id = str(cfg.get("client_id", "")).strip()
|
||||
|
||||
client_secret = os.environ.get("TURNSTONE_OIDC_CLIENT_SECRET", "").strip()
|
||||
if not client_secret:
|
||||
client_secret = str(cfg.get("client_secret", "")).strip()
|
||||
|
||||
scopes = os.environ.get("TURNSTONE_OIDC_SCOPES", "").strip()
|
||||
if not scopes:
|
||||
scopes = str(cfg.get("scopes", "openid email profile")).strip()
|
||||
|
||||
provider_name = os.environ.get("TURNSTONE_OIDC_PROVIDER_NAME", "").strip()
|
||||
if not provider_name:
|
||||
provider_name = str(cfg.get("provider_name", "SSO")).strip()
|
||||
|
||||
role_claim = os.environ.get("TURNSTONE_OIDC_ROLE_CLAIM", "").strip()
|
||||
if not role_claim:
|
||||
role_claim = str(cfg.get("role_claim", "")).strip()
|
||||
|
||||
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
|
||||
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
|
||||
if role_map_raw:
|
||||
role_map = _parse_role_map(role_map_raw)
|
||||
else:
|
||||
cfg_role_map = cfg.get("role_map", {})
|
||||
role_map = dict(cfg_role_map) if isinstance(cfg_role_map, dict) else {}
|
||||
|
||||
password_raw = os.environ.get("TURNSTONE_OIDC_PASSWORD_ENABLED", "").strip().lower()
|
||||
if password_raw:
|
||||
password_enabled = password_raw in ("true", "1", "yes")
|
||||
else:
|
||||
password_enabled = bool(cfg.get("password_enabled", True))
|
||||
|
||||
redirect_base = os.environ.get("TURNSTONE_OIDC_REDIRECT_BASE", "").strip()
|
||||
if not redirect_base:
|
||||
redirect_base = str(cfg.get("redirect_base", "")).strip()
|
||||
redirect_base = redirect_base.rstrip("/")
|
||||
if redirect_base:
|
||||
parsed = urllib.parse.urlparse(redirect_base)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE has invalid scheme, ignoring: %s",
|
||||
redirect_base,
|
||||
)
|
||||
redirect_base = ""
|
||||
elif not parsed.hostname:
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE missing hostname, ignoring: %s",
|
||||
redirect_base,
|
||||
)
|
||||
redirect_base = ""
|
||||
elif parsed.username or parsed.password:
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE must not contain userinfo, ignoring: %s",
|
||||
redirect_base,
|
||||
)
|
||||
redirect_base = ""
|
||||
elif parsed.path or parsed.query or parsed.fragment:
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE must be scheme://host[:port] only, ignoring: %s",
|
||||
redirect_base,
|
||||
)
|
||||
redirect_base = ""
|
||||
else:
|
||||
# Validate port is numeric (urlparse accepts "host:abc" silently).
|
||||
try:
|
||||
parsed.port # noqa: B018 — triggers ValueError on non-numeric port
|
||||
except ValueError:
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE has invalid port, ignoring: %s",
|
||||
redirect_base,
|
||||
)
|
||||
redirect_base = ""
|
||||
if redirect_base and parsed.scheme != "https":
|
||||
log.warning(
|
||||
"TURNSTONE_OIDC_REDIRECT_BASE should use https:// in production: %s",
|
||||
redirect_base,
|
||||
)
|
||||
|
||||
# OIDC is enabled when all three required fields are non-empty.
|
||||
enabled = bool(issuer and client_id and client_secret)
|
||||
|
||||
if enabled:
|
||||
log.info("OIDC enabled: issuer=%s provider=%s", issuer, provider_name)
|
||||
else:
|
||||
log.debug("OIDC not configured (issuer/client_id/client_secret incomplete)")
|
||||
|
||||
return OIDCConfig(
|
||||
enabled=enabled,
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=scopes,
|
||||
provider_name=provider_name,
|
||||
role_claim=role_claim,
|
||||
role_map=role_map,
|
||||
password_enabled=password_enabled,
|
||||
redirect_base=redirect_base,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def discover_oidc(config: OIDCConfig) -> OIDCConfig:
|
||||
"""Fetch OIDC discovery document and return updated config with endpoints.
|
||||
|
||||
On failure, logs a warning and returns config with ``enabled=False``.
|
||||
"""
|
||||
if not config.issuer:
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
url = config.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
doc = resp.json()
|
||||
except Exception as exc:
|
||||
log.warning("OIDC discovery failed for %s: %s", config.issuer, exc)
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
authorization_endpoint = str(doc.get("authorization_endpoint", ""))
|
||||
token_endpoint = str(doc.get("token_endpoint", ""))
|
||||
userinfo_endpoint = str(doc.get("userinfo_endpoint", ""))
|
||||
jwks_uri = str(doc.get("jwks_uri", ""))
|
||||
|
||||
if not authorization_endpoint or not token_endpoint or not jwks_uri:
|
||||
log.warning(
|
||||
"OIDC discovery document missing required endpoints for %s",
|
||||
config.issuer,
|
||||
)
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
log.info("OIDC discovery complete: %s", config.issuer)
|
||||
return dataclasses.replace(
|
||||
config,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
token_endpoint=token_endpoint,
|
||||
userinfo_endpoint=userinfo_endpoint,
|
||||
jwks_uri=jwks_uri,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWKS key management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def fetch_jwks(jwks_uri: str) -> dict[str, Any]:
|
||||
"""Fetch the JWKS key set from the IdP.
|
||||
|
||||
Returns the parsed JSON document (``{"keys": [...]}``) . Called during
|
||||
startup discovery and on-demand when an unknown ``kid`` is encountered
|
||||
(key rotation). Uses ``httpx.AsyncClient`` — never blocks the event loop.
|
||||
|
||||
Raises :class:`OIDCError` on network failures or malformed responses.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(jwks_uri)
|
||||
resp.raise_for_status()
|
||||
result: dict[str, Any] = resp.json()
|
||||
except Exception as exc:
|
||||
raise OIDCError(f"JWKS fetch failed: {exc}") from exc
|
||||
if not isinstance(result.get("keys"), list):
|
||||
raise OIDCError("JWKS document missing 'keys' array")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_pkce_pair() -> tuple[str, str]:
|
||||
"""Generate a PKCE code_verifier and code_challenge pair."""
|
||||
code_verifier = secrets.token_urlsafe(48)
|
||||
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return code_verifier, code_challenge
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
config: OIDCConfig,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
code_verifier: str,
|
||||
) -> str:
|
||||
"""Build the OIDC authorization URL with PKCE."""
|
||||
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": config.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": config.scopes,
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return config.authorization_endpoint + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token exchange
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def exchange_code(
|
||||
config: OIDCConfig,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
code_verifier: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange authorization code for tokens at the token endpoint.
|
||||
|
||||
Raises :class:`OIDCError` on non-200 response.
|
||||
"""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": config.client_id,
|
||||
"client_secret": config.client_secret,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(config.token_endpoint, data=data)
|
||||
except Exception as exc:
|
||||
raise OIDCError(f"Token exchange request failed: {exc}") from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise OIDCError(f"Token endpoint returned {resp.status_code}: {resp.text[:500]}")
|
||||
|
||||
result: dict[str, Any] = resp.json()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID token validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_id_token(
|
||||
raw_token: str,
|
||||
jwks_data: dict[str, Any],
|
||||
config: OIDCConfig,
|
||||
nonce: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate and decode an OIDC ID token. Returns decoded claims.
|
||||
|
||||
*jwks_data* is the pre-fetched JWKS document (the ``{"keys": [...]}``
|
||||
dict). No network I/O happens here — the signing key is resolved
|
||||
locally from the cached key set.
|
||||
|
||||
Raises :class:`OIDCError` on validation failure.
|
||||
"""
|
||||
import jwt
|
||||
from jwt import PyJWK
|
||||
|
||||
# Extract kid from the token header to find the matching key.
|
||||
try:
|
||||
header = jwt.get_unverified_header(raw_token)
|
||||
except jwt.DecodeError as exc:
|
||||
raise OIDCError(f"Invalid ID token header: {exc}") from exc
|
||||
|
||||
kid = header.get("kid") # None if absent, not ""
|
||||
|
||||
# Find matching key in the JWKS by kid.
|
||||
# PyJWK infers the key's algorithm from the JWKS ``alg``/``kty``
|
||||
# fields. jwt.decode() requires the token header's ``alg`` to be in
|
||||
# our _ALLOWED_ID_TOKEN_ALGS allowlist (asymmetric only) AND to match
|
||||
# the key type — preventing algorithm confusion attacks.
|
||||
signing_key = None
|
||||
for key_dict in jwks_data.get("keys", []):
|
||||
if kid is not None and key_dict.get("kid") == kid:
|
||||
try:
|
||||
signing_key = PyJWK(key_dict)
|
||||
except Exception as exc:
|
||||
raise OIDCError(f"Failed to parse signing key: {exc}") from exc
|
||||
break
|
||||
|
||||
# Fallback: if token has no kid and JWKS has exactly one key, use it.
|
||||
if signing_key is None and kid is None:
|
||||
keys = jwks_data.get("keys", [])
|
||||
if len(keys) == 1:
|
||||
try:
|
||||
signing_key = PyJWK(keys[0])
|
||||
except Exception as exc:
|
||||
raise OIDCError(f"Failed to parse signing key: {exc}") from exc
|
||||
|
||||
if signing_key is None:
|
||||
raise OIDCError(f"Signing key '{kid}' not found in JWKS")
|
||||
|
||||
try:
|
||||
claims: dict[str, Any] = jwt.decode(
|
||||
raw_token,
|
||||
signing_key.key,
|
||||
algorithms=_ALLOWED_ID_TOKEN_ALGS,
|
||||
audience=config.client_id,
|
||||
issuer=config.issuer,
|
||||
)
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise OIDCError(f"ID token validation failed: {exc}") from exc
|
||||
|
||||
if claims.get("nonce") != nonce:
|
||||
raise OIDCError("ID token nonce mismatch")
|
||||
|
||||
return claims
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User provisioning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def provision_oidc_user(
|
||||
storage: Any,
|
||||
config: OIDCConfig,
|
||||
claims: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""Match or create a user from OIDC claims. Returns user dict.
|
||||
|
||||
Looks up an existing OIDC identity by (issuer, sub). If found,
|
||||
updates ``last_login`` and applies role mapping. Otherwise creates
|
||||
a new user and OIDC identity record.
|
||||
|
||||
Raises :class:`OIDCError` if user creation fails.
|
||||
"""
|
||||
issuer = config.issuer
|
||||
sub = str(claims["sub"])
|
||||
email = str(claims.get("email", ""))
|
||||
display_name = str(claims.get("name", "") or claims.get("preferred_username", "") or email)
|
||||
|
||||
# Try to find existing identity
|
||||
identity = storage.get_oidc_identity(issuer, sub)
|
||||
if identity is not None:
|
||||
user_id = identity["user_id"]
|
||||
storage.update_oidc_identity_login(issuer, sub)
|
||||
apply_role_mapping(storage, user_id, claims, config)
|
||||
user: dict[str, str] | None = storage.get_user(user_id)
|
||||
if user is None:
|
||||
raise OIDCError(f"OIDC identity references missing user: {user_id}")
|
||||
return user
|
||||
|
||||
# New user -- derive username
|
||||
username = _derive_username(storage, claims)
|
||||
user_id = uuid.uuid4().hex
|
||||
|
||||
storage.create_user(user_id, username, display_name, OIDC_PASSWORD_SENTINEL)
|
||||
storage.create_oidc_identity(issuer, sub, user_id, email)
|
||||
apply_role_mapping(storage, user_id, claims, config)
|
||||
|
||||
# Ensure new OIDC users have at least a default role so they can
|
||||
# access the application. builtin-viewer grants read-only access.
|
||||
user_roles = storage.list_user_roles(user_id)
|
||||
if not user_roles and storage.get_role("builtin-viewer") is not None:
|
||||
storage.assign_role(user_id, "builtin-viewer", "oidc-default")
|
||||
|
||||
created_user: dict[str, str] | None = storage.get_user(user_id)
|
||||
if created_user is None:
|
||||
raise OIDCError(f"Failed to retrieve newly created user: {user_id}")
|
||||
|
||||
log.info("Provisioned OIDC user: %s (%s) from %s", username, user_id, issuer)
|
||||
return created_user
|
||||
|
||||
|
||||
def _derive_username(storage: Any, claims: dict[str, Any]) -> str:
|
||||
"""Derive a unique, valid username from OIDC claims."""
|
||||
from turnstone.core.auth import is_valid_username
|
||||
|
||||
raw = str(claims.get("preferred_username", ""))
|
||||
if not raw:
|
||||
email = str(claims.get("email", ""))
|
||||
raw = email.split("@")[0] if email else ""
|
||||
if not raw:
|
||||
raw = "user"
|
||||
|
||||
# Sanitise: keep only safe chars, truncate.
|
||||
sanitised = _USERNAME_SAFE_RE.sub("", raw)[:64]
|
||||
if not sanitised:
|
||||
sanitised = "user"
|
||||
|
||||
# Check validity and uniqueness.
|
||||
if is_valid_username(sanitised) and storage.get_user_by_username(sanitised) is None:
|
||||
return sanitised
|
||||
|
||||
# Deduplicate: append suffix.
|
||||
for suffix in range(2, 11):
|
||||
candidate = f"{sanitised[:60]}{suffix}"
|
||||
if is_valid_username(candidate) and storage.get_user_by_username(candidate) is None:
|
||||
return candidate
|
||||
|
||||
# Last resort: full UUID suffix with validation + uniqueness check.
|
||||
for _ in range(3):
|
||||
candidate = f"{sanitised[:32]}{uuid.uuid4().hex}"
|
||||
if not is_valid_username(candidate):
|
||||
candidate = f"user{uuid.uuid4().hex}"
|
||||
if storage.get_user_by_username(candidate) is None:
|
||||
return candidate
|
||||
raise OIDCError("Failed to generate unique username")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_role_mapping(
|
||||
storage: Any,
|
||||
user_id: str,
|
||||
claims: dict[str, Any],
|
||||
config: OIDCConfig,
|
||||
) -> None:
|
||||
"""Sync Turnstone roles from OIDC claims.
|
||||
|
||||
If ``config.role_claim`` is set, reads the corresponding claim value,
|
||||
normalises it to a list, and maps each value via ``config.role_map``
|
||||
to a Turnstone role ID. Roles assigned by OIDC on previous logins
|
||||
that are no longer present in the claims are revoked (IdP demotions
|
||||
propagate). Roles assigned manually or by other sources are never
|
||||
touched.
|
||||
"""
|
||||
if not config.role_claim or not config.role_map:
|
||||
return
|
||||
|
||||
claim_value = claims.get(config.role_claim)
|
||||
|
||||
# Normalise to list (could be string, list, or absent from IdP).
|
||||
if claim_value is None:
|
||||
values: list[str] = []
|
||||
elif isinstance(claim_value, str):
|
||||
values = [claim_value]
|
||||
elif isinstance(claim_value, list):
|
||||
values = [str(v) for v in claim_value]
|
||||
else:
|
||||
values = [str(claim_value)]
|
||||
|
||||
# Compute the set of roles the IdP says this user should have.
|
||||
desired_role_ids: set[str] = set()
|
||||
for value in values:
|
||||
role_id = config.role_map.get(value)
|
||||
if role_id and storage.get_role(role_id) is not None:
|
||||
desired_role_ids.add(role_id)
|
||||
|
||||
# Add new roles from claims.
|
||||
for role_id in desired_role_ids:
|
||||
storage.assign_role(user_id, role_id, "oidc")
|
||||
log.debug("Assigned role %s to user %s via OIDC claim", role_id, user_id)
|
||||
|
||||
# Revoke OIDC-assigned roles no longer present in claims.
|
||||
current_roles = storage.list_user_roles(user_id)
|
||||
for role in current_roles:
|
||||
if role.get("assigned_by") == "oidc" and role["role_id"] not in desired_role_ids:
|
||||
storage.unassign_role(user_id, role["role_id"])
|
||||
log.info(
|
||||
"Revoked role %s from user %s (removed from IdP claims)", role["role_id"], user_id
|
||||
)
|
||||
+24
-11
@@ -296,7 +296,7 @@ class ChatSession:
|
||||
self._watch_dispatch_depth = 0
|
||||
# Metacognitive nudges: ephemeral prompts for proactive memory use
|
||||
self._metacog_state: dict[str, float] = {}
|
||||
self._pending_nudge: str | None = None
|
||||
self._pending_nudge: list[str] = []
|
||||
# Cooperative cancellation: set from outside to stop generation
|
||||
self._cancel_event = threading.Event()
|
||||
self._cancelled_partial_msg: dict[str, Any] | None = None
|
||||
@@ -666,7 +666,7 @@ class ChatSession:
|
||||
memory_count=self._visible_memory_count(),
|
||||
cooldown_secs=self._memory_config.nudge_cooldown,
|
||||
):
|
||||
self._pending_nudge = format_nudge("resume")
|
||||
self._pending_nudge.append(format_nudge("resume"))
|
||||
self._init_system_messages()
|
||||
return True
|
||||
|
||||
@@ -807,9 +807,10 @@ class ChatSession:
|
||||
"Use memory(action='search') or memory(action='list') for more."
|
||||
)
|
||||
if self._pending_nudge:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self._pending_nudge)
|
||||
self._pending_nudge = None
|
||||
for nudge in self._pending_nudge:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(nudge)
|
||||
self._pending_nudge.clear()
|
||||
new_system_messages.append({"role": "system", "content": "\n".join(dev_parts)})
|
||||
# Atomic swap — readers see either old or new, never partial
|
||||
self.system_messages = new_system_messages
|
||||
@@ -997,7 +998,7 @@ class ChatSession:
|
||||
# Metacognitive nudge: check for correction/completion signals
|
||||
nudge = self._check_metacognitive_nudge(user_input)
|
||||
if nudge:
|
||||
self._pending_nudge = nudge
|
||||
self._pending_nudge.append(nudge)
|
||||
self._init_system_messages()
|
||||
|
||||
try:
|
||||
@@ -1873,7 +1874,7 @@ class ChatSession:
|
||||
memory_count=self._visible_memory_count(),
|
||||
cooldown_secs=self._memory_config.nudge_cooldown,
|
||||
):
|
||||
self._pending_nudge = format_nudge("denial")
|
||||
self._pending_nudge.append(format_nudge("denial"))
|
||||
self._init_system_messages()
|
||||
|
||||
# Phase 3: execute (check cancellation before starting)
|
||||
@@ -3548,6 +3549,16 @@ class ChatSession:
|
||||
"and functions in every step."
|
||||
)
|
||||
|
||||
def _plan_system_content(self) -> str:
|
||||
"""Plan agent system message: template guardrails + plan identity."""
|
||||
if not self._template_content:
|
||||
return self._PLAN_IDENTITY
|
||||
tpl = self._template_content
|
||||
if len(tpl) > _MAX_TEMPLATE_CONTENT:
|
||||
log.warning("template_content.truncated", length=len(tpl), agent="plan")
|
||||
tpl = tpl[:_MAX_TEMPLATE_CONTENT]
|
||||
return tpl + "\n\n" + self._PLAN_IDENTITY
|
||||
|
||||
_MIN_PLAN_LENGTH = 100
|
||||
_PLAN_REQUIRED_SECTIONS = ("## goal", "## current state", "## plan", "## risks")
|
||||
_MIN_PLAN_SECTIONS = 2
|
||||
@@ -3625,10 +3636,11 @@ class ChatSession:
|
||||
prior_plan_msgs = [msg, self.messages[j]]
|
||||
break
|
||||
|
||||
# Plan agent gets its own identity only — no main session system
|
||||
# prompt or conversation history. It's an autonomous sub-agent.
|
||||
# Plan agent gets template guardrails + its own identity — no tool
|
||||
# patterns, MCP resources, or general conversation history (only
|
||||
# prior plan tool_call/result pairs are forwarded for refinement).
|
||||
agent_messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": self._PLAN_IDENTITY},
|
||||
{"role": "system", "content": self._plan_system_content()},
|
||||
]
|
||||
agent_messages.extend(prior_plan_msgs)
|
||||
agent_messages.append({"role": "user", "content": prompt})
|
||||
@@ -3699,7 +3711,7 @@ class ChatSession:
|
||||
"""Re-run the plan agent incorporating user feedback."""
|
||||
tc_id = f"plan_refine_{uuid.uuid4().hex[:8]}"
|
||||
agent_messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": self._PLAN_IDENTITY},
|
||||
{"role": "system", "content": self._plan_system_content()},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
@@ -3963,6 +3975,7 @@ class ChatSession:
|
||||
"target": target,
|
||||
"message": item["message"],
|
||||
"title": item.get("title", ""),
|
||||
"ws_id": self._ws_id,
|
||||
}
|
||||
|
||||
# Build auth headers for service-to-service call
|
||||
|
||||
@@ -540,12 +540,13 @@ class PostgreSQLBackend:
|
||||
]
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
from turnstone.core.storage._schema import channel_users, oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -2206,7 +2207,7 @@ class PostgreSQLBackend:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
q = q.where(structured_memories.c.scope == scope)
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
q = q.where(structured_memories.c.scope_id == scope_id)
|
||||
q = q.limit(limit)
|
||||
rows = conn.execute(q).fetchall()
|
||||
@@ -2245,7 +2246,7 @@ class PostgreSQLBackend:
|
||||
if scope:
|
||||
where += " AND scope = :scope_filter"
|
||||
params["scope_filter"] = scope
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
where += " AND scope_id = :scope_id_filter"
|
||||
params["scope_id_filter"] = scope_id
|
||||
rows = conn.execute(
|
||||
@@ -2266,7 +2267,7 @@ class PostgreSQLBackend:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
q = q.where(structured_memories.c.scope == scope)
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
q = q.where(structured_memories.c.scope_id == scope_id)
|
||||
result = conn.execute(q).scalar()
|
||||
return int(result or 0)
|
||||
@@ -2460,6 +2461,179 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
postgresql.insert(oidc_identities)
|
||||
.values(
|
||||
issuer=issuer,
|
||||
subject=subject,
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
created=now,
|
||||
last_login=now,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"issuer": row[0],
|
||||
"subject": row[1],
|
||||
"user_id": row[2],
|
||||
"email": row[3],
|
||||
"created": row[4],
|
||||
"last_login": row[5],
|
||||
}
|
||||
return None
|
||||
|
||||
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(oidc_identities)
|
||||
.where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
.values(last_login=now)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
)
|
||||
.where(oidc_identities.c.user_id == user_id)
|
||||
.order_by(oidc_identities.c.created.desc())
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"issuer": r[0],
|
||||
"subject": r[1],
|
||||
"user_id": r[2],
|
||||
"email": r[3],
|
||||
"created": r[4],
|
||||
"last_login": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_identities).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC pending state ----------------------------------------------------
|
||||
|
||||
def create_oidc_pending_state(
|
||||
self, state: str, nonce: str, code_verifier: str, audience: str
|
||||
) -> None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_verifier": code_verifier,
|
||||
"audience": audience,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def pop_oidc_pending_state(
|
||||
self, state: str, max_age_seconds: int = 300
|
||||
) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
# Atomic DELETE...RETURNING for true one-time consumption
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM oidc_pending_states "
|
||||
"WHERE state = :state AND created_at > :cutoff "
|
||||
"RETURNING state, nonce, code_verifier, audience, created_at"
|
||||
),
|
||||
{"state": state, "cutoff": cutoff},
|
||||
).fetchone()
|
||||
# Also clean up the row if it existed but was expired
|
||||
if not row:
|
||||
conn.execute(
|
||||
sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state)
|
||||
)
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"state": row[0],
|
||||
"nonce": row[1],
|
||||
"code_verifier": row[2],
|
||||
"audience": row[3],
|
||||
"created_at": row[4],
|
||||
}
|
||||
|
||||
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -259,6 +259,46 @@ class StorageBackend(Protocol):
|
||||
"""Remove a channel user mapping. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
"""Link an OIDC subject to a turnstone user. No-op if exists."""
|
||||
...
|
||||
|
||||
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
|
||||
"""Lookup turnstone user by OIDC issuer+subject. Returns dict or None."""
|
||||
...
|
||||
|
||||
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
|
||||
"""Update last_login timestamp. Returns True if row existed."""
|
||||
...
|
||||
|
||||
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
|
||||
"""List all OIDC identities linked to a turnstone user."""
|
||||
...
|
||||
|
||||
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
|
||||
"""Remove an OIDC identity link. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- OIDC pending state ----------------------------------------------------
|
||||
|
||||
def create_oidc_pending_state(
|
||||
self, state: str, nonce: str, code_verifier: str, audience: str
|
||||
) -> None:
|
||||
"""Store OIDC authorization flow state for callback validation."""
|
||||
...
|
||||
|
||||
def pop_oidc_pending_state(
|
||||
self, state: str, max_age_seconds: int = 300
|
||||
) -> dict[str, str] | None:
|
||||
"""Fetch and delete pending state atomically. Returns None if expired or missing."""
|
||||
...
|
||||
|
||||
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
|
||||
"""Delete expired pending states. Returns count of deleted rows."""
|
||||
...
|
||||
|
||||
# -- Channel routing -------------------------------------------------------
|
||||
|
||||
def create_channel_route(
|
||||
|
||||
@@ -469,3 +469,31 @@ mcp_servers = sa.Table(
|
||||
)
|
||||
|
||||
sa.Index("idx_mcp_servers_enabled", mcp_servers.c.enabled)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC identity tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oidc_identities = sa.Table(
|
||||
"oidc_identities",
|
||||
metadata,
|
||||
sa.Column("issuer", sa.Text, nullable=False),
|
||||
sa.Column("subject", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("email", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("last_login", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("issuer", "subject"),
|
||||
)
|
||||
|
||||
sa.Index("idx_oidc_identities_user_id", oidc_identities.c.user_id)
|
||||
|
||||
oidc_pending_states = sa.Table(
|
||||
"oidc_pending_states",
|
||||
metadata,
|
||||
sa.Column("state", sa.Text, primary_key=True),
|
||||
sa.Column("nonce", sa.Text, nullable=False),
|
||||
sa.Column("code_verifier", sa.Text, nullable=False),
|
||||
sa.Column("audience", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
@@ -589,12 +589,13 @@ class SQLiteBackend:
|
||||
]
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
from turnstone.core.storage._schema import channel_users, oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -2230,7 +2231,7 @@ class SQLiteBackend:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
q = q.where(structured_memories.c.scope == scope)
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
q = q.where(structured_memories.c.scope_id == scope_id)
|
||||
q = q.limit(limit)
|
||||
rows = conn.execute(q).fetchall()
|
||||
@@ -2269,7 +2270,7 @@ class SQLiteBackend:
|
||||
if scope:
|
||||
where += " AND scope = :scope_filter"
|
||||
params["scope_filter"] = scope
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
where += " AND scope_id = :scope_id_filter"
|
||||
params["scope_id_filter"] = scope_id
|
||||
rows = conn.execute(
|
||||
@@ -2290,7 +2291,7 @@ class SQLiteBackend:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
q = q.where(structured_memories.c.scope == scope)
|
||||
if scope_id:
|
||||
if scope_id and scope:
|
||||
q = q.where(structured_memories.c.scope_id == scope_id)
|
||||
result = conn.execute(q).scalar()
|
||||
return int(result or 0)
|
||||
@@ -2483,6 +2484,178 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_identities).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"issuer": issuer,
|
||||
"subject": subject,
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"created": now,
|
||||
"last_login": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"issuer": row[0],
|
||||
"subject": row[1],
|
||||
"user_id": row[2],
|
||||
"email": row[3],
|
||||
"created": row[4],
|
||||
"last_login": row[5],
|
||||
}
|
||||
return None
|
||||
|
||||
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(oidc_identities)
|
||||
.where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
.values(last_login=now)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
)
|
||||
.where(oidc_identities.c.user_id == user_id)
|
||||
.order_by(oidc_identities.c.created.desc())
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"issuer": r[0],
|
||||
"subject": r[1],
|
||||
"user_id": r[2],
|
||||
"email": r[3],
|
||||
"created": r[4],
|
||||
"last_login": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_identities).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC pending state ----------------------------------------------------
|
||||
|
||||
def create_oidc_pending_state(
|
||||
self, state: str, nonce: str, code_verifier: str, audience: str
|
||||
) -> None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_verifier": code_verifier,
|
||||
"audience": audience,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def pop_oidc_pending_state(
|
||||
self, state: str, max_age_seconds: int = 300
|
||||
) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
# Acquire write lock before SELECT to prevent TOCTOU race
|
||||
conn.execute(sa.text("BEGIN IMMEDIATE"))
|
||||
row = conn.execute(
|
||||
sa.select(
|
||||
oidc_pending_states.c.state,
|
||||
oidc_pending_states.c.nonce,
|
||||
oidc_pending_states.c.code_verifier,
|
||||
oidc_pending_states.c.audience,
|
||||
oidc_pending_states.c.created_at,
|
||||
).where(
|
||||
(oidc_pending_states.c.state == state)
|
||||
& (oidc_pending_states.c.created_at > cutoff)
|
||||
)
|
||||
).fetchone()
|
||||
# Always delete the row (whether valid, expired, or missing is fine)
|
||||
conn.execute(sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state))
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"state": row[0],
|
||||
"nonce": row[1],
|
||||
"code_verifier": row[2],
|
||||
"audience": row[3],
|
||||
"created_at": row[4],
|
||||
}
|
||||
|
||||
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Create OIDC identity and pending state tables.
|
||||
|
||||
Revision ID: 018
|
||||
Revises: 017
|
||||
Create Date: 2026-03-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "018"
|
||||
down_revision = "017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"oidc_identities",
|
||||
sa.Column("issuer", sa.Text, nullable=False),
|
||||
sa.Column("subject", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("email", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("last_login", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("issuer", "subject"),
|
||||
)
|
||||
op.create_index("idx_oidc_identities_user_id", "oidc_identities", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"oidc_pending_states",
|
||||
sa.Column("state", sa.Text, primary_key=True),
|
||||
sa.Column("nonce", sa.Text, nullable=False),
|
||||
sa.Column("code_verifier", sa.Text, nullable=False),
|
||||
sa.Column("audience", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("oidc_pending_states")
|
||||
op.drop_table("oidc_identities")
|
||||
@@ -16,9 +16,20 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from turnstone.core.session import ChatSession, SessionUI
|
||||
|
||||
class _SessionFactory(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = ...,
|
||||
ws_id: str | None = ...,
|
||||
*,
|
||||
template: str | None = ...,
|
||||
) -> ChatSession: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State enum
|
||||
@@ -65,14 +76,14 @@ class WorkstreamManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession],
|
||||
session_factory: _SessionFactory,
|
||||
*,
|
||||
max_workstreams: int = 10,
|
||||
node_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
session_factory: callable(ui, model_alias, ws_id) -> ChatSession.
|
||||
session_factory: callable(ui, model_alias, ws_id, *, template) -> ChatSession.
|
||||
Captures shared config (registry, temperature, …) so the
|
||||
manager can create ChatSession instances without knowing
|
||||
those details. *model_alias* selects a model from the
|
||||
@@ -85,9 +96,7 @@ class WorkstreamManager:
|
||||
"""
|
||||
if max_workstreams < 1:
|
||||
raise ValueError(f"max_workstreams must be >= 1, got {max_workstreams}")
|
||||
self._session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession] = (
|
||||
session_factory
|
||||
)
|
||||
self._session_factory: _SessionFactory = session_factory
|
||||
self._node_id = node_id
|
||||
self._max_workstreams: int = max_workstreams
|
||||
self._workstreams: dict[str, Workstream] = {}
|
||||
@@ -115,6 +124,7 @@ class WorkstreamManager:
|
||||
name: str = "",
|
||||
ui_factory: Callable[..., SessionUI] | None = None,
|
||||
model: str | None = None,
|
||||
template: str | None = None,
|
||||
) -> Workstream:
|
||||
"""Create a new workstream. Returns the new ws.
|
||||
|
||||
@@ -125,6 +135,8 @@ class WorkstreamManager:
|
||||
Args:
|
||||
model: Optional model alias from the registry. ``None`` uses the
|
||||
default model.
|
||||
template: Optional prompt template name passed through to session
|
||||
factory.
|
||||
"""
|
||||
# Fast-fail capacity check (avoids expensive ChatSession creation when full).
|
||||
first_evicted: Workstream | None = None
|
||||
@@ -147,7 +159,7 @@ class WorkstreamManager:
|
||||
ws = Workstream(name=name)
|
||||
if ui_factory:
|
||||
ws.ui = ui_factory(ws.id)
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id)
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id, template=template)
|
||||
|
||||
# Authoritative insert under lock with re-check (another thread may
|
||||
# have filled capacity while we were unlocked).
|
||||
|
||||
+24
-4
@@ -701,9 +701,22 @@ class Bridge:
|
||||
feedback = getattr(resp_msg, "feedback", None)
|
||||
always = getattr(resp_msg, "always", False)
|
||||
self._api_approve(ws_id, approved=approved, feedback=feedback)
|
||||
if always:
|
||||
with self._lock:
|
||||
self._ws_auto_approve[ws_id] = True
|
||||
if always and approved:
|
||||
tool_names = {
|
||||
it.get("func_name", "")
|
||||
for it in items
|
||||
if it.get("needs_approval")
|
||||
and it.get("func_name")
|
||||
and not it.get("error")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
if tool_names:
|
||||
with self._lock:
|
||||
existing = self._ws_approve_tools.get(
|
||||
ws_id, set(DEFAULT_SAFE_TOOLS)
|
||||
)
|
||||
self._ws_approve_tools[ws_id] = existing | tool_names
|
||||
else:
|
||||
log.warning("Approval timeout for ws %s — denying", ws_id)
|
||||
self._api_approve(ws_id, approved=False, feedback="Approval timed out")
|
||||
@@ -825,7 +838,14 @@ class Bridge:
|
||||
if state == "idle":
|
||||
with self._lock:
|
||||
cid = self._active_sends.pop(ws_id, None)
|
||||
self._publish_ws(ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid or ""))
|
||||
self._publish_ws(
|
||||
ws_id,
|
||||
TurnCompleteEvent(
|
||||
ws_id=ws_id,
|
||||
correlation_id=cid or "",
|
||||
content=data.get("content", ""),
|
||||
),
|
||||
)
|
||||
|
||||
elif etype == "ws_rename":
|
||||
self._publish_global(WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")))
|
||||
|
||||
@@ -268,9 +268,16 @@ class TurnCompleteEvent(OutboundEvent):
|
||||
This is a synthetic event produced by the bridge when it detects
|
||||
the ws_state transition to 'idle'. ``correlation_id`` is set for
|
||||
MQ-initiated turns and empty for turns initiated from the server UI.
|
||||
|
||||
``content`` carries the full assistant response text piggybacked on
|
||||
the server's idle SSE event (accumulated server-side in WebUI).
|
||||
Downstream consumers (e.g. Discord bot) use it for catch-up when the
|
||||
streaming path missed events, and as the primary delivery path for
|
||||
bidirectional notification DM forwarding.
|
||||
"""
|
||||
|
||||
type: str = "turn_complete"
|
||||
content: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -174,6 +174,7 @@ class WsStateEvent(ServerEvent):
|
||||
context_ratio: float = 0.0
|
||||
activity: str = ""
|
||||
activity_state: str = ""
|
||||
content: str = "" # populated on idle transitions only
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -16,6 +16,7 @@ import asyncio
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.api.console_schemas import ListWsTemplateSummaryResponse
|
||||
from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
@@ -27,6 +28,7 @@ from turnstone.api.server_schemas import (
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
@@ -237,6 +239,18 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
"GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse
|
||||
)
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
async def list_templates(self) -> ListPromptTemplateSummaryResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/templates", response_model=ListPromptTemplateSummaryResponse
|
||||
)
|
||||
|
||||
async def list_ws_templates(self) -> ListWsTemplateSummaryResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/ws-templates", response_model=ListWsTemplateSummaryResponse
|
||||
)
|
||||
|
||||
# -- memories ------------------------------------------------------------
|
||||
|
||||
async def list_memories(
|
||||
@@ -481,6 +495,14 @@ class TurnstoneServer:
|
||||
def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse:
|
||||
return self._runner.run(self._async.list_saved_workstreams())
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
def list_templates(self) -> ListPromptTemplateSummaryResponse:
|
||||
return self._runner.run(self._async.list_templates())
|
||||
|
||||
def list_ws_templates(self) -> ListWsTemplateSummaryResponse:
|
||||
return self._runner.run(self._async.list_ws_templates())
|
||||
|
||||
# -- memories ------------------------------------------------------------
|
||||
|
||||
def list_memories(
|
||||
|
||||
+189
-33
@@ -67,6 +67,8 @@ _HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
# WebUI — implements SessionUI for browser-based interaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_TURN_CONTENT_CHARS = 256 * 1024 # cap piggybacked content on idle events
|
||||
|
||||
|
||||
class WebUI:
|
||||
"""Browser-based UI using SSE for streaming and HTTP POST for actions.
|
||||
@@ -107,6 +109,10 @@ class WebUI:
|
||||
self._pending_verdicts: list[dict[str, Any]] = []
|
||||
# Last user decision for late-arriving verdicts (set in resolve_approval)
|
||||
self._last_verdict_decision: str = ""
|
||||
# Content accumulator — tokens appended in on_content_token(), joined
|
||||
# and piggybacked onto the ws_state:idle global SSE event, then reset.
|
||||
self._ws_turn_content: list[str] = []
|
||||
self._ws_turn_content_size: int = 0
|
||||
|
||||
def _enqueue(self, data: dict[str, Any]) -> None:
|
||||
with self._listeners_lock:
|
||||
@@ -135,17 +141,23 @@ class WebUI:
|
||||
ctx = self._ws_context_ratio
|
||||
activity = self._ws_current_activity
|
||||
activity_state = self._ws_activity_state
|
||||
WebUI._global_queue.put(
|
||||
{
|
||||
"type": "ws_state",
|
||||
"ws_id": self.ws_id,
|
||||
"state": state,
|
||||
"tokens": tokens,
|
||||
"context_ratio": ctx,
|
||||
"activity": activity,
|
||||
"activity_state": activity_state,
|
||||
}
|
||||
)
|
||||
event: dict[str, Any] = {
|
||||
"type": "ws_state",
|
||||
"ws_id": self.ws_id,
|
||||
"state": state,
|
||||
"tokens": tokens,
|
||||
"context_ratio": ctx,
|
||||
"activity": activity,
|
||||
"activity_state": activity_state,
|
||||
}
|
||||
if state == "idle":
|
||||
event["content"] = "".join(self._ws_turn_content)
|
||||
self._ws_turn_content = []
|
||||
self._ws_turn_content_size = 0
|
||||
elif state == "error":
|
||||
self._ws_turn_content = []
|
||||
self._ws_turn_content_size = 0
|
||||
WebUI._global_queue.put(event)
|
||||
|
||||
def _broadcast_activity(self) -> None:
|
||||
"""Send an activity-change event to the global SSE channel."""
|
||||
@@ -178,6 +190,9 @@ class WebUI:
|
||||
self._enqueue({"type": "reasoning", "text": text})
|
||||
|
||||
def on_content_token(self, text: str) -> None:
|
||||
if self._ws_turn_content_size < _MAX_TURN_CONTENT_CHARS:
|
||||
self._ws_turn_content.append(text)
|
||||
self._ws_turn_content_size += len(text)
|
||||
self._enqueue({"type": "content", "text": text})
|
||||
|
||||
def on_stream_end(self) -> None:
|
||||
@@ -262,7 +277,7 @@ class WebUI:
|
||||
log.debug("Tool policy evaluation failed", exc_info=True)
|
||||
# -- End tool policy evaluation -------------------------------------------
|
||||
|
||||
# Per-tool auto-approve check (server-side, from workstream template)
|
||||
# Per-tool auto-approve check (from workstream template or interactive "Always")
|
||||
if pending and self.auto_approve_tools:
|
||||
pending_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
@@ -890,6 +905,43 @@ async def list_saved_workstreams(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"workstreams": result})
|
||||
|
||||
|
||||
async def list_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/templates — list available prompt templates (read scope)."""
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
templates = storage.list_prompt_templates()
|
||||
summaries = [
|
||||
{
|
||||
"name": t["name"],
|
||||
"category": t.get("category", ""),
|
||||
"is_default": bool(t.get("is_default")),
|
||||
"origin": t.get("origin", "manual"),
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"templates": summaries})
|
||||
|
||||
|
||||
async def list_ws_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/ws-templates — enabled workstream templates summary (read scope)."""
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
templates = storage.list_ws_templates(enabled_only=True)
|
||||
summaries = [
|
||||
{"name": t["name"], "description": t.get("description", ""), "model": t.get("model", "")}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"ws_templates": summaries})
|
||||
|
||||
|
||||
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
|
||||
"""Count workstream states for health/metrics endpoints."""
|
||||
counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0)
|
||||
@@ -954,6 +1006,7 @@ async def metrics_endpoint(request: Request) -> Response:
|
||||
"servers": mc.server_count,
|
||||
"resources": mc.resource_count,
|
||||
"prompts": mc.prompt_count,
|
||||
"errors": mc.error_count,
|
||||
}
|
||||
content = _metrics.generate_text(
|
||||
workstream_states=states,
|
||||
@@ -1056,8 +1109,16 @@ async def approve(request: Request) -> JSONResponse:
|
||||
ws, ui = _get_ws(mgr, ws_id)
|
||||
if not ws or not ui:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
if always and approved:
|
||||
ui.auto_approve = True
|
||||
if always and approved and ui._pending_approval:
|
||||
tool_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in ui._pending_approval.get("items", [])
|
||||
if it.get("needs_approval") and it.get("func_name") and not it.get("error")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
if tool_names:
|
||||
ui.auto_approve_tools.update(tool_names)
|
||||
ui.resolve_approval(approved, feedback)
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
@@ -1178,11 +1239,27 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
resolved_model = body.get("model") or None
|
||||
if ws_tpl and ws_tpl.get("model"):
|
||||
resolved_model = ws_tpl["model"]
|
||||
# Pre-validate prompt template before creating the workstream.
|
||||
# This avoids the create-then-rollback pattern when template is invalid.
|
||||
# Skip when resuming — resumed workstreams restore their own template
|
||||
# from workstream_config, so the request's template is irrelevant.
|
||||
ws_tpl_overrides_prompt = bool(
|
||||
ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"])
|
||||
)
|
||||
resume_ws_id = body.get("resume_ws", "")
|
||||
resolved_template: str | None = None
|
||||
if body_template and not ws_tpl_overrides_prompt and not resume_ws_id:
|
||||
from turnstone.core.memory import get_prompt_template_by_name
|
||||
|
||||
if not get_prompt_template_by_name(body_template):
|
||||
return JSONResponse({"error": f"Template not found: {body_template}"}, status_code=400)
|
||||
resolved_template = body_template
|
||||
try:
|
||||
ws = mgr.create(
|
||||
name=body.get("name", ""),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
|
||||
model=resolved_model,
|
||||
template=resolved_template,
|
||||
)
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
if skip or body.get("auto_approve", False):
|
||||
@@ -1209,7 +1286,6 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
# Atomic workstream resume during creation.
|
||||
resumed = False
|
||||
message_count = 0
|
||||
resume_ws_id = body.get("resume_ws", "")
|
||||
if resume_ws_id and ws.session is not None:
|
||||
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
|
||||
|
||||
@@ -1225,23 +1301,6 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
|
||||
# Per-workstream template override — only when not resumed (resumed
|
||||
# workstreams restore their own template from workstream_config).
|
||||
# Skip validation when the ws_template will override the prompt anyway.
|
||||
ws_tpl_overrides_prompt = bool(
|
||||
ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"])
|
||||
)
|
||||
if body_template and not resumed and ws.session and not ws_tpl_overrides_prompt:
|
||||
from turnstone.core.memory import get_prompt_template_by_name
|
||||
|
||||
if not get_prompt_template_by_name(body_template):
|
||||
# Workstream already created — close it and return error
|
||||
mgr.close(ws.id)
|
||||
return JSONResponse(
|
||||
{"error": f"Template not found: {body_template}"}, status_code=400
|
||||
)
|
||||
ws.session.set_template(body_template)
|
||||
|
||||
# Apply workstream template settings (only for new workstreams)
|
||||
if ws_tpl and not resumed and ws.session:
|
||||
sess = ws.session
|
||||
@@ -1368,6 +1427,30 @@ _VALID_MEMORY_SCOPES = frozenset({"global", "workstream", "user"})
|
||||
_MAX_MEMORY_CONTENT = 65536 # hard upper bound; server may enforce lower via config
|
||||
|
||||
|
||||
def _validate_scope_scope_id(
|
||||
scope: str, scope_id: str, *, require_scope_id: bool = False
|
||||
) -> JSONResponse | None:
|
||||
"""Validate scope/scope_id consistency. Returns error response or None."""
|
||||
scope = scope.strip()
|
||||
scope_id = scope_id.strip()
|
||||
if scope == "global" and scope_id:
|
||||
return JSONResponse(
|
||||
{"error": "scope_id is not allowed with global scope"},
|
||||
status_code=400,
|
||||
)
|
||||
if scope_id and not scope:
|
||||
return JSONResponse(
|
||||
{"error": "scope is required when scope_id is provided"},
|
||||
status_code=400,
|
||||
)
|
||||
if require_scope_id and scope in ("workstream", "user") and not scope_id:
|
||||
return JSONResponse(
|
||||
{"error": f"scope_id is required for {scope} scope"},
|
||||
status_code=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_scope_id(
|
||||
request: Request, provided_scope_id: str = ""
|
||||
) -> tuple[str, JSONResponse | None]:
|
||||
@@ -1402,6 +1485,9 @@ async def list_memories(request: Request) -> JSONResponse:
|
||||
limit = min(int(request.query_params.get("limit", "100")), 200)
|
||||
except (ValueError, TypeError):
|
||||
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
|
||||
err = _validate_scope_scope_id(scope, scope_id)
|
||||
if err:
|
||||
return err
|
||||
if scope == "user":
|
||||
scope_id, err = _resolve_user_scope_id(request, scope_id)
|
||||
if err:
|
||||
@@ -1447,6 +1533,9 @@ async def save_memory(request: Request) -> JSONResponse:
|
||||
scope_id, err = _resolve_user_scope_id(request, scope_id)
|
||||
if err:
|
||||
return err
|
||||
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
|
||||
if err:
|
||||
return err
|
||||
# save_structured_memory normalises the name internally
|
||||
from turnstone.core.memory import normalize_key
|
||||
|
||||
@@ -1490,6 +1579,9 @@ async def search_memories(request: Request) -> JSONResponse:
|
||||
limit = min(int(body.get("limit", 20)), 50)
|
||||
except (ValueError, TypeError):
|
||||
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
|
||||
err = _validate_scope_scope_id(scope, scope_id)
|
||||
if err:
|
||||
return err
|
||||
if scope == "user":
|
||||
scope_id, err = _resolve_user_scope_id(request, scope_id)
|
||||
if err:
|
||||
@@ -1514,6 +1606,9 @@ async def delete_memory_endpoint(request: Request) -> JSONResponse:
|
||||
scope_id, err = _resolve_user_scope_id(request, scope_id)
|
||||
if err:
|
||||
return err
|
||||
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
|
||||
if err:
|
||||
return err
|
||||
if delete_structured_memory(name, scope, scope_id):
|
||||
return JSONResponse({"status": "ok", "name": name})
|
||||
return JSONResponse({"error": f"Memory '{name}' not found"}, status_code=404)
|
||||
@@ -1547,6 +1642,27 @@ async def auth_setup(request: Request) -> Response:
|
||||
return await handle_auth_setup(request, JWT_AUD_SERVER)
|
||||
|
||||
|
||||
async def auth_whoami(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/whoami — return authenticated user info."""
|
||||
from turnstone.core.auth import handle_auth_whoami
|
||||
|
||||
return await handle_auth_whoami(request)
|
||||
|
||||
|
||||
async def oidc_authorize(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
|
||||
from turnstone.core.auth import handle_oidc_authorize
|
||||
|
||||
return await handle_oidc_authorize(request, JWT_AUD_SERVER)
|
||||
|
||||
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
|
||||
from turnstone.core.auth import handle_oidc_callback
|
||||
|
||||
return await handle_oidc_callback(request, JWT_AUD_SERVER)
|
||||
|
||||
|
||||
def config_reload(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/_internal/config-reload — invalidate config cache."""
|
||||
cs = getattr(request.app.state, "config_store", None)
|
||||
@@ -1662,6 +1778,31 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
# Start watch runner (periodic command polling)
|
||||
if app.state.watch_runner:
|
||||
app.state.watch_runner.start()
|
||||
# OIDC discovery (if configured)
|
||||
oidc_config = app.state.oidc_config
|
||||
if oidc_config.enabled:
|
||||
from turnstone.core.oidc import discover_oidc
|
||||
|
||||
try:
|
||||
oidc_config = await discover_oidc(oidc_config)
|
||||
app.state.oidc_config = oidc_config
|
||||
except Exception:
|
||||
log.warning("OIDC discovery failed — OIDC login disabled", exc_info=True)
|
||||
if oidc_config.enabled and oidc_config.jwks_uri:
|
||||
try:
|
||||
from turnstone.core.oidc import fetch_jwks
|
||||
|
||||
app.state.jwks_data = await fetch_jwks(oidc_config.jwks_uri)
|
||||
log.info(
|
||||
"OIDC enabled: %s (%s)",
|
||||
oidc_config.provider_name,
|
||||
oidc_config.issuer,
|
||||
)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
# Shutdown
|
||||
if app.state.watch_runner:
|
||||
@@ -1735,6 +1876,8 @@ def create_app(
|
||||
Route("/api/workstreams", list_workstreams),
|
||||
Route("/api/dashboard", dashboard),
|
||||
Route("/api/workstreams/saved", list_saved_workstreams),
|
||||
Route("/api/templates", list_templates_summary),
|
||||
Route("/api/ws-templates", list_ws_templates_summary),
|
||||
Route("/api/send", send_message, methods=["POST"]),
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
@@ -1752,6 +1895,9 @@ def create_app(
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Route("/api/auth/status", auth_status),
|
||||
Route("/api/auth/setup", auth_setup, methods=["POST"]),
|
||||
Route("/api/auth/whoami", auth_whoami),
|
||||
Route("/api/auth/oidc/authorize", oidc_authorize),
|
||||
Route("/api/auth/oidc/callback", oidc_callback),
|
||||
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
|
||||
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
|
||||
Route("/api/_internal/mcp-status", internal_mcp_status),
|
||||
@@ -1788,6 +1934,14 @@ def create_app(
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
app.state.login_limiter = LoginRateLimiter()
|
||||
|
||||
# OIDC configuration (opt-in via env vars)
|
||||
from turnstone.core.oidc import load_oidc_config
|
||||
|
||||
oidc_config = load_oidc_config()
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.jwks_data = None # populated after async discovery
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -2045,6 +2199,8 @@ def main() -> None:
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
template: str | None = None,
|
||||
) -> ChatSession:
|
||||
assert ui is not None
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
@@ -2077,7 +2233,7 @@ def main() -> None:
|
||||
tool_search=config_store.get("tools.search"),
|
||||
tool_search_threshold=config_store.get("tools.search_threshold"),
|
||||
tool_search_max_results=config_store.get("tools.search_max_results"),
|
||||
template=args.template,
|
||||
template=template if template is not None else args.template,
|
||||
judge_config=live_judge_config,
|
||||
user_id=uid,
|
||||
memory_config=live_memory_config,
|
||||
|
||||
@@ -47,6 +47,34 @@ function initLogin() {
|
||||
overlay.innerHTML = _buildLoginHTML();
|
||||
document.body.appendChild(overlay);
|
||||
_bindLoginEvents();
|
||||
|
||||
// OIDC callback: detect success or error from URL params
|
||||
var _oidcParams = new URLSearchParams(window.location.search);
|
||||
var _oidcError = _oidcParams.get("oidc_error");
|
||||
if (_oidcError) {
|
||||
showLogin();
|
||||
history.replaceState({}, "", window.location.pathname);
|
||||
// Defer: showLogin() triggers async status fetch → _switchMode() → _clearError().
|
||||
// Display after that settles.
|
||||
var _pendingOidcError = _oidcError;
|
||||
setTimeout(function () {
|
||||
_showError(_pendingOidcError);
|
||||
}, 300);
|
||||
} else if (_oidcParams.get("oidc_success")) {
|
||||
history.replaceState({}, "", window.location.pathname);
|
||||
// Fetch permissions before completing login (cookie is already set)
|
||||
fetch("/v1/api/auth/whoami")
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : {};
|
||||
})
|
||||
.then(function (data) {
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function () {
|
||||
_onSuccess(); // Proceed even if permissions fetch fails
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _buildLoginHTML() {
|
||||
@@ -57,6 +85,11 @@ function _buildLoginHTML() {
|
||||
"</h2>" +
|
||||
'<div id="login-subtitle" class="login-subtitle"></div>' +
|
||||
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
|
||||
// --- OIDC SSO button ---
|
||||
'<div id="oidc-section" style="display:none">' +
|
||||
'<button id="oidc-btn" class="oidc-btn" type="button">Continue with SSO</button>' +
|
||||
'<div id="oidc-divider" class="oidc-divider"><span>or</span></div>' +
|
||||
"</div>" +
|
||||
// --- Setup mode fields ---
|
||||
'<div id="setup-fields" style="display:none">' +
|
||||
'<label for="setup-username" class="login-label">Username</label>' +
|
||||
@@ -158,6 +191,31 @@ function _switchMode(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function _updateOIDCUI(data) {
|
||||
var section = document.getElementById("oidc-section");
|
||||
var btn = document.getElementById("oidc-btn");
|
||||
var divider = document.getElementById("oidc-divider");
|
||||
if (!section) return;
|
||||
|
||||
if (!data.oidc_enabled || _authMode === "setup") {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = "";
|
||||
btn.textContent = "Continue with " + (data.oidc_provider_name || "SSO");
|
||||
btn.onclick = function () {
|
||||
window.location.href = "/v1/api/auth/oidc/authorize";
|
||||
};
|
||||
|
||||
if (data.password_enabled === false) {
|
||||
document.getElementById("login-fields").style.display = "none";
|
||||
document.getElementById("login-toggle").style.display = "none";
|
||||
document.getElementById("login-submit").style.display = "none";
|
||||
divider.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearError() {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl && errEl.style.display !== "none") {
|
||||
@@ -194,6 +252,7 @@ function showLogin() {
|
||||
} else {
|
||||
_switchMode("login");
|
||||
}
|
||||
_updateOIDCUI(data);
|
||||
})
|
||||
.catch(function () {
|
||||
// Fallback to login mode
|
||||
|
||||
@@ -377,6 +377,55 @@ body {
|
||||
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
|
||||
|
||||
/* OIDC / SSO */
|
||||
.oidc-btn {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.oidc-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.oidc-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.oidc-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.oidc-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.oidc-divider::before,
|
||||
.oidc-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
.oidc-divider span {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard shortcuts overlay
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+1244
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user