From 079f9130248c2d262c83b4bb849ef69d6e1f9ae5 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:07:45 +0200 Subject: [PATCH] feat(smoke): add compliance-smoke crate + scripts/smoke.sh tenant-gating harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minimal Axum binary that mounts compliance-core's M7.1 middleware on three endpoints (public health, protected GET echo, protected POST echo) so we can prove the tenant-gating contract end-to-end against a live KC before any auth-path PR merges. scripts/smoke.sh drives the binary against the five test users defined in the certifai realm (admin/user → active, trial/frozen/archived) and asserts the exact response code per (user × method × endpoint). Run it once before touching auth, tenant_status, or org_roles code. Validated locally — 15/15 assertions pass: * anon/bogus → 401 on protected, 200 on /health * active/trial → 200 on read + write * frozen → 200 read, 402 write (read-after-cancel gate) * archived → 410 read + 410 write (retention window closed) Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 18 +++++ Cargo.toml | 1 + compliance-smoke/Cargo.toml | 22 ++++++ compliance-smoke/src/main.rs | 111 ++++++++++++++++++++++++++++ scripts/smoke.sh | 136 +++++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 compliance-smoke/Cargo.toml create mode 100644 compliance-smoke/src/main.rs create mode 100755 scripts/smoke.sh diff --git a/Cargo.lock b/Cargo.lock index 0714ef6..efd22e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,19 +700,23 @@ dependencies = [ name = "compliance-core" version = "0.1.0" dependencies = [ + "axum", "bson", "chrono", "hex", + "jsonwebtoken", "mongodb", "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", + "reqwest", "secrecy", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "tokio", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -826,6 +830,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "compliance-smoke" +version = "0.1.0" +dependencies = [ + "axum", + "compliance-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index e1b0315..b09af4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "compliance-graph", "compliance-dast", "compliance-mcp", + "compliance-smoke", ] resolver = "2" diff --git a/compliance-smoke/Cargo.toml b/compliance-smoke/Cargo.toml new file mode 100644 index 0000000..6dfc58b --- /dev/null +++ b/compliance-smoke/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "compliance-smoke" +version = "0.1.0" +edition = "2021" +description = "Tiny Axum service exercising compliance-core M7.1 tenant gating. Run smoke.sh against it before merging anything that touches the auth/tenant path." + +[lints] +workspace = true + +[[bin]] +name = "compliance-smoke" +path = "src/main.rs" + +[dependencies] +compliance-core = { workspace = true, features = ["axum"] } +axum = "0.8" +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } diff --git a/compliance-smoke/src/main.rs b/compliance-smoke/src/main.rs new file mode 100644 index 0000000..fc14c3b --- /dev/null +++ b/compliance-smoke/src/main.rs @@ -0,0 +1,111 @@ +//! 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, + org_roles: Vec, + user_id: String, + user_name: Option, +} + +async fn health() -> Json { + Json(serde_json::json!({ "ok": true })) +} + +async fn echo_read(TenantCtx(ctx): TenantCtx) -> Json { + Json(echo(ctx, "GET")) +} + +async fn echo_write(TenantCtx(ctx): TenantCtx) -> Json { + 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> { + 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(()) +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..8417a23 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# M7.1 tenant-gating smoke test. +# +# Drives compliance-smoke against a live Keycloak realm with five test +# users (one per tenant_status), asserts the response code on each +# endpoint, and exits non-zero on any mismatch. +# +# Pre-reqs (one-time): +# * KC up at $KC_URL with realm $KC_REALM +# * Client $KC_CLIENT has direct-access-grants enabled +# * Users + tenant_status mappers per certifai/keycloak/realm-export.json +# * compliance-smoke binary running and reachable at $SMOKE_URL +# +# Usage: +# scripts/smoke.sh # uses defaults below +# SMOKE_URL=... scripts/smoke.sh + +set -euo pipefail + +KC_URL="${KC_URL:-http://localhost:8080}" +KC_REALM="${KC_REALM:-certifai}" +KC_CLIENT="${KC_CLIENT:-certifai-dashboard}" +SMOKE_URL="${SMOKE_URL:-http://localhost:3010}" + +readonly TOKEN_ENDPOINT="${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" + +PASS=0 +FAIL=0 + +red() { printf '\033[31m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +yellow() { printf '\033[33m%s\033[0m' "$*"; } + +# Fetches an access token via direct access grant. Echoes the raw token. +get_token() { + local user="$1" pass="$2" + curl -sS -X POST "$TOKEN_ENDPOINT" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d "grant_type=password" \ + -d "client_id=${KC_CLIENT}" \ + -d "username=${user}" \ + -d "password=${pass}" \ + -d "scope=openid" \ + | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' +} + +# Hits SMOKE_URL$path with the given method and (optional) bearer token, +# asserts the response status code matches $want. +assert_status() { + local label="$1" method="$2" path="$3" want="$4" token="${5:-}" + local args=(-sS -o /dev/null -w '%{http_code}' -X "$method" "${SMOKE_URL}${path}") + if [[ -n "$token" ]]; then + args+=(-H "Authorization: Bearer ${token}") + fi + local got + got=$(curl "${args[@]}") + if [[ "$got" == "$want" ]]; then + printf ' %s %s %-4s %-15s → %s\n' "$(green PASS)" "$label" "$method" "$path" "$got" + PASS=$((PASS + 1)) + else + printf ' %s %s %-4s %-15s → got %s, want %s\n' "$(red FAIL)" "$label" "$method" "$path" "$got" "$want" + FAIL=$((FAIL + 1)) + fi +} + +header() { + printf '\n%s %s\n' "$(yellow '##')" "$1" +} + +# ---- Pre-flight ---------------------------------------------------------- +header "Pre-flight" +if ! curl -sS -o /dev/null -w '%{http_code}\n' "${SMOKE_URL}/api/v1/health" | grep -q '^200$'; then + printf ' %s smoke service not reachable at %s\n' "$(red ERR)" "$SMOKE_URL" + exit 2 +fi +if ! curl -sS -o /dev/null -w '%{http_code}\n' "${KC_URL}/realms/${KC_REALM}/.well-known/openid-configuration" | grep -q '^200$'; then + printf ' %s Keycloak realm %s not reachable at %s\n' "$(red ERR)" "$KC_REALM" "$KC_URL" + exit 2 +fi +printf ' %s smoke service + Keycloak both up\n' "$(green OK)" + +# ---- Public endpoint -------------------------------------------------- +header "Public endpoint (no auth required)" +assert_status anon GET /api/v1/health 200 + +# ---- Anonymous access to protected endpoints ---------------------------- +header "Anonymous → 401 on protected endpoints" +assert_status anon GET /api/v1/echo 401 +assert_status anon POST /api/v1/echo 401 + +# ---- Bad token ---------------------------------------------------------- +header "Bad token → 401" +assert_status bogus GET /api/v1/echo 401 "not-a-real-jwt" +assert_status bogus POST /api/v1/echo 401 "not-a-real-jwt" + +# ---- Active tenant (admin user) ----------------------------------------- +header "admin@certifai.local (active) → full access" +TOKEN=$(get_token admin@certifai.local admin) +if [[ -z "$TOKEN" ]]; then + printf ' %s failed to fetch token for admin\n' "$(red ERR)" + exit 2 +fi +assert_status active GET /api/v1/echo 200 "$TOKEN" +assert_status active POST /api/v1/echo 200 "$TOKEN" + +# ---- Active tenant (USER role) ------------------------------------------ +header "user@certifai.local (active) → full access" +TOKEN=$(get_token user@certifai.local user) +assert_status active GET /api/v1/echo 200 "$TOKEN" +assert_status active POST /api/v1/echo 200 "$TOKEN" + +# ---- Trial tenant ------------------------------------------------------- +header "trial@acme.local (trial) → full access" +TOKEN=$(get_token trial@acme.local trial) +assert_status trial GET /api/v1/echo 200 "$TOKEN" +assert_status trial POST /api/v1/echo 200 "$TOKEN" + +# ---- Frozen tenant ------------------------------------------------------ +header "frozen@acme.local (frozen) → read-only, writes 402" +TOKEN=$(get_token frozen@acme.local frozen) +assert_status frozen GET /api/v1/echo 200 "$TOKEN" +assert_status frozen POST /api/v1/echo 402 "$TOKEN" + +# ---- Archived tenant ---------------------------------------------------- +header "archived@acme.local (archived) → 410 everywhere" +TOKEN=$(get_token archived@acme.local archived) +assert_status archived GET /api/v1/echo 410 "$TOKEN" +assert_status archived POST /api/v1/echo 410 "$TOKEN" + +# ---- Summary ------------------------------------------------------------ +printf '\n' +if [[ "$FAIL" -gt 0 ]]; then + printf '%s %d passed, %d failed\n' "$(red FAIL)" "$PASS" "$FAIL" + exit 1 +fi +printf '%s %d/%d assertions passed\n' "$(green PASS)" "$PASS" "$PASS"