show in/out schema

This commit is contained in:
aecsocket
2026-07-22 20:14:57 +01:00
parent 28154689f4
commit ea42c644f3
6 changed files with 707 additions and 90 deletions
+2
View File
@@ -117,9 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::global::get_global_issue_detail,
moderation::tech_review::rules::get_rules,
moderation::tech_review::rules::test_rule,
moderation::tech_review::rules::get_rule_affected_details,
moderation::tech_review::rules::create_rule,
moderation::tech_review::rules::update_rule,
moderation::tech_review::rules::delete_rule,
moderation::tech_review::rules_scan::get_rule_schema,
moderation::tech_review::rules_scan::scan_rules,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
@@ -5,13 +5,23 @@ use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use super::rules_scan::{
RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace,
};
use crate::{
auth::check_is_moderator_from_headers,
database::{
PgPool, ReadOnlyPgPool, models::delphi_report_item::DelphiSeverity,
PgPool, ReadOnlyPgPool,
models::{
DBProjectId, DBVersionId, DelphiReportIssueDetailsId,
DelphiReportIssueId, delphi_report_item::DelphiSeverity,
},
redis::RedisPool,
},
models::pats::Scopes,
models::{
ids::{ProjectId, VersionId},
pats::Scopes,
},
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
@@ -24,6 +34,7 @@ const MAX_RULE_TEST_TRACES: usize = 10;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(get_rules)
.service(test_rule)
.service(get_rule_affected_details)
.service(create_rule)
.service(update_rule)
.service(delete_rule);
@@ -39,6 +50,27 @@ pub struct DelphiRule {
pub updated_at: DateTime<Utc>,
pub created_by: Option<i64>,
pub updated_by: Option<i64>,
pub affected_details_count: i64,
pub affected_details: Vec<DelphiRuleAffectedDetail>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DelphiRuleAffectedDetail {
pub detail_id: DelphiReportIssueDetailsId,
pub issue_id: DelphiReportIssueId,
pub project_id: Option<ProjectId>,
pub project_name: Option<String>,
pub project_icon_url: Option<String>,
pub version_id: Option<VersionId>,
pub version_name: Option<String>,
pub version_number: Option<String>,
pub issue_type: String,
pub key: String,
pub jar: Option<String>,
pub file_path: String,
pub original_severity: DelphiSeverity,
pub severity: Option<DelphiSeverity>,
pub hidden: bool,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
@@ -77,33 +109,6 @@ pub struct DelphiRuleEffect {
pub hidden: bool,
}
#[derive(Serialize)]
struct TestRuleInput<'a> {
schema_version: u32,
trace: &'a TestDelphiRuleTrace,
scan: TestRuleScan,
artifact: TestRuleArtifact,
scope: TestRuleScope,
}
#[derive(Serialize)]
struct TestRuleScan {
delphi_version: i32,
}
#[derive(Serialize)]
struct TestRuleArtifact {
size: u32,
hashes: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct TestRuleScope {
project_id: String,
version_id: String,
file_id: String,
}
struct ValidatedRule {
name: String,
rule: String,
@@ -203,22 +208,29 @@ pub async fn test_rule(
Ok(web::Json(TestDelphiRuleResponse { effects }))
}
fn test_rule_input(trace: &TestDelphiRuleTrace) -> TestRuleInput<'_> {
TestRuleInput {
fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput {
RuleInput {
schema_version: 1,
trace,
scan: TestRuleScan { delphi_version: 17 },
artifact: TestRuleArtifact {
size: 412_892,
trace: RuleTrace {
key: trace.key.clone(),
issue_type: trace.issue_type.clone(),
severity: trace.severity,
jar: trace.jar.clone(),
file_path: trace.file_path.clone(),
data: trace.data.clone(),
},
scan: RuleScan { delphi_version: 17 },
artifact: RuleArtifact {
size: Some(412_892),
hashes: BTreeMap::from([
("sha1".to_string(), "0123456789abcdef".to_string()),
("sha512".to_string(), "fedcba9876543210".to_string()),
]),
},
scope: TestRuleScope {
project_id: "example-project".to_string(),
version_id: "example-version".to_string(),
file_id: "example-file".to_string(),
scope: RuleScope {
project_id: Some("example-project".to_string()),
version_id: Some("example-version".to_string()),
file_id: Some("example-file".to_string()),
},
}
}
@@ -250,27 +262,81 @@ pub async fn get_rules(
let rules = sqlx::query!(
r#"
SELECT
id,
name,
rule,
revision,
created_at,
updated_at,
created_by,
updated_by
FROM delphi_rules
WHERE NOT delete_on_next_revision
ORDER BY id
delphi_rule.id,
delphi_rule.name,
delphi_rule.rule,
delphi_rule.revision,
delphi_rule.created_at,
delphi_rule.updated_at,
delphi_rule.created_by,
delphi_rule.updated_by,
COALESCE(preview.affected_details_count, 0)
AS "affected_details_count!",
preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId",
preview.issue_id AS "issue_id?: DelphiReportIssueId",
preview.project_id AS "project_id?: DBProjectId",
preview.project_name AS "project_name?",
preview.project_icon_url AS "project_icon_url?",
preview.version_id AS "version_id?: DBVersionId",
preview.version_name AS "version_name?",
preview.version_number AS "version_number?",
preview.issue_type AS "issue_type?",
preview.key AS "key?",
preview.jar AS "jar?",
preview.file_path AS "file_path?",
preview.original_severity AS "original_severity?: DelphiSeverity",
preview.severity AS "effect_severity?: DelphiSeverity",
preview.hidden AS "hidden?"
FROM delphi_rules delphi_rule
LEFT JOIN LATERAL (
SELECT
effect.detail_id,
detail.issue_id,
version.mod_id AS project_id,
project.name AS project_name,
project.icon_url AS project_icon_url,
version.id AS version_id,
version.name AS version_name,
version.version_number,
issue.issue_type,
detail.key,
detail.jar,
detail.file_path,
detail.severity AS original_severity,
effect.severity,
effect.hidden,
COUNT(*) OVER () AS affected_details_count
FROM delphi_rule_effects effect
INNER JOIN delphi_rule_revisions published
ON published.revision = effect.revision
INNER JOIN delphi_report_issue_details detail
ON detail.id = effect.detail_id
INNER JOIN delphi_report_issues issue
ON issue.id = detail.issue_id
INNER JOIN delphi_reports report
ON report.id = issue.report_id
LEFT JOIN files file ON file.id = report.file_id
LEFT JOIN versions version ON version.id = file.version_id
LEFT JOIN mods project ON project.id = version.mod_id
WHERE effect.rule_id = delphi_rule.id
ORDER BY effect.detail_id DESC
LIMIT 3
) preview ON TRUE
WHERE NOT delphi_rule.delete_on_next_revision
ORDER BY delphi_rule.id, preview.detail_id DESC
"#,
)
.fetch_all(&***ro_pool)
.await
.wrap_internal_err("failed to fetch delphi rules")?;
Ok(web::Json(
rules
.into_iter()
.map(|rule| DelphiRule {
let mut response = Vec::<DelphiRule>::new();
for rule in rules {
if response
.last()
.is_none_or(|existing| existing.id != rule.id)
{
response.push(DelphiRule {
id: rule.id,
name: rule.name,
rule: rule.rule,
@@ -279,6 +345,137 @@ pub async fn get_rules(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
affected_details_count: rule.affected_details_count,
affected_details: Vec::new(),
});
}
if let (
Some(detail_id),
Some(issue_id),
Some(issue_type),
Some(key),
Some(file_path),
Some(original_severity),
Some(hidden),
) = (
rule.detail_id,
rule.issue_id,
rule.issue_type,
rule.key,
rule.file_path,
rule.original_severity,
rule.hidden,
) {
response
.last_mut()
.expect("a delphi rule was inserted above")
.affected_details
.push(DelphiRuleAffectedDetail {
detail_id,
issue_id,
project_id: rule.project_id.map(ProjectId::from),
project_name: rule.project_name,
project_icon_url: rule.project_icon_url,
version_id: rule.version_id.map(VersionId::from),
version_name: rule.version_name,
version_number: rule.version_number,
issue_type,
key,
jar: rule.jar,
file_path,
original_severity,
severity: rule.effect_severity,
hidden,
});
}
}
Ok(web::Json(response))
}
/// List all details affected by a Delphi rule in the published revision.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK, body = Vec<DelphiRuleAffectedDetail>))
)]
#[get("/rules/{id}/effects")]
pub async fn get_rule_affected_details(
req: HttpRequest,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(i64,)>,
) -> Result<web::Json<Vec<DelphiRuleAffectedDetail>>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let (rule_id,) = path.into_inner();
let details = sqlx::query!(
r#"
SELECT
effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId",
detail.issue_id AS "issue_id!: DelphiReportIssueId",
version.mod_id AS "project_id?: DBProjectId",
project.name AS "project_name?",
project.icon_url AS "project_icon_url?",
version.id AS "version_id?: DBVersionId",
version.name AS "version_name?",
version.version_number AS "version_number?",
issue.issue_type,
detail.key,
detail.jar,
detail.file_path,
detail.severity AS "original_severity!: DelphiSeverity",
effect.severity AS "effect_severity: DelphiSeverity",
effect.hidden
FROM delphi_rule_effects effect
INNER JOIN delphi_rule_revisions published
ON published.revision = effect.revision
INNER JOIN delphi_report_issue_details detail
ON detail.id = effect.detail_id
INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id
INNER JOIN delphi_reports report ON report.id = issue.report_id
LEFT JOIN files file ON file.id = report.file_id
LEFT JOIN versions version ON version.id = file.version_id
LEFT JOIN mods project ON project.id = version.mod_id
WHERE effect.rule_id = $1
ORDER BY effect.detail_id DESC
"#,
rule_id,
)
.fetch_all(&***ro_pool)
.await
.wrap_internal_err("failed to fetch details affected by delphi rule")?;
Ok(web::Json(
details
.into_iter()
.map(|detail| DelphiRuleAffectedDetail {
detail_id: detail.detail_id,
issue_id: detail.issue_id,
project_id: detail.project_id.map(ProjectId::from),
project_name: detail.project_name,
project_icon_url: detail.project_icon_url,
version_id: detail.version_id.map(VersionId::from),
version_name: detail.version_name,
version_number: detail.version_number,
issue_type: detail.issue_type,
key: detail.key,
jar: detail.jar,
file_path: detail.file_path,
original_severity: detail.original_severity,
severity: detail.effect_severity,
hidden: detail.hidden,
})
.collect(),
))
@@ -354,6 +551,8 @@ pub async fn create_rule(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
affected_details_count: 0,
affected_details: Vec::new(),
}))
}
@@ -427,6 +626,8 @@ pub async fn update_rule(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
affected_details_count: 0,
affected_details: Vec::new(),
}))
}
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, HashMap};
use actix_web::{HttpRequest, HttpResponse, post, web};
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use ariadne::ids::base62_impl::to_base62;
use bytes::Bytes;
use eyre::{Context as _, Result, eyre};
@@ -9,6 +9,7 @@ use serde::Serialize;
use sqlx::types::Json;
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use utoipa::{PartialSchema, ToSchema};
use super::rules::DelphiRuleEffect;
use crate::{
@@ -26,7 +27,7 @@ const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275;
const PROGRESS_INTERVAL: usize = 50;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(scan_rules);
cfg.service(get_rule_schema).service(scan_rules);
}
#[derive(Serialize)]
@@ -43,41 +44,41 @@ struct RuleScanErrorEvent<'a> {
message: &'a str,
}
#[derive(Serialize)]
struct RuleInput {
schema_version: u32,
trace: RuleTrace,
scan: RuleScan,
artifact: RuleArtifact,
scope: RuleScope,
#[derive(Serialize, utoipa::ToSchema)]
pub(super) struct RuleInput {
pub(super) schema_version: u32,
pub(super) trace: RuleTrace,
pub(super) scan: RuleScan,
pub(super) artifact: RuleArtifact,
pub(super) scope: RuleScope,
}
#[derive(Serialize)]
struct RuleTrace {
key: String,
issue_type: String,
severity: DelphiSeverity,
jar: Option<String>,
file_path: String,
data: HashMap<String, serde_json::Value>,
#[derive(Serialize, utoipa::ToSchema)]
pub(super) struct RuleTrace {
pub(super) key: String,
pub(super) issue_type: String,
pub(super) severity: DelphiSeverity,
pub(super) jar: Option<String>,
pub(super) file_path: String,
pub(super) data: HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
struct RuleScan {
delphi_version: i32,
#[derive(Serialize, utoipa::ToSchema)]
pub(super) struct RuleScan {
pub(super) delphi_version: i32,
}
#[derive(Serialize)]
struct RuleArtifact {
size: Option<i32>,
hashes: BTreeMap<String, String>,
#[derive(Serialize, utoipa::ToSchema)]
pub(super) struct RuleArtifact {
pub(super) size: Option<i32>,
pub(super) hashes: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct RuleScope {
project_id: Option<String>,
version_id: Option<String>,
file_id: Option<String>,
#[derive(Serialize, utoipa::ToSchema)]
pub(super) struct RuleScope {
pub(super) project_id: Option<String>,
pub(super) version_id: Option<String>,
pub(super) file_id: Option<String>,
}
struct CompiledRule {
@@ -98,6 +99,62 @@ struct ScanSummary {
effects: usize,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DelphiRuleSchemaResponse {
pub input: serde_json::Value,
pub output: serde_json::Value,
pub components: BTreeMap<String, serde_json::Value>,
}
/// Get the schemas for the CEL input and output values.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK, body = DelphiRuleSchemaResponse))
)]
#[get("/rules/schema")]
pub async fn get_rule_schema(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<DelphiRuleSchemaResponse>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let mut schemas = Vec::new();
<RuleInput as ToSchema>::schemas(&mut schemas);
<Option<DelphiRuleEffect> as ToSchema>::schemas(&mut schemas);
Ok(web::Json(DelphiRuleSchemaResponse {
input: schema_to_value(<RuleInput as PartialSchema>::schema())?,
output: schema_to_value(
<Option<DelphiRuleEffect> as PartialSchema>::schema(),
)?,
components: schemas
.into_iter()
.map(|(name, schema)| Ok((name, schema_to_value(schema)?)))
.collect::<Result<_, ApiError>>()?,
}))
}
fn schema_to_value<T: Serialize>(
schema: T,
) -> Result<serde_json::Value, ApiError> {
serde_json::to_value(schema).map_err(|error| {
ApiError::Internal(
eyre!(error).wrap_err("failed to serialize Delphi rule schema"),
)
})
}
/// Re-evaluate every Delphi issue detail and atomically publish a new rule revision.
#[utoipa::path(
context_path = "/moderation/tech-review",
@@ -316,12 +373,13 @@ async fn run_scan(
};
for rule in &rules {
let effect = evaluate_rule(&rule.program, &input).wrap_err_with(|| {
format!(
"failed to evaluate delphi rule {} for detail {detail_id}",
rule.id
)
})?;
let effect = evaluate_rule(&rule.program, &input)
.wrap_err_with(|| {
format!(
"failed to evaluate delphi rule {} for detail {detail_id}",
rule.id
)
})?;
if let Some(effect) = effect {
effects.push(MaterializedEffect {
detail_id,