From 46bf83e810bf3ffd9624d84e4ea23701a8d563fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:03:56 +0900 Subject: [PATCH 01/11] feat(api): complete gateway route lifecycle --- docs/api-inventory.md | 32 ++++++ docs/openapi.yaml | 119 ++++++++++++++++++++ src/lib.rs | 253 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 docs/api-inventory.md create mode 100644 docs/openapi.yaml diff --git a/docs/api-inventory.md b/docs/api-inventory.md new file mode 100644 index 0000000..6beed7a --- /dev/null +++ b/docs/api-inventory.md @@ -0,0 +1,32 @@ +# Wardnet HTTP API inventory + +Snapshot: 2026-08-26, `origin/main` at `107117634764c901dff540044585d64088fafedb`. + +| Product area | Existing HTTP contracts | Lifecycle gap after this change | +| --- | --- | --- | +| Health and deployment | `GET /healthz`, `/readyz`, `/api/version`, `/metrics`, `/api/support-bundle` | No authenticated runtime configuration view or reload contract. | +| Gateway and routes | `ANY /gateway/{path}`, `GET/POST /api/routes`, `GET/PUT/DELETE /api/routes/{route_id}`, `POST /api/evaluate` | Route collection pagination and an explicit gateway decision trace lookup remain absent. | +| WAF | `POST /api/waf/coraza/audit` | No rule-set activation/version API; this must follow the Coraza/CRS authority contract rather than inventing rules. | +| IDS | `POST /api/ids/suricata/eve` | No sensor registration, sensor health, or EVE cursor/checkpoint API. | +| AI SOC | `GET /api/soc/llm-config`, `POST /api/soc/analyze` | No analysis job/history/feedback lifecycle. | +| Events and KPIs | `GET /api/events`, `/api/events.ndjson`, `/api/kpis`, `/api/audit-logs` | Event and audit cursor pagination, time ranges, acknowledgement/case state, and retention controls remain absent. | +| DNSBL | `GET/POST /api/dnsbl`, `GET /dnsbl/zone` | Individual lookup/update/delete and serial/conditional zone transfer contracts remain absent. | +| Threat intelligence | `GET /api/threats`, `GET /api/threat-feeds`, `/freshness`; import endpoints for generic feeds, phishing-database, STIX, MISP, TAXII, and OpenCTI | Individual indicator/feed lifecycle and import idempotency keys remain absent. | +| APIM and load balancing | Route CRUD and prefix-based upstream proxying | No upstream pool/member, health-check, retry/circuit-breaker, API consumer, quota, or API-key lifecycle. These need persisted models before endpoints. | +| Credentials and config | Admin token RBAC; integration config status views | No credential registry CRUD/rotation metadata API. Secret values must never be returned. Operational config still lacks a durable KV model. | +| Durability and audit | JSON snapshot persistence and mutation audit rows | No immutable remote audit sink, tenant boundary, or general optimistic-concurrency revision. Route items now use ETag/If-Match. | + +## Route resource contract + +Legacy `POST /api/routes` remains an upsert and keeps its existing response shape/status. +New clients should use the item resource: + +- `GET /api/routes/{route_id}` returns the route and an `ETag` header. +- `PUT /api/routes/{route_id}` creates a missing route. Replacing an existing route requires + `If-Match` with the latest ETag (`428` when absent, `412` when stale). +- `DELETE /api/routes/{route_id}` requires `If-Match` and returns `204`. +- Writes require a write-capable admin principal. An authenticated read-only principal gets + `403`; a missing or invalid credential gets `401`. Successful replace/delete operations are + persisted and audited. + +The machine-readable contract is [openapi.yaml](openapi.yaml). diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..7eb4f40 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,119 @@ +openapi: 3.1.0 +info: + title: Wardnet operator API + version: 0.1.0 + description: Operator contract for the route lifecycle. Legacy collection POST remains supported. +paths: + /api/routes: + get: + summary: List gateway routes + responses: + '200': + description: Route collection + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/Route' } + post: + summary: Legacy route upsert + security: [{ AdminToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Route' } + responses: + '201': { description: Route upserted } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + /api/routes/{route_id}: + parameters: + - name: route_id + in: path + required: true + schema: { type: string } + get: + summary: Get one route and its concurrency token + responses: + '200': + description: Route + headers: + ETag: { schema: { type: string } } + content: + application/json: + schema: { $ref: '#/components/schemas/Route' } + '404': { $ref: '#/components/responses/NotFound' } + put: + summary: Create or conditionally replace one route + security: [{ AdminToken: [] }] + parameters: + - name: If-Match + in: header + required: false + description: Required when the route already exists. + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Route' } + responses: + '200': { description: Route replaced } + '201': { description: Route created } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '412': { description: ETag does not match } + '428': { description: If-Match required for an existing route } + delete: + summary: Conditionally delete one route + security: [{ AdminToken: [] }] + parameters: + - name: If-Match + in: header + required: true + schema: { type: string } + responses: + '204': { description: Route deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '412': { description: ETag does not match } + '428': { description: If-Match required } +components: + securitySchemes: + AdminToken: + type: apiKey + in: header + name: X-Admin-Token + schemas: + Route: + type: object + additionalProperties: false + required: [id, path_prefix, upstream, mode, enabled] + properties: + id: { type: string, minLength: 1 } + path_prefix: { type: string, pattern: '^/' } + upstream: { type: string } + mode: { type: string, enum: [monitor, block] } + enabled: { type: boolean } + block_threshold: { type: [integer, 'null'], minimum: 1, maximum: 100 } + Error: + type: object + required: [error] + properties: + error: { type: string } + responses: + BadRequest: + description: Invalid route or path/body identity mismatch + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Unauthorized: + description: Missing or invalid admin credential + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Forbidden: + description: Authenticated principal is read-only + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + NotFound: + description: Route not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } diff --git a/src/lib.rs b/src/lib.rs index 8f54751..f50b7f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use axum::{ Json, Router, body::Bytes, extract::{DefaultBodyLimit, Path as PathParam, Query, State}, - http::{HeaderMap, Method, StatusCode, Uri}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}, response::{Html, IntoResponse, Response}, routing::{any, get, post}, }; @@ -442,6 +442,10 @@ pub fn build_app(state: AppState) -> Router { .route("/readyz", get(readyz)) .route("/api/version", get(version)) .route("/api/routes", get(list_routes).post(create_route)) + .route( + "/api/routes/{route_id}", + get(get_route).put(replace_route).delete(delete_route), + ) .route("/api/threats", get(list_threats).post(create_threat)) .route("/api/dnsbl", get(list_dnsbl).post(create_dnsbl)) .route("/api/events", get(list_events)) @@ -870,6 +874,136 @@ async fn create_route( } } +async fn get_route( + State(state): State, + PathParam(route_id): PathParam, +) -> Response { + let data = state.inner.read().await; + let Some(route) = data.routes.iter().find(|route| route.id == route_id) else { + return error(StatusCode::NOT_FOUND, "route not found"); + }; + route_response(StatusCode::OK, route) +} + +/// Replaces one route. Existing routes require the ETag returned by GET in +/// `If-Match`; a missing route is created without a precondition. +async fn replace_route( + State(state): State, + PathParam(route_id): PathParam, + headers: HeaderMap, + Json(route): Json, +) -> Response { + if let Some(response) = management_write_denied(&state, &headers) { + return response; + } + if route.id != route_id { + return error(StatusCode::BAD_REQUEST, "path route_id must match body id"); + } + if let Err(message) = validate_route(&route) { + return error(StatusCode::BAD_REQUEST, message); + } + + let expected = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()); + let actor = audit_actor(&state, &headers); + match state + .mutate_and_persist(|data| { + let existing = data.routes.iter().find(|item| item.id == route_id); + let existed = existing.is_some(); + match existing { + Some(_) if expected.is_none() => Err(( + StatusCode::PRECONDITION_REQUIRED, + "If-Match is required when replacing an existing route".to_string(), + )), + Some(current) if expected != Some(route_etag(current).as_str()) => Err(( + StatusCode::PRECONDITION_FAILED, + "route changed; GET the latest representation and retry".to_string(), + )), + _ => { + let status = if existed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + let saved = upsert_route(&mut data.routes, route.clone()); + record_successful_audit_log( + data, + actor, + "replace_route", + "route", + saved.id.clone(), + ); + Ok((status, saved)) + } + } + }) + .await + { + Ok(Ok((status, saved))) => route_response(status, &saved), + Ok(Err((status, message))) => error(status, message), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + +async fn delete_route( + State(state): State, + PathParam(route_id): PathParam, + headers: HeaderMap, +) -> Response { + if let Some(response) = management_write_denied(&state, &headers) { + return response; + } + let expected = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()); + let actor = audit_actor(&state, &headers); + match state + .mutate_and_persist(|data| { + let Some(index) = data.routes.iter().position(|route| route.id == route_id) else { + return Err((StatusCode::NOT_FOUND, "route not found".to_string())); + }; + if expected.is_none() { + return Err(( + StatusCode::PRECONDITION_REQUIRED, + "If-Match is required when deleting a route".to_string(), + )); + } + if expected != Some(route_etag(&data.routes[index]).as_str()) { + return Err(( + StatusCode::PRECONDITION_FAILED, + "route changed; GET the latest representation and retry".to_string(), + )); + } + data.routes.remove(index); + record_successful_audit_log(data, actor, "delete_route", "route", route_id.clone()); + Ok(()) + }) + .await + { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + Ok(Err((status, message))) => error(status, message), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + +fn route_response(status: StatusCode, route: &RouteConfig) -> Response { + let mut response = (status, Json(route.clone())).into_response(); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&route_etag(route)).expect("route ETags contain only ASCII"), + ); + response +} + +fn route_etag(route: &RouteConfig) -> String { + let bytes = serde_json::to_vec(route).expect("RouteConfig is JSON-serializable"); + let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("\"{hash:016x}\"") +} + async fn list_threats(State(state): State) -> Json> { Json(state.inner.read().await.threats.clone()) } @@ -2374,6 +2508,18 @@ fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool { presented.is_some_and(|actual| actual == expected) } +fn management_write_denied(state: &AppState, headers: &HeaderMap) -> Option { + if admin_authorized(state, headers) { + return None; + } + let (status, message) = if admin_authenticated(state, headers) { + (StatusCode::FORBIDDEN, "admin principal is read-only") + } else { + (StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token") + }; + Some(error(status, message)) +} + fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { // Prefer the actor bound to the presented RBAC token, then an explicit // actor header, then a generic label. The token itself is never logged. @@ -3474,6 +3620,111 @@ mod tests { assert_eq!(audit_actor(&state, &named), "carol"); } + #[tokio::test] + async fn route_item_api_enforces_etag_rbac_and_audits_delete() { + let state = AppState::seeded(None) + .with_admin_tokens(parse_admin_tokens("reader:r:readonly,writer:w:write")); + let app = build_app(state); + + let get_response = app_request(&app, empty_request(Method::GET, "/api/routes/demo")).await; + assert_eq!(get_response.status(), StatusCode::OK); + let etag = get_response.headers().get(header::ETAG).unwrap().clone(); + let missing = app_request(&app, empty_request(Method::GET, "/api/routes/missing")).await; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let replacement = serde_json::json!({ + "id": "demo", + "path_prefix": "/demo-v2", + "upstream": "mock://demo-upstream", + "mode": "block", + "enabled": true + }); + let readonly = app_request( + &app, + Request::builder() + .method(Method::PUT) + .uri("/api/routes/demo") + .header("content-type", "application/json") + .header("x-admin-token", "reader") + .header(header::IF_MATCH, etag.clone()) + .body(Body::from(replacement.to_string())) + .unwrap(), + ) + .await; + assert_eq!(readonly.status(), StatusCode::FORBIDDEN); + + let no_precondition = app_request( + &app, + json_request( + Method::PUT, + "/api/routes/demo", + Some("writer"), + &replacement, + ), + ) + .await; + assert_eq!(no_precondition.status(), StatusCode::PRECONDITION_REQUIRED); + + let replaced = app_request( + &app, + Request::builder() + .method(Method::PUT) + .uri("/api/routes/demo") + .header("content-type", "application/json") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, etag) + .body(Body::from(replacement.to_string())) + .unwrap(), + ) + .await; + assert_eq!(replaced.status(), StatusCode::OK); + let replacement_etag = replaced.headers().get(header::ETAG).unwrap().clone(); + + let stale_delete = app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri("/api/routes/demo") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, "\"stale\"") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(stale_delete.status(), StatusCode::PRECONDITION_FAILED); + + let deleted = app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri("/api/routes/demo") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, replacement_etag) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert_eq!( + app_request(&app, empty_request(Method::GET, "/api/routes/demo")) + .await + .status(), + StatusCode::NOT_FOUND + ); + + let audit: Vec = json_body( + app_request( + &app, + authed_empty_request(Method::GET, "/api/audit-logs", "reader"), + ) + .await, + ) + .await; + assert!(audit.iter().any(|entry| { + entry.action == "delete_route" && entry.resource_id == "demo" && entry.actor == "w" + })); + } + #[tokio::test] async fn readonly_token_can_read_audit_logs_but_cannot_write() { let tokens = parse_admin_tokens("write:ops:admin,read:auditor:readonly"); From a9baf087cb1d0e06f7ec4b0c7801d75e5fb86eef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 22:06:31 +0900 Subject: [PATCH 02/11] fix(api): honor conditional route write semantics --- docs/openapi.yaml | 5 +- src/lib.rs | 128 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7eb4f40..827092f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -72,7 +72,8 @@ paths: parameters: - name: If-Match in: header - required: true + required: false + description: Required for every delete; a missing value returns 428. schema: { type: string } responses: '204': { description: Route deleted } @@ -98,7 +99,7 @@ components: upstream: { type: string } mode: { type: string, enum: [monitor, block] } enabled: { type: boolean } - block_threshold: { type: [integer, 'null'], minimum: 1, maximum: 100 } + block_threshold: { type: [integer, 'null'], minimum: 1 } Error: type: object required: [error] diff --git a/src/lib.rs b/src/lib.rs index f50b7f6..a940d78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -236,6 +236,31 @@ impl AppState { Ok(result) } + async fn try_mutate_and_persist( + &self, + mutate: impl FnOnce(&mut AppData) -> Result, + ) -> Result, String> { + let _guard = self.persist_lock.lock().await; + let (result, snapshot, previous) = { + let mut data = self.inner.write().await; + let previous = data.clone(); + let result = match mutate(&mut data) { + Ok(value) => value, + Err(error) => { + *data = previous; + return Ok(Err(error)); + } + }; + (result, data.clone(), previous) + }; + if let Err(error) = self.persist_snapshot(&snapshot).await { + let mut data = self.inner.write().await; + *data = previous; + return Err(error); + } + Ok(Ok(result)) + } + async fn persist_snapshot(&self, data: &AppData) -> Result<(), String> { let Some(path) = self.state_path.as_deref() else { return Ok(()); @@ -853,8 +878,8 @@ async fn create_route( headers: HeaderMap, Json(route): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); @@ -886,7 +911,7 @@ async fn get_route( } /// Replaces one route. Existing routes require the ETag returned by GET in -/// `If-Match`; a missing route is created without a precondition. +/// `If-Match`; a missing route is created only when no precondition is supplied. async fn replace_route( State(state): State, PathParam(route_id): PathParam, @@ -908,18 +933,26 @@ async fn replace_route( .and_then(|value| value.to_str().ok()); let actor = audit_actor(&state, &headers); match state - .mutate_and_persist(|data| { + .try_mutate_and_persist(|data| { let existing = data.routes.iter().find(|item| item.id == route_id); let existed = existing.is_some(); match existing { + None if expected.is_some() => Err(( + StatusCode::PRECONDITION_FAILED, + "If-Match requires an existing route".to_string(), + )), Some(_) if expected.is_none() => Err(( StatusCode::PRECONDITION_REQUIRED, "If-Match is required when replacing an existing route".to_string(), )), - Some(current) if expected != Some(route_etag(current).as_str()) => Err(( - StatusCode::PRECONDITION_FAILED, - "route changed; GET the latest representation and retry".to_string(), - )), + Some(current) + if expected != Some("*") && expected != Some(route_etag(current).as_str()) => + { + Err(( + StatusCode::PRECONDITION_FAILED, + "route changed; GET the latest representation and retry".to_string(), + )) + } _ => { let status = if existed { StatusCode::OK @@ -959,9 +992,16 @@ async fn delete_route( .and_then(|value| value.to_str().ok()); let actor = audit_actor(&state, &headers); match state - .mutate_and_persist(|data| { + .try_mutate_and_persist(|data| { let Some(index) = data.routes.iter().position(|route| route.id == route_id) else { - return Err((StatusCode::NOT_FOUND, "route not found".to_string())); + return Err(if expected.is_some() { + ( + StatusCode::PRECONDITION_FAILED, + "If-Match requires an existing route".to_string(), + ) + } else { + (StatusCode::NOT_FOUND, "route not found".to_string()) + }); }; if expected.is_none() { return Err(( @@ -969,7 +1009,7 @@ async fn delete_route( "If-Match is required when deleting a route".to_string(), )); } - if expected != Some(route_etag(&data.routes[index]).as_str()) { + if expected != Some("*") && expected != Some(route_etag(&data.routes[index]).as_str()) { return Err(( StatusCode::PRECONDITION_FAILED, "route changed; GET the latest representation and retry".to_string(), @@ -3665,6 +3705,32 @@ mod tests { .await; assert_eq!(no_precondition.status(), StatusCode::PRECONDITION_REQUIRED); + let missing_with_precondition = app_request( + &app, + Request::builder() + .method(Method::PUT) + .uri("/api/routes/new") + .header("content-type", "application/json") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, "*") + .body(Body::from( + serde_json::json!({ + "id": "new", + "path_prefix": "/new", + "upstream": "mock://new", + "mode": "monitor", + "enabled": true + }) + .to_string(), + )) + .unwrap(), + ) + .await; + assert_eq!( + missing_with_precondition.status(), + StatusCode::PRECONDITION_FAILED + ); + let replaced = app_request( &app, Request::builder() @@ -3678,8 +3744,6 @@ mod tests { ) .await; assert_eq!(replaced.status(), StatusCode::OK); - let replacement_etag = replaced.headers().get(header::ETAG).unwrap().clone(); - let stale_delete = app_request( &app, Request::builder() @@ -3699,12 +3763,27 @@ mod tests { .method(Method::DELETE) .uri("/api/routes/demo") .header("x-admin-token", "writer") - .header(header::IF_MATCH, replacement_etag) + .header(header::IF_MATCH, "*") .body(Body::empty()) .unwrap(), ) .await; assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + let missing_delete_with_precondition = app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri("/api/routes/demo") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, "*") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!( + missing_delete_with_precondition.status(), + StatusCode::PRECONDITION_FAILED + ); assert_eq!( app_request(&app, empty_request(Method::GET, "/api/routes/demo")) .await @@ -3746,7 +3825,7 @@ mod tests { ), ) .await; - assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); let created = app_request( &app, @@ -6689,6 +6768,25 @@ mod tests { ); let app = build_app(state); + let rejected = app_request( + &app, + json_request( + Method::PUT, + "/api/routes/mock", + None, + &RouteConfig { + id: "mock".to_string(), + path_prefix: "/unchanged".to_string(), + upstream: "mock://mock".to_string(), + mode: EnforcementMode::Monitor, + enabled: true, + block_threshold: None, + }, + ), + ) + .await; + assert_eq!(rejected.status(), StatusCode::PRECONDITION_REQUIRED); + let route_response = app_request( &app, json_request( From 07c5345b5fd99794a25a8db916344e79c4820276 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 22:43:00 +0900 Subject: [PATCH 03/11] docs(api): document route write denial --- docs/openapi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 827092f..4253076 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -27,6 +27,7 @@ paths: '201': { description: Route upserted } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } /api/routes/{route_id}: parameters: - name: route_id From 28927631014eb7d0975b51b1bd02cccb1061d08b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:00:51 +0900 Subject: [PATCH 04/11] fix(api): distinguish readonly management principals --- src/lib.rs | 93 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a940d78..986f768 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -599,8 +599,8 @@ async fn clearfolio_submit( PathParam(kind): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } let Some(config) = state.clearfolio.clone() else { return error( @@ -646,8 +646,8 @@ async fn clearfolio_status( PathParam(job_id): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } let Some(config) = state.clearfolio.clone() else { return error( @@ -770,8 +770,8 @@ async fn soc_analyze( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } let Some(config) = state.soc_llm.clone() else { return error( @@ -1053,8 +1053,8 @@ async fn create_threat( headers: HeaderMap, Json(indicator): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_threat(&indicator) { return error(StatusCode::BAD_REQUEST, message); @@ -1089,8 +1089,8 @@ async fn create_dnsbl( headers: HeaderMap, Json(entry): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_dnsbl(&entry) { return error(StatusCode::BAD_REQUEST, message); @@ -1228,8 +1228,8 @@ async fn update_commercial_license( headers: HeaderMap, Json(profile): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_commercial_profile(&profile) { return error(StatusCode::BAD_REQUEST, message); @@ -1284,8 +1284,8 @@ async fn import_threat_feed( headers: HeaderMap, Json(feed): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_threat_feed_import(&feed) { return error(StatusCode::BAD_REQUEST, message); @@ -1336,8 +1336,8 @@ async fn import_stix_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1427,8 +1427,8 @@ async fn import_misp_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1518,8 +1518,8 @@ async fn import_opencti_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1630,8 +1630,8 @@ async fn poll_taxii_collection( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if request.feed_id.trim().is_empty() || request.source.trim().is_empty() { return error( @@ -1814,8 +1814,8 @@ async fn import_suricata_eve( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -1917,8 +1917,8 @@ async fn import_coraza_audit( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -2083,8 +2083,8 @@ async fn import_phishing_database_feed( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(response) = management_write_denied(&state, &headers) { + return response; } if let Err(message) = validate_phishing_database_import_request(&request) { return error(StatusCode::BAD_REQUEST, message); @@ -3827,6 +3827,43 @@ mod tests { .await; assert_eq!(denied.status(), StatusCode::FORBIDDEN); + let denied_threat = app_request( + &app, + json_request( + Method::POST, + "/api/threats", + Some("read"), + &ThreatIndicator { + value: "example.invalid".to_string(), + indicator_type: "domain".to_string(), + severity: Severity::High, + source: "unit".to_string(), + ttl_seconds: 60, + }, + ), + ) + .await; + assert_eq!(denied_threat.status(), StatusCode::FORBIDDEN); + + let denied_dnsbl = app_request( + &app, + json_request( + Method::POST, + "/api/dnsbl", + Some("read"), + &DnsblEntry { + address: "192.0.2.1".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "test".to_string(), + source: "unit".to_string(), + ttl_seconds: 60, + prefix_len: None, + }, + ), + ) + .await; + assert_eq!(denied_dnsbl.status(), StatusCode::FORBIDDEN); + let created = app_request( &app, json_request( From 4fc314a1011f3d15ae9cd8fa32db190621ee36fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:37:31 +0900 Subject: [PATCH 05/11] fix(api): accept If-Match entity-tag lists --- src/lib.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 986f768..6b3f2ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -945,14 +945,10 @@ async fn replace_route( StatusCode::PRECONDITION_REQUIRED, "If-Match is required when replacing an existing route".to_string(), )), - Some(current) - if expected != Some("*") && expected != Some(route_etag(current).as_str()) => - { - Err(( - StatusCode::PRECONDITION_FAILED, - "route changed; GET the latest representation and retry".to_string(), - )) - } + Some(current) if !if_match_satisfied(expected, &route_etag(current)) => Err(( + StatusCode::PRECONDITION_FAILED, + "route changed; GET the latest representation and retry".to_string(), + )), _ => { let status = if existed { StatusCode::OK @@ -1009,7 +1005,7 @@ async fn delete_route( "If-Match is required when deleting a route".to_string(), )); } - if expected != Some("*") && expected != Some(route_etag(&data.routes[index]).as_str()) { + if !if_match_satisfied(expected, &route_etag(&data.routes[index])) { return Err(( StatusCode::PRECONDITION_FAILED, "route changed; GET the latest representation and retry".to_string(), @@ -1044,6 +1040,15 @@ fn route_etag(route: &RouteConfig) -> String { format!("\"{hash:016x}\"") } +fn if_match_satisfied(value: Option<&str>, current_etag: &str) -> bool { + value.is_some_and(|value| { + value + .split(',') + .map(str::trim) + .any(|candidate| candidate == "*" || candidate == current_etag) + }) +} + async fn list_threats(State(state): State) -> Json> { Json(state.inner.read().await.threats.clone()) } @@ -3738,7 +3743,10 @@ mod tests { .uri("/api/routes/demo") .header("content-type", "application/json") .header("x-admin-token", "writer") - .header(header::IF_MATCH, etag) + .header( + header::IF_MATCH, + format!("\"stale\", {}", etag.to_str().unwrap()), + ) .body(Body::from(replacement.to_string())) .unwrap(), ) From f6b5978c4f61910285d3a997afa9fdfdf2f1fc5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:11:09 +0900 Subject: [PATCH 06/11] fix(api): require delete precondition before lookup --- src/lib.rs | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6b3f2ba..2505ee5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -986,25 +986,21 @@ async fn delete_route( let expected = headers .get(header::IF_MATCH) .and_then(|value| value.to_str().ok()); + if expected.is_none() { + return error( + StatusCode::PRECONDITION_REQUIRED, + "If-Match is required when deleting a route", + ); + } let actor = audit_actor(&state, &headers); match state .try_mutate_and_persist(|data| { let Some(index) = data.routes.iter().position(|route| route.id == route_id) else { - return Err(if expected.is_some() { - ( - StatusCode::PRECONDITION_FAILED, - "If-Match requires an existing route".to_string(), - ) - } else { - (StatusCode::NOT_FOUND, "route not found".to_string()) - }); - }; - if expected.is_none() { return Err(( - StatusCode::PRECONDITION_REQUIRED, - "If-Match is required when deleting a route".to_string(), + StatusCode::PRECONDITION_FAILED, + "If-Match requires an existing route".to_string(), )); - } + }; if !if_match_satisfied(expected, &route_etag(&data.routes[index])) { return Err(( StatusCode::PRECONDITION_FAILED, @@ -3765,6 +3761,21 @@ mod tests { .await; assert_eq!(stale_delete.status(), StatusCode::PRECONDITION_FAILED); + let missing_delete_without_precondition = app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri("/api/routes/missing") + .header("x-admin-token", "writer") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!( + missing_delete_without_precondition.status(), + StatusCode::PRECONDITION_REQUIRED + ); + let deleted = app_request( &app, Request::builder() From 85a4e43446db8041d4d685b09663920595ac1d97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:06:45 -0700 Subject: [PATCH 07/11] feat(api): add DNSBL resource lifecycle (#124) --- docs/api-inventory.md | 10 +- docs/openapi.yaml | 47 ++++++++ src/lib.rs | 265 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 6beed7a..707eebe 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -10,7 +10,7 @@ Snapshot: 2026-08-26, `origin/main` at `107117634764c901dff540044585d64088fafedb | IDS | `POST /api/ids/suricata/eve` | No sensor registration, sensor health, or EVE cursor/checkpoint API. | | AI SOC | `GET /api/soc/llm-config`, `POST /api/soc/analyze` | No analysis job/history/feedback lifecycle. | | Events and KPIs | `GET /api/events`, `/api/events.ndjson`, `/api/kpis`, `/api/audit-logs` | Event and audit cursor pagination, time ranges, acknowledgement/case state, and retention controls remain absent. | -| DNSBL | `GET/POST /api/dnsbl`, `GET /dnsbl/zone` | Individual lookup/update/delete and serial/conditional zone transfer contracts remain absent. | +| DNSBL | `GET/POST /api/dnsbl`, `GET/PUT/DELETE /api/dnsbl/{address}`, `GET /dnsbl/zone` | Serial/conditional zone transfer contracts remain absent. | | Threat intelligence | `GET /api/threats`, `GET /api/threat-feeds`, `/freshness`; import endpoints for generic feeds, phishing-database, STIX, MISP, TAXII, and OpenCTI | Individual indicator/feed lifecycle and import idempotency keys remain absent. | | APIM and load balancing | Route CRUD and prefix-based upstream proxying | No upstream pool/member, health-check, retry/circuit-breaker, API consumer, quota, or API-key lifecycle. These need persisted models before endpoints. | | Credentials and config | Admin token RBAC; integration config status views | No credential registry CRUD/rotation metadata API. Secret values must never be returned. Operational config still lacks a durable KV model. | @@ -30,3 +30,11 @@ New clients should use the item resource: persisted and audited. The machine-readable contract is [openapi.yaml](openapi.yaml). + +## DNSBL resource contract + +- `GET /api/dnsbl/{address}` returns one IPv4 or IPv6 entry and an `ETag` header. +- `PUT /api/dnsbl/{address}` creates a missing entry. Replacing an existing entry requires + `If-Match`; the path and body addresses must match. +- `DELETE /api/dnsbl/{address}` requires `If-Match` and returns `204`. +- Writes use the same write-capable admin, persistence, rollback, and audit contract as routes. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4253076..d7c978b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -83,6 +83,53 @@ paths: '404': { $ref: '#/components/responses/NotFound' } '412': { description: ETag does not match } '428': { description: If-Match required } + /api/dnsbl/{address}: + parameters: + - name: address + in: path + required: true + schema: + oneOf: + - { type: string, format: ipv4 } + - { type: string, format: ipv6 } + get: + summary: Get one DNSBL entry and its concurrency token + responses: + '200': + description: DNSBL entry + headers: + ETag: { schema: { type: string } } + '404': { $ref: '#/components/responses/NotFound' } + put: + summary: Create or conditionally replace one DNSBL entry + security: [{ AdminToken: [] }] + parameters: + - name: If-Match + in: header + required: false + schema: { type: string } + responses: + '200': { description: DNSBL entry replaced } + '201': { description: DNSBL entry created } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '412': { description: ETag does not match } + '428': { description: If-Match required for an existing entry } + delete: + summary: Conditionally delete one DNSBL entry + security: [{ AdminToken: [] }] + parameters: + - name: If-Match + in: header + required: true + schema: { type: string } + responses: + '204': { description: DNSBL entry deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '412': { description: ETag does not match or entry does not exist } + '428': { description: If-Match required } components: securitySchemes: AdminToken: diff --git a/src/lib.rs b/src/lib.rs index 2505ee5..5d14c35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -473,6 +473,10 @@ pub fn build_app(state: AppState) -> Router { ) .route("/api/threats", get(list_threats).post(create_threat)) .route("/api/dnsbl", get(list_dnsbl).post(create_dnsbl)) + .route( + "/api/dnsbl/{address}", + get(get_dnsbl).put(replace_dnsbl).delete(delete_dnsbl), + ) .route("/api/events", get(list_events)) .route("/api/audit-logs", get(list_audit_logs)) .route("/api/events.ndjson", get(events_ndjson)) @@ -1085,6 +1089,149 @@ async fn list_dnsbl(State(state): State) -> Json> { Json(state.inner.read().await.dnsbl.clone()) } +async fn get_dnsbl( + State(state): State, + PathParam(address): PathParam, +) -> Response { + let data = state.inner.read().await; + let Some(entry) = data.dnsbl.iter().find(|entry| entry.address == address) else { + return error(StatusCode::NOT_FOUND, "DNSBL entry not found"); + }; + dnsbl_response(StatusCode::OK, entry) +} + +async fn replace_dnsbl( + State(state): State, + PathParam(address): PathParam, + headers: HeaderMap, + Json(entry): Json, +) -> Response { + if let Some(response) = management_write_denied(&state, &headers) { + return response; + } + if entry.address != address { + return error( + StatusCode::BAD_REQUEST, + "path address must match body address", + ); + } + if let Err(message) = validate_dnsbl(&entry) { + return error(StatusCode::BAD_REQUEST, message); + } + let expected = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()); + let actor = audit_actor(&state, &headers); + match state + .try_mutate_and_persist(|data| { + let existing = data.dnsbl.iter().find(|item| item.address == address); + let existed = existing.is_some(); + match existing { + None if expected.is_some() => Err(( + StatusCode::PRECONDITION_FAILED, + "If-Match requires an existing DNSBL entry".to_string(), + )), + Some(_) if expected.is_none() => Err(( + StatusCode::PRECONDITION_REQUIRED, + "If-Match is required when replacing an existing DNSBL entry".to_string(), + )), + Some(current) if !if_match_satisfied(expected, &dnsbl_etag(current)) => Err(( + StatusCode::PRECONDITION_FAILED, + "DNSBL entry changed; GET the latest representation and retry".to_string(), + )), + _ => { + let status = if existed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); + record_successful_audit_log( + data, + actor, + "replace_dnsbl", + "dnsbl_entry", + saved.address.to_string(), + ); + Ok((status, saved)) + } + } + }) + .await + { + Ok(Ok((status, saved))) => dnsbl_response(status, &saved), + Ok(Err((status, message))) => error(status, message), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + +async fn delete_dnsbl( + State(state): State, + PathParam(address): PathParam, + headers: HeaderMap, +) -> Response { + if let Some(response) = management_write_denied(&state, &headers) { + return response; + } + let expected = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()); + if expected.is_none() { + return error( + StatusCode::PRECONDITION_REQUIRED, + "If-Match is required when deleting a DNSBL entry", + ); + } + let actor = audit_actor(&state, &headers); + match state + .try_mutate_and_persist(|data| { + let Some(index) = data.dnsbl.iter().position(|entry| entry.address == address) else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "If-Match requires an existing DNSBL entry".to_string(), + )); + }; + if !if_match_satisfied(expected, &dnsbl_etag(&data.dnsbl[index])) { + return Err(( + StatusCode::PRECONDITION_FAILED, + "DNSBL entry changed; GET the latest representation and retry".to_string(), + )); + } + data.dnsbl.remove(index); + record_successful_audit_log( + data, + actor, + "delete_dnsbl", + "dnsbl_entry", + address.to_string(), + ); + Ok(()) + }) + .await + { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + Ok(Err((status, message))) => error(status, message), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + +fn dnsbl_response(status: StatusCode, entry: &DnsblEntry) -> Response { + let mut response = (status, Json(entry.clone())).into_response(); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&dnsbl_etag(entry)).expect("DNSBL ETags contain only ASCII"), + ); + response +} + +fn dnsbl_etag(entry: &DnsblEntry) -> String { + let bytes = serde_json::to_vec(entry).expect("DnsblEntry is JSON-serializable"); + let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("\"{hash:016x}\"") +} + async fn create_dnsbl( State(state): State, headers: HeaderMap, @@ -3823,6 +3970,124 @@ mod tests { })); } + #[tokio::test] + async fn dnsbl_item_api_enforces_identity_etag_rbac_and_audits_delete() { + let state = AppState::seeded(None) + .with_admin_tokens(parse_admin_tokens("reader:r:readonly,writer:w:write")); + let app = build_app(state); + let uri = "/api/dnsbl/203.0.113.10"; + + let current = app_request(&app, empty_request(Method::GET, uri)).await; + assert_eq!(current.status(), StatusCode::OK); + let etag = current.headers().get(header::ETAG).unwrap().clone(); + assert_eq!( + app_request(&app, empty_request(Method::GET, "/api/dnsbl/198.51.100.1")) + .await + .status(), + StatusCode::NOT_FOUND + ); + + let replacement = serde_json::json!({ + "address": "203.0.113.10", + "code": "127.0.0.3", + "reason": "updated scanner", + "source": "operator", + "ttl_seconds": 600 + }); + let readonly = Request::builder() + .method(Method::PUT) + .uri(uri) + .header("content-type", "application/json") + .header("x-admin-token", "reader") + .header(header::IF_MATCH, etag.clone()) + .body(Body::from(replacement.to_string())) + .unwrap(); + assert_eq!( + app_request(&app, readonly).await.status(), + StatusCode::FORBIDDEN + ); + + let mismatch = serde_json::json!({ + "address": "198.51.100.1", + "code": "127.0.0.3", + "reason": "updated scanner", + "source": "operator", + "ttl_seconds": 600 + }); + assert_eq!( + app_request( + &app, + json_request(Method::PUT, uri, Some("writer"), &mismatch) + ) + .await + .status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + app_request( + &app, + json_request(Method::PUT, uri, Some("writer"), &replacement) + ) + .await + .status(), + StatusCode::PRECONDITION_REQUIRED + ); + + let replace = Request::builder() + .method(Method::PUT) + .uri(uri) + .header("content-type", "application/json") + .header("x-admin-token", "writer") + .header(header::IF_MATCH, etag) + .body(Body::from(replacement.to_string())) + .unwrap(); + assert_eq!(app_request(&app, replace).await.status(), StatusCode::OK); + assert_eq!( + app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header("x-admin-token", "writer") + .header(header::IF_MATCH, "\"stale\"") + .body(Body::empty()) + .unwrap(), + ) + .await + .status(), + StatusCode::PRECONDITION_FAILED + ); + assert_eq!( + app_request( + &app, + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header("x-admin-token", "writer") + .header(header::IF_MATCH, "*") + .body(Body::empty()) + .unwrap(), + ) + .await + .status(), + StatusCode::NO_CONTENT + ); + + let audit: Vec = json_body( + app_request( + &app, + authed_empty_request(Method::GET, "/api/audit-logs", "reader"), + ) + .await, + ) + .await; + assert!(audit.iter().any(|entry| { + entry.action == "delete_dnsbl" + && entry.resource_id == "203.0.113.10" + && entry.actor == "w" + })); + } + #[tokio::test] async fn readonly_token_can_read_audit_logs_but_cannot_write() { let tokens = parse_admin_tokens("write:ops:admin,read:auditor:readonly"); From 8e7625fabb175ed6f9dcbfdbbc151100a07eac66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:46:08 +0900 Subject: [PATCH 08/11] fix(api): authenticate malformed DNSBL writes first --- src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5d14c35..88eeed3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1089,10 +1089,23 @@ async fn list_dnsbl(State(state): State) -> Json> { Json(state.inner.read().await.dnsbl.clone()) } +fn parse_dnsbl_path_address(address: &str) -> Result { + address.parse().map_err(|_| { + error( + StatusCode::BAD_REQUEST, + "DNSBL address must be an IP address", + ) + }) +} + async fn get_dnsbl( State(state): State, - PathParam(address): PathParam, + PathParam(address): PathParam, ) -> Response { + let address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + Err(response) => return response, + }; let data = state.inner.read().await; let Some(entry) = data.dnsbl.iter().find(|entry| entry.address == address) else { return error(StatusCode::NOT_FOUND, "DNSBL entry not found"); @@ -1102,13 +1115,17 @@ async fn get_dnsbl( async fn replace_dnsbl( State(state): State, - PathParam(address): PathParam, + PathParam(address): PathParam, headers: HeaderMap, Json(entry): Json, ) -> Response { if let Some(response) = management_write_denied(&state, &headers) { return response; } + let address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + Err(response) => return response, + }; if entry.address != address { return error( StatusCode::BAD_REQUEST, @@ -1167,12 +1184,16 @@ async fn replace_dnsbl( async fn delete_dnsbl( State(state): State, - PathParam(address): PathParam, + PathParam(address): PathParam, headers: HeaderMap, ) -> Response { if let Some(response) = management_write_denied(&state, &headers) { return response; } + let address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + Err(response) => return response, + }; let expected = headers .get(header::IF_MATCH) .and_then(|value| value.to_str().ok()); @@ -3977,6 +3998,26 @@ mod tests { let app = build_app(state); let uri = "/api/dnsbl/203.0.113.10"; + let malformed = app_request( + &app, + empty_request(Method::GET, "/api/dnsbl/not-an-address"), + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + assert_eq!( + malformed.headers().get(header::CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!( + app_request( + &app, + empty_request(Method::DELETE, "/api/dnsbl/not-an-address") + ) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + let current = app_request(&app, empty_request(Method::GET, uri)).await; assert_eq!(current.status(), StatusCode::OK); let etag = current.headers().get(header::ETAG).unwrap().clone(); From 9bb12990d1274e6b2c9c83f949fecbd5f799ba30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 15:50:13 +0900 Subject: [PATCH 09/11] fix(clippy): reduce error variant size in parse_dnsbl_path_address --- src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 88eeed3..06130ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1089,9 +1089,9 @@ async fn list_dnsbl(State(state): State) -> Json> { Json(state.inner.read().await.dnsbl.clone()) } -fn parse_dnsbl_path_address(address: &str) -> Result { +fn parse_dnsbl_path_address(address: &str) -> Result { address.parse().map_err(|_| { - error( + ( StatusCode::BAD_REQUEST, "DNSBL address must be an IP address", ) @@ -1104,7 +1104,7 @@ async fn get_dnsbl( ) -> Response { let address = match parse_dnsbl_path_address(&address) { Ok(address) => address, - Err(response) => return response, + Err((status, message)) => return error(status, message), }; let data = state.inner.read().await; let Some(entry) = data.dnsbl.iter().find(|entry| entry.address == address) else { @@ -1124,7 +1124,7 @@ async fn replace_dnsbl( } let address = match parse_dnsbl_path_address(&address) { Ok(address) => address, - Err(response) => return response, + Err((status, message)) => return error(status, message), }; if entry.address != address { return error( @@ -1192,7 +1192,7 @@ async fn delete_dnsbl( } let address = match parse_dnsbl_path_address(&address) { Ok(address) => address, - Err(response) => return response, + Err((status, message)) => return error(status, message), }; let expected = headers .get(header::IF_MATCH) From 236cdcd416cc4c14236001380eb84c016ed59c2f Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 28 Aug 2026 02:38:25 +0900 Subject: [PATCH 10/11] docs(openapi): align delete contracts with runtime --- docs/openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d7c978b..769fe5b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -80,8 +80,7 @@ paths: '204': { description: Route deleted } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } - '412': { description: ETag does not match } + '412': { description: ETag does not match or route does not exist } '428': { description: If-Match required } /api/dnsbl/{address}: parameters: @@ -122,7 +121,8 @@ paths: parameters: - name: If-Match in: header - required: true + required: false + description: Required for every delete; a missing value returns 428. schema: { type: string } responses: '204': { description: DNSBL entry deleted } From bab3c72bb72e041ad494932ae07b0fb2401991ec Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 28 Aug 2026 06:44:22 +0900 Subject: [PATCH 11/11] docs(api): describe malformed DNSBL lookup response --- docs/openapi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 769fe5b..f2a5238 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -98,6 +98,7 @@ paths: description: DNSBL entry headers: ETag: { schema: { type: string } } + '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } put: summary: Create or conditionally replace one DNSBL entry