fix(api): refuse POST /builds for a run id another owner holds - #1008
Conversation
POST /builds had only the 409 for a completed run. A job still in the registry - queued, running, or failed before writing a manifest - was returned with 200 to whoever named its run id, with created_by, the build's response body and its error. Apply check_existing_run_access, the rule POST /build has had since #635. Closes #991 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
리뷰 메모 (본인 PR 이라 R3 승인은 다른 사람이 해야 합니다 —
|
Closes #996 ## 문제 재시작으로 중단된 run 은 `mark_interrupted_runs` 가 이벤트 저장소에만 `run_failed`(`credentials_required`)를 남긴다. 재시작 뒤에는 레지스트리가 비어 있고 매니페스트도 없어서 `GET /builds/{run_id}` 와 `GET /builds/{run_id}/events` 가 모두 404 다. 빌드를 폴링하던 클라이언트는 "다시 제출하라" 대신 "그런 run 은 없다"를 받는다. ## 변경 내용 - **제출자를 남긴다.** 이벤트 저장소(`_build_events.sqlite`)에 `run_submissions(run_id, owner_id, created_by, submitted_at)` 테이블을 추가하고, 비동기 제출 시 `run_submitted` 이벤트와 함께 기록한다. 소유자는 지금까지 메모리의 레지스트리와 (끝난 뒤의) 매니페스트에만 있었다. 테이블은 `CREATE TABLE IF NOT EXISTS` 로 추가되고 기존 행·스키마 버전은 건드리지 않는다. - **상태 조회.** `build_status`: 레지스트리 → 매니페스트 → **이벤트 저장소의 종료 이벤트**. 중단된 run 은 `status: failed`, `error`(이벤트 메시지), `code: credentials_required`, `created_at`(제출 시각), `updated_at`(실패 시각)으로 답한다. 정상 종료(`run_finished`)는 여기서 답하지 않는다 — 그 경우의 근거는 매니페스트다. - **권한.** `check_active_run_access`: 매니페스트 → 레지스트리 → **제출 기록**으로 소유권을 판정한다. 다른 사용자는 없는 run 과 같은 답(멀티유저에서 404)을 받는다. - 계약 1.77.0 → **1.78.0** (additive): `BuildJob.code`(optional, `credentials_required`), `BuildJob` 설명. CHANGELOG `[Unreleased]` Fixed. ## 한계 - 이 버전 이전에 제출된 run 은 제출 기록이 없어서 재시작 뒤 여전히 404 다. 기존 run 을 소급해 채울 근거(소유자)가 디스크에 없다. - `mark_interrupted_runs` 가 실패 이벤트를 남기는 조건은 바꾸지 않았다. ## #1008 과의 관계 #1008 은 `routes/_guards.py` 의 `check_existing_run_access` docstring 과 `routes/builds.py` 를 고친다. 이 PR 은 같은 파일의 `check_active_run_access` 를 고치고 `routes/builds.py` 는 건드리지 않는다. 겹치는 것은 계약 버전 줄과 CHANGELOG 뿐이다 — 나중에 머지되는 쪽이 버전을 하나 올리면 된다. ## 검증 - `tests/unit/test_interrupted_run_status.py`: 소유자는 `failed` + `code` 를 받는다 / 소유자는 `run_failed` 이벤트를 본다 / 다른 사용자는 두 경로 모두 없는 run 과 같은 404 이고 본문에 사유가 없다 / 제출된 적 없는 run 은 404 / 제출 기록은 한 번만 쓰인다. - 전체 스위트 1회: `1 failed, 4429 passed` — 실패한 하나는 이 PR 의 새 테스트였다. 첫 번째 프로세스의 worker 를 테스트 도중에 풀어 줘서 그 worker 가 이벤트를 더 쓰는 경합이었고(실제 재시작에서는 그 프로세스가 죽는다), worker 를 테스트가 끝날 때까지 막아 두도록 고쳤다. 고친 뒤에는 이 파일과 `test_ephemeral_credentials.py` 를 3회 반복 실행해 모두 통과(`19 passed` ×3)했고, **전체 스위트를 다시 돌리지는 않았다.** - `check_contract_compat.py`: `1.77.0 -> 1.78.0` 호환. ruff, mypy 통과. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
main 을 합쳤습니다. 충돌은 |
) Refs #1025 ## 문제 #1022 리뷰에서 짚은 경로다. `check_existing_run_access` 는 매니페스트와 작업 레지스트리만 본다. 재시작으로 중단된 run 은 둘 다 없어서 그 `run_id` 가 "새 run" 으로 통과하는데, 이벤트 저장소에는 제출자가 남아 있고(#996) 그 사람은 여전히 그 run 이 왜 끝났는지 읽을 수 있다. 다른 사용자가 그 id 로 빌드하면 자기 작업이 도는 동안 첫 제출자의 이벤트를 읽고, 다시 중단되면 자기 실패를 첫 제출자에게 남긴다. ## 변경 - `routes/_guards.py`: `check_existing_run_access` 가 매니페스트 → 레지스트리 다음에 제출 기록을 본다. `check_active_run_access` 와 같은 소유 규칙(`ownership_allows`)이다. - 다른 소유자의 기록이 있으면 403 `forbidden: not run owner` (이 함수의 기존 거절과 같은 본문) - 제출자 본인은 통과한다 — 중단된 run 의 메시지가 안내하는 재제출이다 - 기록이 없는 id 는 지금처럼 새 빌드다 - CHANGELOG `[Unreleased]` Fixed. 계약은 바꾸지 않았다(403 은 이미 선언된 응답이고 본문도 같다). ## #1008 과의 관계 지금 `main` 에서 이 함수를 부르는 것은 동기 `POST /build` 뿐이다(`routes/core.py`). 이슈가 적은 비동기 `POST /builds` 경로는 #1008 이 같은 함수를 연결해야 닫힌다. 그래서 `Closes` 가 아니라 `Refs` 로 두었다 — #1008 머지 뒤 `POST /builds` 로 같은 시나리오를 고정하는 테스트를 더하고 이슈를 닫겠다. #1008 은 `routes/builds.py` 와 이 파일의 주석 두 곳을 바꾸고 이 PR 은 함수 본문을 바꾸므로, 어느 쪽이 먼저 들어가도 충돌은 작을 것으로 본다(직접 합쳐 보지는 않았다). ## 테스트 `tests/unit/test_interrupted_run_status.py` 에 3개: - 다른 사용자가 중단된 run id 로 `POST /build` → 403, 제출 기록의 소유자는 그대로 - 제출자 본인은 가드를 통과 - 아무도 제출하지 않은 id 는 누구에게나 통과 ## 검증 - 수정 전 가드로 돌리면 새 테스트가 실패한다: `FAILED …::test_another_user_cannot_build_under_the_interrupted_run_id`, `1 failed, 8 passed`. - 수정 후 `pytest tests/unit/test_interrupted_run_status.py tests/unit/test_json_array_reader.py tests/unit/test_service.py tests/unit/test_service_jobs.py` → `235 passed`. - `ruff check src tests`, `ruff format --check src tests`, `mypy src` 통과. - 전체 스위트는 로컬에서 돌리지 않았다 — CI 에 맡긴다. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
owner 의 명시적 지시로
남는 것: 소유권 검사와 |
|
정정: 위 코멘트대로 머지되지 않았습니다. |
|
머지됐습니다 (2026-10-05T08:13:19Z, head owner 의 지시로 |
Closes #1025 #1027 이 `check_existing_run_access` 가 제출 기록을 보게 했고, #1008 이 비동기 `POST /builds` 를 그 가드로 연결했다. 이슈가 적은 경로(다른 사용자가 중단된 run 의 id 로 **비동기** 제출)는 그 둘이 합쳐져야 닫히므로, #1027 은 `Refs` 로 두고 이 테스트를 #1008 뒤로 미뤘었다. 코드 변경은 없다. ## 테스트 (`tests/unit/test_interrupted_run_status.py`) - 다른 사용자가 중단된 run id 로 `POST /builds` → 403 `forbidden: not run owner`. 레지스트리에 작업이 생기지 않고, 그 사용자는 여전히 그 run 의 이벤트를 404 로 받으며, 원래 제출자가 읽는 이벤트에 두 번째 `run_submitted` 가 없고 제출 기록의 소유자가 그대로다. - 제출자 본인은 같은 id 로 다시 제출할 수 있다 (202) — 중단된 run 의 메시지가 안내하는 동작. ## 검증 - 가드의 제출 기록 확인을 임시로 꺼 두고 돌리면 새 테스트가 실패한다: `FAILED …::test_another_user_cannot_submit_under_the_interrupted_run_id` (동기 경로 테스트와 함께 `2 failed, 9 passed`). 되돌린 뒤 통과. - `pytest tests/unit/test_interrupted_run_status.py tests/unit/test_json_array_reader.py` 를 5회 → 5회 모두 `28 passed`. (이 fixture 가 한 번 메모리 측정 테스트를 건드린 적이 있어 함께 돌렸다.) - 처음 쓴 단언 하나는 버렸다: 제출 전후의 이벤트 목록 전체를 비교했더니 3회 모두 실패했다. 테스트의 "첫 프로세스" 워커가 gate 에 닿기 전까지 자기 이벤트를 계속 쓰기 때문이다 — 실제 재시작에서는 죽었을 스레드다. 그래서 `run_submitted` 개수와 소유자만 본다. - `ruff check tests`, `ruff format` 통과. 전체 스위트는 CI 에 맡긴다. CHANGELOG 의 #1025 항목에서 "비동기 경로는 #1008 뒤에" 라는 문장을 지금 사실로 고쳤다. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #991
문제
비동기
POST /builds에는 완료된 run 에 대한 409 만 있었다. 레지스트리에만 있는 작업 — 대기 중, 실행 중, 매니페스트를 남기지 못한 실패 — 은 run_id 를 댄 누구에게나 200 으로 돌아갔고, 그 스냅샷에는created_by, 빌드 응답 본문, 에러 문자열이 들어 있다. 조회 경로(GET /builds/{run_id})는check_active_run_access로 막혀 있으므로 재제출이 그 게이트의 우회로였다.변경 내용
routes/builds.py:run_id가 주어지면check_existing_run_access를 먼저 부른다. 동기POST /build가 fix: before any multi-user deployment: synchronous build skips the run_id ownership check and uses a global publish credential #635 부터 쓰는 규칙 그대로다 — 소유자는 지금처럼 기존 작업(200) 또는 409 를 받고, 다른 사용자는 403forbidden: not run owner. 없는 run_id 는 새 빌드다.routes/core.py,routes/_guards.py: "비동기 경로에는 게이트가 있다 / 409 로 막는다"는 틀린 주석 두 곳을 고쳤다.contract/builder-api.yaml:submitBuild의 403 설명에 이 경우를 적었다. 403 은 이미 선언된 상태 코드이고 설명만 바뀌므로 계약 버전은 그대로다 (check_contract_compat.py:1.76.0 -> 1.76.0).[Unreleased]Fixed.테스트
tests/unit/test_service_jobs.py::TestSubmitBuildRunIdOwnership— 5개:검증
로컬에서 테스트를 실행하지 못했다. 이 환경에서
files.pythonhosted.org에 연결되지 않아 (curl종료 코드 35,pypi.org는 200)uv sync가 실패했고, 쓸 수 있는 가상환경이 없었다. 수정 전 테스트가 실패하는 것도 직접 보지 못했다. 로컬에서 돌린 것:로컬 ruff 는 0.16.8 로 저장소가 고정한 버전과 다를 수 있다. 테스트·mypy·coverage 는 이 PR 의 CI 가 첫 실행이다.
review:R3— 사용자 간 노출(auth)이라 작성자가 아닌 사람의 승인이 필요하다.🤖 Generated with Claude Code