Skip to content

Добавить выдачу репозиториев через GitHub OAuth - #48

Closed
nolick6667 wants to merge 3 commits into
mainfrom
feature/public-join-github-oauth
Closed

Добавить выдачу репозиториев через GitHub OAuth#48
nolick6667 wants to merge 3 commits into
mainfrom
feature/public-join-github-oauth

Conversation

@nolick6667

@nolick6667 nolick6667 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Выполнено задание #46

@markpolyak

Copy link
Copy Markdown
Owner

Код-ревью (автоматическое, high effort)

8 поисковых угла × верификация каждого кандидата отдельным агентом. Ниже — 8 подтверждённых находок, от самой серьёзной к менее серьёзной.

1. FORWARDED_ALLOW_IPS — фиктивный контроль безопасности

docker-compose.example.yaml, .env.example

Переменная задокументирована как решение для корректного rate-limiting за reverse proxy, но нигде не используется в коде (grep -rn FORWARDED_ALLOW_IPS --include=*.py — 0 совпадений). Ни ProxyHeadersMiddleware, ни --forwarded-allow-ips не подключены ни в main.py, ни в backend.Dockerfile (CMD не менялся: uvicorn main:app --host 0.0.0.0 --port 8000).

Следствие: Limiter(key_func=get_remote_address) за Caddy будет читать IP самого прокси для всех запросов — все студенты попадут в один и тот же rate-limit bucket на /join/*/start (10/мин) и /join/callback (20/мин). Один активный студент исчерпает лимит для всей группы. ProxyHeadersMiddleware используется только в tests/test_join_rate_limit.py, оборачивая тестовое приложение, а не main.app.

2. lab_id разрешается по-разному в /join и /grade

main.py:340 (get_join_lab_config)

/join ищет лабу по точному строковому ключу YAML, а /grade (parse_lab_id) нормализует через int(...). На реальных конфигах courses/operating-systems-2025.yaml / 2026.yaml есть одновременно лабы с ключами "01" и "1". Тест test_join_info_uses_exact_lab_key подтверждает: /join/{course}/1 → 404, /join/{course}/01 → 200. Но для /grade оба значения lab_id нормализуются в одно и то же число — то есть parse_lab_id("01") и parse_lab_id("1") совпадают, и grade_lab не может отличить ЛР0.1 от ЛР1 в этих конфигах: запрос с lab_id="01" может быть тихо оценён по конфигу другой лабы (другой github-prefix, другие файлы/CI).

3. Race-recovery в provision() не проверяет происхождение репозитория

grading/repository_provisioner.py:300

При 409/422 от generate-from-template API код перепроверяет только repository_exists() (обычный GET, 200/404), но не то, что найденный репозиторий действительно создан из нужного шаблона. Имя репозитория — f"{github_prefix}-{join_key}", а github_prefix — свободный YAML-текст без проверки уникальности между курсами.

Следствие: если два курса/потока в одной GitHub-организации используют одинаковый github-prefix, либо студент заранее вручную создал репозиторий с таким именем, provision() посчитает это "гонкой", пропустит создание и выдаст доступ к чужому/несвязанному репозиторию. Такой сценарий не покрыт тестами.

4. start_github_oauth не перехватывает HTTPException

main.py:591

Вызов get_join_lab_config(course_id, lab_id) в start_github_oauth не обёрнут в try/except, в отличие от /join/callback, который для того же вызова перехватывает HTTPException и делает дружелюбный редирект на страницу ошибки.

Следствие: переход по /join/{course}/{lab}/start с несуществующим/опечатанным course_id/lab_id (например, старая закладка после переименования курса) вернёт голый JSON {"detail": "..."} вместо оформленной страницы ошибки JoinLab. Не покрыто тестами.

5. Дублирующийся GitHub-клиент без timeout и обработки rate-limit в старом коде

grading/github_client.py

PR добавляет новый GitHubRepositoryClient с явным timeout (REQUEST_TIMEOUT = (3.05, 15)) и детекцией rate-limit (X-RateLimit-Remaining, Retry-After, 429/403), но не переносит эти же исправления в существующий GitHubClient, который использует grade_lab.

Следствие: GitHubClient.get_job_logs/get_check_runs/etc. без timeout могут повиснуть на медленном ответе GitHub, а любой не-200 статус (включая 403/429 rate-limit) трактуется как "данных нет" — при исчерпании лимита GitHub grading может молча вернуть неверный результат вместо явной ошибки.

6. Удаление и пересоздание ещё не принятого приглашения

grading/repository_provisioner.py:341 (_ensure_access)

Если найдено pending-приглашение, код безусловно удаляет его и создаёт заново (подтверждено тестом test_pending_invitation_is_deleted_and_sent_again — DELETE+PUT), хотя permission во втором вызове всегда одинаковый (push) — то есть пересоздание не несёт функциональной пользы.

Следствие: двойной клик по "Присоединиться" или повторная загрузка страницы во время обработки первого запроса удалит и заново создаст приглашение — вероятно, повторное письмо от GitHub и инвалидация ссылки из первого письма, если студент уже открыл её в другой вкладке.

7. Join-flow не пишет ничего в Google Sheets

main.py:630

В отличие от register_student и grade_lab, которые читают/пишут в таблицу — систему истины проекта, — весь OAuth join-flow нигде не обращается к Sheets. Это осознанное ограничение объёма фичи (по документации — дополнительный путь, не замена регистрации), но teacher не увидит в таблице, кто присоединился через OAuth, и если финальный редирект после успешного provisioning потеряется (закрытая вкладка, сбой сети), нигде не останется записи об этом.

8. 404 от generate-from-template неоднозначен

grading/repository_provisioner.py:226

Любой 404 от GitHub при создании репозитория из шаблона трактуется как template_unavailable, хотя GitHub возвращает одинаковый generic 404 как при отсутствующем шаблоне, так и при нехватке прав токена на приватный шаблон/организацию — различить эти случаи по ответу API невозможно (ограничение самого GitHub API), но результат может ввести преподавателя в заблуждение при отладке (похоже на опечатку в конфиге, хотя на деле проблема в правах GITHUB_TOKEN).


Проверено и опровергнуто (не включено выше): несовпадение регистра username между join/grade-потоками (обе ветки одинаково не приводят к нижнему регистру), отсутствие catch-all в github_oauth_callback (все исключения перехватываются типизированными классами, подтверждено тестами), глобальные OAuth env vars вместо per-course конфига (OAuth используется только для идентификации через read:user, привилегированные действия уже используют per-course organization + существующий GITHUB_TOKEN), отсутствие обновления списка env vars в CLAUDE.md (нет явного правила, требующего это).

@nolick6667

Copy link
Copy Markdown
Collaborator Author

Исправления по ревью добавлены в коммите d8a0686.

Что проверено и исправлено:

  • независимые OAuth-запуски в нескольких вкладках и одноразовые nonce-cookie;
  • понятные redirect-ошибки для /join/.../start, callback и rate limit;
  • таймауты/классификация ошибок GitHub API без вывода токенов в логи;
  • безопасная обработка гонки создания с проверкой исходного template repository;
  • пагинация коллабораторов и приглашений, обязательный delete + re-invite;
  • безопасная ссылка результата, timeout frontend-запроса и локализация ru/en/zh;
  • доверенные proxy IP и точные CORS origins;
  • обновлены уязвимые frontend-зависимости.

Локально прошли: 282 pytest-теста, 7 frontend-тестов, frontend build, Ruff, Bandit для новых GitHub-модулей, npm audit и pip-audit (0 известных уязвимостей), сборка и HTTP smoke-test обоих Docker-образов. /register, grade_lab, старые модули оценивания и форма регистрации не изменялись.

Для ручной проверки использовался реальный приватный шаблон radjab-labgrader-test/grader-task1-template (is_template=true). В конфиги курсов преподавателя это тестовое значение намеренно не добавлено. Пожалуйста, подтвердите точный production template-repo для курса suai-os-2026; после подтверждения достаточно указать его в нужной лабе в формате owner/repo.

@nolick6667

Copy link
Copy Markdown
Collaborator Author

Здравствуйте, @markpolyak! Спасибо за подробное ревью. Замечания проверены повторно, исправления опубликованы.

Последний коммит: c4411a8.

Результат по каждому пункту ревью:

  1. FORWARDED_ALLOW_IPS

Исправлено. Переменная теперь действительно передаётся Uvicorn через параметры --proxy-headers и --forwarded-allow-ips в backend.Dockerfile.

За reverse proxy Slowapi получает адрес студента, а произвольный X-Forwarded-For от недоверенного клиента не используется. Добавлены тесты для доверенного proxy и попытки обхода ограничения через поддельный заголовок.

  1. Различная обработка lab_id в /join и /grade

В /join сохранён точный поиск строкового ключа лабораторной из YAML, поэтому значения "01" и "1" не смешиваются.

Нормализация lab_id внутри старой функции grade_lab существовала до PR. Она намеренно не изменялась, поскольку изменение /grade, grade_lab и существующей системы оценивания прямо исключено из объёма ТЗ.

Таким образом, OAuth-часть не создаёт нового смешивания лабораторных, но указанная проблема старого /grade остаётся как отдельный технический долг.

  1. Небезопасная обработка гонки создания репозитория

Исправлено. Ответы 409/422 больше не считаются успешной гонкой только по факту появления репозитория.

После гонки дополнительно проверяется поле template_repository.full_name. Доступ выдаётся только тогда, когда появившийся репозиторий действительно создан из ожидаемого шаблона. Чужой репозиторий с таким же именем отклоняется.

Добавлены отдельные тесты успешной гонки и репозитория от другого шаблона.

  1. Необработанный HTTPException в start_github_oauth

Исправлено. Ошибки неизвестного курса, лабораторной и отсутствующего template-repo теперь преобразуются в понятный redirect на страницу JoinLab, а не показываются пользователю как JSON FastAPI.

Добавлены тесты неизвестной лабораторной и ошибки конфигурации.

  1. Старый grading/github_client.py без таймаутов и обработки rate limit

Не изменялся. Этот клиент используется старой логикой grade_lab, изменение которой не входит в ТЗ.

Новые GitHubOAuthClient и GitHubRepositoryClient имеют явные connect/read timeout, обработку сетевых ошибок, 429, 403, Retry-After и исчерпания GitHub rate limit.

Проблема старого клиента остаётся отдельным техническим долгом существующей системы оценивания.

  1. Удаление и повторное создание pending-приглашения

Оставлено намеренно, поскольку DELETE + повторный PUT прямо требуется постановкой задачи. Повторный PUT при уже существующем приглашении не обеспечивает повторную отправку уведомления, поэтому старое приглашение удаляется и создаётся заново.

Дополнительно обработан параллельный callback: если приглашение уже было принято или удалено другим запросом, система повторно проверяет прямой доступ перед созданием нового приглашения.

Добавлены тесты pending-приглашения, параллельного удаления и повторной проверки доступа.

  1. Join-flow не записывает данные в Google Sheets

Не добавлялось, поскольку запись результата OAuth-выдачи в Google Sheets отсутствует в постановке задачи.

Join-flow является дополнительным способом получения репозитория и не заменяет существующие /register и grade_lab. Эти endpoints и используемые ими таблицы намеренно не изменялись.

  1. Неоднозначный 404 при генерации из шаблона

Обработка уточнена. Поскольку GitHub действительно возвращает одинаковый 404 для отсутствующего приватного шаблона и недостаточных прав серверного токена, пользователю возвращается общий код template_unavailable.

В серверной диагностике теперь явно указаны обе возможные причины: шаблон не найден либо у GITHUB_TOKEN недостаточно прав. Сообщение больше не утверждает, что причиной обязательно является опечатка в конфигурации.

Дополнительно исправлено:

  • независимые OAuth-запуски в нескольких вкладках;
  • отдельные одноразовые nonce-cookie для каждого запуска;
  • защита OAuth state от подделки, повторного использования и истечения;
  • понятные redirect-ошибки callback и rate limit;
  • исключение OAuth-токенов и параметров callback из логов;
  • пагинация прямых коллабораторов и приглашений;
  • безопасная проверка ссылки на GitHub-репозиторий;
  • timeout frontend-запроса;
  • локализация всех новых ошибок на ru/en/zh;
  • безопасная работа при недоступном localStorage;
  • точные CORS origins вместо wildcard с cookie;
  • обновление уязвимых frontend-зависимостей;
  • исправление замечаний Ruff в новых тестах.

Проверки после исправлений:

  • 282 backend-теста — успешно;
  • 7 frontend-тестов — успешно;
  • frontend production build — успешно;
  • Ruff нового Python-кода — успешно;
  • Bandit новых GitHub-модулей — успешно;
  • npm audit — 0 известных уязвимостей;
  • pip-audit — 0 известных уязвимостей;
  • оба Docker-образа собраны и проверены HTTP smoke-тестом;
  • все GitHub Actions в PR завершились успешно;
  • реальные токены, .env, credentials.json и OAuth Client Secret в Git-историю не попали;
  • полный ручной сценарий OAuth → создание приватного репозитория → выдача приглашения → повторный вход прошёл успешно.

Ограничения ТЗ соблюдены: /register, grade_lab, старые модули оценивания, форма регистрации и конфигурации курсов не изменялись.

Оставшиеся замечания существующего кода, не относящиеся к ТЗ:

  • неодинаковая нормализация lab_id в старом /grade;
  • отсутствие timeout и полноценной обработки GitHub rate limit в старом grading/github_client.py;
  • отдельный запрос к GitHub без timeout в старом /register;
  • 27 ESLint-замечаний существующего frontend: неиспользуемые переменные и импорты, а также отсутствующие PropTypes в старых компонентах;
  • Bandit продолжает видеть известную строку-заглушку super-secret-key, однако приложение теперь запрещает запуск с отсутствующим или таким значением ключа;
  • Vite предупреждает о размере общего frontend-бандла. Сборка проходит, а разделение всего старого приложения на динамические чанки является отдельной задачей.
    \

markpolyak pushed a commit that referenced this pull request Sep 4, 2026
Переносит фронтенд /join/:courseId/:labId из PR #48
(feature/public-join-github-oauth) поверх бэкенд-контрактов PR #49,
адаптируя контракт: lab_short_name вместо lab_name, HTTP 400 вместо 409
для неготовой лабы, reason= вместо error= и repo_url=/username= вместо
repository= в редиректе после OAuth-колбэка.

- JoinLab/index.jsx, styled.js, state.js, state.test.js - компонент из
  #48 (переключатель языка на странице, полный набор переводов ошибок),
  адаптированный под query-параметры и коды ошибок #49
- language.js/.test.js - хранение выбранного языка интерфейса
- i18n.js - язык интерфейса запоминается между визитами
  (readStoredLanguage/persistLanguage + applyDocumentLanguage)
- api/index.js - fetchJoinLab/getJoinStartUrl вместо fetchJoinLabInfo,
  400 вместо 409 как признак неготовой лабы
- App.jsx - роуты /join/error и /join/:courseId/:labId рендерят JoinLab
  напрямую, joinLabWrapper.jsx удалён (не может обслужить /join/error,
  у которого нет параметров)
- package.json - добавлен скрипт "test": "node --test src" (без
  апгрейда react-router-dom и прочих правок зависимостей из #48)
- locales/{ru,en,zh} - namespace join.* из #48 вместо плоских joinXxx
  ключей #49; добавлены join.errors.createForbidden (CREATE_FORBIDDEN)
  и join.usernameLabel (логин студента в success-панели)
- state.js: ERROR_TRANSLATION_KEYS переписан под коды ошибок #49 (реcursion
  main.py redirect reasons, RepoProvisioner.error_code, fetchJoinLab)
- Кнопка "Назад" на страницу перенесена (стиль ButtonBack из
  course-list/styled, текст "← Назад" не переведён - как и в остальных
  компонентах проекта, где эта кнопка используется)

Точечная правка бэкенда (main.py):
- добавлен _join_error_redirect() - результат для случая, когда
  course_id/lab_id ещё неизвестны (битый/просроченный/отсутствующий state)
- join_callback оборачивает _parse_join_state() в try/except и
  редиректит на /join/error?status=error&reason=invalid_state вместо
  сырого HTTPException(400)
- tests/test_join_endpoints.py: три существующих теста
  (invalid/missing/expired state) переписаны под новое поведение
  (редирект вместо исключения)

Проверено: pytest tests/ (226 passed), npm run lint/test/build.
@markpolyak

Copy link
Copy Markdown
Owner

Код из этого PR использован в #49

@markpolyak markpolyak closed this Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants