From 42c44b2f35fe690a788ff6a323c29ad5b03d955e Mon Sep 17 00:00:00 2001 From: rejoicetukura-blip Date: Tue, 20 Oct 2026 07:36:33 +0000 Subject: [PATCH] fix(api): revoke API keys, validate Stellar addresses, fix replay progress, extend OpenAPI Closes #1735 Closes #1737 Closes #1740 Closes #1742 --- services/api/openapi.yaml | 149 ++++++++++++++++++++++++++++++++- services/api/src/blockchain.rs | 24 ++++-- services/api/src/db.rs | 11 +++ services/api/src/handlers.rs | 90 ++++++++++++++++++++ services/api/src/main.rs | 6 +- 5 files changed, 272 insertions(+), 8 deletions(-) diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index f1c3ec7e..3fc318ef 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -55,6 +55,153 @@ paths: "500": $ref: "#/components/responses/ApiError" + /health/live: + get: + tags: [health] + operationId: getHealthLive + summary: Liveness probe + responses: + "200": + description: Process is alive + content: + text/plain: + schema: + type: string + + /health/ready: + get: + tags: [health] + operationId: getHealthReady + summary: Readiness probe + responses: + "200": + description: Service is ready + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ApiError" + + /health/dependencies: + get: + tags: [health] + operationId: getHealthDependencies + summary: Dependency health (database, cache, RPC) + responses: + "200": + description: Dependency status + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "503": + $ref: "#/components/responses/ApiError" + + /metrics: + get: + tags: [health] + operationId: getMetrics + summary: Prometheus metrics + responses: + "200": + description: Metrics in Prometheus text format + content: + text/plain: + schema: + type: string + + /api/v1/newsletter/gdpr/request-token: + post: + tags: [newsletter] + operationId: newsletterGdprRequestToken + summary: Request a GDPR verification token by email + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailRequest" + responses: + "200": + description: Verification email sent if the address is subscribed + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/NewsletterResponse" + "429": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/admin/api-keys: + get: + tags: [audit] + operationId: listApiKeys + summary: List active API keys (admin) + security: + - ApiKeyAuth: [] + responses: + "200": + description: Active API keys + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "401": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/admin/api-keys/rotate: + post: + tags: [audit] + operationId: rotateApiKey + summary: Issue a new API key and expire the old one after an overlap window (admin) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + responses: + "201": + description: New key issued + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "401": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/admin/api-keys/{id}: + delete: + tags: [audit] + operationId: revokeApiKey + summary: Revoke an API key immediately (admin) + security: + - ApiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Key revoked + "401": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" + /api/v1/statistics: get: tags: [markets] @@ -246,7 +393,7 @@ paths: required: true schema: type: string - description: Stellar address of the user + description: Stellar public key (G…, 56 chars, valid checksum) - $ref: "#/components/parameters/apiVersion" - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/pageSize" diff --git a/services/api/src/blockchain.rs b/services/api/src/blockchain.rs index e9efd881..a76b9359 100644 --- a/services/api/src/blockchain.rs +++ b/services/api/src/blockchain.rs @@ -275,6 +275,9 @@ pub struct ReplayRequest { pub struct ReplayProgress { pub from_ledger: u32, pub events_replayed: usize, + /// Highest ledger of any stored event (or `from_ledger` if none were stored). + #[serde(default)] + pub last_ledger: u32, pub completed: bool, } @@ -1421,13 +1424,18 @@ impl BlockchainClient { let events = self.fetch_events_since(from_ledger).await .map_err(BlockchainError::from_anyhow)?; - let events_replayed = events.len(); + let mut events_replayed = 0usize; + let mut last_ledger = from_ledger; + let mut skipped_beyond_tip = false; for event in events { // Only store events up to the confirmed tip (idempotent by key) if event.ledger > confirmed_tip { + skipped_beyond_tip = true; continue; } + events_replayed += 1; + last_ledger = last_ledger.max(event.ledger); let event_key = format!("{}:event:{}", keys::CHAIN_PREFIX, event.id); self.cache .set_json(&event_key, &event, Duration::from_secs(30 * 60)) @@ -1438,13 +1446,17 @@ impl BlockchainClient { let progress = ReplayProgress { from_ledger, events_replayed, - completed: true, + last_ledger, + completed: !skipped_beyond_tip, }; - self.cache - .set_json(&progress_key, &progress, Duration::from_secs(60 * 60)) - .await - .map_err(|e| BlockchainError::Internal(e.to_string()))?; + // Only cache a completed replay; an incomplete one must be re-runnable. + if progress.completed { + self.cache + .set_json(&progress_key, &progress, Duration::from_secs(60 * 60)) + .await + .map_err(|e| BlockchainError::Internal(e.to_string()))?; + } tracing::info!(from_ledger, events_replayed, "event replay completed"); Ok(progress) diff --git a/services/api/src/db.rs b/services/api/src/db.rs index a8e9569c..9f61b162 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -987,6 +987,17 @@ impl Database { Ok(result.rows_affected() > 0) } + /// Revoke an active API key by id. Returns `true` if a key was revoked. + pub async fn api_key_revoke(&self, id: uuid::Uuid) -> anyhow::Result { + let result = self.with_timeout("api_key_revoke", sqlx::query( + "UPDATE api_keys SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL", + ) + .bind(id) + .execute(&self.pool)).await.map_err(anyhow::Error::from)?; + + Ok(result.rows_affected() > 0) + } + /// Validate a raw API key: compute its SHA-256 hash, then return the record /// if it exists, is not revoked, and is not expired. pub async fn api_key_validate(&self, key_hash: &str) -> anyhow::Result> { diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index 314bc8a3..dfec0a1a 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -1300,6 +1300,9 @@ pub async fn blockchain_user_bets( Path(user): Path, Query(query): Query, ) -> Result { + if !is_valid_stellar_public_key(&user) { + return Err(ApiError::bad_request("invalid Stellar public key")); + } let page_size = query.limit(); // cursor encodes the page number (0-based). // Clamp to a sane maximum before any arithmetic to prevent overflow: @@ -1913,6 +1916,81 @@ pub async fn rotate_api_key( )) } +/// DELETE /api/v1/admin/api-keys/{id} +/// +/// Revokes an API key immediately by setting `revoked_at`. Keys are validated +/// against the database on every request (no in-process cache), so the +/// revocation takes effect at once. +pub async fn revoke_api_key( + State(state): State>, + Path(id): Path, +) -> Result { + let revoked = state + .db + .api_key_revoke(id) + .await + .map_err(into_api_error)?; + if !revoked { + return Err(ApiError::not_found("API key not found or already revoked")); + } + + let entry = crate::audit::AuditLogEntry { + id: None, + timestamp: chrono::Utc::now(), + actor: "admin".to_string(), + actor_ip: None, + action: "api_key_revoke".to_string(), + resource_type: "api_key".to_string(), + resource_id: Some(id.to_string()), + details: None, + status: crate::audit::AuditStatus::Success, + error_message: None, + request_id: None, + user_agent: None, + }; + if let Err(e) = state.audit_logger.log(entry).await { + tracing::error!("Failed to write api-key revoke audit log: {e}"); + } + tracing::info!(key_id = %id, "API key revoked"); + + Ok(StatusCode::NO_CONTENT) +} + +/// Returns true if `s` is a valid Stellar public key (`G…`, 56 chars, base32 + CRC16 checksum). +fn is_valid_stellar_public_key(s: &str) -> bool { + if s.len() != 56 || !s.starts_with('G') { + return false; + } + let mut bits: u32 = 0; + let mut nbits = 0; + let mut bytes = Vec::with_capacity(35); + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'2'..=b'7' => c - b'2' + 26, + _ => return false, + } as u32; + bits = (bits << 5) | v; + nbits += 5; + if nbits >= 8 { + nbits -= 8; + bytes.push((bits >> nbits) as u8); + bits &= (1 << nbits) - 1; + } + } + if bytes.len() != 35 || bytes[0] != 6 << 3 { + return false; + } + let mut crc: u16 = 0; + for &b in &bytes[..33] { + crc ^= (b as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x1021 } else { crc << 1 }; + } + } + crc.to_le_bytes() == [bytes[33], bytes[34]] +} + /// Response item for GET /api/v1/admin/api-keys #[derive(Debug, Serialize)] pub struct ApiKeyListItem { @@ -1957,6 +2035,18 @@ mod tests { use super::*; use axum::http::StatusCode; + #[test] + fn stellar_public_key_validation() { + assert!(is_valid_stellar_public_key( + "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX" + )); + assert!(!is_valid_stellar_public_key( + "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZY" + )); + assert!(!is_valid_stellar_public_key("short")); + assert!(!is_valid_stellar_public_key(&"G".repeat(56))); + } + /// The HTTP response body from ApiError::internal must never contain the /// underlying error message — only a generic sanitised string. /// The root cause is emitted to tracing (and therefore OTel spans) instead. diff --git a/services/api/src/main.rs b/services/api/src/main.rs index b6af3562..cf57cadc 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -22,7 +22,7 @@ use std::{sync::Arc, time::Duration}; use axum::{ http::{HeaderName, HeaderValue, Method}, middleware, - routing::{get, post}, + routing::{delete, get, post}, Router, }; use tokio::net::TcpListener; @@ -456,6 +456,10 @@ async fn main() -> anyhow::Result<()> { "/api/v1/admin/api-keys/rotate", post(handlers::rotate_api_key), ) + .route( + "/api/v1/admin/api-keys/:id", + delete(handlers::revoke_api_key), + ) .layer(middleware::from_fn_with_state( state.clone(), idempotency::idempotency_middleware,