1
0
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:
SREDevOps.org Agent
2026-10-25 04:34:13 -03:00
committed by GitHub
parent 73a65d98d2
commit ed1398dfd1
2 changed files with 151 additions and 21 deletions

View File

@@ -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),

View File

@@ -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)