feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts
This commit is contained in:
@ -10,8 +10,10 @@ name = "pm-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pm-ca = { path = "../pm-ca" }
|
||||
pm-core = { path = "../pm-core" }
|
||||
pm-auth = { path = "../pm-auth" }
|
||||
pm-reports = { path = "../pm-reports" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
|
||||
@ -42,6 +42,8 @@ pub struct AppState {
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
/// In-memory store for single-use WebSocket authentication tickets.
|
||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@ -77,6 +79,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
// Initialise the internal CA. Panics in production if CA files are missing
|
||||
// or corrupt — this is intentional; the service cannot operate without mTLS.
|
||||
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
|
||||
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "CA init failed (dev mode)");
|
||||
panic!("CA initialization failed: {}", e);
|
||||
});
|
||||
|
||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||
|
||||
// Background task: purge expired WS tickets every 30 seconds.
|
||||
@ -103,6 +115,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
signing_key_pem,
|
||||
auth_config,
|
||||
ws_tickets,
|
||||
ca: Arc::new(ca),
|
||||
};
|
||||
|
||||
let app = build_router(state);
|
||||
@ -128,6 +141,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(routes::auth::protected_router())
|
||||
// Hosts
|
||||
.nest("/hosts", routes::hosts::router())
|
||||
// Host-scoped certificate endpoints (merged separately to avoid conflict)
|
||||
.nest("/hosts", routes::ca::host_cert_router())
|
||||
// Groups
|
||||
.nest("/groups", routes::groups::router())
|
||||
// Users
|
||||
@ -140,8 +155,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
// Maintenance windows (nested under hosts path param)
|
||||
.nest("/hosts/:host_id/maintenance-windows", routes::maintenance_windows::router())
|
||||
// CA root certificate download
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
// Certificate list / renew / revoke
|
||||
.nest("/certificates", routes::ca::certs_router())
|
||||
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
|
||||
.merge(routes::ws::ticket_router())
|
||||
// Reports
|
||||
.nest("/reports", routes::reports::router())
|
||||
// Apply auth middleware to all the above
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
|
||||
349
crates/pm-web/src/routes/ca.rs
Normal file
349
crates/pm-web/src/routes/ca.rs
Normal file
@ -0,0 +1,349 @@
|
||||
//! CA / certificate management routes.
|
||||
//!
|
||||
//! ca_router() → mounted at /api/v1/ca
|
||||
//! GET /root.crt download_root_ca (any authed role)
|
||||
//!
|
||||
//! certs_router() → mounted at /api/v1/certificates
|
||||
//! GET / list_certificates (any authed role)
|
||||
//! POST /:cert_id/renew renew_cert (admin only)
|
||||
//! DELETE /:cert_id revoke_cert (admin only)
|
||||
//!
|
||||
//! host_cert_router() → merged under /api/v1/hosts
|
||||
//! GET /:host_id/client.crt download_client_cert (admin only)
|
||||
//! POST /:host_id/certificates issue_client_cert (admin only)
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, Response, StatusCode},
|
||||
response::Json,
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ── Router constructors ───────────────────────────────────────────────────────
|
||||
|
||||
/// Handles routes mounted at /api/v1/ca
|
||||
pub fn ca_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/root.crt", get(download_root_ca))
|
||||
}
|
||||
|
||||
/// Handles routes mounted at /api/v1/certificates
|
||||
pub fn certs_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_certificates))
|
||||
.route("/:cert_id/renew", post(renew_cert))
|
||||
.route("/:cert_id", delete(revoke_cert))
|
||||
}
|
||||
|
||||
/// Handles cert-specific paths merged under /api/v1/hosts.
|
||||
/// Only adds paths not already claimed by the hosts router.
|
||||
pub fn host_cert_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/:host_id/client.crt", get(download_client_cert))
|
||||
.route("/:host_id/certificates", post(issue_client_cert))
|
||||
}
|
||||
|
||||
// ── Shared types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Row returned from the `certificates` table.
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
struct CertRow {
|
||||
id: Uuid,
|
||||
host_id: Option<Uuid>,
|
||||
serial_number: String,
|
||||
common_name: String,
|
||||
/// Cast to TEXT in all queries to avoid custom-enum decode.
|
||||
status: String,
|
||||
issued_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Query params for `list_certificates`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CertListQuery {
|
||||
host_id: Option<Uuid>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `issue_client_cert`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IssueCertRequest {
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
// ── Helper: build PEM download response ──────────────────────────────────────
|
||||
|
||||
fn pem_response(
|
||||
pem: String,
|
||||
filename: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/x-pem-file")
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.body(Body::from(pem))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to build PEM response");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Response build error" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper: admin-only guard ──────────────────────────────────────────────────
|
||||
|
||||
fn require_admin(user: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
if !user.role.is_admin() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helper: map sqlx error to 500 ─────────────────────────────────────────────
|
||||
|
||||
fn db_error(e: sqlx::Error) -> (StatusCode, Json<Value>) {
|
||||
tracing::error!(error = %e, "Database error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
}
|
||||
|
||||
// ── GET /api/v1/ca/root.crt ───────────────────────────────────────────────────
|
||||
|
||||
/// Download the root CA certificate as a PEM file.
|
||||
async fn download_root_ca(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||
let pem = state.ca.root_cert_pem().to_owned();
|
||||
pem_response(pem, "ca.crt")
|
||||
}
|
||||
|
||||
// ── GET /api/v1/certificates ──────────────────────────────────────────────────
|
||||
|
||||
/// List certificates with optional `?host_id=` and `?status=` filters.
|
||||
async fn list_certificates(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
Query(q): Query<CertListQuery>,
|
||||
) -> Result<Json<Vec<CertRow>>, (StatusCode, Json<Value>)> {
|
||||
// Use the non-macro query_as form — avoids needing DATABASE_URL at compile
|
||||
// time. status is cast to TEXT so sqlx decodes it into String directly.
|
||||
let rows: Vec<CertRow> = match (q.host_id, q.status.as_deref()) {
|
||||
(Some(hid), Some(st)) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
status::text AS status,
|
||||
issued_at, expires_at, revoked_at
|
||||
FROM certificates
|
||||
WHERE host_id = $1 AND status::text = $2
|
||||
ORDER BY issued_at DESC"#,
|
||||
)
|
||||
.bind(hid)
|
||||
.bind(st)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
(Some(hid), None) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
status::text AS status,
|
||||
issued_at, expires_at, revoked_at
|
||||
FROM certificates
|
||||
WHERE host_id = $1
|
||||
ORDER BY issued_at DESC"#,
|
||||
)
|
||||
.bind(hid)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
(None, Some(st)) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
status::text AS status,
|
||||
issued_at, expires_at, revoked_at
|
||||
FROM certificates
|
||||
WHERE status::text = $1
|
||||
ORDER BY issued_at DESC"#,
|
||||
)
|
||||
.bind(st)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
(None, None) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
status::text AS status,
|
||||
issued_at, expires_at, revoked_at
|
||||
FROM certificates
|
||||
ORDER BY issued_at DESC"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(db_error)?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/hosts/:host_id/client.crt ────────────────────────────────────
|
||||
|
||||
/// Download the most recent active client certificate PEM for a host.
|
||||
async fn download_client_cert(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(host_id): Path<Uuid>,
|
||||
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||
require_admin(&auth)?;
|
||||
|
||||
let cert_pem: Option<String> = sqlx::query_scalar(
|
||||
r#"SELECT cert_pem
|
||||
FROM certificates
|
||||
WHERE host_id = $1
|
||||
AND status = 'active'::cert_status
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(host_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "Failed to fetch client cert");
|
||||
db_error(e)
|
||||
})?;
|
||||
|
||||
match cert_pem {
|
||||
Some(pem) => pem_response(pem, "client.crt"),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"code": "not_found",
|
||||
"message": "No active certificate found for this host"
|
||||
}
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/v1/hosts/:host_id/certificates ─────────────────────────────────
|
||||
|
||||
/// Issue a new mTLS client certificate for a host.
|
||||
/// **The private key is returned only once — the caller must save it.**
|
||||
async fn issue_client_cert(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(host_id): Path<Uuid>,
|
||||
Json(req): Json<IssueCertRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
require_admin(&auth)?;
|
||||
|
||||
let issued = state
|
||||
.ca
|
||||
.issue_client_cert(host_id, &req.hostname, &state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, hostname = %req.hostname,
|
||||
"Failed to issue client cert");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"cert_pem": issued.cert_pem,
|
||||
"key_pem": issued.key_pem,
|
||||
"serial_number": issued.serial_number,
|
||||
"expires_at": issued.expires_at,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── POST /api/v1/certificates/:cert_id/renew ─────────────────────────────────
|
||||
|
||||
/// Revoke the specified certificate and issue a replacement with the same CN.
|
||||
async fn renew_cert(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(cert_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
require_admin(&auth)?;
|
||||
|
||||
let issued = state
|
||||
.ca
|
||||
.renew_cert(cert_id, &state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(error = %e, %cert_id, "Failed to renew cert");
|
||||
if msg.contains("not found") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"cert_pem": issued.cert_pem,
|
||||
"key_pem": issued.key_pem,
|
||||
"serial_number": issued.serial_number,
|
||||
"expires_at": issued.expires_at,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/certificates/:cert_id ─────────────────────────────────────
|
||||
|
||||
/// Revoke a certificate by ID. Sets status to 'revoked' in the database.
|
||||
async fn revoke_cert(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(cert_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
require_admin(&auth)?;
|
||||
|
||||
state
|
||||
.ca
|
||||
.revoke_cert(cert_id, &state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(error = %e, %cert_id, "Failed to revoke cert");
|
||||
if msg.contains("not found") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!(%cert_id, "Certificate revoked via API");
|
||||
Ok(Json(json!({ "revoked": true })))
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
//! Route modules for the pm-web API.
|
||||
pub mod auth;
|
||||
pub mod ca;
|
||||
pub mod discovery;
|
||||
pub mod groups;
|
||||
pub mod hosts;
|
||||
@ -8,3 +9,5 @@ pub mod jobs;
|
||||
pub mod status;
|
||||
pub mod users;
|
||||
pub mod ws;
|
||||
|
||||
pub mod reports;
|
||||
|
||||
153
crates/pm-web/src/routes/reports.rs
Normal file
153
crates/pm-web/src/routes/reports.rs
Normal file
@ -0,0 +1,153 @@
|
||||
//! Report generation endpoints.
|
||||
//!
|
||||
//! GET /api/v1/reports/compliance?format=csv|pdf&from=...&to=...&group_id=...
|
||||
//! GET /api/v1/reports/patch-history?format=csv|pdf&from=...&to=...
|
||||
//! GET /api/v1/reports/vulnerability?format=csv|pdf&from=...&to=...
|
||||
//! GET /api/v1/reports/audit?format=csv|pdf&from=...&to=...
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Query, State},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use pm_reports::{ReportParams, ReportType};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ReportQuery {
|
||||
/// "csv" or "pdf" (defaults to "csv")
|
||||
format: Option<String>,
|
||||
from: Option<chrono::DateTime<chrono::Utc>>,
|
||||
to: Option<chrono::DateTime<chrono::Utc>>,
|
||||
group_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/compliance", get(compliance_report))
|
||||
.route("/patch-history", get(patch_history_report))
|
||||
.route("/vulnerability", get(vulnerability_report))
|
||||
.route("/audit", get(audit_report))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_report(
|
||||
db: sqlx::PgPool,
|
||||
params: ReportParams,
|
||||
use_pdf: bool,
|
||||
csv_name: &'static str,
|
||||
pdf_name: &'static str,
|
||||
) -> Response {
|
||||
let (ct, disposition, result) = if use_pdf {
|
||||
let disp = format!("attachment; filename=\"{}\"", pdf_name);
|
||||
let data = pm_reports::generate_pdf(&db, ¶ms).await;
|
||||
("application/pdf", disp, data)
|
||||
} else {
|
||||
let disp = format!("attachment; filename=\"{}\"", csv_name);
|
||||
let data = pm_reports::generate_csv(&db, ¶ms).await;
|
||||
("text/csv; charset=utf-8", disp, data)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(ct),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
|
||||
);
|
||||
(headers, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "report generation failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Report error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn compliance_report(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ReportQuery>,
|
||||
) -> Response {
|
||||
let params = ReportParams {
|
||||
report_type: ReportType::Compliance,
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
group_id: q.group_id,
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
"compliance-report.csv",
|
||||
"compliance-report.pdf",
|
||||
).await
|
||||
}
|
||||
|
||||
async fn patch_history_report(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ReportQuery>,
|
||||
) -> Response {
|
||||
let params = ReportParams {
|
||||
report_type: ReportType::PatchHistory,
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
group_id: q.group_id,
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
"patch-history-report.csv",
|
||||
"patch-history-report.pdf",
|
||||
).await
|
||||
}
|
||||
|
||||
async fn vulnerability_report(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ReportQuery>,
|
||||
) -> Response {
|
||||
let params = ReportParams {
|
||||
report_type: ReportType::Vulnerability,
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
group_id: q.group_id,
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
"vulnerability-report.csv",
|
||||
"vulnerability-report.pdf",
|
||||
).await
|
||||
}
|
||||
|
||||
async fn audit_report(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ReportQuery>,
|
||||
) -> Response {
|
||||
let params = ReportParams {
|
||||
report_type: ReportType::Audit,
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
group_id: q.group_id,
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
"audit-report.csv",
|
||||
"audit-report.pdf",
|
||||
).await
|
||||
}
|
||||
Reference in New Issue
Block a user