Add scoped personal API keys, OpenAPI documentation and security regressions

This commit is contained in:
omar
2026-09-26 01:02:02 +03:00
parent 9bb6ed29ac
commit 87a501f507
12 changed files with 5319 additions and 18 deletions
+3
View File
@@ -2,6 +2,9 @@
Go API, worker and watchdog, Windows PowerShell runner, PostgreSQL schema, RFB/H.264 recorder and reusable trusted operator tools for the Otche Windows/Defender observation service. Results are observations, not a certificate that an uploaded file is safe.
Automation: [API keys, curl quickstart and API contract](docs/API.md); [OpenAPI 3.1 source](internal/api/openapi.json). The running API serves the public specification at `/api/v1/openapi.json`; the sibling frontend renders its read-only reference at `/docs`. API keys are owner-scoped, never administrator credentials.
## Repository ownership
This is the canonical backend source. API, worker and watchdog are **separate processes/containers built from one Go module and one command**; their shared internal packages are not duplicated into services. The unsigned reviewed `windows/` source is owned here; operators sign their own deployment copy. Frontend source belongs only to [otche-frontend](https://git.qomar.pw/otche/otche-frontend). Compose and portable installation instructions belong to [otche-deploy](https://git.qomar.pw/otche/otche-deploy). Clone the three repositories as siblings; there is no parent monorepo or submodule requirement.
+113 -3
View File
@@ -1,9 +1,113 @@
# Отче HTTP API contract
# Отче HTTP API: автоматизация и контракт
Base `/api/v1`; JSON snake_case; RFC3339 UTC dates; UUID IDs; errors `{ "error": { "code": "invalid_request", "message": "..." } }`. Lists `{ "items": [...] }`. Same origin only; browser fetch `credentials: include`. Session cookie HttpOnly SameSite=Strict Secure (local development flag permits HTTP). `GET /auth/session` returns `{user:{id,username,role},csrf_token}` or 401; `POST /auth/login` `{username,password}` returns same shape and cookie. All authenticated writes require `X-CSRF-Token` plus matching Origin. `POST /auth/logout` returns 204. Roles `admin|operator`. Admin reads all jobs; operators only own jobs. No browser PVE secrets.
Базовый путь — `/api/v1`. [Машиночитаемая спецификация OpenAPI 3.1](../internal/api/openapi.json) встроена в API и публично доступна по `GET /api/v1/openapi.json`; интерфейс справки — `/docs`. JSON использует snake_case, даты RFC3339, идентификаторы UUID. Ошибка: `{ "error": { "code": "invalid_request", "message": "..." } }`. Исключения: файловые HTTP Range/conditional errors от `ServeContent` могут иметь пустое или text/plain тело, а не JSON.
Для автоматизации передавайте `Authorization: Bearer $OTCHE_API_KEY`. Ключ даёт доступ **только к данным своего владельца**, даже если выпущен администратором. Наличие Authorization имеет приоритет над cookie; неверный/истёкший/отозванный ключ никогда не переключает запрос на браузерную сессию. Не передавайте ключ в query string, имя файла, ссылку или журнал. Используйте HTTPS с проверкой сертификата (при частном CA настройте trust store/`--cacert`, не `-k`). CORS не включён. Примеры используют переменные окружения без настоящего адреса и секрета; не включайте shell tracing и не публикуйте вывод с токеном.
Браузерный альтернативный доступ — same-origin cookie `otche_session` (`HttpOnly; SameSite=Strict; Secure`, Path `/api`, срок 12 часов; HTTP допускается только явным development-флагом). `GET /auth/session` возвращает `{user:{id,username,role},csrf_token}` или 401. `POST /auth/login` с `{username,password}` требует точный Origin, создаёт cookie и возвращает тот же объект; Bearer для входа запрещён. **Все cookie-мутации требуют Origin и X-CSRF-Token**, кроме входа, когда CSRF ещё нет. `POST /auth/logout` возвращает 204, завершает только текущую сессию, не отзывает ключи. У cookie-admin сохраняется административный обзор; у API-ключа его нет. Публичны health и OpenAPI.
Malformed `{id}` path parameters return HTTP 404 `not_found` before database lookup.
## Выпуск, права и отзыв ключа
Создайте ключ в разделе «API-ключи» из обычной cookie-сессии. Выдавайте только нужные права: каждое независимо, запись **не** включает чтение.
| Право | Маршруты относительно `/api/v1` |
| --- | --- |
| `profiles:read` | `GET /profiles` |
| `jobs:read` | `GET /dashboard`, `/jobs`, `/jobs/{id}`, `/jobs/{id}/events` |
| `jobs:write` | `POST /jobs`, `/jobs/{id}/cancel`, `/jobs/{id}/retry` |
| `uploads:write` | `POST /uploads` |
| `uploads:read` | `GET /uploads/{id}/content` |
| `artifacts:read` | `GET /jobs/{id}/artifacts`, `/artifacts/{id}/content`, `/attempts/{id}/video` |
Управление ключами **только по cookie-сессии**, не ключом:
- `GET /api-keys` → `{items:[metadata]}`, включая истёкшие/отозванные.
- `POST /api-keys` с `{name,scopes,expires_in_days}` → 201 `{key:metadata,token}`. Имя после trim — 1–80 символов без управляющих, scopes — непустой список уникальных значений из таблицы, срок — обязательное целое 1–365 дней (UI предлагает 90). Максимум 20 неистёкших неотозванных ключей на пользователя.
- `DELETE /api-keys/{id}` → 204; повтор для своего отозванного ключа идемпотентен, неизвестный/чужой id — 404.
- `metadata`: `id,name,prefix,scopes,created_at,expires_at,last_used_at,revoked_at`; последние два времени nullable. `last_used_at` обновляется с точностью до минуты после успешной аутентификации; операция после неё может завершиться 403/404.
Токен `otche_` + 64 lowercase hex показывается **единственный раз**. В БД только SHA-256 токена, prefix — первые 18 символов. Сохраните токен в секрет-хранилище; потерянный токен не восстанавливается, выпустите замену. При ротации сначала проверьте новый ключ, затем отзовите старый. Отзыв закрывает новые запросы, но не отменяет уже принятые задания и in-flight запросы. Сброс пароля администратором или отключение аккаунта навсегда отзывает все его ключи; повторное включение их не оживляет.
## Быстрый старт: curl + jq (POSIX shell)
Задайте `OTCHE_ORIGIN` (HTTPS origin без завершающего `/`), `OTCHE_API_KEY` и `SAMPLE_PATH` безопасным способом вне истории команд. Для этого примера нужны `profiles:read`, `uploads:write`, `jobs:write`, `jobs:read`, `artifacts:read`; `uploads:read` нужен только для скачивания оригинала. `curl --fail-with-body` требует современный curl; `jq` разбирает JSON. Не сохраняйте ключ в примерах/репозитории.
```sh
: "${OTCHE_ORIGIN:?}" "${OTCHE_API_KEY:?}" "${SAMPLE_PATH:?}"
API="${OTCHE_ORIGIN%/}/api/v1"
# Выберите явно один из допущенных профилей, а не случайный первый.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/profiles" |
jq '.items[] | select(.enabled and .state == "published" and .qualification == "qualified" and .current_revision_id != null) | {id,name,online_available}'
# Установите PROFILE_ID в UUID выбранного профиля.
: "${PROFILE_ID:?}"
# X-Filename — percent-encoded basename; body — исходные байты, НЕ multipart.
FILENAME_HEADER=$(printf '%s' "${SAMPLE_PATH##*/}" | jq -sRr @uri)
UPLOAD=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" \
-H 'Content-Type: application/octet-stream' -H "X-Filename: $FILENAME_HEADER" \
--data-binary "@$SAMPLE_PATH" "$API/uploads") || exit 1
UPLOAD_ID=$(printf '%s' "$UPLOAD" | jq -er '.id') || exit 1
# Полные валидные offline-настройки; DLL+rundll32 требует явного export.
REQUEST=$(jq -n --arg upload "$UPLOAD_ID" --arg profile "$PROFILE_ID" '{
upload_id:$upload, profile_ids:[$profile], settings:{
internet:"offline", allowed_cidrs:[], duration_seconds:60,
filename:"original", privilege:"user", args_mode:"none", args:[],
set_zoneid:true, dll_mode:"regsvr32", dll_export:"",
architecture:"auto", wsh_host:"cscript", msi_ui:"full", grub_paths:[]
}
}')
JOB=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" -H 'Content-Type: application/json' \
--data-binary "$REQUEST" "$API/jobs") || exit 1
JOB_ID=$(printf '%s' "$JOB" | jq -er '.id') || exit 1
# Polling; это не SSE. queued/running ещё не terminal.
while :; do
JOB=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID") || exit 1
STATUS=$(printf '%s' "$JOB" | jq -er '.status') || exit 1
case "$STATUS" in completed|cancelled|failed) break ;; esac
sleep 3
done
printf '%s' "$JOB" | jq '.runs[] | {profile_name,attempts:[.attempts[] | {id,outcome,findings,telemetry,cleanup,error,report}]}'
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID/artifacts" |
jq '.items[] | {id,kind,filename,size,sha256}'
# Установите ARTIFACT_ID в UUID нужного артефакта.
: "${ARTIFACT_ID:?}"
# Сохранение под собственным именем, без исполнения и без доверия имени артефакта.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" \
--output ./artifact.download "$API/artifacts/$ARTIFACT_ID/content"
```
Исходник и артефакты могут быть вредоносными: не открывайте их на доверенном хосте. Размер загрузки и общая квота устанавливаются оператором; очередь/доказательства изоляции/готовность worker проверяются одинаково для UI и ключа. Принятое задание не гарантирует запуск образца: читайте `outcome`, `findings`, `telemetry`, `cleanup` и nullable отчёт, а не только `status=completed`. `not_observed` — «не наблюдалось в этом прогоне», **не** «файл безопасен».
### Ошибки, повтор и пагинация
- 401 `unauthenticated`: отсутствующий, неверный, истёкший или отозванный ключ/сессия; 401 `invalid_credentials` относится к login.
- 403 `insufficient_scope`: ключу не хватает права; `session_required`: выбран cookie-only маршрут; для cookie возможны `origin_rejected`, `csrf_rejected`.
- 400 `invalid_request`/`invalid_settings`: исправьте тело; `invalid_filename`/`empty_upload` — загрузку. 409 `key_limit` — лимит активных ключей.
- 409 `admission_closed`/`profile_unavailable`/`revision_unavailable`: реальные предпосылки выполнения не соблюдены; 409 `job_active` запрещает retry активного задания или unfinished Attempt. Смена/ослабление ключа не обходит эти проверки.
- 413 `upload_too_large`/`upload_failed`: размер или незавершённая загрузка; 429 `quota_exceeded`: квота хранилища, не новый лимитер API. Только login имеет account-based 429 `rate_limited` с `Retry-After: 900`.
- Нет idempotency-key протокола. **Не повторяйте POST автоматически** после таймаута/разрыва: операция могла выполниться. Повтор upload/create/retry может создавать новые ресурсы/попытки; сначала сверяйте доступное состояние. GET можно повторять с разумной задержкой. Не считайте каждый ответ ошибкой JSON (скачивание имеет стандартные HTTP exceptions).
- `/jobs`: по умолчанию page=1, page_size=25; размер максимум 100 (больший обрезается), page>100000 — 400. Ответ содержит `total,page,page_size,next_page`; items — краткие Job без queue_ahead/attempts. Поиск q ≤128 UTF-8 байт; имя ILIKE либо точный SHA-256, status — точное совпадение.
- `/jobs/{id}/events?after=N`: id ASC, максимум 500. Передавайте последний полученный id, пока не получите <500 элементов, затем продолжайте polling с тем же курсором. Остальные списки автоматизации не имеют page/page_size.
- Файлы поддерживают `Range: bytes=…`: 206 с Content-Range, 416 при неудовлетворимом диапазоне; conditional GET может дать 304 без тела. Обычные файлы возвращаются application/octet-stream attachment, только MP4 — video/mp4 inline.
## Длительность, online и Grub
`duration_seconds` — ровно одно из 30, 60, 90, 120, 180, 300, 600, 900, 1200. Это окно наблюдения, **не** SLA всего задания: очередь, provisioning, подготовка рабочего стола, сбор и cleanup добавляют время. Для преждевременно заблокированного образца фактические start/PID/duration могут быть null.
Начинайте с offline (без NIC). Для online нужен отдельный актуальный proof владельца, online_available профиля и явный `allowed_cidrs`. Политика неизменяема; IPv6/имена/порты/диапазоны не принимаются. `0.0.0.0/0` означает публичный IPv4, не всю сеть: защищённая инфраструктура всегда закрыта. Grub собирает только явно указанные обычные локальные Windows-файлы выбранным user/admin token после наблюдения; не произвольное чтение SYSTEM/host. Снимки best effort, не атомарные: `changed`, `missing`, `access_denied` и частичный архив — честные результаты, не Defender findings. Подробные ограничения ниже остаются частью контракта.
## Job submission and settings
`POST /uploads` body raw bytes (`application/octet-stream`), filename in `X-Filename` (encodeURIComponent). Response 201 `{id,filename,size,sha256,created_at}`. Original bytes immutable. No multipart. `GET /uploads/{id}/content` authorized attachment.
@@ -112,10 +216,12 @@ Missing or partial telemetry is not an empty clean report. Fixed collector field
## Profiles and administration
`GET /profiles` -> list Profile `{id,name,os,architecture,enabled,qualification,state,current_revision_id,online_available,reason,metadata:object}`; state `maintenance|published`; qualification `unqualified|qualified|drifted`; metadata contains observed OS/Defender telemetry only, no VM/token secrets. `name` is the sole editable full display label. Admission rejects unqualified/unconfigured profiles.
`GET /profiles` -> list Profile `{id,name,os,architecture,enabled,qualification,state,current_revision_id,online_available,reason,metadata}`; state `maintenance|published`; qualification `unqualified|qualified|drifted`; metadata is an object (empty before observations) or null after qualification without observed metadata; it contains observed OS/Defender telemetry only, no VM/token secrets. `name` is the sole editable full display label. Admission rejects unqualified/unconfigured profiles.
Windows names are entered manually, for example `Windows 11 Pro — build 26200.6584`. Neither qualification nor an OS update changes that label; only an explicit profile-name edit does. Interfaces render `Profile.name` and the immutable `Run.profile_name` verbatim. Actual `report.environment.os_build` remains technical telemetry for reports/fingerprints/drift, never a naming source. Migration freezes legacy Runs at their currently known profile name before subsequent edits; it cannot reconstruct names that were never stored and does not invent historical builds. Revisions retain separate identifiers and technical metadata, not generated build-qualified names.
Следующие `/admin/*` маршруты описаны только как справка для cookie-admin интерфейса. Они **не входят в automation OpenAPI и всегда недоступны API-ключам**, в том числе ключам администратора.
Admin: `GET /admin/users`; `POST /admin/users` `{username,password,role}`; `PATCH /admin/users/{id}` `{role?,disabled?,password?}`. User `{id,username,role,disabled,created_at}`.
`GET /admin/profiles`; `POST /admin/profiles` `{name,os,architecture}` -> Profile (maintenance, unqualified). `PATCH /admin/profiles/{id}` `{name?,enabled?}`. `POST /admin/profiles/{id}/maintenance` closes admission, returns profile plus drain status; never powers off live master. `POST /admin/profiles/{id}/publish` `{revision_id}` selects a previously worker-validated stopped source revision; never accepts VMID from browser. Returns HTTP 409 `unqualified_revision` without a passed qualification, `stale_revision` if a newer revision exists for the same source reference (including another profile), or `source_not_drained` while old-revision Attempts await cloning. `POST /admin/profiles/{id}/qualify` queues qualification, returns `{id,status}`. Qualified revision configuration and credentials are worker-only CLI/file-backed, not browser secrets.
`GET /admin/bindings` -> list `{owner_id,pool,iso_storage,disk_storage,node,configured,reason,isolation_expires_at,online_ready,online_expires_at,online_reason}` (metadata only). Expiries are nullable RFC3339: null means not validated. Effective `configured` requires successful worker validation **and** a future isolation expiry at request time. Expired/missing isolation expiry closes dashboard readiness and job admission even if a stale stored flag was true. Online submit/retry additionally requires `online_ready` **and** future `online_expires_at`, plus the selected profile's `online_available`; missing/expired online proof returns HTTP 409 `admission_closed` with a generic safe reason. The bindings response computes effective `online_ready` at read time rather than exposing a stale true flag. Missing online readiness does not disable offline admission. Bindings are provisioned by operator CLI with file-backed credentials and genuine probe evidence, no secret edits/browser storage; UI shows actual expiry/prerequisites rather than a fake credential form.
@@ -129,3 +235,7 @@ Admin: `GET /admin/users`; `POST /admin/users` `{username,password,role}`; `PATC
## Deployment
The sibling frontend Vite build writes `dist/`; nginx reverse proxy same-origin `/api/` to Go API:8080, static UI fallback. Go API and worker separate containers/process modes. PostgreSQL, private artifact volume; PVE secret config mounted in worker only. No demo data/runtime mock adapters. Empty installs show real empty states and fail-closed integration prerequisites.
## Проверка контракта
`go test ./internal/api` включает публичную раздачу OpenAPI, HTTP-методы, разрешение schema references, независимые scopes, cookie/Bearer alternatives, required/enum/nullability и краткие/detail DTO. Интеграционные тесты реального хранения, выпуска/отзыва ключей, owner isolation и admission требуют `OTCHE_TEST_DATABASE_URL`, указывающий **только на отдельную одноразовую PostgreSQL БД**, не production. Без этой переменной database-backed тесты пропускаются; зелёный результат с skip не доказывает работу интеграции. Запуск полного `go test ./...` и frontend Playwright описан в README соответствующего sibling-репозитория.
+3
View File
@@ -126,6 +126,9 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request) {
if e == nil {
_, e = tx.Exec(r.Context(), `DELETE FROM sessions WHERE user_id=$1`, id)
}
if e == nil && (in.Password != nil || in.Disabled != nil && *in.Disabled) {
_, e = tx.Exec(r.Context(), `UPDATE api_keys SET revoked_at=now() WHERE owner_id=$1 AND revoked_at IS NULL`, id)
}
if e != nil {
s.dbError(w, e)
return
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"unicode"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"otche/internal/store"
)
const apiKeyMetadata = `jsonb_build_object('id',id,'name',name,'prefix',prefix,'scopes',scopes,'created_at',created_at,'expires_at',expires_at,'last_used_at',last_used_at,'revoked_at',revoked_at)`
func validAPIKeyScope(scope string) bool {
switch scope {
case "profiles:read", "jobs:read", "jobs:write", "uploads:read", "uploads:write", "artifacts:read":
return true
}
return false
}
func (s *Server) authenticateAPIKey(w http.ResponseWriter, r *http.Request, scope string) (identity, bool) {
reject := func() (identity, bool) {
w.Header().Set("WWW-Authenticate", `Bearer realm="otche"`)
fail(w, http.StatusUnauthorized, "unauthenticated", "Invalid or expired API key")
return identity{}, false
}
values := r.Header.Values("Authorization")
if len(values) != 1 {
return reject()
}
parts := strings.Fields(values[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || len(parts[1]) != 70 || !strings.HasPrefix(parts[1], "otche_") {
return reject()
}
for _, c := range parts[1][6:] {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return reject()
}
}
a := identity{Role: "operator"} // Keys never inherit the session administrator's cross-owner bypass.
var id string
var scopes []string
err := s.db.QueryRow(r.Context(), `SELECT k.id::text,u.id::text,u.username,k.scopes FROM api_keys k JOIN users u ON u.id=k.owner_id WHERE k.token_hash=$1 AND k.revoked_at IS NULL AND k.expires_at>now() AND NOT u.disabled`, hashToken(parts[1])).Scan(&id, &a.ID, &a.Username, &scopes)
if errors.Is(err, pgx.ErrNoRows) {
return reject()
}
if err != nil {
s.dbError(w, err)
return identity{}, false
}
if scope == "" {
fail(w, http.StatusForbidden, "session_required", "This operation requires an interactive session")
return identity{}, false
}
permitted := false
for _, granted := range scopes {
if granted == scope {
permitted = true
break
}
}
if !permitted {
w.Header().Set("WWW-Authenticate", `Bearer realm="otche", error="insufficient_scope", scope="`+scope+`"`)
fail(w, http.StatusForbidden, "insufficient_scope", "API key lacks required scope: "+scope)
return identity{}, false
}
// Approximate successful authentication activity, without a write on every poll.
_, err = s.db.Exec(r.Context(), `UPDATE api_keys SET last_used_at=now() WHERE id=$1 AND (last_used_at IS NULL OR last_used_at<now()-interval '1 minute')`, id)
if err != nil {
s.dbError(w, err)
return identity{}, false
}
return a, true
}
func (s *Server) apiKeyRoutes() {
s.mux.HandleFunc("GET /api/v1/api-keys", s.protected(false, s.apiKeys))
s.mux.HandleFunc("POST /api/v1/api-keys", s.protected(false, s.createAPIKey))
s.mux.HandleFunc("DELETE /api/v1/api-keys/{id}", s.protected(false, s.revokeAPIKey))
}
func (s *Server) apiKeys(w http.ResponseWriter, r *http.Request) {
s.list(w, r, `SELECT `+apiKeyMetadata+` FROM api_keys WHERE owner_id=$1 ORDER BY created_at DESC,id`, user(r).ID)
}
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresInDays int `json:"expires_in_days"`
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
invalidName := !utf8.ValidString(in.Name) || utf8.RuneCountInString(in.Name) < 1 || utf8.RuneCountInString(in.Name) > 80
for _, c := range in.Name {
invalidName = invalidName || unicode.IsControl(c)
}
if invalidName || in.ExpiresInDays < 1 || in.ExpiresInDays > 365 || len(in.Scopes) == 0 || len(in.Scopes) > 6 {
fail(w, 400, "invalid_request", "Use a name of 1-80 characters, 1-365 expiry days and explicit scopes")
return
}
seen := make(map[string]bool, len(in.Scopes))
for _, scope := range in.Scopes {
if !validAPIKeyScope(scope) || seen[scope] {
fail(w, 400, "invalid_request", "Scopes must be known and unique")
return
}
seen[scope] = true
}
tx, err := s.db.Begin(r.Context())
if err != nil {
s.dbError(w, err)
return
}
defer tx.Rollback(r.Context())
// Serialize issuance per owner so concurrent requests cannot exceed the cap.
var owner string
err = tx.QueryRow(r.Context(), `SELECT id::text FROM users WHERE id=$1 AND NOT disabled FOR UPDATE`, user(r).ID).Scan(&owner)
if err != nil {
s.dbError(w, err)
return
}
var count int
err = tx.QueryRow(r.Context(), `SELECT count(*) FROM api_keys WHERE owner_id=$1 AND revoked_at IS NULL AND expires_at>now()`, owner).Scan(&count)
if err != nil {
s.dbError(w, err)
return
}
if count >= 20 {
fail(w, 409, "key_limit", "At most 20 active API keys per account; revoke an unused key first")
return
}
token := "otche_" + randomToken()
var metadata []byte
err = tx.QueryRow(r.Context(), `INSERT INTO api_keys(id,owner_id,name,token_hash,prefix,scopes,expires_at) VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 day') RETURNING `+apiKeyMetadata,
store.NewID(), owner, in.Name, hashToken(token), token[:18], in.Scopes, in.ExpiresInDays).Scan(&metadata)
if err == nil {
err = tx.Commit(r.Context())
}
if err != nil {
s.dbError(w, err)
return
}
// No secret in list, logs or URL; this is the only response containing it.
jsonResponse(w, 201, struct {
Key json.RawMessage `json:"key"`
Token string `json:"token"`
}{metadata, token})
}
func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) {
tag, err := s.db.Exec(r.Context(), `UPDATE api_keys SET revoked_at=COALESCE(revoked_at,now()) WHERE id=$1::uuid AND owner_id=$2`, r.PathValue("id"), user(r).ID)
if err != nil {
s.dbError(w, err)
return
}
if tag.RowsAffected() == 0 {
fail(w, 404, "not_found", "Resource not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
+370
View File
@@ -0,0 +1,370 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"otche/internal/store"
)
// Real PostgreSQL and the actual HTTP router: credentials must not expand authority.
func TestAPIKeySecurityBoundary(t *testing.T) {
url := os.Getenv("OTCHE_TEST_DATABASE_URL")
if url == "" {
t.Skip("OTCHE_TEST_DATABASE_URL required for isolated PostgreSQL regression")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := "test_" + strings.ReplaceAll(store.NewID(), "-", "")
if _, err = admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer admin.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE")
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
db, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = store.Migrate(ctx, db); err != nil {
t.Fatal(err)
}
s, err := New(db, Config{Origin: "https://localhost", ArtifactRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
type session struct{ id, token, csrf string }
makeUser := func(name, role string) session {
t.Helper()
v := session{store.NewID(), randomToken(), randomToken()}
exec(`INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'unused',$3)`, v.id, name, role)
exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, hashToken(v.token), v.id, v.csrf)
return v
}
owner := makeUser("key-admin", "admin")
other := makeUser("other-owner", "operator")
request := func(method, path, body string, ss *session, authorization *string, csrf bool) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/v1"+path, strings.NewReader(body))
if ss != nil {
r.AddCookie(&http.Cookie{Name: "otche_session", Value: ss.token})
if csrf {
r.Header.Set("Origin", "https://localhost")
r.Header.Set("X-CSRF-Token", ss.csrf)
}
}
if authorization != nil {
r.Header.Set("Authorization", *authorization)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
return w
}
expect := func(w *httptest.ResponseRecorder, status int) {
t.Helper()
if w.Code != status {
t.Fatalf("expected HTTP %d got %d: %s", status, w.Code, w.Body)
}
}
type credential struct {
Key struct{ ID, Prefix string }
Token string
}
issue := func(ss session, scopes []string) credential {
t.Helper()
body, _ := json.Marshal(map[string]any{"name": "CI key", "expires_in_days": 90, "scopes": scopes})
w := request("POST", "/api-keys", string(body), &ss, nil, true)
expect(w, 201)
var c credential
if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil {
t.Fatal(err)
}
return c
}
allScopes := []string{"profiles:read", "jobs:read", "jobs:write", "uploads:read", "uploads:write", "artifacts:read"}
key := issue(owner, allScopes)
bearer := "Bearer " + key.Token
if len(key.Token) != 70 || !strings.HasPrefix(key.Token, "otche_") {
t.Fatal("malformed issued credential")
}
var stored string
if err = db.QueryRow(ctx, `SELECT token_hash FROM api_keys WHERE id=$1`, key.Key.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != hashToken(key.Token) || strings.Contains(stored, key.Token) {
t.Fatal("credential not stored as digest")
}
listed := request("GET", "/api-keys", "", &owner, nil, false)
expect(listed, 200)
if strings.Contains(listed.Body.String(), key.Token) || strings.Contains(listed.Body.String(), stored) || strings.Contains(listed.Body.String(), "token_hash") {
t.Fatal("credential leaked from list")
}
expect(request("GET", "/profiles", "", nil, &bearer, false), 200)
expect(request("HEAD", "/profiles", "", nil, &bearer, false), 200)
var used *time.Time
if err = db.QueryRow(ctx, `SELECT last_used_at FROM api_keys WHERE id=$1`, key.Key.ID).Scan(&used); err != nil || used == nil {
t.Fatal("successful authentication not recorded", err)
}
// Invalid explicit credentials never downgrade to an otherwise valid browser session.
for _, invalid := range []string{"", "Basic abc", "Bearer otche_bad", "Bearer " + strings.Repeat("0", 64)} {
expect(request("GET", "/profiles", "", &owner, &invalid, false), 401)
}
unknown := "Bearer otche_" + strings.Repeat("0", 64)
expect(request("GET", "/profiles", "", &owner, &unknown, false), 401)
duplicate := httptest.NewRequest("GET", "/api/v1/profiles", nil)
duplicate.Header.Add("Authorization", bearer)
duplicate.Header.Add("Authorization", bearer)
duplicateResponse := httptest.NewRecorder()
s.ServeHTTP(duplicateResponse, duplicate)
expect(duplicateResponse, 401)
expect(request("GET", "/profiles?api_key="+key.Token, "", nil, nil, false), 401)
for _, path := range []string{"/api-keys", "/auth/session", "/admin/users"} {
expect(request("GET", path, "", &owner, &bearer, false), 403)
}
expect(request("POST", "/auth/login", `{}`, nil, &bearer, false), 403)
expect(request("POST", "/auth/logout", `{}`, &owner, &bearer, true), 403)
expect(request("POST", "/api-keys", `{}`, &owner, nil, false), 403)
for _, origin := range []string{"https://localhost", "https://foreign.invalid"} {
r := httptest.NewRequest("DELETE", "/api/v1/api-keys/"+key.Key.ID, nil)
r.AddCookie(&http.Cookie{Name: "otche_session", Value: owner.token})
r.Header.Set("Origin", origin)
r.Header.Set("X-CSRF-Token", "wrong")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
expect(w, 403)
}
expect(request("DELETE", "/api-keys/"+key.Key.ID, "", &other, nil, true), 404)
readOnly := issue(owner, []string{"profiles:read"})
readBearer := "Bearer " + readOnly.Token
expect(request("GET", "/jobs", "", nil, &readBearer, false), 403)
expect(request("POST", "/uploads", "", nil, &readBearer, false), 403)
expect(request("POST", "/jobs", `{}`, nil, &readBearer, false), 403)
expect(request("POST", "/api-keys", `{}`, nil, &readBearer, false), 403)
writeOnly := issue(owner, []string{"jobs:write"})
writeBearer := "Bearer " + writeOnly.Token
expect(request("GET", "/jobs", "", nil, &writeBearer, false), 403)
// A scoped write reaches normal validation without cookie Origin/CSRF.
expect(request("POST", "/jobs", `{}`, nil, &writeBearer, false), 400)
missingID := store.NewID()
for _, granted := range allScopes {
limited := issue(owner, []string{granted})
authorization := "Bearer " + limited.Token
for _, endpoint := range []struct {
method, path, scope string
status int
}{
{"GET", "/profiles", "profiles:read", 200},
{"GET", "/dashboard", "jobs:read", 200},
{"GET", "/jobs", "jobs:read", 200},
{"GET", "/jobs/" + missingID, "jobs:read", 404},
{"GET", "/jobs/" + missingID + "/events", "jobs:read", 404},
{"POST", "/jobs", "jobs:write", 400},
{"POST", "/jobs/" + missingID + "/cancel", "jobs:write", 404},
{"POST", "/jobs/" + missingID + "/retry", "jobs:write", 404},
{"POST", "/uploads", "uploads:write", 400},
{"GET", "/uploads/" + missingID + "/content", "uploads:read", 404},
{"GET", "/jobs/" + missingID + "/artifacts", "artifacts:read", 404},
{"GET", "/artifacts/" + missingID + "/content", "artifacts:read", 404},
{"GET", "/attempts/" + missingID + "/video", "artifacts:read", 404},
} {
want := 403
if endpoint.scope == granted {
want = endpoint.status
}
t.Run(granted+"/"+endpoint.method+endpoint.path, func(t *testing.T) {
w := request(endpoint.method, endpoint.path, `{}`, nil, &authorization, false)
if w.Code != want {
t.Fatalf("scope boundary: expected%d got%d %s", want, w.Code, w.Body)
}
})
}
}
for _, body := range []string{
`{"name":"bad","expires_in_days":0,"scopes":["jobs:read"]}`,
`{"name":"bad","expires_in_days":366,"scopes":["jobs:read"]}`,
`{"name":"bad","expires_in_days":1,"scopes":[]}`,
`{"name":"bad","expires_in_days":1,"scopes":["admin"]}`,
`{"name":"bad","expires_in_days":1,"scopes":["jobs:read","jobs:read"]}`,
`{"name":"bad\u0000","expires_in_days":1,"scopes":["jobs:read"]}`,
} {
expect(request("POST", "/api-keys", body, &owner, nil, true), 400)
}
// Seed two owners' completed observations, including private downloadable artifacts.
makeJob := func(ss session) (string, string, string, string) {
upload, job, run, attempt, profile, revision, artifact := store.NewID(), store.NewID(), store.NewID(), store.NewID(), store.NewID(), store.NewID(), store.NewID()
exec(`INSERT INTO uploads(id,owner_id,filename,size,sha256,storage_key) VALUES($1::uuid,$2,'sample.exe',4,'digest',$1::uuid::text)`, upload, ss.id)
exec(`INSERT INTO jobs(id,owner_id,upload_id,execution_filename,settings,status) VALUES($1,$2,$3,'sample.exe','{}','finished')`, job, ss.id, upload)
exec(`INSERT INTO profiles(id,name,os,architecture) VALUES($1,'Windows test','windows','x64')`, profile)
exec(`INSERT INTO revisions(id,profile_id,source_ref,fingerprint,config_digest) VALUES($1,$2,'source','fingerprint','digest')`, revision, profile)
exec(`INSERT INTO runs(id,job_id,profile_id,revision_id,profile_name,status) VALUES($1,$2,$3,$4,'Windows test','finished')`, run, job, profile, revision)
exec(`INSERT INTO attempts(id,run_id,command_id,phase) VALUES($1,$2,$3,'finished')`, attempt, run, store.NewID())
exec(`INSERT INTO artifacts(id,job_id,attempt_id,kind,filename,content_type,size,sha256,storage_key) VALUES($1::uuid,$2,$3,'report','report.json','application/json',4,'digest',$1::uuid::text)`, artifact, job, attempt)
if err := os.WriteFile(s.cfg.ArtifactRoot+"/"+upload, []byte("data"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(s.cfg.ArtifactRoot+"/"+artifact, []byte("data"), 0600); err != nil {
t.Fatal(err)
}
return job, upload, artifact, attempt
}
ownJob, ownUpload, ownArtifact, _ := makeJob(owner)
foreignJob, foreignUpload, foreignArtifact, foreignAttempt := makeJob(other)
for _, path := range []string{"/jobs/" + ownJob, "/uploads/" + ownUpload + "/content", "/artifacts/" + ownArtifact + "/content"} {
expect(request("GET", path, "", nil, &bearer, false), 200)
}
for _, path := range []string{"/jobs/" + foreignJob, "/jobs/" + foreignJob + "/events", "/jobs/" + foreignJob + "/artifacts", "/uploads/" + foreignUpload + "/content", "/artifacts/" + foreignArtifact + "/content", "/attempts/" + foreignAttempt + "/video"} {
expect(request("GET", path, "", nil, &bearer, false), 404)
}
for _, suffix := range []string{"/cancel", "/retry"} {
expect(request("POST", "/jobs/"+foreignJob+suffix, `{}`, nil, &bearer, false), 404)
}
list := request("GET", "/jobs", "", nil, &bearer, false)
expect(list, 200)
if !strings.Contains(list.Body.String(), ownJob) || strings.Contains(list.Body.String(), foreignJob) {
t.Fatal("admin-owned key escaped owner isolation")
}
// Browser administrator access remains unchanged.
expect(request("GET", "/jobs/"+foreignJob, "", &owner, nil, false), 200)
// Exercise the automation protocol through the real handlers, not fixture echoes.
uploadRequest := httptest.NewRequest("POST", "/api/v1/uploads", strings.NewReader("harmless integration fixture"))
uploadRequest.Header.Set("Authorization", bearer)
uploadRequest.Header.Set("X-Filename", "integration.exe")
uploadResponse := httptest.NewRecorder()
s.ServeHTTP(uploadResponse, uploadRequest)
expect(uploadResponse, 201)
var uploaded struct{ ID, SHA256 string }
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &uploaded); err != nil {
t.Fatal(err)
}
if uploaded.SHA256 != hashToken("harmless integration fixture") {
t.Fatal("upload digest mismatch")
}
downloaded := request("GET", "/uploads/"+uploaded.ID+"/content", "", nil, &bearer, false)
expect(downloaded, 200)
if downloaded.Body.String() != "harmless integration fixture" {
t.Fatal("uploaded content changed")
}
var profileID string
if err := db.QueryRow(ctx, `SELECT profile_id::text FROM runs WHERE job_id=$1`, ownJob).Scan(&profileID); err != nil {
t.Fatal(err)
}
settings := `{"internet":"offline","duration_seconds":30,"filename":"original","privilege":"user","args_mode":"none","args":[],"set_zoneid":false}`
jobInput := fmt.Sprintf(`{"upload_id":%q,"profile_ids":[%q],"settings":%s}`, uploaded.ID, profileID, settings)
expect(request("POST", "/jobs", jobInput, nil, &bearer, false), 409)
exec(`INSERT INTO bindings(owner_id,pool,iso_storage,disk_storage,node,configured,isolation_expires_at) VALUES($1,'test','test','test','test',true,now()+interval '1 hour')`, owner.id)
exec(`INSERT INTO heartbeats(name,seen_at,ready) VALUES('worker',now(),true)`)
// Credentials do not bypass qualification, even for administrator-owned keys.
expect(request("POST", "/jobs", jobInput, nil, &bearer, false), 409)
exec(`UPDATE profiles SET enabled=true,state='published',qualification='qualified',current_revision_id=(SELECT id FROM revisions WHERE profile_id=$1) WHERE id=$1`, profileID)
createdJob := request("POST", "/jobs", jobInput, nil, &bearer, false)
expect(createdJob, 201)
var admitted struct {
ID string
CancelRequested bool `json:"cancel_requested"`
Runs []struct {
ProfileName string `json:"profile_name"`
Attempts []struct{ ID string }
}
}
if err := json.Unmarshal(createdJob.Body.Bytes(), &admitted); err != nil {
t.Fatal(err)
}
if len(admitted.Runs) != 1 || admitted.Runs[0].ProfileName != "Windows test" || len(admitted.Runs[0].Attempts) != 1 {
t.Fatal("admission did not snapshot selected source")
}
for _, path := range []string{"/dashboard", "/jobs/" + admitted.ID + "/events", "/jobs/" + admitted.ID + "/artifacts", "/attempts/" + admitted.Runs[0].Attempts[0].ID + "/video"} {
expect(request("GET", path, "", nil, &bearer, false), 200)
}
expect(request("POST", "/jobs/"+admitted.ID+"/retry", `{}`, nil, &bearer, false), 409)
canceled := request("POST", "/jobs/"+admitted.ID+"/cancel", `{}`, nil, &bearer, false)
expect(canceled, 200)
if err := json.Unmarshal(canceled.Body.Bytes(), &admitted); err != nil || !admitted.CancelRequested {
t.Fatal("cancel not observable", err)
}
exec(`UPDATE attempts SET phase='finished' WHERE run_id IN(SELECT id FROM runs WHERE job_id=$1)`, admitted.ID)
exec(`UPDATE jobs SET status='finished' WHERE id=$1`, admitted.ID)
retried := request("POST", "/jobs/"+admitted.ID+"/retry", `{}`, nil, &bearer, false)
expect(retried, 200)
if err := json.Unmarshal(retried.Body.Bytes(), &admitted); err != nil || admitted.CancelRequested || len(admitted.Runs[0].Attempts) != 2 {
t.Fatal("retry did not create a fresh attempt", err)
}
// A newly expired isolation proof closes admission for keys too.
exec(`UPDATE bindings SET isolation_expires_at=now()-interval '1 second' WHERE owner_id=$1`, owner.id)
expect(request("POST", "/jobs", jobInput, nil, &bearer, false), 409)
expect(request("DELETE", "/api-keys/"+key.Key.ID, "", &owner, nil, true), 204)
expect(request("DELETE", "/api-keys/"+key.Key.ID, "", &owner, nil, true), 204)
expect(request("GET", "/profiles", "", nil, &bearer, false), 401)
exec(`UPDATE api_keys SET created_at=now()-interval '2 days',expires_at=now()-interval '1 day' WHERE id=$1`, readOnly.Key.ID)
expect(request("GET", "/profiles", "", nil, &readBearer, false), 401)
// Disabling and then reenabling via the real admin API cannot resurrect keys.
otherKey := issue(other, []string{"profiles:read"})
otherBearer := "Bearer " + otherKey.Token
expect(request("PATCH", "/admin/users/"+other.id, `{"disabled":true}`, &owner, nil, true), 200)
expect(request("GET", "/profiles", "", nil, &otherBearer, false), 401)
expect(request("PATCH", "/admin/users/"+other.id, `{"disabled":false}`, &owner, nil, true), 200)
expect(request("GET", "/profiles", "", nil, &otherBearer, false), 401)
// Reestablish a session after the admin mutation to issue a replacement.
exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, hashToken(other.token), other.id, other.csrf)
replacement := issue(other, []string{"profiles:read"})
replacementBearer := "Bearer " + replacement.Token
expect(request("PATCH", "/admin/users/"+other.id, `{"password":"New-regression-only-password"}`, &owner, nil, true), 200)
expect(request("GET", "/profiles", "", nil, &replacementBearer, false), 401)
// Concurrent issuance shares the owner lock, not a racy count-then-insert.
capped := makeUser("concurrent-owner", "operator")
statuses := make(chan int, 24)
var wg sync.WaitGroup
for range 24 {
wg.Add(1)
go func() {
defer wg.Done()
statuses <- request("POST", "/api-keys", `{"name":"parallel","expires_in_days":1,"scopes":["profiles:read"]}`, &capped, nil, true).Code
}()
}
wg.Wait()
close(statuses)
created, limited := 0, 0
for status := range statuses {
switch status {
case 201:
created++
case 409:
limited++
default:
t.Fatalf("unexpected concurrent issue status %d", status)
}
}
if created != 20 || limited != 4 {
t.Fatalf("cap race: created=%d limited=%d", created, limited)
}
var cappedID string
if err := db.QueryRow(ctx, `SELECT id::text FROM api_keys WHERE owner_id=$1 LIMIT 1`, capped.id).Scan(&cappedID); err != nil {
t.Fatal(err)
}
expect(request("DELETE", "/api-keys/"+cappedID, "", &capped, nil, true), 204)
issue(capped, []string{"profiles:read"})
db.Close()
expect(request("GET", "/profiles", "", nil, &writeBearer, false), 500)
fmt.Println("API keys: scopes, owner isolation, expiry/revocation, credential precedence, session CSRF, account resets, concurrent cap and database failure verified")
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
_ "embed"
"net/http"
)
//go:embed openapi.json
var openAPIDocument []byte
func (s *Server) documentationRoutes() {
s.mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(openAPIDocument)
})
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// Exercise the public HTTP surface, without database/session availability.
func servedOpenAPI(t *testing.T) map[string]any {
t.Helper()
s := &Server{mux: http.NewServeMux()}
s.documentationRoutes()
r := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
r.Header.Set("Authorization", "Bearer deliberately-invalid")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("public OpenAPI status = %d: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("OpenAPI Content-Type = %q", ct)
}
var document map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &document); err != nil {
t.Fatalf("decode served OpenAPI: %v", err)
}
return document
}
func documentObject(t *testing.T, root map[string]any, path ...string) map[string]any {
t.Helper()
value := root
for _, name := range path {
next, ok := value[name].(map[string]any)
if !ok {
t.Fatalf("missing object %s at %s", name, strings.Join(path, "."))
}
value = next
}
return value
}
func TestOpenAPIPublicDocumentAndMethods(t *testing.T) {
document := servedOpenAPI(t)
if document["openapi"] != "3.1.0" {
t.Fatalf("unsupported OpenAPI dialect: %v", document["openapi"])
}
s := &Server{mux: http.NewServeMux()}
s.documentationRoutes()
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodDelete} {
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(method, "/api/v1/openapi.json", nil))
want := http.StatusMethodNotAllowed
if method == http.MethodGet || method == http.MethodHead {
want = http.StatusOK
}
if w.Code != want {
t.Errorf("%s document: status %d, want %d", method, w.Code, want)
}
}
}
func TestOpenAPIAuthorizationContract(t *testing.T) {
document := servedOpenAPI(t)
paths := documentObject(t, document, "paths")
expected := map[string]string{
"get /profiles": "profiles:read",
"get /dashboard": "jobs:read",
"get /jobs": "jobs:read",
"get /jobs/{id}": "jobs:read",
"get /jobs/{id}/events": "jobs:read",
"post /jobs": "jobs:write",
"post /jobs/{id}/cancel": "jobs:write",
"post /jobs/{id}/retry": "jobs:write",
"post /uploads": "uploads:write",
"get /uploads/{id}/content": "uploads:read",
"get /jobs/{id}/artifacts": "artifacts:read",
"get /artifacts/{id}/content": "artifacts:read",
"get /attempts/{id}/video": "artifacts:read",
}
seenScopes := map[string]bool{}
for route, scope := range expected {
parts := strings.SplitN(route, " ", 2)
op := documentObject(t, paths, parts[1], parts[0])
if op["x-required-scope"] != scope {
t.Errorf("%s scope = %v, want %s", route, op["x-required-scope"], scope)
}
seenScopes[scope] = true
security, ok := op["security"].([]any)
if !ok || len(security) != 2 {
t.Fatalf("%s needs bearer OR cookie alternatives", route)
}
bearer, ok := security[0].(map[string]any)
if !ok || !reflect.DeepEqual(bearer["BearerAuth"], []any{}) {
t.Fatalf("%s HTTP bearer must not declare OAuth scopes", route)
}
cookie, ok := security[1].(map[string]any)
if !ok || !reflect.DeepEqual(cookie["CookieSession"], []any{}) {
t.Fatalf("%s missing cookie alternative", route)
}
_, csrf := cookie["CSRFToken"]
if csrf != (parts[0] == "post") {
t.Errorf("%s cookie CSRF requirement = %v", route, csrf)
}
}
if len(seenScopes) != 6 {
t.Fatalf("documented independent scopes = %d", len(seenScopes))
}
for _, route := range []string{"get /api-keys", "post /api-keys", "delete /api-keys/{id}", "get /auth/session", "post /auth/logout", "post /auth/login"} {
parts := strings.SplitN(route, " ", 2)
op := documentObject(t, paths, parts[1], parts[0])
if op["x-session-only"] != true || op["x-required-scope"] != nil {
t.Errorf("%s must not advertise API-key access", route)
}
for _, requirement := range op["security"].([]any) {
if _, bearer := requirement.(map[string]any)["BearerAuth"]; bearer {
t.Errorf("%s advertises forbidden bearer access", route)
}
}
}
for _, path := range []string{"/health", "/openapi.json"} {
op := documentObject(t, paths, path, "get")
if !reflect.DeepEqual(op["security"], []any{}) {
t.Errorf("%s must be public", path)
}
}
}
func TestOpenAPIConsumerShapes(t *testing.T) {
document := servedOpenAPI(t)
schemas := documentObject(t, document, "components", "schemas")
for _, tc := range []struct {
schema string
field string
values []any
}{
{"SettingsInput", "duration_seconds", []any{float64(30), float64(60), float64(90), float64(120), float64(180), float64(300), float64(600), float64(900), float64(1200)}},
{"SettingsInput", "internet", []any{"offline", "online"}},
{"Attempt", "findings", []any{"unknown", "detected", "not_observed"}},
{"Attempt", "telemetry", []any{"pending", "complete", "partial", "unavailable"}},
{"Attempt", "cleanup", []any{"pending", "complete", "evidence_held", "failed"}},
} {
field := documentObject(t, schemas, tc.schema, "properties", tc.field)
if !reflect.DeepEqual(field["enum"], tc.values) {
t.Errorf("%s.%s enum = %v, want %v", tc.schema, tc.field, field["enum"], tc.values)
}
}
for schema, fields := range map[string][]string{
"JobInput": {"upload_id", "profile_ids", "settings"},
"APIKeyInput": {"name", "scopes", "expires_in_days"},
"APIKeyCreated": {"key", "token"},
"JobPage": {"items", "total", "page", "page_size", "next_page"},
} {
decl := documentObject(t, schemas, schema)
required, ok := decl["required"].([]any)
if !ok {
t.Fatalf("%s required fields missing", schema)
}
for _, field := range fields {
found := false
for _, item := range required {
found = found || item == field
}
if !found {
t.Errorf("%s.%s must be required for consumers", schema, field)
}
}
}
for schema, fields := range map[string][]string{
"APIKey": {"last_used_at", "revoked_at"},
"Attempt": {"report", "allocation", "claimed_at", "started_at", "finished_at", "deadline_at"},
"Job": {"queue_ahead"},
"JobPage": {"next_page"},
"Event": {"attempt_id"},
} {
for _, field := range fields {
decl := documentObject(t, schemas, schema, "properties", field)
variants, ok := decl["anyOf"].([]any)
nullable := false
if ok {
for _, variant := range variants {
nullable = nullable || variant.(map[string]any)["type"] == "null"
}
}
if !nullable {
t.Errorf("%s.%s must preserve unknown/null", schema, field)
}
}
}
for _, schema := range []string{"JobSummary", "RunSummary"} {
properties := documentObject(t, schemas, schema, "properties")
if properties["attempts"] != nil || properties["queue_ahead"] != nil {
t.Errorf("%s incorrectly promises detail-only fields", schema)
}
}
expiry := documentObject(t, schemas, "APIKeyInput", "properties", "expires_in_days")
if expiry["minimum"] != float64(1) || expiry["maximum"] != float64(365) {
t.Errorf("key expiry bounds = %v", expiry)
}
profiles := documentObject(t, schemas, "JobInput", "properties", "profile_ids")
if profiles["minItems"] != float64(1) || profiles["maxItems"] != float64(12) || profiles["uniqueItems"] != true {
t.Errorf("profile selection bounds = %v", profiles)
}
paths := documentObject(t, document, "paths")
for _, path := range []string{"/uploads/{id}/content", "/artifacts/{id}/content"} {
responses := documentObject(t, paths, path, "get", "responses")
for _, code := range []string{"200", "206", "304", "416"} {
if responses[code] == nil {
t.Errorf("%s missing binary/conditional response %s", path, code)
}
}
}
}
func TestOpenAPILocalReferencesResolve(t *testing.T) {
document := servedOpenAPI(t)
var visit func(any)
visit = func(value any) {
switch node := value.(type) {
case map[string]any:
if ref, ok := node["$ref"].(string); ok && strings.HasPrefix(ref, "#/") {
var target any = document
for _, part := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
object, ok := target.(map[string]any)
if !ok {
t.Fatalf("broken local reference %s", ref)
}
target = object[part]
}
if target == nil {
t.Errorf("unresolved local reference %s", ref)
}
}
for _, child := range node {
visit(child)
}
case []any:
for _, child := range node {
visit(child)
}
}
}
visit(document)
}
+13 -13
View File
@@ -132,21 +132,21 @@ func (v *Settings) validate(filename string) error {
}
func (s *Server) jobRoutes() {
m := s.mux
m.HandleFunc("POST /api/v1/uploads", s.protected(false, s.upload))
m.HandleFunc("GET /api/v1/uploads/{id}/content", s.protected(false, s.uploadContent))
m.HandleFunc("POST /api/v1/jobs", s.protected(false, s.createJob))
m.HandleFunc("GET /api/v1/jobs", s.protected(false, s.jobs))
m.HandleFunc("GET /api/v1/jobs/{id}", s.protected(false, s.getJob))
m.HandleFunc("POST /api/v1/jobs/{id}/cancel", s.protected(false, s.cancelJob))
m.HandleFunc("POST /api/v1/jobs/{id}/retry", s.protected(false, s.retryJob))
m.HandleFunc("GET /api/v1/jobs/{id}/events", s.protected(false, s.events))
m.HandleFunc("GET /api/v1/jobs/{id}/artifacts", s.protected(false, s.artifacts))
m.HandleFunc("GET /api/v1/artifacts/{id}/content", s.protected(false, s.artifactContent))
m.HandleFunc("GET /api/v1/attempts/{id}/video", s.protected(false, s.video))
m.HandleFunc("GET /api/v1/profiles", s.protected(false, func(w http.ResponseWriter, r *http.Request) {
m.HandleFunc("POST /api/v1/uploads", s.protectedScope(false, "uploads:write", s.upload))
m.HandleFunc("GET /api/v1/uploads/{id}/content", s.protectedScope(false, "uploads:read", s.uploadContent))
m.HandleFunc("POST /api/v1/jobs", s.protectedScope(false, "jobs:write", s.createJob))
m.HandleFunc("GET /api/v1/jobs", s.protectedScope(false, "jobs:read", s.jobs))
m.HandleFunc("GET /api/v1/jobs/{id}", s.protectedScope(false, "jobs:read", s.getJob))
m.HandleFunc("POST /api/v1/jobs/{id}/cancel", s.protectedScope(false, "jobs:write", s.cancelJob))
m.HandleFunc("POST /api/v1/jobs/{id}/retry", s.protectedScope(false, "jobs:write", s.retryJob))
m.HandleFunc("GET /api/v1/jobs/{id}/events", s.protectedScope(false, "jobs:read", s.events))
m.HandleFunc("GET /api/v1/jobs/{id}/artifacts", s.protectedScope(false, "artifacts:read", s.artifacts))
m.HandleFunc("GET /api/v1/artifacts/{id}/content", s.protectedScope(false, "artifacts:read", s.artifactContent))
m.HandleFunc("GET /api/v1/attempts/{id}/video", s.protectedScope(false, "artifacts:read", s.video))
m.HandleFunc("GET /api/v1/profiles", s.protectedScope(false, "profiles:read", func(w http.ResponseWriter, r *http.Request) {
s.list(w, r, `SELECT to_jsonb(p) FROM profiles p ORDER BY name`)
}))
m.HandleFunc("GET /api/v1/dashboard", s.protected(false, s.dashboard))
m.HandleFunc("GET /api/v1/dashboard", s.protectedScope(false, "jobs:read", s.dashboard))
}
const ownerStorageQuery = `SELECT COALESCE((SELECT sum(size) FROM uploads WHERE owner_id=$1),0)+COALESCE((SELECT sum(a.size) FROM artifacts a JOIN jobs j ON j.id=a.job_id WHERE j.owner_id=$1),0)`
File diff suppressed because it is too large Load Diff
+19
View File
@@ -130,11 +130,24 @@ func (s *Server) origin(w http.ResponseWriter, r *http.Request) bool {
return true
}
func (s *Server) protected(admin bool, h http.HandlerFunc) http.HandlerFunc {
return s.protectedScope(admin, "", h)
}
// An empty scope is session-only; new routes must opt in to automation explicitly.
func (s *Server) protectedScope(admin bool, scope string, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if id := r.PathValue("id"); id != "" && !uuidRE.MatchString(id) {
fail(w, 404, "not_found", "Resource not found")
return
}
if _, present := r.Header["Authorization"]; present {
a, ok := s.authenticateAPIKey(w, r, scope)
if !ok {
return
}
h(w, r.WithContext(context.WithValue(r.Context(), authKey{}, a)))
return
}
c, err := r.Cookie("otche_session")
if err != nil || len(c.Value) != 64 {
fail(w, 401, "unauthenticated", "Sign in required")
@@ -226,6 +239,8 @@ func (s *Server) routes() {
m.HandleFunc("POST /api/v1/auth/login", s.login)
m.HandleFunc("GET /api/v1/auth/session", s.protected(false, s.session))
m.HandleFunc("POST /api/v1/auth/logout", s.protected(false, s.logout))
s.apiKeyRoutes()
s.documentationRoutes()
s.jobRoutes()
s.adminRoutes()
}
@@ -234,6 +249,10 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, 200, map[string]any{"user": map[string]string{"id": a.ID, "username": a.Username, "role": a.Role}, "csrf_token": a.CSRF})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if _, present := r.Header["Authorization"]; present {
s.authenticateAPIKey(w, r, "")
return
}
if !s.origin(w, r) {
return
}
+13
View File
@@ -47,3 +47,16 @@ CREATE INDEX IF NOT EXISTS events_orphan ON events(created_at) WHERE job_id IS N
ALTER TABLE bindings ADD COLUMN IF NOT EXISTS online_ready boolean NOT NULL DEFAULT false;
ALTER TABLE bindings ADD COLUMN IF NOT EXISTS online_expires_at timestamptz;
ALTER TABLE bindings ADD COLUMN IF NOT EXISTS online_reason text NOT NULL DEFAULT 'Online network not configured';
CREATE TABLE IF NOT EXISTS api_keys(
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES users(id),
name text NOT NULL CHECK(char_length(name) BETWEEN 1 AND 80),
token_hash text UNIQUE NOT NULL CHECK(token_hash ~ '^[0-9a-f]{64}$'),
prefix text NOT NULL,
scopes text[] NOT NULL CHECK(cardinality(scopes) BETWEEN 1 AND 6 AND scopes <@ ARRAY['profiles:read','jobs:read','jobs:write','uploads:read','uploads:write','artifacts:read']::text[]),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL CHECK(expires_at > created_at),
last_used_at timestamptz,
revoked_at timestamptz
);
CREATE INDEX IF NOT EXISTS api_keys_owner ON api_keys(owner_id,created_at DESC);
+3 -2
View File
@@ -67,8 +67,9 @@ func TestExecuteReservationRejectionAndCommittedCloneFailure(t *testing.T) {
data = map[string]any{"name": "otche-" + a.ID, "tags": "otche;otche-owner-" + a.OwnerID + ";otche-attempt-" + a.ID}
case r.URL.Path == "/api2/json/nodes/node/qemu/8101/status/current" && !reject:
data = map[string]any{"status": "stopped"}
case strings.HasSuffix(r.URL.Path, "/agent/file-read") && !reject:
http.NotFound(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/api2/json/nodes/node/qemu/8101/agent/exec" && !reject:
// Evidence snapshots use bounded PowerShell reads; this clone never started.
http.Error(w, "guest agent is not running", http.StatusServiceUnavailable)
return
default:
t.Errorf("unexpected PVE request %s %s", r.Method, r.URL.Path)