Skip to main content

architect_sdk/handlers/
reports.rs

1//! Read-only reporting-query handlers.
2//!
3//! A *report* is a config kind (`_sys_reports`) holding trusted, parameterized SQL. Registration
4//! is restricted to the Platform Admin tenant; execution is authorized per-run via authrs (same
5//! model as generated entity APIs) and runs inside a sandboxed, read-only transaction:
6//! `SET TRANSACTION READ ONLY` + `statement_timeout` + an SDK-enforced row cap, plus RLS
7//! `app.tenant_id` for RLS tenants and an optional `SET LOCAL ROLE` to a dedicated read-only role.
8//!
9//! Registration/CRUD of report definitions lives here too (`POST /config/reports`,
10//! `PUT`/`DELETE /config/reports/:id`); reports carry no DDL so these are pure metadata writes.
11
12use crate::case::value_keys_to_camel_case;
13use crate::config::{compile_report, ReportConfig, ResolvedReport};
14use crate::error::AppError;
15use crate::extractors::tenant::{ActAsTenant, TenantId};
16use crate::extractors::user::UserId;
17use crate::handlers::config::{get_config, reload_model, replace_config};
18use crate::handlers::entity::{resolve_tenant_context, TenantContext};
19use crate::service::{CrudService, RequestValidator, TenantExecutor};
20use crate::state::AppState;
21use crate::store::DEFAULT_PACKAGE_ID;
22use axum::extract::{Path, State};
23use axum::http::StatusCode;
24use axum::response::IntoResponse;
25use axum::Json;
26use serde::Deserialize;
27use serde_json::{json, Value};
28use std::collections::HashMap;
29
30/// `statement_timeout` (ms) applied to report queries. Env `ARCHITECT_REPORT_TIMEOUT_MS`, default 30000.
31fn report_timeout_ms() -> u64 {
32    const DEFAULT: u64 = 30_000;
33    std::env::var("ARCHITECT_REPORT_TIMEOUT_MS")
34        .ok()
35        .and_then(|v| v.parse::<u64>().ok())
36        .filter(|&n| n > 0)
37        .unwrap_or(DEFAULT)
38}
39
40/// SDK-enforced maximum rows returned by a report. Env `ARCHITECT_REPORT_MAX_ROWS`, default 10000.
41fn report_max_rows() -> usize {
42    const DEFAULT: usize = 10_000;
43    std::env::var("ARCHITECT_REPORT_MAX_ROWS")
44        .ok()
45        .and_then(|v| v.parse::<usize>().ok())
46        .filter(|&n| n > 0)
47        .unwrap_or(DEFAULT)
48}
49
50/// Optional dedicated read-only DB role for report execution. Env `ARCHITECT_REPORT_ROLE`.
51fn report_role() -> Option<String> {
52    std::env::var("ARCHITECT_REPORT_ROLE")
53        .ok()
54        .map(|s| s.trim().to_string())
55        .filter(|s| !s.is_empty())
56}
57
58/// Whether result caching is enabled globally. Env `ARCHITECT_REPORT_CACHE` in {1,true,yes,on}.
59fn report_cache_enabled() -> bool {
60    std::env::var("ARCHITECT_REPORT_CACHE")
61        .map(|v| {
62            matches!(
63                v.trim().to_ascii_lowercase().as_str(),
64                "1" | "true" | "yes" | "on"
65            )
66        })
67        .unwrap_or(false)
68}
69
70/// Default cache TTL (seconds) when a report does not specify its own. Env
71/// `ARCHITECT_REPORT_CACHE_TTL_SECS`, default 300.
72fn report_cache_default_ttl() -> i64 {
73    const DEFAULT: i64 = 300;
74    std::env::var("ARCHITECT_REPORT_CACHE_TTL_SECS")
75        .ok()
76        .and_then(|v| v.parse::<i64>().ok())
77        .filter(|&n| n >= 0)
78        .unwrap_or(DEFAULT)
79}
80
81/// Effective TTL for a report: its own `cache_ttl_secs` override, else the global default.
82/// Returns `None` when caching is disabled globally or the effective TTL is 0 (never cache).
83fn effective_cache_ttl(report: &ResolvedReport) -> Option<i64> {
84    if !report_cache_enabled() {
85        return None;
86    }
87    let ttl = report
88        .cache_ttl_secs
89        .unwrap_or_else(report_cache_default_ttl);
90    if ttl > 0 {
91        Some(ttl)
92    } else {
93        None
94    }
95}
96
97/// 64-bit FNV-1a hash rendered as 16 hex chars — a bounded, deterministic cache key. Collisions are
98/// astronomically unlikely and, because the cached envelope records the exact report/tenant/params,
99/// a collision fails verification and is treated as a miss (never a wrong hit).
100fn fnv1a_hex(s: &str) -> String {
101    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
102    for b in s.as_bytes() {
103        hash ^= *b as u64;
104        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
105    }
106    format!("{:016x}", hash)
107}
108
109/// Deterministic string over the merged params, with keys sorted, for the cache key.
110fn canonical_params(params: &HashMap<String, Value>) -> String {
111    let sorted: std::collections::BTreeMap<&String, &Value> = params.iter().collect();
112    serde_json::to_string(&sorted).unwrap_or_default()
113}
114
115/// Build the cache key from report identity, effective tenant, SQL (so a definition change
116/// invalidates), and the merged params.
117fn cache_key_for(
118    report: &ResolvedReport,
119    tenant_id: &str,
120    params: &HashMap<String, Value>,
121) -> String {
122    let material = format!(
123        "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{}",
124        report.package_id,
125        tenant_id,
126        report.id,
127        report.sql,
128        canonical_params(params),
129    );
130    fnv1a_hex(&material)
131}
132
133#[derive(Deserialize, Default)]
134pub struct RunReportRequest {
135    #[serde(default)]
136    pub params: HashMap<String, Value>,
137}
138
139/// Registration/mutation of report definitions is restricted to the Platform Admin tenant, since
140/// the report body is trusted SQL executed verbatim.
141fn require_platform_admin(
142    state: &AppState,
143    tenant_id_opt: &Option<String>,
144) -> Result<(), AppError> {
145    let tenant_id = tenant_id_opt
146        .as_deref()
147        .filter(|s| !s.is_empty())
148        .ok_or_else(|| AppError::BadRequest("X-Tenant-ID header is required".into()))?;
149    state
150        .tenant_registry
151        .get(tenant_id)
152        .ok_or_else(|| AppError::NotFound(format!("tenant not found: {}", tenant_id)))?;
153    if tenant_id != crate::tenant::platform_tenant_id() {
154        return Err(AppError::Forbidden(
155            "report registration is restricted to the Platform Admin tenant".into(),
156        ));
157    }
158    Ok(())
159}
160
161/// Begin a read-only, sandboxed transaction on the tenant's data pool. Works for both tenant
162/// strategies (unlike `begin_rls_tx`, which only opens a tx for RLS). Applies, in order:
163/// `SET TRANSACTION READ ONLY`, `statement_timeout`, an optional read-only `ROLE`, and — for RLS
164/// tenants — `SET LOCAL app.tenant_id` so row-level policies enforce isolation regardless of SQL.
165async fn begin_readonly_tx(
166    state: &AppState,
167    ctx: &TenantContext,
168) -> Result<crate::db::pool::DbTransaction, AppError> {
169    let pool = ctx.migration_pool();
170    let mut tx = pool.begin().await?;
171    if let Some(sql) = state.dialect.set_read_only_sql() {
172        sqlx::query(&sql).execute(&mut *tx).await?;
173    }
174    if let Some(sql) = state.dialect.set_statement_timeout_sql(report_timeout_ms()) {
175        sqlx::query(&sql).execute(&mut *tx).await?;
176    }
177    if let Some(role) = report_role() {
178        if let Some(sql) = state.dialect.set_role_sql(&role) {
179            sqlx::query(&sql).execute(&mut *tx).await?;
180        }
181    }
182    if let TenantContext::Rls { tenant_id, .. } = ctx {
183        if let Some(sql) = state.dialect.set_tenant_session_sql(tenant_id) {
184            sqlx::query(&sql).execute(&mut *tx).await?;
185        }
186    }
187    Ok(tx)
188}
189
190/// Clone the resolved report out of the active model (dropping the read lock before any await).
191fn lookup_report(state: &AppState, report_id: &str) -> Result<ResolvedReport, AppError> {
192    let guard = state
193        .model
194        .read()
195        .map_err(|_| AppError::BadRequest("state lock".into()))?;
196    guard
197        .report(report_id)
198        .cloned()
199        .ok_or_else(|| AppError::NotFound(format!("report not found: {}", report_id)))
200}
201
202/// Serialize a report's public metadata (never the SQL) for list/get responses.
203fn report_metadata(r: &ResolvedReport) -> Value {
204    let params: Vec<Value> = r
205        .param_order
206        .iter()
207        .map(|name| {
208            let rule = r.rules.get(name);
209            json!({
210                "name": name,
211                "required": rule.and_then(|x| x.required).unwrap_or(false),
212                "default": r.defaults.get(name).cloned().unwrap_or(Value::Null),
213                "db_type": r.casts.get(name).cloned(),
214            })
215        })
216        .collect();
217    json!({
218        "id": r.id,
219        "name": r.name,
220        "description": r.description,
221        "schemas": r.schemas,
222        "params": params,
223        "cache_ttl_secs": r.cache_ttl_secs,
224    })
225}
226
227/// Validate a cached envelope against the current request and, if fresh and matching, build the
228/// run response. Returns `None` (a miss) when the envelope is expired, malformed, or does not match
229/// the exact report/tenant/params (guards against the astronomically unlikely key collision).
230fn cache_hit_response(
231    envelope: &Value,
232    report: &ResolvedReport,
233    effective_tenant: &str,
234    params: &HashMap<String, Value>,
235) -> Option<Value> {
236    let expires_at = envelope.get("expires_at").and_then(Value::as_str)?;
237    let expires = chrono::DateTime::parse_from_rfc3339(expires_at).ok()?;
238    if expires <= chrono::Utc::now() {
239        return None;
240    }
241    if envelope.get("report_id").and_then(Value::as_str) != Some(report.id.as_str()) {
242        return None;
243    }
244    if envelope.get("tenant_id").and_then(Value::as_str) != Some(effective_tenant) {
245        return None;
246    }
247    if envelope.get("params").and_then(Value::as_str) != Some(canonical_params(params).as_str()) {
248        return None;
249    }
250    let result = envelope
251        .get("result")
252        .cloned()
253        .unwrap_or(Value::Array(vec![]));
254    let count = envelope
255        .get("row_count")
256        .and_then(Value::as_i64)
257        .unwrap_or(0);
258    let truncated = envelope
259        .get("truncated")
260        .and_then(Value::as_bool)
261        .unwrap_or(false);
262    Some(json!({
263        "data": result,
264        "meta": { "count": count, "report": report.id, "truncated": truncated, "cached": true },
265    }))
266}
267
268/// POST /api/v1/reports/:report_id/run — execute a report and return its rows.
269pub async fn run_report(
270    Path(report_id): Path<String>,
271    TenantId(tenant_id_opt): TenantId,
272    ActAsTenant(act_as_opt): ActAsTenant,
273    UserId(user_id_opt): UserId,
274    State(state): State<AppState>,
275    body: Option<Json<RunReportRequest>>,
276) -> Result<impl IntoResponse, AppError> {
277    let req = body.map(|Json(b)| b).unwrap_or_default();
278
279    let ctx = resolve_tenant_context(
280        &state,
281        tenant_id_opt.as_deref(),
282        act_as_opt.as_deref(),
283        None,
284    )
285    .await?;
286
287    let report = lookup_report(&state, &report_id)?;
288
289    crate::authrs::check_report_permission_opt(
290        &state.authrs_client,
291        tenant_id_opt.as_deref(),
292        user_id_opt.as_deref(),
293        &report,
294        "run",
295    )
296    .await?;
297
298    // Merge declared defaults for absent params, then validate the merged set.
299    let mut params = req.params;
300    for (name, default) in &report.defaults {
301        params
302            .entry(name.clone())
303            .or_insert_with(|| default.clone());
304    }
305    RequestValidator::validate(&params, &report.rules)?;
306
307    // Effective tenant (an act-as target when impersonating, else the caller) scopes the cache.
308    let effective_tenant = act_as_opt
309        .as_deref()
310        .filter(|s| !s.is_empty())
311        .or(tenant_id_opt.as_deref())
312        .unwrap_or("")
313        .to_string();
314
315    // Cache lookup (when enabled): a fresh, verified envelope short-circuits execution.
316    let ttl = effective_cache_ttl(&report);
317    let cache_key = ttl.map(|_| cache_key_for(&report, &effective_tenant, &params));
318    if let Some(key) = &cache_key {
319        if let Some(env) = crate::store::report_cache_get(
320            ctx.config_pool(),
321            &effective_tenant,
322            &report.package_id,
323            key,
324        )
325        .await?
326        {
327            if let Some(resp) = cache_hit_response(&env, &report, &effective_tenant, &params) {
328                return Ok((StatusCode::OK, Json(resp)));
329            }
330        }
331    }
332
333    // Bind values in positional order; a referenced-but-absent param binds NULL.
334    let binds: Vec<Value> = report
335        .param_order
336        .iter()
337        .map(|name| params.get(name).cloned().unwrap_or(Value::Null))
338        .collect();
339
340    // Wrap the trusted SQL in an outer LIMIT so the SDK caps result size; fetch one extra row to
341    // detect truncation.
342    let max_rows = report_max_rows();
343    let inner = report.sql.trim().trim_end_matches(';');
344    let wrapped = format!(
345        "SELECT * FROM ({}) AS _report LIMIT {}",
346        inner,
347        max_rows + 1
348    );
349
350    let mut tx = begin_readonly_tx(&state, &ctx).await?;
351    let mut rows = {
352        let mut exec = TenantExecutor::conn(&mut tx, state.dialect.as_ref());
353        CrudService::run_readonly_query(&mut exec, &wrapped, &binds).await?
354    };
355    // Read-only tx: drop without commit (rolls back cleanly).
356    drop(tx);
357
358    let truncated = rows.len() > max_rows;
359    if truncated {
360        rows.truncate(max_rows);
361    }
362    for row in &mut rows {
363        value_keys_to_camel_case(row);
364    }
365    let count = rows.len();
366
367    // Populate the cache on a miss (best-effort: a cache write failure must not fail the request).
368    if let (Some(key), Some(ttl)) = (&cache_key, ttl) {
369        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl);
370        let envelope = json!({
371            "expires_at": expires_at.to_rfc3339(),
372            "report_id": report.id,
373            "tenant_id": effective_tenant,
374            "params": canonical_params(&params),
375            "result": rows,
376            "row_count": count,
377            "truncated": truncated,
378        });
379        if let Err(e) = crate::store::report_cache_put(
380            ctx.config_pool(),
381            state.dialect.as_ref(),
382            &effective_tenant,
383            &report.package_id,
384            key,
385            &envelope,
386        )
387        .await
388        {
389            tracing::warn!(report = %report.id, error = %e, "report cache write failed");
390        }
391    }
392
393    Ok((
394        StatusCode::OK,
395        Json(json!({
396            "data": rows,
397            "meta": { "count": count, "report": report.id, "truncated": truncated, "cached": false },
398        })),
399    ))
400}
401
402/// GET /api/v1/reports — list report metadata (never the SQL).
403pub async fn list_reports(
404    TenantId(tenant_id_opt): TenantId,
405    State(state): State<AppState>,
406) -> Result<impl IntoResponse, AppError> {
407    // Validate the tenant (same requirement as other config/data routes).
408    resolve_tenant_context(&state, tenant_id_opt.as_deref(), None, None).await?;
409    let guard = state
410        .model
411        .read()
412        .map_err(|_| AppError::BadRequest("state lock".into()))?;
413    let mut data: Vec<Value> = guard.reports.values().map(report_metadata).collect();
414    data.sort_by(|a, b| {
415        a["id"]
416            .as_str()
417            .unwrap_or("")
418            .cmp(b["id"].as_str().unwrap_or(""))
419    });
420    let count = data.len();
421    Ok((
422        StatusCode::OK,
423        Json(json!({ "data": data, "meta": { "count": count } })),
424    ))
425}
426
427/// GET /api/v1/reports/:report_id — metadata for one report.
428pub async fn get_report(
429    Path(report_id): Path<String>,
430    TenantId(tenant_id_opt): TenantId,
431    State(state): State<AppState>,
432) -> Result<impl IntoResponse, AppError> {
433    resolve_tenant_context(&state, tenant_id_opt.as_deref(), None, None).await?;
434    let report = lookup_report(&state, &report_id)?;
435    Ok((
436        StatusCode::OK,
437        Json(json!({ "data": report_metadata(&report) })),
438    ))
439}
440
441/// GET /api/v1/config/reports — raw stored report definitions (Platform Admin only, includes SQL).
442pub async fn get_reports_config(
443    TenantId(tenant_id_opt): TenantId,
444    State(state): State<AppState>,
445) -> Result<impl IntoResponse, AppError> {
446    require_platform_admin(&state, &tenant_id_opt)?;
447    let out = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
448    let count = out.len();
449    Ok((
450        StatusCode::OK,
451        Json(json!({ "data": out, "meta": { "count": count } })),
452    ))
453}
454
455/// Compile + optionally EXPLAIN-validate a set of report definitions before persisting.
456async fn validate_reports(
457    state: &AppState,
458    tenant_id_opt: &Option<String>,
459    bodies: &[Value],
460) -> Result<Vec<ResolvedReport>, AppError> {
461    let cfgs: Vec<ReportConfig> = serde_json::from_value(Value::Array(bodies.to_vec()))
462        .map_err(|e| AppError::BadRequest(format!("invalid reports: {}", e)))?;
463    let compiled: Vec<ResolvedReport> = cfgs
464        .iter()
465        .map(compile_report)
466        .collect::<Result<_, _>>()
467        .map_err(AppError::Config)?;
468
469    let needs_explain = compiled.iter().any(|r| r.validate_on_register);
470    if needs_explain {
471        let ctx = resolve_tenant_context(state, tenant_id_opt.as_deref(), None, None).await?;
472        for r in &compiled {
473            if !r.validate_on_register {
474                continue;
475            }
476            let mut tx = begin_readonly_tx(state, &ctx).await?;
477            let explain = format!("EXPLAIN {}", r.sql.trim().trim_end_matches(';'));
478            let nulls = vec![Value::Null; r.param_order.len()];
479            let mut exec = TenantExecutor::conn(&mut tx, state.dialect.as_ref());
480            CrudService::run_readonly_query(&mut exec, &explain, &nulls)
481                .await
482                .map_err(|e| {
483                    AppError::Validation(format!("report '{}' failed validation: {}", r.id, e))
484                })?;
485        }
486    }
487    Ok(compiled)
488}
489
490/// POST /api/v1/config/reports — replace the whole standalone (`_default`) report set.
491pub async fn post_reports(
492    TenantId(tenant_id_opt): TenantId,
493    State(state): State<AppState>,
494    Json(body): Json<Vec<Value>>,
495) -> Result<impl IntoResponse, AppError> {
496    require_platform_admin(&state, &tenant_id_opt)?;
497    validate_reports(&state, &tenant_id_opt, &body).await?;
498
499    let (out, num) = replace_config(
500        &state.pool,
501        "reports",
502        body,
503        false,
504        DEFAULT_PACKAGE_ID,
505        None,
506    )
507    .await?;
508    if num > 0 {
509        reload_model(&state).await?;
510    }
511    let count = out.len();
512    Ok((
513        StatusCode::OK,
514        Json(json!({ "data": out, "meta": { "count": count } })),
515    ))
516}
517
518/// PUT /api/v1/config/reports/:report_id — upsert a single standalone report by id
519/// (read-merge-write, preserving the rest of the set).
520pub async fn put_report_by_id(
521    Path(report_id): Path<String>,
522    TenantId(tenant_id_opt): TenantId,
523    State(state): State<AppState>,
524    Json(mut body): Json<Value>,
525) -> Result<impl IntoResponse, AppError> {
526    require_platform_admin(&state, &tenant_id_opt)?;
527
528    let obj = body
529        .as_object_mut()
530        .ok_or_else(|| AppError::BadRequest("report body must be a JSON object".into()))?;
531    // Path id is authoritative.
532    obj.insert("id".into(), Value::String(report_id.clone()));
533
534    // Compile + EXPLAIN-validate this one report.
535    validate_reports(&state, &tenant_id_opt, std::slice::from_ref(&body)).await?;
536
537    // Merge into the current _default set.
538    let current = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
539    let mut merged: Vec<Value> = current
540        .into_iter()
541        .filter(|r| r.get("id").and_then(Value::as_str) != Some(report_id.as_str()))
542        .collect();
543    merged.push(body.clone());
544
545    let (_out, num) = replace_config(
546        &state.pool,
547        "reports",
548        merged,
549        false,
550        DEFAULT_PACKAGE_ID,
551        None,
552    )
553    .await?;
554    if num > 0 {
555        reload_model(&state).await?;
556    }
557    Ok((StatusCode::OK, Json(json!({ "data": body }))))
558}
559
560/// DELETE /api/v1/config/reports/:report_id — remove a single standalone report by id.
561pub async fn delete_report_by_id(
562    Path(report_id): Path<String>,
563    TenantId(tenant_id_opt): TenantId,
564    State(state): State<AppState>,
565) -> Result<impl IntoResponse, AppError> {
566    require_platform_admin(&state, &tenant_id_opt)?;
567
568    let current = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
569    let existed = current
570        .iter()
571        .any(|r| r.get("id").and_then(Value::as_str) == Some(report_id.as_str()));
572    if !existed {
573        return Err(AppError::NotFound(format!(
574            "report not found: {}",
575            report_id
576        )));
577    }
578    let merged: Vec<Value> = current
579        .into_iter()
580        .filter(|r| r.get("id").and_then(Value::as_str) != Some(report_id.as_str()))
581        .collect();
582
583    let (_out, num) = replace_config(
584        &state.pool,
585        "reports",
586        merged,
587        false,
588        DEFAULT_PACKAGE_ID,
589        None,
590    )
591    .await?;
592    if num > 0 {
593        reload_model(&state).await?;
594    }
595    Ok((
596        StatusCode::OK,
597        Json(json!({ "data": { "id": report_id, "deleted": true } })),
598    ))
599}