diff --git a/Cargo.lock b/Cargo.lock
index 1c3493e8e0..c7b1fff036 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -437,6 +437,23 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "antlr4rust"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6"
+dependencies = [
+ "better_any",
+ "bit-set 0.8.0",
+ "byteorder",
+ "lazy_static",
+ "murmur3",
+ "once_cell",
+ "parking_lot",
+ "typed-arena",
+ "uuid 1.23.3",
+]
+
[[package]]
name = "anyhow"
version = "1.0.100"
@@ -1415,10 +1432,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"base64 0.21.7",
- "pastey",
+ "pastey 0.1.1",
"serde",
]
+[[package]]
+name = "better_any"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4"
+
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -1445,7 +1468,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
- "bit-vec",
+ "bit-vec 0.6.3",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec 0.8.0",
]
[[package]]
@@ -1454,6 +1486,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -1883,6 +1921,22 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cel"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814"
+dependencies = [
+ "antlr4rust",
+ "base64 0.22.1",
+ "lazy_static",
+ "nom 7.1.3",
+ "pastey 0.2.3",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "censor"
version = "0.3.0"
@@ -2843,6 +2897,15 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
+[[package]]
+name = "delphi-cel-experiment"
+version = "0.1.0"
+dependencies = [
+ "cel",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -3508,7 +3571,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
- "bit-set",
+ "bit-set 0.5.3",
"regex-automata",
"regex-syntax",
]
@@ -5433,6 +5496,7 @@ dependencies = [
"base64 0.22.1",
"bitflags 2.9.4",
"bytes",
+ "cel",
"censor",
"chrono",
"clap 4.5.48",
@@ -6170,6 +6234,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb585ade2549a017db2e35978b77c319214fa4b37cede841e27954dd6e8f3ca8"
+[[package]]
+name = "murmur3"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "mutually_exclusive_features"
version = "0.1.0"
@@ -7178,6 +7251,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "path-util"
version = "0.0.0"
@@ -11671,6 +11750,12 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
[[package]]
name = "typed-path"
version = "0.12.0"
diff --git a/Cargo.toml b/Cargo.toml
index a5aed6cf76..5d1fa375b6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
+ "packages/delphi-cel-experiment",
"packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
@@ -55,6 +56,7 @@ bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
censor = "0.3.0"
+cel = { version = "0.14.0", default-features = false, features = ["json"] }
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = [
diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue
index 9352acabba..dc4a051390 100644
--- a/apps/frontend/src/pages/moderation.vue
+++ b/apps/frontend/src/pages/moderation.vue
@@ -15,7 +15,14 @@
diff --git a/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json
new file mode 100644
index 0000000000..5c2a54af63
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tUPDATE delphi_rule_revisions\n\t\tSET active = FALSE\n\t\tWHERE rule_id = $1 AND active\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8"
+}
diff --git a/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json
new file mode 100644
index 0000000000..e22ef8fa7c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json
@@ -0,0 +1,60 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\tpriority,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 3,
+ "name": "created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "updated",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "created_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 6,
+ "name": "updated_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int4",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421"
+}
diff --git a/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json
new file mode 100644
index 0000000000..0caf56d981
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json
@@ -0,0 +1,61 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\tpriority = $3,\n\t\t\tupdated = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 3,
+ "name": "created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "updated",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "created_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 6,
+ "name": "updated_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Int4",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020"
+}
diff --git a/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json
new file mode 100644
index 0000000000..de87121fdd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tINSERT INTO delphi_rule_revisions (rule_id, expression, created_by)\n\t\tVALUES ($1, $2, $3)\n\t\tRETURNING id, expression, created, created_by\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "expression",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 3,
+ "name": "created_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe"
+}
diff --git a/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json
new file mode 100644
index 0000000000..80d5ade693
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json
@@ -0,0 +1,80 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tSELECT\n\t\t\tr.id,\n\t\t\tr.name,\n\t\t\tr.priority,\n\t\t\tr.created,\n\t\t\tr.updated,\n\t\t\tr.created_by,\n\t\t\tr.updated_by,\n\t\t\trr.id AS revision_id,\n\t\t\trr.expression,\n\t\t\trr.created AS revision_created,\n\t\t\trr.created_by AS revision_created_by\n\t\tFROM delphi_rules r\n\t\tINNER JOIN delphi_rule_revisions rr\n\t\t\tON rr.rule_id = r.id\n\t\t\tAND rr.active\n\t\tORDER BY r.priority DESC, r.id\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 3,
+ "name": "created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "updated",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "created_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 6,
+ "name": "updated_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 7,
+ "name": "revision_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 8,
+ "name": "expression",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 9,
+ "name": "revision_created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "revision_created_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true,
+ false,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078"
+}
diff --git a/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json
new file mode 100644
index 0000000000..97b8370fb4
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e"
+}
diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml
index 3da97e6fa8..50ac869f4a 100644
--- a/apps/labrinth/Cargo.toml
+++ b/apps/labrinth/Cargo.toml
@@ -34,6 +34,7 @@ base64 = { workspace = true }
bitflags = { workspace = true }
bytes = { workspace = true }
censor = { workspace = true }
+cel = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
clickhouse = { workspace = true, features = ["time", "uuid"] }
diff --git a/apps/labrinth/migrations/20260715120000_delphi_rules.sql b/apps/labrinth/migrations/20260715120000_delphi_rules.sql
new file mode 100644
index 0000000000..527528dcaa
--- /dev/null
+++ b/apps/labrinth/migrations/20260715120000_delphi_rules.sql
@@ -0,0 +1,50 @@
+CREATE TABLE delphi_rules (
+ id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ name TEXT NOT NULL CHECK (BTRIM(name) <> ''),
+ priority INTEGER NOT NULL DEFAULT 0,
+ created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ created_by BIGINT REFERENCES users (id)
+ ON DELETE SET NULL
+ ON UPDATE CASCADE,
+ updated_by BIGINT REFERENCES users (id)
+ ON DELETE SET NULL
+ ON UPDATE CASCADE
+);
+
+CREATE TABLE delphi_rule_revisions (
+ id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ rule_id BIGINT NOT NULL REFERENCES delphi_rules (id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ expression TEXT NOT NULL CHECK (BTRIM(expression) <> ''),
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ created_by BIGINT REFERENCES users (id)
+ ON DELETE SET NULL
+ ON UPDATE CASCADE
+);
+
+CREATE UNIQUE INDEX delphi_rule_revisions_active
+ ON delphi_rule_revisions (rule_id)
+ WHERE active;
+
+CREATE INDEX delphi_rule_revisions_rule_id
+ ON delphi_rule_revisions (rule_id, created DESC);
+
+CREATE TABLE delphi_rule_effects (
+ rule_revision_id BIGINT NOT NULL REFERENCES delphi_rule_revisions (id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ issue_detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details (id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ severity delphi_severity,
+ hidden BOOLEAN NOT NULL DEFAULT FALSE,
+ created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (rule_revision_id, issue_detail_id),
+ CHECK (severity IS NOT NULL OR hidden)
+);
+
+CREATE INDEX delphi_rule_effects_issue_detail_id
+ ON delphi_rule_effects (issue_detail_id);
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index 8ab03e1d12..acb31a4f36 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -115,6 +115,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::search_projects,
moderation::tech_review::global::search_global_issue_details,
moderation::tech_review::global::get_global_issue_detail,
+ moderation::tech_review::rules::get_rules,
+ moderation::tech_review::rules::create_rule,
+ moderation::tech_review::rules::update_rule,
+ moderation::tech_review::rules::delete_rule,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
moderation::tech_review::update_issue_details,
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
index 2bd5097c58..fedf2288f2 100644
--- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
@@ -44,10 +44,12 @@ use crate::{
use eyre::eyre;
pub mod global;
+pub mod rules;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(search_projects)
.configure(global::config)
+ .configure(rules::config)
.service(get_project_report)
.service(get_report)
.service(get_issue)
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs
new file mode 100644
index 0000000000..f0946c3de6
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs
@@ -0,0 +1,379 @@
+use actix_web::{HttpRequest, delete, get, post, put, web};
+use chrono::{DateTime, Utc};
+use eyre::eyre;
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ auth::check_is_moderator_from_headers,
+ database::{PgPool, ReadOnlyPgPool, redis::RedisPool},
+ models::pats::Scopes,
+ queue::session::AuthQueue,
+ routes::ApiError,
+ util::error::Context,
+};
+
+const MAX_RULE_NAME_LENGTH: usize = 128;
+const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536;
+
+pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
+ cfg.service(get_rules)
+ .service(create_rule)
+ .service(update_rule)
+ .service(delete_rule);
+}
+
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct DelphiRule {
+ pub id: i64,
+ pub name: String,
+ pub priority: i32,
+ pub expression: String,
+ pub revision_id: i64,
+ pub created: DateTime,
+ pub updated: DateTime,
+ pub revision_created: DateTime,
+ pub created_by: Option,
+ pub updated_by: Option,
+ pub revision_created_by: Option,
+}
+
+#[derive(Debug, Deserialize, utoipa::ToSchema)]
+pub struct WriteDelphiRule {
+ pub name: String,
+ pub priority: i32,
+ pub expression: String,
+}
+
+struct ValidatedRule {
+ name: String,
+ priority: i32,
+ expression: String,
+}
+
+impl WriteDelphiRule {
+ fn validate(self) -> Result {
+ let name = self.name.trim().to_string();
+ if name.is_empty() {
+ return Err(ApiError::Request(eyre!("rule name cannot be empty")));
+ }
+ if name.chars().count() > MAX_RULE_NAME_LENGTH {
+ return Err(ApiError::Request(eyre!(
+ "rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters"
+ )));
+ }
+
+ let expression = self.expression.trim().to_string();
+ if expression.is_empty() {
+ return Err(ApiError::Request(eyre!(
+ "rule expression cannot be empty"
+ )));
+ }
+ if expression.len() > MAX_RULE_EXPRESSION_LENGTH {
+ return Err(ApiError::Request(eyre!(
+ "rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes"
+ )));
+ }
+
+ cel::Program::compile(&expression).map_err(|error| {
+ ApiError::Request(eyre!("invalid cel expression: {error}"))
+ })?;
+
+ Ok(ValidatedRule {
+ name,
+ priority: self.priority,
+ expression,
+ })
+ }
+}
+
+/// List the current revision of every Delphi rule.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ responses((status = OK, body = Vec))
+)]
+#[get("/rules")]
+pub async fn get_rules(
+ req: HttpRequest,
+ pool: web::Data,
+ ro_pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+) -> Result>, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let rows = sqlx::query!(
+ r#"
+ SELECT
+ r.id,
+ r.name,
+ r.priority,
+ r.created,
+ r.updated,
+ r.created_by,
+ r.updated_by,
+ rr.id AS revision_id,
+ rr.expression,
+ rr.created AS revision_created,
+ rr.created_by AS revision_created_by
+ FROM delphi_rules r
+ INNER JOIN delphi_rule_revisions rr
+ ON rr.rule_id = r.id
+ AND rr.active
+ ORDER BY r.priority DESC, r.id
+ "#,
+ )
+ .fetch_all(&***ro_pool)
+ .await
+ .wrap_internal_err("failed to fetch delphi rules")?;
+
+ Ok(web::Json(
+ rows.into_iter()
+ .map(|row| DelphiRule {
+ id: row.id,
+ name: row.name,
+ priority: row.priority,
+ expression: row.expression,
+ revision_id: row.revision_id,
+ created: row.created,
+ updated: row.updated,
+ revision_created: row.revision_created,
+ created_by: row.created_by,
+ updated_by: row.updated_by,
+ revision_created_by: row.revision_created_by,
+ })
+ .collect(),
+ ))
+}
+
+/// Create a Delphi rule and its first revision.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ request_body = WriteDelphiRule,
+ responses((status = OK, body = DelphiRule))
+)]
+#[post("/rules")]
+pub async fn create_rule(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ let user = check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_WRITE,
+ )
+ .await?;
+ let rule = body.into_inner().validate()?;
+ let user_id = user.id.0 as i64;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("failed to begin delphi rule transaction")?;
+
+ let row = sqlx::query!(
+ r#"
+ INSERT INTO delphi_rules (
+ name,
+ priority,
+ created_by,
+ updated_by
+ )
+ VALUES ($1, $2, $3, $3)
+ RETURNING id, name, priority, created, updated, created_by, updated_by
+ "#,
+ rule.name,
+ rule.priority,
+ user_id,
+ )
+ .fetch_one(&mut transaction)
+ .await
+ .wrap_internal_err("failed to create delphi rule")?;
+
+ let revision = sqlx::query!(
+ r#"
+ INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
+ VALUES ($1, $2, $3)
+ RETURNING id, expression, created, created_by
+ "#,
+ row.id,
+ rule.expression,
+ user_id,
+ )
+ .fetch_one(&mut transaction)
+ .await
+ .wrap_internal_err("failed to create delphi rule revision")?;
+
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("failed to commit delphi rule transaction")?;
+
+ Ok(web::Json(DelphiRule {
+ id: row.id,
+ name: row.name,
+ priority: row.priority,
+ expression: revision.expression,
+ revision_id: revision.id,
+ created: row.created,
+ updated: row.updated,
+ revision_created: revision.created,
+ created_by: row.created_by,
+ updated_by: row.updated_by,
+ revision_created_by: revision.created_by,
+ }))
+}
+
+/// Replace a Delphi rule and create a new current revision.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ request_body = WriteDelphiRule,
+ responses((status = OK, body = DelphiRule), (status = NOT_FOUND))
+)]
+#[put("/rules/{id}")]
+pub async fn update_rule(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path<(i64,)>,
+ body: web::Json,
+) -> Result, ApiError> {
+ let user = check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_WRITE,
+ )
+ .await?;
+ let (id,) = path.into_inner();
+ let rule = body.into_inner().validate()?;
+ let user_id = user.id.0 as i64;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("failed to begin delphi rule transaction")?;
+
+ let row = sqlx::query!(
+ r#"
+ UPDATE delphi_rules
+ SET
+ name = $2,
+ priority = $3,
+ updated = CURRENT_TIMESTAMP,
+ updated_by = $4
+ WHERE id = $1
+ RETURNING id, name, priority, created, updated, created_by, updated_by
+ "#,
+ id,
+ rule.name,
+ rule.priority,
+ user_id,
+ )
+ .fetch_optional(&mut transaction)
+ .await
+ .wrap_internal_err("failed to update delphi rule")?
+ .ok_or(ApiError::NotFound)?;
+
+ sqlx::query!(
+ r#"
+ UPDATE delphi_rule_revisions
+ SET active = FALSE
+ WHERE rule_id = $1 AND active
+ "#,
+ id,
+ )
+ .execute(&mut transaction)
+ .await
+ .wrap_internal_err("failed to deactivate delphi rule revision")?;
+
+ let revision = sqlx::query!(
+ r#"
+ INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
+ VALUES ($1, $2, $3)
+ RETURNING id, expression, created, created_by
+ "#,
+ id,
+ rule.expression,
+ user_id,
+ )
+ .fetch_one(&mut transaction)
+ .await
+ .wrap_internal_err("failed to create delphi rule revision")?;
+
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("failed to commit delphi rule transaction")?;
+
+ Ok(web::Json(DelphiRule {
+ id: row.id,
+ name: row.name,
+ priority: row.priority,
+ expression: revision.expression,
+ revision_id: revision.id,
+ created: row.created,
+ updated: row.updated,
+ revision_created: revision.created,
+ created_by: row.created_by,
+ updated_by: row.updated_by,
+ revision_created_by: revision.created_by,
+ }))
+}
+
+/// Delete a Delphi rule and all its revisions and materialized effects.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ responses((status = OK), (status = NOT_FOUND))
+)]
+#[delete("/rules/{id}")]
+pub async fn delete_rule(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path<(i64,)>,
+) -> Result<(), ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_WRITE,
+ )
+ .await?;
+ let (id,) = path.into_inner();
+
+ let deleted = sqlx::query!(
+ "DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
+ id,
+ )
+ .fetch_optional(&**pool)
+ .await
+ .wrap_internal_err("failed to delete delphi rule")?;
+
+ if deleted.is_none() {
+ return Err(ApiError::NotFound);
+ }
+
+ Ok(())
+}
diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts
index 0012474334..ef2af98c29 100644
--- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts
+++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts
@@ -6,6 +6,54 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
return 'labrinth_tech_review_internal'
}
+ public async getRules(): Promise {
+ return this.client.request(
+ '/moderation/tech-review/rules',
+ {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'GET',
+ },
+ )
+ }
+
+ public async createRule(
+ rule: Labrinth.TechReview.Internal.WriteDelphiRule,
+ ): Promise {
+ return this.client.request(
+ '/moderation/tech-review/rules',
+ {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'POST',
+ body: rule,
+ },
+ )
+ }
+
+ public async updateRule(
+ id: number,
+ rule: Labrinth.TechReview.Internal.WriteDelphiRule,
+ ): Promise {
+ return this.client.request(
+ `/moderation/tech-review/rules/${id}`,
+ {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'PUT',
+ body: rule,
+ },
+ )
+ }
+
+ public async deleteRule(id: number): Promise {
+ return this.client.request(`/moderation/tech-review/rules/${id}`, {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'DELETE',
+ })
+ }
+
/**
* Search for projects awaiting technical review.
*
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index cbe00e4f90..4c586cb44f 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -2226,6 +2226,26 @@ export namespace Labrinth {
export namespace TechReview {
export namespace Internal {
+ export type DelphiRule = {
+ id: number
+ name: string
+ priority: number
+ expression: string
+ revision_id: number
+ created: string
+ updated: string
+ revision_created: string
+ created_by: number | null
+ updated_by: number | null
+ revision_created_by: number | null
+ }
+
+ export type WriteDelphiRule = {
+ name: string
+ priority: number
+ expression: string
+ }
+
export type SearchProjectsRequest = {
limit?: number
page?: number
diff --git a/packages/delphi-cel-experiment/Cargo.toml b/packages/delphi-cel-experiment/Cargo.toml
new file mode 100644
index 0000000000..34ea4460b0
--- /dev/null
+++ b/packages/delphi-cel-experiment/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "delphi-cel-experiment"
+version = "0.1.0"
+edition.workspace = true
+publish = false
+
+[dependencies]
+cel = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
diff --git a/packages/delphi-cel-experiment/src/main.rs b/packages/delphi-cel-experiment/src/main.rs
new file mode 100644
index 0000000000..92fe06bd33
--- /dev/null
+++ b/packages/delphi-cel-experiment/src/main.rs
@@ -0,0 +1,173 @@
+use std::{collections::BTreeMap, error::Error, io};
+
+use cel::{Context, Program};
+use serde::{Deserialize, Serialize};
+
+const EFFECT_RULE: &str = r#"
+ input.trace.issue_type == "OBFUSCATED_NAMES"
+ && input.trace.severity == "high"
+ && "confidence" in input.trace.data
+ && input.trace.data.confidence >= 0.9
+ ? {
+ "severity": "low",
+ "hidden": false
+ }
+ : null
+"#;
+
+#[derive(Debug, Serialize)]
+struct RuleInput {
+ schema_version: u32,
+ trace: TraceInput,
+ scan: ScanInput,
+ artifact: ArtifactInput,
+ scope: ScopeInput,
+}
+
+#[derive(Debug, Serialize)]
+struct TraceInput {
+ key: String,
+ issue_type: String,
+ severity: DelphiSeverity,
+ jar: Option,
+ file_path: String,
+ data: BTreeMap,
+}
+
+#[derive(Debug, Serialize)]
+struct ScanInput {
+ delphi_version: i32,
+}
+
+#[derive(Debug, Serialize)]
+struct ArtifactInput {
+ size: u32,
+ hashes: BTreeMap,
+}
+
+#[derive(Debug, Serialize)]
+struct ScopeInput {
+ project_id: String,
+ version_id: String,
+ file_id: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+enum DelphiSeverity {
+ Low,
+ Medium,
+ High,
+ Severe,
+}
+
+#[derive(Debug, PartialEq, Eq, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RuleEffect {
+ #[serde(default)]
+ severity: Option,
+ #[serde(default)]
+ hidden: bool,
+}
+
+fn main() -> Result<(), Box> {
+ let input = example_input();
+ println!(
+ "CEL input context:\n{}",
+ serde_json::to_string_pretty(&input)?
+ );
+
+ let effect = evaluate_rule(EFFECT_RULE, &input)?;
+ println!("\nCEL map decoded as RuleEffect:\n{effect:#?}");
+
+ Ok(())
+}
+
+fn example_input() -> RuleInput {
+ RuleInput {
+ schema_version: 1,
+ trace: TraceInput {
+ key: "known-safe:obfuscated-bootstrap".to_string(),
+ issue_type: "OBFUSCATED_NAMES".to_string(),
+ severity: DelphiSeverity::High,
+ jar: Some("META-INF/jars/embedded.jar".to_string()),
+ file_path: "com/example/Bootstrap.class".to_string(),
+ data: BTreeMap::from([
+ ("confidence".to_string(), serde_json::json!(0.97)),
+ ("symbol_count".to_string(), serde_json::json!(42)),
+ ]),
+ },
+ scan: ScanInput { delphi_version: 17 },
+ artifact: ArtifactInput {
+ size: 412_892,
+ hashes: BTreeMap::from([
+ ("sha1".to_string(), "0123456789abcdef".to_string()),
+ ("sha512".to_string(), "fedcba9876543210".to_string()),
+ ]),
+ },
+ scope: ScopeInput {
+ project_id: "AANobbMI".to_string(),
+ version_id: "IIJJKKLL".to_string(),
+ file_id: "XXYYZZ00".to_string(),
+ },
+ }
+}
+
+fn evaluate_rule(
+ expression: &str,
+ input: &RuleInput,
+) -> Result