Fence key issuance against session reset and restrict write-only job responses

This commit is contained in:
omar
2026-09-26 01:21:03 +03:00
parent 87a501f507
commit 57958b3fe6
7 changed files with 167 additions and 14 deletions
+4
View File
@@ -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.
+16 -2
View File
@@ -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 {
+70 -2
View File
@@ -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)
+33
View File
@@ -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)
+5 -1
View File
@@ -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
+35 -8
View File
@@ -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": {
+4 -1
View File
@@ -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}$`)