Bind operator sessions to upstream Gitea identity #1373

Merged
rockachopa merged 1 commits from timmy/1372-bind-sessions-upstream-identity into main 2026-08-25 01:33:53 +00:00
9 changed files with 256 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
"""

View File

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

View File

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

View File

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