mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
21663d1567
Adds the data model and admin UI surface required by the OAuth-MCP flow.
Phase 2 of the per-user delegation initiative.
Schema:
- migration 049 creates mcp_user_tokens (PK user_id, server_name) and
mcp_oauth_pending (PK state, indexed by created_at)
- eight new columns on mcp_servers: auth_type ('none' / 'static' /
'oauth_user', NOT NULL DEFAULT 'static') plus six oauth_* config
fields and oauth_as_issuer_cached
- post-upgrade UPDATE normalises auth_type to 'none' for streamable-http
rows whose headers are NULL/empty/'{}'; stdio rows are left at the
'static' default (auth_type is HTTP-auth-only)
- _schema.py kept in lockstep with the migration so metadata.create_all
and alembic upgrade produce identical shapes
- mcp_user_tokens / mcp_oauth_pending TypedDicts in _protocol.py for
Phase 3/4 use (no CRUD methods yet)
Storage / API:
- create_mcp_server gains the eight kwargs across protocol + sqlite +
postgresql
- MCP_SERVER_MUTABLE picks up auth_type and the six text oauth_* fields;
oauth_client_secret_ct is intentionally NOT in the whitelist — Phase 3
will own ciphertext writes via a dedicated method
- McpServerInfo + Create/Update Pydantic schemas extended; oauth_client_secret
accepted as plaintext input but discarded (Phase 3 wires encryption)
Admin handlers:
- _parse_auth_type validates against {'none', 'static', 'oauth_user'} and
rejects empty / unknown values; shared between create and update
- when auth_type changes away from 'oauth_user', the oauth_* config
columns are explicitly nulled in the same UPDATE so the row stays
consistent
- _clean_oauth_text caps text fields at 512 chars (URLs at 2048) to bound
admin write surface
- _mask_mcp_secrets now masks oauth_client_secret_ct to '***' regardless
of reveal=true (write-only field)
- audit detail dict redacts oauth_client_secret if present
Frontend:
- new "Multitenant Authorization" fieldset on the MCP-server modal with
three radio buttons (None / Shared / Per-user OAuth 2.1)
- conditional OAuth subform: AS URL, registration mode (preregistered /
dcr; cimd is future), client ID, client secret, scopes, audience
- secret input is autocomplete=off and never round-trips on edit
- audience auto-populates from the MCP server URL on blur
- headers textarea hidden and submitted as {} when auth_type is 'none' or
'oauth_user' so flipping the radio cleans up server-side state
Tests: storage round-trip for the new columns, oauth_pending table smoke,
migration 049 upgrade/downgrade with stdio-vs-http normalisation, four
admin-API tests for auth_type validation and oauth_*-clear-on-flip-away.
Suite passes 5284 (matched pre-Phase-2 baseline 5267 + 17 new).
Stacks on Phase 0; no behavioural change for existing rows.
(cherry picked from commit d675b237a3)
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
"""Tests for alembic migration 049 (OAuth-MCP schema).
|
|
|
|
Drives ``command.upgrade`` from a programmatic Alembic config against
|
|
an isolated SQLite database per test, then asserts:
|
|
|
|
* the two new tables (``mcp_user_tokens``, ``mcp_oauth_pending``) exist,
|
|
* the eight new ``mcp_servers`` columns exist,
|
|
* the post-upgrade ``UPDATE mcp_servers`` normalization rewrites rows
|
|
with empty / missing headers to ``auth_type='none'`` while leaving
|
|
rows with non-empty headers at ``auth_type='static'``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
_MIGRATIONS_DIR = str(
|
|
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
|
)
|
|
|
|
|
|
def _alembic_cfg(db_path: Path) -> Config:
|
|
cfg = Config()
|
|
cfg.set_main_option("script_location", _MIGRATIONS_DIR)
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
|
return cfg
|
|
|
|
|
|
class TestMigration049:
|
|
def test_creates_new_tables_and_columns(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "049.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
|
|
# Walk forward through 048 first, then explicitly to 049 so we
|
|
# exercise the *upgrade* function (not just the schema's `head`).
|
|
command.upgrade(cfg, "048")
|
|
command.upgrade(cfg, "049")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
inspector = sa.inspect(engine)
|
|
tables = set(inspector.get_table_names())
|
|
assert "mcp_user_tokens" in tables
|
|
assert "mcp_oauth_pending" in tables
|
|
|
|
mcp_cols = {c["name"] for c in inspector.get_columns("mcp_servers")}
|
|
new_cols = {
|
|
"auth_type",
|
|
"oauth_client_id",
|
|
"oauth_client_secret_ct",
|
|
"oauth_scopes",
|
|
"oauth_audience",
|
|
"oauth_registration_mode",
|
|
"oauth_authorization_server_url",
|
|
"oauth_as_issuer_cached",
|
|
}
|
|
assert new_cols.issubset(mcp_cols), new_cols - mcp_cols
|
|
|
|
# Index check on mcp_oauth_pending.
|
|
indexes = {ix["name"] for ix in inspector.get_indexes("mcp_oauth_pending")}
|
|
assert "idx_mcp_pending_created" in indexes
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_normalizes_empty_headers_to_none(self, tmp_path: Path) -> None:
|
|
"""Streamable-http rows with NULL / '' / '{}' headers become
|
|
auth_type='none'; rows with non-empty headers stay 'static'.
|
|
Stdio rows always stay 'static' regardless of headers — the
|
|
column value is opaque when there is no HTTP transport."""
|
|
db_path = tmp_path / "049-norm.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
|
|
# Apply everything up to 048, seed rows, then apply 049.
|
|
command.upgrade(cfg, "048")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO mcp_servers (
|
|
server_id, name, transport, command, args, url,
|
|
headers, env, auto_approve, enabled, created_by,
|
|
registry_name, registry_version, registry_meta,
|
|
created, updated
|
|
) VALUES (
|
|
:sid, :name, :transport, '', '[]',
|
|
'https://x', :headers, '{}', 0, 1, '', NULL, '',
|
|
'{}', '2026-05-04T11:00:00', '2026-05-04T11:00:00'
|
|
)
|
|
"""
|
|
),
|
|
[
|
|
{
|
|
"sid": "s-empty-str",
|
|
"name": "empty-str",
|
|
"transport": "streamable-http",
|
|
"headers": "",
|
|
},
|
|
{
|
|
"sid": "s-empty-obj",
|
|
"name": "empty-obj",
|
|
"transport": "streamable-http",
|
|
"headers": "{}",
|
|
},
|
|
{
|
|
"sid": "s-with-headers",
|
|
"name": "with-headers",
|
|
"transport": "streamable-http",
|
|
"headers": '{"Authorization":"Bearer x"}',
|
|
},
|
|
# Stdio rows must keep the 'static' default, even
|
|
# though their headers are empty — auth_type is
|
|
# opaque for stdio.
|
|
{
|
|
"sid": "s-stdio-empty",
|
|
"name": "stdio-empty",
|
|
"transport": "stdio",
|
|
"headers": "{}",
|
|
},
|
|
{
|
|
"sid": "s-stdio-null",
|
|
"name": "stdio-null",
|
|
"transport": "stdio",
|
|
"headers": "",
|
|
},
|
|
],
|
|
)
|
|
|
|
command.upgrade(cfg, "049")
|
|
|
|
with engine.connect() as conn:
|
|
rows = dict(conn.execute(sa.text("SELECT name, auth_type FROM mcp_servers")).all())
|
|
assert rows["empty-str"] == "none"
|
|
assert rows["empty-obj"] == "none"
|
|
assert rows["with-headers"] == "static"
|
|
# Stdio rows must remain at the 'static' column default even
|
|
# when headers are empty — the migration only touches HTTP
|
|
# rows where auth_type is semantically meaningful.
|
|
assert rows["stdio-empty"] == "static"
|
|
assert rows["stdio-null"] == "static"
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_full_chain_to_head(self, tmp_path: Path) -> None:
|
|
"""Sanity: running ``upgrade head`` on a fresh DB yields the
|
|
same end-state column set as ``_schema.metadata``."""
|
|
db_path = tmp_path / "049-head.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "head")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
from turnstone.core.storage._schema import mcp_servers
|
|
|
|
inspector = sa.inspect(engine)
|
|
actual = {c["name"] for c in inspector.get_columns("mcp_servers")}
|
|
expected = {c.name for c in mcp_servers.columns}
|
|
assert expected.issubset(actual), expected - actual
|
|
finally:
|
|
engine.dispose()
|