Require fresh authorization and audit recap time logging #590
|
|
@ -166,6 +166,7 @@
|
||||||
pull_merged: 'Pull request merged',
|
pull_merged: 'Pull request merged',
|
||||||
pull_review_approved: 'Pull request approved',
|
pull_review_approved: 'Pull request approved',
|
||||||
pull_review_changes_requested: 'Changes requested',
|
pull_review_changes_requested: 'Changes requested',
|
||||||
|
gitea_time_logged: 'Gitea time logged',
|
||||||
};
|
};
|
||||||
page.events.forEach(event => {
|
page.events.forEach(event => {
|
||||||
const row = root.document.createElement('article');
|
const row = root.document.createElement('article');
|
||||||
|
|
|
||||||
52
src/main.py
52
src/main.py
|
|
@ -279,6 +279,7 @@ StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
"close_issue",
|
"close_issue",
|
||||||
|
"log_recap_time",
|
||||||
"revoke_device",
|
"revoke_device",
|
||||||
"revoke_all_sessions",
|
"revoke_all_sessions",
|
||||||
"enroll_passkey",
|
"enroll_passkey",
|
||||||
|
|
@ -430,6 +431,19 @@ class TodayRecapTimeLog(TodayRecap):
|
||||||
log_identities: list[str] = Field(min_length=1, max_length=20)
|
log_identities: list[str] = Field(min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
def _recap_time_log_target(payload: TodayRecapTimeLog) -> str:
|
||||||
|
selected_minutes = {
|
||||||
|
item.identity: item.actual_minutes
|
||||||
|
for item in payload.items
|
||||||
|
if item.identity in payload.log_identities
|
||||||
|
}
|
||||||
|
material = "\0".join(
|
||||||
|
[payload.session_id]
|
||||||
|
+ [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)]
|
||||||
|
)
|
||||||
|
return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}"
|
||||||
|
|
||||||
|
|
||||||
class LaterOperation(BaseModel):
|
class LaterOperation(BaseModel):
|
||||||
operation_id: str = Field(min_length=1, max_length=100)
|
operation_id: str = Field(min_length=1, max_length=100)
|
||||||
action: Literal["defer", "restore"]
|
action: Literal["defer", "restore"]
|
||||||
|
|
@ -1921,7 +1935,14 @@ async def save_today_recap(payload: TodayRecap, response: Response):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/today/recaps/log-time")
|
@app.post("/api/v1/today/recaps/log-time")
|
||||||
async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Response):
|
async def save_today_recap_and_log_time(
|
||||||
|
payload: TodayRecapTimeLog,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
step_up_grant: str | None = Header(
|
||||||
|
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||||
|
),
|
||||||
|
):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
if len(set(payload.log_identities)) != len(payload.log_identities):
|
if len(set(payload.log_identities)) != len(payload.log_identities):
|
||||||
raise HTTPException(status_code=422, detail="time log targets must be unique")
|
raise HTTPException(status_code=422, detail="time log targets must be unique")
|
||||||
|
|
@ -1936,6 +1957,12 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
|
||||||
if requested.actual_minutes <= 0:
|
if requested.actual_minutes <= 0:
|
||||||
raise ValueError("only non-zero recap time can be logged")
|
raise ValueError("only non-zero recap time can be logged")
|
||||||
selected.append(requested)
|
selected.append(requested)
|
||||||
|
await _require_step_up(
|
||||||
|
request,
|
||||||
|
step_up_grant,
|
||||||
|
action="log_recap_time",
|
||||||
|
target=_recap_time_log_target(payload),
|
||||||
|
)
|
||||||
recap = await asyncio.to_thread(
|
recap = await asyncio.to_thread(
|
||||||
_today_store().save_recap,
|
_today_store().save_recap,
|
||||||
login,
|
login,
|
||||||
|
|
@ -1959,6 +1986,21 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
|
||||||
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
|
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
|
||||||
)
|
)
|
||||||
if state == "claimed":
|
if state == "claimed":
|
||||||
|
repository, number = gitea_proxy.issue_time_target(item.identity)
|
||||||
|
journal = _security_event_store()
|
||||||
|
try:
|
||||||
|
operation_id = await asyncio.to_thread(
|
||||||
|
journal.reserve,
|
||||||
|
"gitea_time_logged",
|
||||||
|
target=f"{repository}#{number}",
|
||||||
|
)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_today_store().finish_time_log,
|
||||||
|
login, payload.session_id, item.identity, succeeded=False,
|
||||||
|
)
|
||||||
|
results.append({"identity": item.identity, "status": "retry"})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
|
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|
@ -1967,6 +2009,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
|
||||||
_today_store().finish_time_log,
|
_today_store().finish_time_log,
|
||||||
login, payload.session_id, item.identity, succeeded=False,
|
login, payload.session_id, item.identity, succeeded=False,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
status = "retry"
|
status = "retry"
|
||||||
else:
|
else:
|
||||||
status = "verify"
|
status = "verify"
|
||||||
|
|
@ -1976,6 +2022,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
|
||||||
_today_store().finish_time_log,
|
_today_store().finish_time_log,
|
||||||
login, payload.session_id, item.identity, succeeded=True,
|
login, payload.session_id, item.identity, succeeded=True,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
state = "logged"
|
state = "logged"
|
||||||
results.append({
|
results.append({
|
||||||
"identity": item.identity,
|
"identity": item.identity,
|
||||||
|
|
|
||||||
|
|
@ -815,3 +815,9 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||||
assert 'id="security-activity-list"' in html
|
assert 'id="security-activity-list"' in html
|
||||||
assert '>Load older activity</button>' in html
|
assert '>Load older activity</button>' in html
|
||||||
assert '#sign-out-all { min-height:44px; }' in html
|
assert '#sign-out-all { min-height:44px; }' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||||
|
source = SESSION_JS.read_text()
|
||||||
|
|
||||||
|
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,24 @@ ROOT = Path(__file__).parents[1]
|
||||||
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
||||||
|
|
||||||
|
|
||||||
|
async def recap_time_grant(client, headers, payload):
|
||||||
|
challenge = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time", json=payload, headers=headers
|
||||||
|
)
|
||||||
|
assert challenge.status_code == 428
|
||||||
|
authorization = await client.post(
|
||||||
|
"/api/v1/fresh-authorization",
|
||||||
|
json={
|
||||||
|
"access_token": "correct horse battery staple",
|
||||||
|
"action": "log_recap_time",
|
||||||
|
"target": challenge.json()["detail"]["target"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert authorization.status_code == 201
|
||||||
|
return authorization.json()["grant"]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
|
def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
|
||||||
html = (ROOT / "frontend" / "index.html").read_text()
|
html = (ROOT / "frontend" / "index.html").read_text()
|
||||||
source = TODAY_RECAP.read_text()
|
source = TODAY_RECAP.read_text()
|
||||||
|
|
@ -99,6 +117,148 @@ async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped
|
||||||
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
async def log_time(identity, seconds):
|
||||||
|
calls.append((identity, seconds))
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||||
|
payload = {
|
||||||
|
"session_id": "authorization-session",
|
||||||
|
"items": [{
|
||||||
|
"identity": "issue:stackchain/dashboard:589:",
|
||||||
|
"estimate_minutes": 30,
|
||||||
|
"actual_minutes": 42,
|
||||||
|
}],
|
||||||
|
"log_identities": ["issue:stackchain/dashboard:589:"],
|
||||||
|
}
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||||
|
history_before = await client.get("/api/v1/today/recaps")
|
||||||
|
target = missing.json()["detail"]["target"]
|
||||||
|
authorization = await client.post(
|
||||||
|
"/api/v1/fresh-authorization",
|
||||||
|
json={
|
||||||
|
"access_token": "correct horse battery staple",
|
||||||
|
"action": "log_recap_time",
|
||||||
|
"target": target,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
grant = authorization.json()["grant"]
|
||||||
|
changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]}
|
||||||
|
mismatched = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=changed,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant},
|
||||||
|
)
|
||||||
|
accepted = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant},
|
||||||
|
)
|
||||||
|
replay = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert missing.status_code == mismatched.status_code == replay.status_code == 428
|
||||||
|
assert missing.json()["detail"]["action"] == "log_recap_time"
|
||||||
|
assert target.startswith("today-recap:") and len(target) == 44
|
||||||
|
assert history_before.json()["recaps"] == []
|
||||||
|
assert accepted.status_code == 200
|
||||||
|
assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||||
|
security_db = tmp_path / "security.sqlite3"
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db))
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
async def log_time(identity, _seconds):
|
||||||
|
if ":590:" in identity:
|
||||||
|
raise httpx.ReadTimeout("response was lost after sending")
|
||||||
|
if ":591:" in identity:
|
||||||
|
raise httpx.ConnectError("request was not sent")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||||
|
payload = {
|
||||||
|
"session_id": "private-recap-session",
|
||||||
|
"items": [
|
||||||
|
{"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes}
|
||||||
|
for number, minutes in [(589, 11), (590, 12), (591, 13)]
|
||||||
|
],
|
||||||
|
"log_identities": [
|
||||||
|
f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||||
|
authorization = await client.post(
|
||||||
|
"/api/v1/fresh-authorization",
|
||||||
|
json={
|
||||||
|
"access_token": "correct horse battery staple",
|
||||||
|
"action": "log_recap_time",
|
||||||
|
"target": challenge.json()["detail"]["target"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]},
|
||||||
|
)
|
||||||
|
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||||
|
|
||||||
|
assert response.json()["time_logs"] == [
|
||||||
|
{"identity": "issue:stackchain/dashboard:589:", "status": "logged"},
|
||||||
|
{"identity": "issue:stackchain/dashboard:590:", "status": "verify"},
|
||||||
|
{"identity": "issue:stackchain/dashboard:591:", "status": "retry"},
|
||||||
|
]
|
||||||
|
time_events = [event for event in events if event["kind"] == "gitea_time_logged"]
|
||||||
|
assert [(event["target"], event["status"]) for event in time_events] == [
|
||||||
|
("stackchain/dashboard#590", "pending"),
|
||||||
|
("stackchain/dashboard#589", "completed"),
|
||||||
|
]
|
||||||
|
persisted = security_db.read_bytes()
|
||||||
|
assert b"private-recap-session" not in persisted
|
||||||
|
assert challenge.json()["detail"]["target"].encode() not in persisted
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
|
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
|
@ -129,8 +289,18 @@ async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypa
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
created = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
first_grant = await recap_time_grant(client, headers, payload)
|
||||||
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
created = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": first_grant},
|
||||||
|
)
|
||||||
|
replay_grant = await recap_time_grant(client, headers, payload)
|
||||||
|
replay = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": replay_grant},
|
||||||
|
)
|
||||||
|
|
||||||
assert created.status_code == replay.status_code == 200
|
assert created.status_code == replay.status_code == 200
|
||||||
assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
|
assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
|
||||||
|
|
@ -172,8 +342,18 @@ async def test_ambiguous_time_log_failure_is_not_reposted_automatically(monkeypa
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
first = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
first_grant = await recap_time_grant(client, headers, payload)
|
||||||
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
first = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": first_grant},
|
||||||
|
)
|
||||||
|
replay_grant = await recap_time_grant(client, headers, payload)
|
||||||
|
replay = await client.post(
|
||||||
|
"/api/v1/today/recaps/log-time",
|
||||||
|
json=payload,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": replay_grant},
|
||||||
|
)
|
||||||
|
|
||||||
assert calls == 1
|
assert calls == 1
|
||||||
assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
|
assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user