security: fresh-authorize CI job reruns (Closes #1481)
CI / lint (pull_request) Successful in 4m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m40s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-27 20:08:15 +00:00
1 parent 1484c579f9
commit f23be99ab4
4 files changed
+242

No files matched your search

+156
View File
@@ -18,6 +18,10 @@ def test_release_rollback_is_a_supported_step_up_action():
assert "prepare_release_rollback" in get_args(main.StepUpAction)
def test_ci_job_retry_is_a_supported_step_up_action():
assert "retry_ci_job" in get_args(main.StepUpAction)
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -125,6 +129,158 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"]
@pytest.mark.anyio
async def test_ci_job_reruns_require_exact_one_time_authorization_and_audit_both_flows(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
operation_id = f"operation-{len(lifecycle)}"
lifecycle.append(("reserve", kind, target, operation_id))
return operation_id
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def capabilities(repository, number):
return {"authored": True, "assigned": False}
async def retry_pull(repository, number, head_sha, run_id, job_index):
lifecycle.append(("retry-pull", repository, number, head_sha, run_id, job_index))
return {"status": "queued"}
async def release_access(repository, number, commit_sha):
return True
async def retry_release(repository, commit_sha, run_id, job_index):
lifecycle.append(("retry-release", repository, commit_sha, run_id, job_index))
return {"status": "queued"}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry_pull)
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access)
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry_release)
transport = httpx.ASGITransport(app=main.app)
pull_path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
release_path = (
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234"
"/checks/91/jobs/3/retry"
)
pull_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
release_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
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"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post(
pull_path, json={"expected_head_sha": "abc1234"}, headers=headers
)
wrong_grant = await fresh_grant(
client, "retry_ci_job", "stackchain/api#7@different:actions/91/jobs/3"
)
mismatched = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": wrong_grant},
)
pull_grant = await fresh_grant(client, "retry_ci_job", pull_target)
retried_pull = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
replayed = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
release_grant = await fresh_grant(client, "retry_ci_job", release_target)
retried_release = await client.post(
release_path,
headers={**headers, "X-Step-Up-Grant": release_grant},
)
assert missing.status_code == 428
assert missing.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "retry_ci_job",
"target": pull_target,
}
assert mismatched.status_code == 428
assert retried_pull.status_code == 202
assert replayed.status_code == 428
assert retried_release.status_code == 202
assert lifecycle == [
("reserve", "ci_job_retried", pull_target, "operation-0"),
("retry-pull", "stackchain/api", 7, "abc1234", 91, 3),
("finalize", "operation-0"),
("reserve", "ci_job_retried", release_target, "operation-3"),
("retry-release", "stackchain/api", "abc1234", 91, 3),
("finalize", "operation-3"),
]
@pytest.mark.anyio
async def test_ci_job_retry_fails_closed_when_security_activity_is_unavailable(
access_control, monkeypatch
):
retry_calls = []
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
from src.security_event_store import SecurityEventStoreError
raise SecurityEventStoreError("unavailable")
async def retry(*args):
retry_calls.append(args)
return {"status": "queued"}
transport = httpx.ASGITransport(app=main.app)
path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
target = "stackchain/api#7@abc1234:actions/91/jobs/3"
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"}
)
grant = await fresh_grant(client, "retry_ci_job", target)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry)
response = await client.post(
path,
json={"expected_head_sha": "abc1234"},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
assert response.status_code == 503
assert response.json() == {
"error": "Security activity is temporarily unavailable. No job was retried."
}
assert retry_calls == []
@pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch
+6
View File
@@ -18,6 +18,12 @@ def test_release_rollback_has_a_specific_security_activity_label():
assert "release_rollback_prepared: 'Release rollback prepared'" in source
def test_ci_job_retry_has_a_specific_security_activity_label():
source = SECURITY_CENTER.read_text()
assert "ci_job_retried: 'CI job retried'" in source
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});