diff --git a/docs/api-inventory.md b/docs/api-inventory.md new file mode 100644 index 0000000..707eebe --- /dev/null +++ b/docs/api-inventory.md @@ -0,0 +1,40 @@ +# 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/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. | +| 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). + +## 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 new file mode 100644 index 0000000..f2a5238 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,169 @@ +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' } + '403': { $ref: '#/components/responses/Forbidden' } + /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: false + description: Required for every delete; a missing value returns 428. + schema: { type: string } + responses: + '204': { description: Route deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '412': { description: ETag does not match or route does not exist } + '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 } } + '400': { $ref: '#/components/responses/BadRequest' } + '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: false + description: Required for every delete; a missing value returns 428. + 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: + 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 } + 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..06130ca 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}, }; @@ -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(()); @@ -442,8 +467,16 @@ 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/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)) @@ -570,8 +603,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( @@ -617,8 +650,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( @@ -741,8 +774,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( @@ -849,8 +882,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); @@ -870,6 +903,152 @@ 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 only when no precondition is supplied. +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 + .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 !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 + } 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()); + 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(( + 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, + "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}\"") +} + +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()) } @@ -879,8 +1058,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); @@ -910,13 +1089,177 @@ 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(|_| { + ( + StatusCode::BAD_REQUEST, + "DNSBL address must be an IP address", + ) + }) +} + +async fn get_dnsbl( + State(state): State, + PathParam(address): PathParam, +) -> Response { + let address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + 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 { + 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; + } + let address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + Err((status, message)) => return error(status, message), + }; + 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 address = match parse_dnsbl_path_address(&address) { + Ok(address) => address, + Err((status, message)) => return error(status, message), + }; + 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, 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); @@ -1054,8 +1397,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); @@ -1110,8 +1453,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); @@ -1162,8 +1505,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( @@ -1253,8 +1596,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( @@ -1344,8 +1687,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( @@ -1456,8 +1799,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( @@ -1640,8 +1983,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, @@ -1743,8 +2086,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, @@ -1909,8 +2252,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); @@ -2374,6 +2717,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 +3829,306 @@ 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 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() + .method(Method::PUT) + .uri("/api/routes/demo") + .header("content-type", "application/json") + .header("x-admin-token", "writer") + .header( + header::IF_MATCH, + format!("\"stale\", {}", etag.to_str().unwrap()), + ) + .body(Body::from(replacement.to_string())) + .unwrap(), + ) + .await; + assert_eq!(replaced.status(), StatusCode::OK); + 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 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() + .method(Method::DELETE) + .uri("/api/routes/demo") + .header("x-admin-token", "writer") + .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 + .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 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 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(); + 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"); @@ -3495,7 +4150,44 @@ mod tests { ), ) .await; - assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + 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, @@ -6438,6 +7130,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(