CI / Check (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
112 lines
3.6 KiB
Rust
112 lines
3.6 KiB
Rust
//! M7.1 smoke service.
|
|
//!
|
|
//! A standalone Axum binary whose only job is to host the
|
|
//! [`compliance_core::auth`] middleware + [`compliance_core::tenant_ctx`]
|
|
//! extractor on three endpoints, so `scripts/smoke.sh` can prove the
|
|
//! tenant-gating contract end-to-end before any auth-path PR merges.
|
|
//!
|
|
//! Endpoints:
|
|
//! * `GET /api/v1/health` — public, never authenticated.
|
|
//! * `GET /api/v1/echo` — protected read; returns the [`TenantContext`].
|
|
//! * `POST /api/v1/echo` — protected write; exercises the `Frozen → 402`
|
|
//! gate on the same handler.
|
|
//!
|
|
//! Configuration (env):
|
|
//! * `KEYCLOAK_URL` — e.g. `http://localhost:8080`. Required.
|
|
//! * `KEYCLOAK_REALM` — e.g. `certifai`. Required.
|
|
//! * `SMOKE_PORT` — defaults to `3010`.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{middleware, routing::get, Extension, Json, Router};
|
|
use compliance_core::{
|
|
auth::{require_jwt_auth, require_tenant_status, JwksState},
|
|
tenant_ctx::TenantCtx,
|
|
};
|
|
use serde::Serialize;
|
|
use tokio::sync::RwLock;
|
|
|
|
#[derive(Serialize)]
|
|
struct EchoResponse {
|
|
method: &'static str,
|
|
tenant_id: String,
|
|
tenant_slug: String,
|
|
plan: String,
|
|
status: String,
|
|
products: Vec<String>,
|
|
org_roles: Vec<String>,
|
|
user_id: String,
|
|
user_name: Option<String>,
|
|
}
|
|
|
|
async fn health() -> Json<serde_json::Value> {
|
|
Json(serde_json::json!({ "ok": true }))
|
|
}
|
|
|
|
async fn echo_read(TenantCtx(ctx): TenantCtx) -> Json<EchoResponse> {
|
|
Json(echo(ctx, "GET"))
|
|
}
|
|
|
|
async fn echo_write(TenantCtx(ctx): TenantCtx) -> Json<EchoResponse> {
|
|
Json(echo(ctx, "POST"))
|
|
}
|
|
|
|
fn echo(ctx: compliance_core::TenantContext, method: &'static str) -> EchoResponse {
|
|
EchoResponse {
|
|
method,
|
|
tenant_id: ctx.tenant_id,
|
|
tenant_slug: ctx.tenant_slug,
|
|
plan: ctx.plan,
|
|
status: ctx.status.to_string(),
|
|
products: ctx.products,
|
|
org_roles: ctx.org_roles.iter().map(|r| format!("{r:?}")).collect(),
|
|
user_id: ctx.user_id,
|
|
user_name: ctx.user_name,
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
let kc_url = std::env::var("KEYCLOAK_URL")
|
|
.map_err(|_| "KEYCLOAK_URL is required (e.g. http://localhost:8080)")?;
|
|
let kc_realm = std::env::var("KEYCLOAK_REALM")
|
|
.map_err(|_| "KEYCLOAK_REALM is required (e.g. certifai)")?;
|
|
let port: u16 = std::env::var("SMOKE_PORT")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(3010);
|
|
|
|
let jwks_url = format!("{kc_url}/realms/{kc_realm}/protocol/openid-connect/certs");
|
|
let jwks_state = JwksState {
|
|
jwks: Arc::new(RwLock::new(None)),
|
|
jwks_url: jwks_url.clone(),
|
|
};
|
|
|
|
// Layers execute outermost-first. The Extension must be registered
|
|
// before `require_jwt_auth` so the middleware can read JwksState; the
|
|
// status gate must run after JWT so `TenantContext` is in extensions.
|
|
let app = Router::new()
|
|
.route("/api/v1/health", get(health))
|
|
.route("/api/v1/echo", get(echo_read).post(echo_write))
|
|
.layer(middleware::from_fn(require_tenant_status))
|
|
.layer(middleware::from_fn(require_jwt_auth))
|
|
.layer(Extension(jwks_state));
|
|
|
|
let addr = format!("0.0.0.0:{port}");
|
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
tracing::info!(
|
|
port,
|
|
jwks = %jwks_url,
|
|
"compliance-smoke listening — try `scripts/smoke.sh`"
|
|
);
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|