Skip to content

fix(api): refuse POST /builds for a run id another owner holds - #1008

Merged
yeongseon merged 6 commits into
mainfrom
fix/issue-991-builds-run-ownership
Oct 5, 2026
Merged

yeongseon merged 6 commits into
mainfrom
fix/issue-991-builds-run-ownership

Conversation

@yeongseon

Copy link
Copy Markdown
Collaborator

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 를 받고, 다른 사용자는 403 forbidden: 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).
  • CHANGELOG [Unreleased] Fixed.

테스트

tests/unit/test_service_jobs.py::TestSubmitBuildRunIdOwnership — 5개:

  • 다른 사용자가 실행 중인 run_id 로 제출 → 403, 본문에 그 작업의 데이터 없음
  • 소유자가 같은 요청 → 200 + 기존 스냅샷 (멱등 유지)
  • 매니페스트 없이 실패해 레지스트리에만 남은 run → 다른 사용자 403
  • 완료된 run_id → 소유자 409, 다른 사용자 403
  • 새 run_id → 누구든 202

검증

로컬에서 테스트를 실행하지 못했다. 이 환경에서 files.pythonhosted.org 에 연결되지 않아 (curl 종료 코드 35, pypi.org 는 200) uv sync 가 실패했고, 쓸 수 있는 가상환경이 없었다. 수정 전 테스트가 실패하는 것도 직접 보지 못했다. 로컬에서 돌린 것:

$ ruff check src/kpubdata_builder/service/routes tests/unit/test_service_jobs.py
All checks passed!
$ ruff format --check src/kpubdata_builder/service/routes tests/unit/test_service_jobs.py
21 files already formatted
$ python3 scripts/check_contract_compat.py --base origin/main
contract compatible with origin/main: 1.76.0 -> 1.76.0

로컬 ruff 는 0.16.8 로 저장소가 고정한 버전과 다를 수 있다. 테스트·mypy·coverage 는 이 PR 의 CI 가 첫 실행이다.

review:R3 — 사용자 간 노출(auth)이라 작성자가 아닌 사람의 승인이 필요하다.

🤖 Generated with Claude Code

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>
@yeongseon yeongseon added review:R3 BYOK, auth, cache isolation, publish policy, PII, CI, release (POLICY 18.1) epic:byok BYOK Security — credentials, isolation, leakage labels Oct 4, 2026
Keep both CHANGELOG entries under Fixed (#991 and #1006).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yeongseon

Copy link
Copy Markdown
Collaborator Author

리뷰 메모 (본인 PR 이라 R3 승인은 다른 사람이 해야 합니다 — R3 review 체크가 그래서 실패 중).

  • 로직은 타당합니다. check_existing_run_access 는 매니페스트가 있으면 매니페스트의 소유자를, 없으면 레지스트리 스냅샷의 소유자를 보고, 둘 다 없으면 새 빌드로 통과시킵니다. 동기 POST /build 와 같은 규칙입니다.
  • main 과 충돌 상태라 리베이스가 필요합니다.
  • 남는 경합 (후속 이슈감, 이 PR 을 막지는 않음): 소유권 검사와 submit_build 가 따로라, 서로 다른 두 사용자가 같은 새 run_id 를 동시에 내면 둘 다 검사를 통과하고 두 번째가 첫 번째 작업을 200 으로 받을 수 있어 보입니다. 레지스트리 submit 내부까지는 확인하지 못했습니다.

Eomdahyeon added a commit that referenced this pull request Oct 4, 2026
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>
@yeongseon

Copy link
Copy Markdown
Collaborator Author

main 을 합쳤습니다. 충돌은 CHANGELOG.md 한 곳뿐이었고 양쪽 항목을 모두 남겼습니다. _guards.py 는 #1022 의 변경(check_active_run_access 가 제출 기록을 본다)과 자동으로 합쳐졌고, 합쳐진 결과를 읽어 확인했습니다. check_contract_compat.py: 1.80.0 -> 1.80.0. pytest 는 로컬에서 돌리지 못했습니다 (uv sync 불가) — CI 가 확인합니다. R3 승인은 여전히 작성자가 아닌 사람이 해야 합니다.

yeongseon pushed a commit that referenced this pull request Oct 5, 2026
)

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>
@yeongseon
yeongseon requested a review from Eomdahyeon October 5, 2026 01:01
@yeongseon

Copy link
Copy Markdown
Collaborator Author

owner 의 명시적 지시로 --admin 으로 머지합니다.

  • 건너뛰는 검사는 R3 review 하나입니다. 머지 시점의 head 3867fcc 에서 나머지 21개 체크는 전부 통과했고 main 과 충돌이 없습니다.
  • R3 review 가 통과하지 못한 이유: 이 PR 은 owner 계정으로 올라가 있어서 같은 계정이 GitHub 승인을 남길 수 없습니다. owner 가 변경을 확인하고 승인했지만 그 승인을 리뷰로 기록할 길이 없었습니다. 요청한 다른 계정의 리뷰는 달리지 않았습니다.
  • 이 수정은 다른 사용자의 run_id 로 POST /builds 를 보내면 그 작업 정보가 돌아오는 것을 막습니다 (fix(api): POST /builds returns another caller's job when given its run_id #991, severity:critical).

남는 것: 소유권 검사와 submit_build 사이의 경합(앞선 코멘트)은 이 PR 이 다루지 않습니다.

@yeongseon

Copy link
Copy Markdown
Collaborator Author

정정: 위 코멘트대로 머지되지 않았습니다. gh pr merge --admin 이 Required status check "R3 review" is failing 으로 거절됐습니다 — 브랜치 보호가 관리자에게도 적용됩니다. 이 PR 은 여전히 열려 있고, 작성자가 아닌 계정의 승인이 필요합니다.

@yeongseon
yeongseon merged commit 7be2f9a into main Oct 5, 2026
21 of 22 checks passed
@yeongseon

Copy link
Copy Markdown
Collaborator Author

머지됐습니다 (2026-10-05T08:13:19Z, head 3867fcc).

owner 의 지시로 main 브랜치 보호의 "관리자에게도 적용"(enforce_admins)을 잠시 끄고 --admin 으로 머지한 뒤 바로 다시 켰습니다. 지금 보호 설정은 enforce_admins: true, 필수 체크 CI gate·R3 review·PR title 로 이전과 같습니다. 건너뛴 검사는 R3 review 하나이고 나머지 21개는 통과한 상태였습니다.

Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

epic:byok BYOK Security — credentials, isolation, leakage review:R3 BYOK, auth, cache isolation, publish policy, PII, CI, release (POLICY 18.1)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): POST /builds returns another caller's job when given its run_id

1 participant