From d6e6cf2e6718b138be60cc9cdf972afb23215042 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 01:12:42 +0000 Subject: [PATCH] feat: bind operator sessions to upstream identity (Closes #1372) --- frontend/login.js | 2 +- src/dashboard_auth.py | 8 ++ src/main.py | 59 ++++++++++++++- src/session_store.py | 38 ++++++++-- tests/test_dashboard_auth.py | 94 ++++++++++++++++++++++-- tests/test_dashboard_session_frontend.py | 22 ++++++ tests/test_login_frontend.py | 2 +- tests/test_security_activity.py | 5 ++ tests/test_session_store.py | 41 +++++++++++ 9 files changed, 256 insertions(+), 15 deletions(-) diff --git a/frontend/login.js b/frontend/login.js index 88b379e..63c6330 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -119,7 +119,7 @@ return; } if (reason === 'session-idle') { - status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.'; + status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.'; return; } if (reason !== 'session-revoked') return; diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 8341078..83188fa 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -32,6 +32,8 @@ class Session: csrf: str expires_at: int idle_expires_at: int | None = None + principal_id: int | None = None + principal_login: str | None = None @dataclass(frozen=True) @@ -145,6 +147,8 @@ def issue_session( *, device_label: str = "This device", management_id: str | None = None, + principal_id: int | None = None, + principal_login: str | None = None, ) -> tuple[str, Session]: issued_at = int(time.time() if now is None else now) ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) @@ -165,6 +169,8 @@ def issue_session( session.expires_at, device_label=device_label, management_id=management_id, + principal_id=principal_id, + principal_login=principal_login, ) return f"{encoded}.{signature}", session @@ -211,6 +217,8 @@ def verify_session_with_reason( csrf=session.csrf, expires_at=session.expires_at, idle_expires_at=getattr(status, "idle_expires_at", None), + principal_id=getattr(status, "principal_id", None), + principal_login=getattr(status, "principal_login", None), ) ) diff --git a/src/main.py b/src/main.py index 2d9799a..8338a52 100644 --- a/src/main.py +++ b/src/main.py @@ -429,6 +429,21 @@ class ReadinessPayloadError(ValueError): """Raised when Gitea returns a structurally invalid readiness payload.""" +async def _upstream_identity() -> tuple[int, str]: + upstream = await current_user() + principal_id = upstream.get("id") if isinstance(upstream, dict) else None + principal_login = upstream.get("login") if isinstance(upstream, dict) else None + if ( + not isinstance(principal_id, int) + or isinstance(principal_id, bool) + or principal_id <= 0 + or not isinstance(principal_login, str) + or not principal_login + ): + raise ValueError("Gitea identity response is incomplete") + return principal_id, principal_login + + class DashboardSignIn(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) device_label: str = Field(default="This device", min_length=1, max_length=64) @@ -1563,6 +1578,27 @@ async def require_operator_session(request: Request, call_next): status_code=503, headers={"Cache-Control": "no-store"}, ) + if session is not None: + try: + current_principal_id, current_login = await _upstream_identity() + except Exception: + return JSONResponse( + {"detail": "Gitea identity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "5"}, + ) + if session.principal_id != current_principal_id: + return JSONResponse( + { + "detail": "Connected Gitea account changed; sign in again", + "code": "session_idle", + "reason": "upstream_identity_changed", + "previous_login": session.principal_login, + "current_login": current_login, + }, + status_code=401, + headers={"Cache-Control": "no-store"}, + ) if not public and session is None: if path.startswith("/api/"): payload = {"detail": "Authentication required"} @@ -1736,9 +1772,20 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response status_code=503, headers={"Cache-Control": "no-store"}, ) + try: + principal_id, principal_login = await _upstream_identity() + except Exception: + return JSONResponse( + {"detail": "Gitea identity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "5"}, + ) try: signed, session = await asyncio.to_thread( - dashboard_auth.issue_session, device_label=payload.device_label + dashboard_auth.issue_session, + device_label=payload.device_label, + principal_id=principal_id, + principal_login=principal_login, ) except dashboard_auth.SessionStoreError: return JSONResponse( @@ -2326,11 +2373,21 @@ async def verify_passkey_authentication( target="sign_in:dashboard", ) raise ValueError("stale passkey counter") + try: + principal_id, principal_login = await _upstream_identity() + except Exception: + return JSONResponse( + {"detail": "Gitea identity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "5"}, + ) await dashboard_auth.revoke_managed_session(stored.management_id) signed, session = await asyncio.to_thread( dashboard_auth.issue_session, device_label=stored.device_label, management_id=stored.management_id, + principal_id=principal_id, + principal_login=principal_login, ) except dashboard_auth.SessionStoreError: raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") diff --git a/src/session_store.py b/src/session_store.py index 9eca794..e894ed3 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -18,10 +18,20 @@ class SessionStatus(str): """String-compatible status carrying the server-confirmed idle deadline.""" idle_expires_at: int | None + principal_id: int | None + principal_login: str | None - def __new__(cls, value: str, idle_expires_at: int | None = None): + def __new__( + cls, + value: str, + idle_expires_at: int | None = None, + principal_id: int | None = None, + principal_login: str | None = None, + ): instance = super().__new__(cls, value) instance.idle_expires_at = idle_expires_at + instance.principal_id = principal_id + instance.principal_login = principal_login return instance @@ -71,7 +81,9 @@ class SessionStore: management_id TEXT, device_label TEXT, created_at INTEGER, - last_active_at INTEGER + last_active_at INTEGER, + principal_id INTEGER, + principal_login TEXT ) """ ) @@ -98,6 +110,8 @@ class SessionStore: "device_label": "TEXT", "created_at": "INTEGER", "last_active_at": "INTEGER", + "principal_id": "INTEGER", + "principal_login": "TEXT", } for name, column_type in additions.items(): if name not in columns: @@ -135,6 +149,8 @@ class SessionStore: *, device_label: str = "This device", management_id: str | None = None, + principal_id: int | None = None, + principal_login: str | None = None, ) -> None: label = " ".join(str(device_label).split())[:64] or "This device" now = int(self.clock()) @@ -145,8 +161,9 @@ class SessionStore: ) connection.execute( "INSERT INTO active_sessions(" - "session_hash, expires_at, management_id, device_label, created_at, last_active_at" - ") VALUES (?, ?, ?, ?, ?, ?)", + "session_hash, expires_at, management_id, device_label, created_at, last_active_at, " + "principal_id, principal_login" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( self._digest(session_id), expires_at, @@ -154,6 +171,8 @@ class SessionStore: label, now, now, + principal_id, + principal_login, ), ) except (OSError, sqlite3.Error) as exc: @@ -176,7 +195,8 @@ class SessionStore: ) -> str: now = int(self.clock()) query = ( - "SELECT expires_at, last_active_at FROM active_sessions " + "SELECT expires_at, last_active_at, principal_id, principal_login " + "FROM active_sessions " "WHERE session_hash = ?" ) parameters = (self._digest(session_id),) @@ -185,7 +205,11 @@ class SessionStore: with self._connect() as connection: row = connection.execute(query, parameters).fetchone() except sqlite3.OperationalError as exc: - if "no such column: last_active_at" not in str(exc): + missing_migrated_column = any( + f"no such column: {name}" in str(exc) + for name in ("last_active_at", "principal_id", "principal_login") + ) + if not missing_migrated_column: raise with self._connect(initialize=True) as connection: row = connection.execute(query, parameters).fetchone() @@ -196,7 +220,7 @@ class SessionStore: idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds)) if idle_expires_at <= now: return SessionStatus("idle", idle_expires_at) - return SessionStatus("active", idle_expires_at) + return SessionStatus("active", idle_expires_at, row[2], row[3]) def touch( self, session_id: str, expires_at: int, *, idle_timeout_seconds: int diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 73e62c5..22838c1 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -10,7 +10,7 @@ import pytest from fastapi import Request from src import main -from src.session_store import SessionStoreError +from src.session_store import SessionStatus, SessionStoreError from src.views import FRONTEND_BUILD @@ -29,6 +29,83 @@ def access_control(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3") monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60") + async def upstream_user(): + return {"id": 42, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", upstream_user) + + +def test_signed_session_carries_bound_upstream_identity(access_control): + signed, _ = main.dashboard_auth.issue_session( + now=1_000, + principal_id=42, + principal_login="timmy", + ) + + verification = main.dashboard_auth.verify_session_with_reason(signed, now=1_001) + + assert verification.session is not None + assert verification.session.principal_id == 42 + assert verification.session.principal_login == "timmy" + + +@pytest.mark.anyio +async def test_session_is_blocked_when_upstream_account_changes( + access_control, monkeypatch +): + identity = {"id": 42, "login": "timmy"} + + async def upstream_user(): + return dict(identity) + + monkeypatch.setattr(main, "current_user", upstream_user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + signed_in = await client.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple"}, + ) + identity.update(login="renamed-timmy") + same_principal = await client.get("/api/v1/security-events") + identity.update(id=84, login="other-operator") + protected = await client.get("/api/v1/security-events") + + assert signed_in.status_code == 200 + assert same_principal.status_code == 200 + assert protected.status_code == 401 + assert protected.json() == { + "detail": "Connected Gitea account changed; sign in again", + "code": "session_idle", + "reason": "upstream_identity_changed", + "previous_login": "timmy", + "current_login": "other-operator", + } + + +@pytest.mark.anyio +async def test_session_identity_lookup_outage_is_retryable(access_control, monkeypatch): + available = True + + async def upstream_user(): + if not available: + raise RuntimeError("credential lookup details") + return {"id": 42, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", upstream_user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + signed_in = await client.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple"}, + ) + available = False + protected = await client.get("/api/v1/security-events") + + assert signed_in.status_code == 200 + assert protected.status_code == 503 + assert protected.json() == {"detail": "Gitea identity is temporarily unavailable"} + assert protected.headers["retry-after"] == "5" + assert "credential lookup" not in protected.text async def fresh_grant(client, action: str, target: str) -> str: @@ -1904,7 +1981,7 @@ async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, mon assert before.status_code == 200 assert after.status_code == 401 assert after.headers["cache-control"] == "no-store" - assert calls == 1 + assert calls == 4 @pytest.mark.anyio @@ -1918,7 +1995,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con class SlowStore: def status(self, session_id, expires_at, *, idle_timeout_seconds): time.sleep(0.15) - return "active" + return SessionStatus( + "active", principal_id=42, principal_login="timmy" + ) monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore()) private_request = asyncio.create_task(client.get("/api/v1/session")) @@ -2084,7 +2163,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop( class SlowStore: def status(self, session_id, expires_at, *, idle_timeout_seconds): time.sleep(0.02) - return "active" + return SessionStatus( + "active", principal_id=42, principal_login="timmy" + ) def revoke(self, session_id): time.sleep(0.15) @@ -2126,6 +2207,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea( await client.post( "/api/v1/session", json={"access_token": "correct horse battery staple"} ) + called = False class BrokenStore: def status(self, session_id, expires_at, *, idle_timeout_seconds): @@ -2174,7 +2256,9 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control, class BrokenStore: def status(self, session_id, expires_at, *, idle_timeout_seconds): - return "active" + return SessionStatus( + "active", principal_id=42, principal_login="timmy" + ) def revoke(self, session_id): raise SessionStoreError("database path and secret details") diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index a93ea72..08443c1 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -558,6 +558,28 @@ process.stdout.write(JSON.stringify(state)); assert result["replacedAfterDeletion"] is True +def test_upstream_identity_change_preserves_private_work_and_requires_sign_in(): + result = run_session_scenario( + """ +state.responseStatus = 401; +state.responsePayload = { + detail:'Connected Gitea account changed; sign in again', + code:'session_idle', reason:'upstream_identity_changed', + previous_login:'timmy', current_login:'other-operator', +}; +await boundary.fetch('/dashboard/api/v1/live'); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["remaining"] == ["stackchain.private", "gitea.preference"] + assert result["deletedDatabases"] == [] + assert result["deletedCaches"] == [] + assert result["workerMessages"] == [] + assert result["replaced"] == ["/dashboard/login?reason=session-idle"] + + def test_idle_response_locks_without_clearing_private_queued_work(): result = run_session_scenario( """ diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index e77a8e2..cdfb198 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -54,7 +54,7 @@ process.stdout.write(JSON.stringify({{ status: status.textContent }})); ) assert json.loads(result.stdout)["status"] == ( - "Stackchain locked after inactivity. Your drafts and queued work are still " + "Stackchain locked. Your drafts and queued work are still " "on this device. Sign in to resume." ) diff --git a/tests/test_security_activity.py b/tests/test_security_activity.py index a2e44cb..f892fdf 100644 --- a/tests/test_security_activity.py +++ b/tests/test_security_activity.py @@ -18,6 +18,11 @@ def security_access(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3")) monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3")) + + async def upstream_user(): + return {"id": 42, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", upstream_user) return tmp_path diff --git a/tests/test_session_store.py b/tests/test_session_store.py index bde13d6..23d4d09 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -88,6 +88,23 @@ def test_session_status_reports_idle_without_background_validation_extending_act assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle" +def test_active_session_status_carries_bound_upstream_identity(tmp_path): + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0) + + store.activate( + "phone-session", + 3_000, + principal_id=42, + principal_login="timmy", + ) + + status = store.status("phone-session", 3_000, idle_timeout_seconds=900) + + assert status == "active" + assert status.principal_id == 42 + assert status.principal_login == "timmy" + + def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path): now = [1_000.0] store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) @@ -230,6 +247,30 @@ def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now assert last_active_at == 1_000 +def test_identity_status_migrates_registry_that_already_has_idle_tracking(tmp_path): + database = tmp_path / "sessions.sqlite3" + digest = SessionStore._digest("existing-session") + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE active_sessions (" + "session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, " + "management_id TEXT, device_label TEXT, created_at INTEGER, last_active_at INTEGER" + ")" + ) + connection.execute( + "INSERT INTO active_sessions VALUES (?, ?, ?, ?, ?, ?)", + (digest, 2_000, "legacy-device", "Existing device", 900, 1_000), + ) + + status = SessionStore(database, clock=lambda: 1_000.0).status( + "existing-session", 2_000, idle_timeout_seconds=900 + ) + + assert status == "active" + assert status.principal_id is None + assert status.principal_login is None + + def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path): database = tmp_path / "sessions.sqlite3" digest = SessionStore._digest("existing-session")