mirror of
https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git
synced 2026-11-03 09:23:25 +00:00
feat(gateway): SSO landing auto-connects and offers recreate sandbox (#15)
After oauth2-proxy authentication, the gateway now renders a minimal SSO landing (no signup/token forms) that auto-connects to the sandbox and offers a single 'Recreate sandbox' action for a clean start. - _sso_session: stop blocking on wait_ready (provisioning is non-blocking; the proxy path still awaits readiness), so the landing reports 'Provisioning' instead of stalling for WAKE_TIMEOUT - landing: SSO branch renders a minimal page (signed-in user + sandbox state + auto-connect meta-refresh), replacing the generic signup/token forms - new POST /recreate: deletes the claim (cascade pod+PVC), waits out finalizers, re-provisions, and reconnects; CSRF-protected, SSO-only - tests: 109 passing (SSO landing provision/connect/recreate cases) Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
This commit is contained in:
committed by
GitHub
parent
73a65d98d2
commit
ed1398dfd1
@@ -622,10 +622,12 @@ class Gateway:
|
|||||||
"""Provision ``remote`` idempotently and mint/refresh a session token.
|
"""Provision ``remote`` idempotently and mint/refresh a session token.
|
||||||
|
|
||||||
The authenticating proxy has already verified ``remote``, so it is
|
The authenticating proxy has already verified ``remote``, so it is
|
||||||
trusted. A brand-new user is provisioned like ``signup`` (claim created,
|
trusted. A brand-new user is provisioned (claim created, token minted);
|
||||||
token minted, sandbox awaited); an existing user whose session cookie is
|
an existing user whose session cookie is missing or no longer matches
|
||||||
missing or no longer matches gets a rotated token so a fresh cookie can
|
gets a rotated token so a fresh cookie can be issued. A still-valid
|
||||||
be issued. A still-valid session is reused untouched.
|
session is reused untouched. Provisioning is non-blocking: the sandbox
|
||||||
|
is awaited by the proxy path (``_resolve_sandbox``), so the landing page
|
||||||
|
can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``.
|
||||||
"""
|
"""
|
||||||
claim = await self.k8s.get_claim(remote)
|
claim = await self.k8s.get_claim(remote)
|
||||||
token, cookie_user = self.credentials(headers, cookies)
|
token, cookie_user = self.credentials(headers, cookies)
|
||||||
@@ -643,7 +645,6 @@ class Gateway:
|
|||||||
await self.k8s.patch_token(remote, token_hash)
|
await self.k8s.patch_token(remote, token_hash)
|
||||||
else:
|
else:
|
||||||
await self._touch(remote)
|
await self._touch(remote)
|
||||||
await self.wait_ready(remote, self.settings.wake_timeout)
|
|
||||||
else:
|
else:
|
||||||
await self.k8s.patch_token(remote, token_hash)
|
await self.k8s.patch_token(remote, token_hash)
|
||||||
await self._touch(remote)
|
await self._touch(remote)
|
||||||
@@ -835,14 +836,65 @@ class Gateway:
|
|||||||
async def healthz(self, request: Request) -> JSONResponse:
|
async def healthz(self, request: Request) -> JSONResponse:
|
||||||
return JSONResponse({"status": "ok"})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sso_page(user: str, state: str) -> str:
|
||||||
|
"""Minimal SSO landing: connect to the sandbox, or offer to recreate it.
|
||||||
|
|
||||||
|
No signup/token forms — the authenticating proxy has already identified
|
||||||
|
the user. A "Provisioning" sandbox refreshes itself until a pod exists;
|
||||||
|
any other state auto-connects to ``/u/<user>/`` and offers the single
|
||||||
|
"Recreate sandbox" action.
|
||||||
|
"""
|
||||||
|
safe_user = html.escape(user)
|
||||||
|
path = quote(user)
|
||||||
|
if state == "Provisioning":
|
||||||
|
status = "Provisioning"
|
||||||
|
body = (
|
||||||
|
'<meta http-equiv="refresh" content="3">'
|
||||||
|
"<p>Provisioning your sandbox… this can take a minute.</p>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = html.escape(state)
|
||||||
|
confirm = (
|
||||||
|
'onsubmit="return confirm(\'Delete this sandbox and start fresh? '
|
||||||
|
"Your workspace data will be wiped.')\""
|
||||||
|
)
|
||||||
|
body = (
|
||||||
|
f'<meta http-equiv="refresh" content="1;url=/u/{path}/">'
|
||||||
|
"<p>Connecting to your sandbox…</p>"
|
||||||
|
f'<form method="post" action="/recreate" {confirm}>'
|
||||||
|
'<button type="submit">Recreate sandbox</button></form>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||||
|
"<title>DSH sandbox</title><style>"
|
||||||
|
"body{font-family:system-ui,sans-serif;max-width:42rem;margin:3rem auto;"
|
||||||
|
"padding:0 1rem;line-height:1.5}"
|
||||||
|
"form{margin:1rem 0}button{padding:.4rem 1rem}"
|
||||||
|
"</style></head><body>"
|
||||||
|
"<h1>DeepSeek Harness sandbox</h1>"
|
||||||
|
f"<p>Signed in as <strong>{safe_user}</strong>.</p>"
|
||||||
|
f"<p>Sandbox status: <strong>{status}</strong>.</p>"
|
||||||
|
f"{body}"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
async def landing(self, request: Request) -> Response:
|
async def landing(self, request: Request) -> Response:
|
||||||
remote = self.remote_user(request.headers)
|
remote = self.remote_user(request.headers)
|
||||||
if remote:
|
if remote:
|
||||||
response = RedirectResponse(f"/u/{remote}/", status_code=303)
|
# SSO: auto-provision + connect. A temporary response captures the
|
||||||
await self._sso_session(
|
# session cookie minted by _sso_session; the real page is rendered
|
||||||
|
# once the sandbox state is known.
|
||||||
|
response = HTMLResponse()
|
||||||
|
claim = await self._sso_session(
|
||||||
remote, request.headers, request.cookies, response
|
remote, request.headers, request.cookies, response
|
||||||
)
|
)
|
||||||
return response
|
sandbox = await self._sandbox_of(claim) if claim else None
|
||||||
|
page = HTMLResponse(self._sso_page(remote, derive_state(sandbox)))
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
page.headers["set-cookie"] = response.headers["set-cookie"]
|
||||||
|
return page
|
||||||
token, user = self.credentials(request.headers, request.cookies)
|
token, user = self.credentials(request.headers, request.cookies)
|
||||||
session = ""
|
session = ""
|
||||||
if user and token:
|
if user and token:
|
||||||
@@ -1043,6 +1095,38 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
{"user": user, "note": "claim deleted; sandbox and PVC cascade"}
|
{"user": user, "note": "claim deleted; sandbox and PVC cascade"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def recreate(self, request: Request) -> Response:
|
||||||
|
"""Delete and re-provision the SSO user's sandbox for a clean start.
|
||||||
|
|
||||||
|
SSO-only: the identity comes from the trusted proxy header. Deleting the
|
||||||
|
claim cascades to the sandbox pod and its PVC, wiping user state; we poll
|
||||||
|
until the claim is fully gone (finalizers) before recreating to avoid a
|
||||||
|
name-collision 409, then reconnect.
|
||||||
|
"""
|
||||||
|
remote = self.remote_user(request.headers)
|
||||||
|
if not remote:
|
||||||
|
return self._error(401, "unauthorized")
|
||||||
|
_token, cookie_user = self.credentials(request.headers, request.cookies)
|
||||||
|
if cookie_user is not None and cookie_user != remote:
|
||||||
|
return self._error(401, "unauthorized")
|
||||||
|
if self._mutation_from_cookie(request.headers, "POST", cookie_user):
|
||||||
|
return self._error(403, "cross-origin request rejected")
|
||||||
|
await self.k8s.delete_claim(remote)
|
||||||
|
self._clear_handshake(remote)
|
||||||
|
async with self.state.lock:
|
||||||
|
self.state.last_activity.pop(remote, None)
|
||||||
|
self.state.in_flight.pop(remote, None)
|
||||||
|
# Wait out any finalizer so the claim name is reusable.
|
||||||
|
for _ in range(30):
|
||||||
|
if await self.k8s.get_claim(remote) is None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
response = RedirectResponse(f"/u/{remote}/", status_code=303)
|
||||||
|
await self._sso_session(
|
||||||
|
remote, request.headers, request.cookies, response
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
# -- HTTP proxy --------------------------------------------------------
|
# -- HTTP proxy --------------------------------------------------------
|
||||||
|
|
||||||
async def proxy_http(self, request: Request) -> Response:
|
async def proxy_http(self, request: Request) -> Response:
|
||||||
@@ -1447,6 +1531,7 @@ def create_app(
|
|||||||
Route("/users", gateway.signup, methods=["POST"]),
|
Route("/users", gateway.signup, methods=["POST"]),
|
||||||
Route("/login", gateway.login, methods=["POST"]),
|
Route("/login", gateway.login, methods=["POST"]),
|
||||||
Route("/logout", gateway.logout, methods=["POST"]),
|
Route("/logout", gateway.logout, methods=["POST"]),
|
||||||
|
Route("/recreate", gateway.recreate, methods=["POST"]),
|
||||||
Route("/users/{user}", gateway.get_user),
|
Route("/users/{user}", gateway.get_user),
|
||||||
Route("/users/{user}", gateway.delete_user, methods=["DELETE"]),
|
Route("/users/{user}", gateway.delete_user, methods=["DELETE"]),
|
||||||
Route("/u/{user}", gateway.redirect_to_workspace),
|
Route("/u/{user}", gateway.redirect_to_workspace),
|
||||||
|
|||||||
@@ -1460,32 +1460,36 @@ def test_remote_user_returns_valid_and_rejects_invalid(env):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sso_landing_provisions_new_user_and_redirects(env, monkeypatch):
|
async def test_sso_landing_new_user_shows_provisioning(env):
|
||||||
enable_sso(env)
|
enable_sso(env)
|
||||||
|
|
||||||
async def ready(user, timeout):
|
|
||||||
return {"metadata": {"name": f"dsh-{user}-sbx"}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(env.gateway, "wait_ready", ready)
|
|
||||||
async with asgi_client(env) as client:
|
async with asgi_client(env) as client:
|
||||||
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
||||||
assert response.status_code == 303
|
assert response.status_code == 200
|
||||||
assert response.headers["location"] == "/u/alice/"
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
assert "Signed in as <strong>alice</strong>" in response.text
|
||||||
|
assert "Provisioning" in response.text
|
||||||
|
assert "Recreate sandbox" not in response.text
|
||||||
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
|
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
|
||||||
assert [c[0] for c in env.k8s.created] == ["alice"]
|
assert [c[0] for c in env.k8s.created] == ["alice"]
|
||||||
assert env.k8s.token_patches == []
|
assert env.k8s.token_patches == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sso_landing_reuses_valid_session_without_rotation(env):
|
async def test_sso_landing_ready_connects_and_offers_recreate_only(env):
|
||||||
enable_sso(env)
|
enable_sso(env)
|
||||||
add_user(env.k8s)
|
add_user(env.k8s)
|
||||||
async with asgi_client(
|
async with asgi_client(
|
||||||
env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}
|
env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
||||||
assert response.status_code == 303
|
assert response.status_code == 200
|
||||||
assert response.headers["location"] == "/u/alice/"
|
assert "Signed in as <strong>alice</strong>" in response.text
|
||||||
|
assert "Recreate sandbox" in response.text
|
||||||
|
# No signup/token forms for SSO users.
|
||||||
|
assert 'action="/users"' not in response.text
|
||||||
|
assert 'action="/login"' not in response.text
|
||||||
|
# Auto-connects to the workspace.
|
||||||
|
assert "url=/u/alice/" in response.text
|
||||||
assert env.k8s.created == []
|
assert env.k8s.created == []
|
||||||
assert env.k8s.token_patches == []
|
assert env.k8s.token_patches == []
|
||||||
|
|
||||||
@@ -1496,13 +1500,54 @@ async def test_sso_landing_rotates_token_when_session_missing(env):
|
|||||||
add_user(env.k8s)
|
add_user(env.k8s)
|
||||||
async with asgi_client(env) as client:
|
async with asgi_client(env) as client:
|
||||||
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
||||||
assert response.status_code == 303
|
assert response.status_code == 200
|
||||||
assert response.headers["location"] == "/u/alice/"
|
assert "Recreate sandbox" in response.text
|
||||||
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
|
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
|
||||||
assert [p[0] for p in env.k8s.token_patches] == ["alice"]
|
assert [p[0] for p in env.k8s.token_patches] == ["alice"]
|
||||||
assert env.k8s.created == []
|
assert env.k8s.created == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recreate_deletes_and_reprovisions(env):
|
||||||
|
enable_sso(env)
|
||||||
|
add_user(env.k8s)
|
||||||
|
async with asgi_client(env) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/recreate", headers={"X-Forwarded-User": "alice"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/u/alice/"
|
||||||
|
assert env.k8s.deleted == ["alice"]
|
||||||
|
assert [c[0] for c in env.k8s.created] == ["alice"]
|
||||||
|
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recreate_requires_sso(env):
|
||||||
|
add_user(env.k8s)
|
||||||
|
async with asgi_client(env) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/recreate", headers={"X-Forwarded-User": "alice"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert env.k8s.deleted == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recreate_rejects_cross_origin(env):
|
||||||
|
enable_sso(env)
|
||||||
|
add_user(env.k8s)
|
||||||
|
async with asgi_client(
|
||||||
|
env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/recreate",
|
||||||
|
headers={"X-Forwarded-User": "alice", "Origin": "http://evil.test"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert env.k8s.deleted == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sso_get_user_authorizes_via_header(env):
|
async def test_sso_get_user_authorizes_via_header(env):
|
||||||
enable_sso(env)
|
enable_sso(env)
|
||||||
|
|||||||
Reference in New Issue
Block a user