From 6cfe536ba1f0d7231951c3e056758d7f92175a4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:06:21 +0900 Subject: [PATCH] feat(api): add DNSBL resource lifecycle --- 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 6beed7a4..707eebea 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 4253076b..d7c978bf 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 2505ee58..5d14c35d 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");