Fence key issuance against session reset and restrict write-only job responses
This commit is contained in:
@@ -21,6 +21,8 @@ Malformed `{id}` path parameters return HTTP 404 `not_found` before database loo
|
||||
| `uploads:read` | `GET /uploads/{id}/content` |
|
||||
| `artifacts:read` | `GET /jobs/{id}/artifacts`, `/artifacts/{id}/content`, `/attempts/{id}/video` |
|
||||
|
||||
Независимость scopes относится к доступу к маршрутам и объёму ответа записи. Ключ с `jobs:write`, но **без `jobs:read`**, получает от `POST /jobs` (201), `/jobs/{id}/cancel` (200), `/jobs/{id}/retry` (200) строго `{id,status,cancel_requested}` (`JobAcknowledgement`), без настроек, Runs/Attempts, отчётов и метаданных артефактов. Cookie-сессия или ключ, у которого также есть `jobs:read`, получает полный `Job`. Право `jobs:read` включает уже вложенные в подробный Job метаданные артефактов и видео; **скачивание содержимого**, отдельный список артефактов и отдельный видео-манифест требуют `artifacts:read`. Наличие ссылки в Job не даёт разрешение её скачать.
|
||||
|
||||
Управление ключами **только по cookie-сессии**, не ключом:
|
||||
|
||||
- `GET /api-keys` → `{items:[metadata]}`, включая истёкшие/отозванные.
|
||||
@@ -128,6 +130,8 @@ Readable live logs are captured as bounded **best-effort open-length snapshots**
|
||||
|
||||
`GET /jobs?page=1&page_size=25&q=&status=` -> `{items:Job[],total,page,page_size,next_page:null|number}`; `GET /jobs/{id}` Job with runs; `POST /jobs/{id}/cancel` -> Job; `POST /jobs/{id}/retry` -> same Job with a new Attempt for every Run (including previously successful Runs), preserving every prior Attempt and original immutable settings/revisions, admission rechecked. Retry returns HTTP 409 `job_active` if the Job is queued/running **or any of its Attempts is unfinished**, even if the stored Job status is terminal. Job `{id,owner_id,upload_id,filename,execution_filename,sha256,size,settings,status,created_at,updated_at,cancel_requested,runs:Run[]}`. Status `queued|running|completed|cancelled|failed`.
|
||||
|
||||
Для трёх mutation-ответов create/cancel/retry полный `Job` выше относится только к cookie-сессии или API-ключу с `jobs:read`; ключ только с `jobs:write` получает ограниченный `JobAcknowledgement` из раздела прав. GET-список возвращает `JobSummary[]` с `RunSummary[]`, без вложенных Attempts и queue_ahead.
|
||||
|
||||
Retry also revalidates the stored immutable network policy and current profile online availability without changing either the destination list or source revision. Historical online jobs without `allowed_cidrs` (and any other now-invalid policy) return HTTP 409 `invalid_settings`; submit a new job with explicit destinations. Missing historical destinations mean unavailable/legacy information, not unrestricted access.
|
||||
|
||||
Job detail additionally returns `queue_ahead: number|null`: the number of queued, never-claimed Attempts from **other Jobs** created before this Job's earliest queued, never-claimed Attempt. The count includes all owners for both operators and admins; it exposes no other Job/owner IDs. It is null when this Job has no queued, never-claimed Attempt. It counts Attempts, not Jobs, and is not a completion-time estimate.
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *Server) authenticateAPIKey(w http.ResponseWriter, r *http.Request, scop
|
||||
return reject()
|
||||
}
|
||||
}
|
||||
a := identity{Role: "operator"} // Keys never inherit the session administrator's cross-owner bypass.
|
||||
a := identity{Role: "operator", RestrictedJobResponse: true} // 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)
|
||||
@@ -58,9 +58,11 @@ func (s *Server) authenticateAPIKey(w http.ResponseWriter, r *http.Request, scop
|
||||
}
|
||||
permitted := false
|
||||
for _, granted := range scopes {
|
||||
if granted == "jobs:read" {
|
||||
a.RestrictedJobResponse = false
|
||||
}
|
||||
if granted == scope {
|
||||
permitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !permitted {
|
||||
@@ -126,6 +128,18 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
s.dbError(w, err)
|
||||
return
|
||||
}
|
||||
// A reset may have revoked this session while issuance waited for the owner lock.
|
||||
// This separate READ COMMITTED statement sees the reset's committed changes.
|
||||
var liveSession bool
|
||||
err = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM sessions WHERE token_hash=$1 AND user_id=$2 AND expires_at>now())`, user(r).TokenHash, owner).Scan(&liveSession)
|
||||
if err != nil {
|
||||
s.dbError(w, err)
|
||||
return
|
||||
}
|
||||
if !liveSession {
|
||||
fail(w, 401, "unauthenticated", "Session expired")
|
||||
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 {
|
||||
|
||||
@@ -247,6 +247,15 @@ func TestAPIKeySecurityBoundary(t *testing.T) {
|
||||
}
|
||||
// Browser administrator access remains unchanged.
|
||||
expect(request("GET", "/jobs/"+foreignJob, "", &owner, nil, false), 200)
|
||||
writeCancel := request("POST", "/jobs/"+ownJob+"/cancel", `{}`, nil, &writeBearer, false)
|
||||
expect(writeCancel, 200)
|
||||
var acknowledgment map[string]any
|
||||
if err := json.Unmarshal(writeCancel.Body.Bytes(), &acknowledgment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acknowledgment) != 3 || acknowledgment["id"] != ownJob || acknowledgment["status"] != "finished" || acknowledgment["cancel_requested"] != false {
|
||||
t.Fatal("write-only cancel leaked report instead of acknowledgment", writeCancel.Body)
|
||||
}
|
||||
// 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)
|
||||
@@ -278,8 +287,14 @@ func TestAPIKeySecurityBoundary(t *testing.T) {
|
||||
// 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)
|
||||
createdJob := request("POST", "/jobs", jobInput, nil, &writeBearer, false)
|
||||
expect(createdJob, 201)
|
||||
acknowledgment = nil
|
||||
if err := json.Unmarshal(createdJob.Body.Bytes(), &acknowledgment); err != nil || len(acknowledgment) != 3 || acknowledgment["status"] != "queued" {
|
||||
t.Fatal("write-only create leaked report", err, createdJob.Body)
|
||||
}
|
||||
createdJob = request("GET", "/jobs/"+acknowledgment["id"].(string), "", nil, &bearer, false)
|
||||
expect(createdJob, 200)
|
||||
var admitted struct {
|
||||
ID string
|
||||
CancelRequested bool `json:"cancel_requested"`
|
||||
@@ -305,7 +320,13 @@ func TestAPIKeySecurityBoundary(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
retried := request("POST", "/jobs/"+admitted.ID+"/retry", `{}`, nil, &writeBearer, false)
|
||||
expect(retried, 200)
|
||||
acknowledgment = nil
|
||||
if err := json.Unmarshal(retried.Body.Bytes(), &acknowledgment); err != nil || len(acknowledgment) != 3 || acknowledgment["status"] != "queued" || acknowledgment["cancel_requested"] != false {
|
||||
t.Fatal("write-only retry leaked report", err, retried.Body)
|
||||
}
|
||||
retried = request("GET", "/jobs/"+admitted.ID, "", 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)
|
||||
@@ -331,6 +352,53 @@ func TestAPIKeySecurityBoundary(t *testing.T) {
|
||||
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)
|
||||
// Hold the same users lock as password reset, then let an authenticated issuance wait.
|
||||
racing := makeUser("reset-race", "operator")
|
||||
reset, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reset.Rollback(ctx)
|
||||
if _, err = reset.Exec(ctx, `UPDATE users SET password_hash='reset' WHERE id=$1`, racing.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = reset.Exec(ctx, `DELETE FROM sessions WHERE user_id=$1`, racing.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = reset.Exec(ctx, `UPDATE api_keys SET revoked_at=now() WHERE owner_id=$1`, racing.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
issued <- request("POST", "/api-keys", `{"name":"race","scopes":["profiles:read"],"expires_in_days":1}`, &racing, nil, true)
|
||||
}()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
var waiting bool
|
||||
if err = db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid<>pg_backend_pid() AND wait_event_type='Lock' AND query LIKE 'SELECT id::text FROM users WHERE id=$1 AND NOT disabled FOR UPDATE%')`).Scan(&waiting); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if waiting {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("issuance did not reach owner lock")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if err = reset.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case w := <-issued:
|
||||
expect(w, 401)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("issuance did not unblock")
|
||||
}
|
||||
var survivors int
|
||||
if err = db.QueryRow(ctx, `SELECT count(*) FROM api_keys WHERE owner_id=$1 AND revoked_at IS NULL`, racing.id).Scan(&survivors); err != nil || survivors != 0 {
|
||||
t.Fatal("key survived session reset", survivors, err)
|
||||
}
|
||||
// Concurrent issuance shares the owner lock, not a racy count-then-insert.
|
||||
capped := makeUser("concurrent-owner", "operator")
|
||||
statuses := make(chan int, 24)
|
||||
|
||||
@@ -216,6 +216,39 @@ func TestOpenAPIConsumerShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIWriteOnlyAcknowledgement(t *testing.T) {
|
||||
document := servedOpenAPI(t)
|
||||
schema := documentObject(t, document, "components", "schemas", "JobAcknowledgement")
|
||||
properties := documentObject(t, schema, "properties")
|
||||
if schema["additionalProperties"] != false || len(properties) != 3 {
|
||||
t.Fatalf("write-only acknowledgement must reject all detail fields: %v", schema)
|
||||
}
|
||||
if !reflect.DeepEqual(schema["required"], []any{"id", "status", "cancel_requested"}) {
|
||||
t.Fatalf("write-only acknowledgement required fields = %v", schema["required"])
|
||||
}
|
||||
if documentObject(t, properties, "id")["format"] != "uuid" || documentObject(t, properties, "cancel_requested")["type"] != "boolean" {
|
||||
t.Fatal("acknowledgement must preserve UUID and cancellation flag types")
|
||||
}
|
||||
wantStatus := documentObject(t, document, "components", "schemas", "Job", "properties", "status")
|
||||
if !reflect.DeepEqual(properties["status"], wantStatus) {
|
||||
t.Fatal("acknowledgement must preserve real Job statuses")
|
||||
}
|
||||
for _, response := range []struct{ path, code string }{
|
||||
{"/jobs", "201"},
|
||||
{"/jobs/{id}/cancel", "200"},
|
||||
{"/jobs/{id}/retry", "200"},
|
||||
} {
|
||||
decl := documentObject(t, document, "paths", response.path, "post", "responses", response.code, "content", "application/json", "schema")
|
||||
want := []any{
|
||||
map[string]any{"$ref": "#/components/schemas/Job"},
|
||||
map[string]any{"$ref": "#/components/schemas/JobAcknowledgement"},
|
||||
}
|
||||
if !reflect.DeepEqual(decl["oneOf"], want) {
|
||||
t.Errorf("POST %s must allow full Job OR strict write-only acknowledgement: %v", response.path, decl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPILocalReferencesResolve(t *testing.T) {
|
||||
document := servedOpenAPI(t)
|
||||
var visit func(any)
|
||||
|
||||
@@ -422,7 +422,11 @@ const jobQuery = `SELECT to_jsonb(j)||jsonb_build_object('filename',u.filename,'
|
||||
|
||||
func (s *Server) respondJob(w http.ResponseWriter, r *http.Request, id string, status int) {
|
||||
a := user(r)
|
||||
v, e := s.queryObject(r.Context(), jobQuery, id, a.ID, a.Role)
|
||||
query := jobQuery
|
||||
if a.RestrictedJobResponse {
|
||||
query = `SELECT jsonb_build_object('id',id,'status',status,'cancel_requested',cancel_requested) FROM jobs WHERE id=$1 AND owner_id=$2 AND $3='operator'`
|
||||
}
|
||||
v, e := s.queryObject(r.Context(), query, id, a.ID, a.Role)
|
||||
if e != nil {
|
||||
s.dbError(w, e)
|
||||
return
|
||||
|
||||
@@ -777,11 +777,14 @@
|
||||
"x-required-scope": "jobs:write",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Успех",
|
||||
"description": "Cookie-сессия или ключ с jobs:read получает полный Job. Ключ без jobs:read получает только JobAcknowledgement: id, status, cancel_requested.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Job"
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/Job" },
|
||||
{ "$ref": "#/components/schemas/JobAcknowledgement" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -987,7 +990,7 @@
|
||||
"Задания"
|
||||
],
|
||||
"summary": "Подробности задания",
|
||||
"description": "Полный Job с Attempts и их report/video/artifacts. Исторические попытки не удаляются.",
|
||||
"description": "Полный Job с Attempts и их report/video/artifacts. jobs:read включает эти вложенные метаданные артефактов и видео, но скачивание содержимого, отдельный список артефактов и отдельный видео-манифест требуют artifacts:read. Исторические попытки не удаляются.",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
@@ -1071,7 +1074,7 @@
|
||||
"Задания"
|
||||
],
|
||||
"summary": "Запросить отмену задания",
|
||||
"description": "Для queued/running выставляет cancel_requested=true, остановка асинхронна. Для terminal job не меняет состояние. Возвращает текущее Job, не гарантию завершённого cleanup.",
|
||||
"description": "Для queued/running выставляет cancel_requested=true, остановка асинхронна. Для terminal job не меняет состояние. Возвращает Job либо ограниченное подтверждение в зависимости от jobs:read, не гарантию завершённого cleanup.",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
@@ -1084,11 +1087,14 @@
|
||||
"x-required-scope": "jobs:write",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успех",
|
||||
"description": "Cookie-сессия или ключ с jobs:read получает полный Job. Ключ без jobs:read получает только JobAcknowledgement: id, status, cancel_requested.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Job"
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/Job" },
|
||||
{ "$ref": "#/components/schemas/JobAcknowledgement" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1178,11 +1184,14 @@
|
||||
"x-required-scope": "jobs:write",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успех",
|
||||
"description": "Cookie-сессия или ключ с jobs:read получает полный Job. Ключ без jobs:read получает только JobAcknowledgement: id, status, cancel_requested.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Job"
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/Job" },
|
||||
{ "$ref": "#/components/schemas/JobAcknowledgement" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4014,6 +4023,24 @@
|
||||
],
|
||||
"description": "Список и dashboard: нет queue_ahead и вложенных attempts. Runs упорядочены по profile_name, id."
|
||||
},
|
||||
"JobAcknowledgement": {
|
||||
"type": "object",
|
||||
"description": "Ограниченное подтверждение create/cancel/retry для API-ключа с jobs:write без jobs:read. Не содержит настроек, отчётов, Runs, Attempts и метаданных артефактов.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["queued", "running", "completed", "cancelled", "failed"]
|
||||
},
|
||||
"cancel_requested": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "status", "cancel_requested"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Job": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -35,7 +35,10 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
dummyHash []byte
|
||||
}
|
||||
type identity struct{ ID, Username, Role, CSRF, TokenHash string }
|
||||
type identity struct {
|
||||
ID, Username, Role, CSRF, TokenHash string
|
||||
RestrictedJobResponse bool
|
||||
}
|
||||
type authKey struct{}
|
||||
|
||||
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
Reference in New Issue
Block a user