Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/api-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
47 changes: 47 additions & 0 deletions docs/openapi.yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No DnsblEntry schema in OpenAPI components

components.schemas defines Route but no DnsblEntry, so the DNSBL body $refs have no target. Completing the DNSBL contract requires adding a DnsblEntry schema (address, code, reason, source, ttl_seconds, optional prefix_len).

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Comment on lines +98 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 DNSBL GET response omits its JSON body

The 200 response for GET /api/dnsbl/{address} documents only the ETag header and no content, unlike the route GET at openapi.yaml. The handler returns a JSON entry body, so the contract understates the actual response shape.

Suggested change
'200':
description: DNSBL entry
headers:
ETag: { schema: { type: string } }
'200':
description: DNSBL entry
headers:
ETag: { schema: { type: string } }
content:
application/json:
schema: { $ref: '#/components/schemas/DnsblEntry' }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

'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 }
Comment on lines +103 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 DNSBL PUT declares no request body

The put operation for /api/dnsbl/{address} declares no requestBody, unlike the route put at openapi.yaml. The handler requires a JSON DnsblEntry body, so a client generated from this contract sends no body and every create/replace fails.

Suggested change
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 }
put:
summary: Create or conditionally replace one DNSBL entry
security: [{ AdminToken: [] }]
parameters:
- name: If-Match
in: header
required: false
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/DnsblEntry' }
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 }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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:
Expand Down
265 changes: 265 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -1085,6 +1089,149 @@ async fn list_dnsbl(State(state): State<AppState>) -> Json<Vec<DnsblEntry>> {
Json(state.inner.read().await.dnsbl.clone())
}

async fn get_dnsbl(
State(state): State<AppState>,
PathParam(address): PathParam<IpAddr>,
) -> 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<AppState>,
PathParam(address): PathParam<IpAddr>,
headers: HeaderMap,
Json(entry): Json<DnsblEntry>,
) -> 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<AppState>,
PathParam(address): PathParam<IpAddr>,
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),
}
}
Comment on lines +1092 to +1216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: DNSBL item handlers mirror route lifecycle

get_dnsbl, replace_dnsbl, and delete_dnsbl replicate the route lifecycle: write gate, path/body identity check, validation, If-Match precondition arms, transactional persist-with-rollback, and audit logging. ETag helpers match route_etag/route_response. Address equality uses parsed IpAddr, so IPv6 canonicalization is handled. No logic divergence found.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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<AppState>,
headers: HeaderMap,
Expand Down Expand Up @@ -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<AuditLogEntry> = 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");
Expand Down
Loading