feat: bind operator sessions to upstream identity (Closes #1372)
This commit is contained in:
parent
53706f18ab
commit
d6e6cf2e67
|
|
@ -119,7 +119,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (reason === 'session-idle') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (reason !== 'session-revoked') return;
|
if (reason !== 'session-revoked') return;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class Session:
|
||||||
csrf: str
|
csrf: str
|
||||||
expires_at: int
|
expires_at: int
|
||||||
idle_expires_at: int | None = None
|
idle_expires_at: int | None = None
|
||||||
|
principal_id: int | None = None
|
||||||
|
principal_login: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -145,6 +147,8 @@ def issue_session(
|
||||||
*,
|
*,
|
||||||
device_label: str = "This device",
|
device_label: str = "This device",
|
||||||
management_id: str | None = None,
|
management_id: str | None = None,
|
||||||
|
principal_id: int | None = None,
|
||||||
|
principal_login: str | None = None,
|
||||||
) -> tuple[str, Session]:
|
) -> tuple[str, Session]:
|
||||||
issued_at = int(time.time() if now is None else now)
|
issued_at = int(time.time() if now is None else now)
|
||||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||||
|
|
@ -165,6 +169,8 @@ def issue_session(
|
||||||
session.expires_at,
|
session.expires_at,
|
||||||
device_label=device_label,
|
device_label=device_label,
|
||||||
management_id=management_id,
|
management_id=management_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
principal_login=principal_login,
|
||||||
)
|
)
|
||||||
return f"{encoded}.{signature}", session
|
return f"{encoded}.{signature}", session
|
||||||
|
|
||||||
|
|
@ -211,6 +217,8 @@ def verify_session_with_reason(
|
||||||
csrf=session.csrf,
|
csrf=session.csrf,
|
||||||
expires_at=session.expires_at,
|
expires_at=session.expires_at,
|
||||||
idle_expires_at=getattr(status, "idle_expires_at", None),
|
idle_expires_at=getattr(status, "idle_expires_at", None),
|
||||||
|
principal_id=getattr(status, "principal_id", None),
|
||||||
|
principal_login=getattr(status, "principal_login", None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
59
src/main.py
59
src/main.py
|
|
@ -429,6 +429,21 @@ class ReadinessPayloadError(ValueError):
|
||||||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
"""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):
|
class DashboardSignIn(BaseModel):
|
||||||
access_token: str = Field(min_length=1, max_length=1_024)
|
access_token: str = Field(min_length=1, max_length=1_024)
|
||||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
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,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
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 not public and session is None:
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
payload = {"detail": "Authentication required"}
|
payload = {"detail": "Authentication required"}
|
||||||
|
|
@ -1736,9 +1772,20 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
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:
|
try:
|
||||||
signed, session = await asyncio.to_thread(
|
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:
|
except dashboard_auth.SessionStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -2326,11 +2373,21 @@ async def verify_passkey_authentication(
|
||||||
target="sign_in:dashboard",
|
target="sign_in:dashboard",
|
||||||
)
|
)
|
||||||
raise ValueError("stale passkey counter")
|
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)
|
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||||
signed, session = await asyncio.to_thread(
|
signed, session = await asyncio.to_thread(
|
||||||
dashboard_auth.issue_session,
|
dashboard_auth.issue_session,
|
||||||
device_label=stored.device_label,
|
device_label=stored.device_label,
|
||||||
management_id=stored.management_id,
|
management_id=stored.management_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
principal_login=principal_login,
|
||||||
)
|
)
|
||||||
except dashboard_auth.SessionStoreError:
|
except dashboard_auth.SessionStoreError:
|
||||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,20 @@ class SessionStatus(str):
|
||||||
"""String-compatible status carrying the server-confirmed idle deadline."""
|
"""String-compatible status carrying the server-confirmed idle deadline."""
|
||||||
|
|
||||||
idle_expires_at: int | None
|
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 = super().__new__(cls, value)
|
||||||
instance.idle_expires_at = idle_expires_at
|
instance.idle_expires_at = idle_expires_at
|
||||||
|
instance.principal_id = principal_id
|
||||||
|
instance.principal_login = principal_login
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,7 +81,9 @@ class SessionStore:
|
||||||
management_id TEXT,
|
management_id TEXT,
|
||||||
device_label TEXT,
|
device_label TEXT,
|
||||||
created_at INTEGER,
|
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",
|
"device_label": "TEXT",
|
||||||
"created_at": "INTEGER",
|
"created_at": "INTEGER",
|
||||||
"last_active_at": "INTEGER",
|
"last_active_at": "INTEGER",
|
||||||
|
"principal_id": "INTEGER",
|
||||||
|
"principal_login": "TEXT",
|
||||||
}
|
}
|
||||||
for name, column_type in additions.items():
|
for name, column_type in additions.items():
|
||||||
if name not in columns:
|
if name not in columns:
|
||||||
|
|
@ -135,6 +149,8 @@ class SessionStore:
|
||||||
*,
|
*,
|
||||||
device_label: str = "This device",
|
device_label: str = "This device",
|
||||||
management_id: str | None = None,
|
management_id: str | None = None,
|
||||||
|
principal_id: int | None = None,
|
||||||
|
principal_login: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||||
now = int(self.clock())
|
now = int(self.clock())
|
||||||
|
|
@ -145,8 +161,9 @@ class SessionStore:
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO active_sessions("
|
"INSERT INTO active_sessions("
|
||||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
"session_hash, expires_at, management_id, device_label, created_at, last_active_at, "
|
||||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
"principal_id, principal_login"
|
||||||
|
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
self._digest(session_id),
|
self._digest(session_id),
|
||||||
expires_at,
|
expires_at,
|
||||||
|
|
@ -154,6 +171,8 @@ class SessionStore:
|
||||||
label,
|
label,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
principal_id,
|
||||||
|
principal_login,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
|
@ -176,7 +195,8 @@ class SessionStore:
|
||||||
) -> str:
|
) -> str:
|
||||||
now = int(self.clock())
|
now = int(self.clock())
|
||||||
query = (
|
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 = ?"
|
"WHERE session_hash = ?"
|
||||||
)
|
)
|
||||||
parameters = (self._digest(session_id),)
|
parameters = (self._digest(session_id),)
|
||||||
|
|
@ -185,7 +205,11 @@ class SessionStore:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(query, parameters).fetchone()
|
row = connection.execute(query, parameters).fetchone()
|
||||||
except sqlite3.OperationalError as exc:
|
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
|
raise
|
||||||
with self._connect(initialize=True) as connection:
|
with self._connect(initialize=True) as connection:
|
||||||
row = connection.execute(query, parameters).fetchone()
|
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))
|
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
||||||
if idle_expires_at <= now:
|
if idle_expires_at <= now:
|
||||||
return SessionStatus("idle", idle_expires_at)
|
return SessionStatus("idle", idle_expires_at)
|
||||||
return SessionStatus("active", idle_expires_at)
|
return SessionStatus("active", idle_expires_at, row[2], row[3])
|
||||||
|
|
||||||
def touch(
|
def touch(
|
||||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import pytest
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
from src.session_store import SessionStoreError
|
from src.session_store import SessionStatus, SessionStoreError
|
||||||
from src.views import FRONTEND_BUILD
|
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_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
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:
|
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 before.status_code == 200
|
||||||
assert after.status_code == 401
|
assert after.status_code == 401
|
||||||
assert after.headers["cache-control"] == "no-store"
|
assert after.headers["cache-control"] == "no-store"
|
||||||
assert calls == 1
|
assert calls == 4
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -1918,7 +1995,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||||
class SlowStore:
|
class SlowStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
time.sleep(0.15)
|
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())
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||||
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
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:
|
class SlowStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
return "active"
|
return SessionStatus(
|
||||||
|
"active", principal_id=42, principal_login="timmy"
|
||||||
|
)
|
||||||
|
|
||||||
def revoke(self, session_id):
|
def revoke(self, session_id):
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
|
|
@ -2126,6 +2207,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
)
|
)
|
||||||
|
called = False
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
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:
|
class BrokenStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
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):
|
def revoke(self, session_id):
|
||||||
raise SessionStoreError("database path and secret details")
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,28 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["replacedAfterDeletion"] is True
|
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():
|
def test_idle_response_locks_without_clearing_private_queued_work():
|
||||||
result = run_session_scenario(
|
result = run_session_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ process.stdout.write(JSON.stringify({{ status: status.textContent }}));
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(result.stdout)["status"] == (
|
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."
|
"on this device. Sign in to resume."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ def security_access(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
|
||||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.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
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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):
|
def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
|
||||||
now = [1_000.0]
|
now = [1_000.0]
|
||||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[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
|
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):
|
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
||||||
database = tmp_path / "sessions.sqlite3"
|
database = tmp_path / "sessions.sqlite3"
|
||||||
digest = SessionStore._digest("existing-session")
|
digest = SessionStore._digest("existing-session")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user