likwid/backend/src/plugins/builtin/moderation_ledger.rs
Marco Allegretti d4bcba405b backend: modify 56 files
Verified changes:
- modify backend/src/api/analytics.rs
- modify backend/src/api/approvals.rs
- modify backend/src/api/auth.rs
- modify backend/src/api/comments.rs
- modify backend/src/api/communities.rs
- modify backend/src/api/conflicts.rs
- modify backend/src/api/delegation.rs
- modify backend/src/api/deliberation.rs
- modify backend/src/api/demo.rs
- modify backend/src/api/exports.rs
- modify backend/src/api/federation.rs
- modify backend/src/api/gitlab.rs
- modify backend/src/api/invitations.rs
- modify backend/src/api/lifecycle.rs
- modify backend/src/api/mod.rs
- modify backend/src/api/moderation.rs
- modify backend/src/api/moderation_ledger.rs
- modify backend/src/api/notifications.rs
- modify backend/src/api/permissions.rs
- modify backend/src/api/plugins.rs
- modify backend/src/api/proposals.rs
- modify backend/src/api/roles.rs
- modify backend/src/api/self_moderation.rs
- modify backend/src/api/settings.rs
- modify backend/src/api/users.rs
- modify backend/src/api/voting_config.rs
- modify backend/src/api/workflows.rs
- modify backend/src/auth/jwt.rs
- modify backend/src/auth/middleware.rs
- modify backend/src/auth/mod.rs
- modify backend/src/demo/mod.rs
- modify backend/src/main.rs
- modify backend/src/models/community.rs
- modify backend/src/models/mod.rs
- modify backend/src/models/proposal.rs
- modify backend/src/models/user.rs
- modify backend/src/plugins/builtin/conflict_resolution.rs
- modify backend/src/plugins/builtin/decision_workflows.rs
- modify backend/src/plugins/builtin/federation.rs
- modify backend/src/plugins/builtin/governance_analytics.rs
- modify backend/src/plugins/builtin/moderation_ledger.rs
- modify backend/src/plugins/builtin/proposal_lifecycle.rs
- modify backend/src/plugins/builtin/public_data_export.rs
- modify backend/src/plugins/builtin/self_moderation.rs
- modify backend/src/plugins/builtin/structured_deliberation.rs
- modify backend/src/plugins/hooks.rs
- modify backend/src/plugins/manager.rs
- modify backend/src/plugins/wasm/host_api.rs
- modify backend/src/plugins/wasm/plugin.rs
- modify backend/src/plugins/wasm/runtime.rs
- modify backend/src/rate_limit.rs
- modify backend/src/voting/mod.rs
- modify backend/src/voting/quadratic.rs
- modify backend/src/voting/ranked_choice.rs
- modify backend/src/voting/schulze.rs
- modify backend/src/voting/star.rs

Diffstat:
- 56 files changed, 2697 insertions(+), 1629 deletions(-)
2026-02-03 17:54:39 +01:00

652 lines
22 KiB
Rust

use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::plugins::{
hooks::{HookContext, PluginError},
manager::{Plugin, PluginMetadata, PluginScope, PluginSystem},
};
/// Moderation Ledger Plugin
///
/// Creates an immutable, cryptographically-chained log of all moderation decisions.
/// This plugin is NON-DEACTIVATABLE by design - transparency is not optional.
///
/// Features:
/// - Immutable entries with SHA-256 hash chain
/// - Full audit trail with actor, target, reason, evidence
/// - Chain verification for tamper detection
/// - Export to JSON/CSV
/// - Community-scoped with independent chains
pub struct ModerationLedgerPlugin;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModerationActionType {
// Content moderation
ContentRemove,
ContentHide,
ContentRestore,
ContentEdit,
ContentFlag,
ContentUnflag,
// User moderation
UserWarn,
UserMute,
UserUnmute,
UserSuspend,
UserUnsuspend,
UserBan,
UserUnban,
UserRoleChange,
// Community moderation
CommunitySettingChange,
CommunityRuleAdd,
CommunityRuleEdit,
CommunityRuleRemove,
// Proposal/voting moderation
ProposalClose,
ProposalReopen,
ProposalArchive,
VoteInvalidate,
VoteRestore,
// Escalation
EscalateToAdmin,
EscalateToCommunity,
AppealReceived,
AppealResolved,
}
impl ModerationActionType {
pub fn as_db_str(&self) -> &'static str {
match self {
Self::ContentRemove => "content_remove",
Self::ContentHide => "content_hide",
Self::ContentRestore => "content_restore",
Self::ContentEdit => "content_edit",
Self::ContentFlag => "content_flag",
Self::ContentUnflag => "content_unflag",
Self::UserWarn => "user_warn",
Self::UserMute => "user_mute",
Self::UserUnmute => "user_unmute",
Self::UserSuspend => "user_suspend",
Self::UserUnsuspend => "user_unsuspend",
Self::UserBan => "user_ban",
Self::UserUnban => "user_unban",
Self::UserRoleChange => "user_role_change",
Self::CommunitySettingChange => "community_setting_change",
Self::CommunityRuleAdd => "community_rule_add",
Self::CommunityRuleEdit => "community_rule_edit",
Self::CommunityRuleRemove => "community_rule_remove",
Self::ProposalClose => "proposal_close",
Self::ProposalReopen => "proposal_reopen",
Self::ProposalArchive => "proposal_archive",
Self::VoteInvalidate => "vote_invalidate",
Self::VoteRestore => "vote_restore",
Self::EscalateToAdmin => "escalate_to_admin",
Self::EscalateToCommunity => "escalate_to_community",
Self::AppealReceived => "appeal_received",
Self::AppealResolved => "appeal_resolved",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerEntry {
pub id: Uuid,
pub sequence_number: i64,
pub community_id: Option<Uuid>,
pub actor_user_id: Uuid,
pub actor_role: String,
pub action_type: String,
pub target_type: String,
pub target_id: Uuid,
pub reason: String,
pub rule_reference: Option<String>,
pub evidence: Option<Value>,
pub duration_hours: Option<i32>,
pub decision_type: String,
pub entry_hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainVerificationResult {
pub is_valid: bool,
pub total_entries: i64,
pub broken_at_sequence: Option<i64>,
pub expected_hash: Option<String>,
pub actual_hash: Option<String>,
pub error_message: Option<String>,
}
/// Service for interacting with the moderation ledger
pub struct LedgerService;
impl LedgerService {
/// Create a new ledger entry
#[allow(clippy::too_many_arguments)]
pub async fn create_entry(
pool: &PgPool,
community_id: Option<Uuid>,
actor_user_id: Uuid,
actor_role: &str,
action_type: ModerationActionType,
target_type: &str,
target_id: Uuid,
reason: &str,
rule_reference: Option<&str>,
evidence: Option<Value>,
target_snapshot: Option<Value>,
duration_hours: Option<i32>,
decision_type: &str,
vote_proposal_id: Option<Uuid>,
vote_result: Option<Value>,
) -> Result<Uuid, PluginError> {
let entry_id: Uuid = sqlx::query_scalar(
r#"SELECT create_ledger_entry(
$1, $2, $3, $4::moderation_action_type, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
)"#,
)
.bind(community_id)
.bind(actor_user_id)
.bind(actor_role)
.bind(action_type.as_db_str())
.bind(target_type)
.bind(target_id)
.bind(reason)
.bind(rule_reference)
.bind(evidence)
.bind(target_snapshot)
.bind(duration_hours)
.bind(decision_type)
.bind(vote_proposal_id)
.bind(vote_result)
.fetch_one(pool)
.await?;
Ok(entry_id)
}
/// Get ledger entries for a community
pub async fn get_entries(
pool: &PgPool,
community_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> Result<Vec<LedgerEntry>, PluginError> {
let entries = sqlx::query_as!(
LedgerEntry,
r#"SELECT
id,
sequence_number,
community_id,
actor_user_id,
actor_role,
action_type::text AS "action_type!",
target_type,
target_id,
reason,
rule_reference,
evidence,
duration_hours,
decision_type,
entry_hash,
created_at
FROM moderation_ledger
WHERE community_id IS NOT DISTINCT FROM $1
ORDER BY sequence_number DESC
LIMIT $2 OFFSET $3"#,
community_id,
limit,
offset,
)
.fetch_all(pool)
.await?;
Ok(entries)
}
/// Get a single entry by ID
pub async fn get_entry(
pool: &PgPool,
entry_id: Uuid,
) -> Result<Option<LedgerEntry>, PluginError> {
let entry = sqlx::query_as!(
LedgerEntry,
r#"SELECT
id,
sequence_number,
community_id,
actor_user_id,
actor_role,
action_type::text AS "action_type!",
target_type,
target_id,
reason,
rule_reference,
evidence,
duration_hours,
decision_type,
entry_hash,
created_at
FROM moderation_ledger
WHERE id = $1"#,
entry_id,
)
.fetch_optional(pool)
.await?;
Ok(entry)
}
/// Get entries targeting a specific entity
pub async fn get_entries_for_target(
pool: &PgPool,
target_type: &str,
target_id: Uuid,
) -> Result<Vec<LedgerEntry>, PluginError> {
let entries = sqlx::query_as!(
LedgerEntry,
r#"SELECT
id,
sequence_number,
community_id,
actor_user_id,
actor_role,
action_type::text AS "action_type!",
target_type,
target_id,
reason,
rule_reference,
evidence,
duration_hours,
decision_type,
entry_hash,
created_at
FROM moderation_ledger
WHERE target_type = $1 AND target_id = $2
ORDER BY sequence_number DESC"#,
target_type,
target_id,
)
.fetch_all(pool)
.await?;
Ok(entries)
}
/// Verify the chain integrity
pub async fn verify_chain(
pool: &PgPool,
community_id: Option<Uuid>,
) -> Result<ChainVerificationResult, PluginError> {
let result = sqlx::query!(
r#"SELECT
is_valid,
total_entries,
broken_at_sequence,
expected_hash,
actual_hash,
error_message
FROM verify_ledger_chain($1)"#,
community_id,
)
.fetch_one(pool)
.await?;
Ok(ChainVerificationResult {
is_valid: result.is_valid.unwrap_or(false),
total_entries: result.total_entries.unwrap_or(0),
broken_at_sequence: result.broken_at_sequence,
expected_hash: result.expected_hash,
actual_hash: result.actual_hash,
error_message: result.error_message,
})
}
/// Get statistics for a community
pub async fn get_stats(
pool: &PgPool,
community_id: Option<Uuid>,
) -> Result<Value, PluginError> {
let stats = sqlx::query!(
r#"SELECT
action_type::text AS action_type,
decision_type,
total_actions,
unique_actors,
unique_targets
FROM v_moderation_stats
WHERE community_id IS NOT DISTINCT FROM $1"#,
community_id,
)
.fetch_all(pool)
.await?;
let summary: Vec<Value> = stats
.into_iter()
.map(|s| {
json!({
"action_type": s.action_type,
"decision_type": s.decision_type,
"total_actions": s.total_actions,
"unique_actors": s.unique_actors,
"unique_targets": s.unique_targets,
})
})
.collect();
Ok(json!({ "stats": summary }))
}
/// Export ledger entries as JSON
pub async fn export_json(
pool: &PgPool,
community_id: Option<Uuid>,
) -> Result<Value, PluginError> {
let entries = Self::get_entries(pool, community_id, 100000, 0).await?;
let verification = Self::verify_chain(pool, community_id).await?;
Ok(json!({
"export_version": "1.0",
"exported_at": chrono::Utc::now(),
"community_id": community_id,
"chain_verification": verification,
"entries": entries,
}))
}
}
#[async_trait]
impl Plugin for ModerationLedgerPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: "moderation_ledger",
version: "1.0.0",
description: "Immutable, cryptographically-chained log of all moderation decisions. Cannot be deactivated.",
is_core: true, // Core plugin - cannot be disabled
scope: PluginScope::Global,
default_enabled: true,
settings_schema: Some(json!({
"type": "object",
"properties": {
"require_reason_min_length": {
"type": "integer",
"title": "Minimum reason length",
"description": "Minimum characters required for moderation justifications",
"default": 20,
"minimum": 10,
"maximum": 500
},
"require_rule_reference": {
"type": "boolean",
"title": "Require rule reference",
"description": "Require moderators to cite a specific community rule",
"default": false
},
"public_ledger": {
"type": "boolean",
"title": "Public ledger",
"description": "Allow all community members to view the moderation ledger",
"default": true
}
}
})),
}
}
fn register(&self, system: &mut PluginSystem) {
let plugin_name = self.metadata().name.to_string();
// Hook: Log content removal
system.add_action(
"moderation.content_removed",
plugin_name.clone(),
1, // Highest priority - must log before anything else
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name.clone();
Box::pin(async move {
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_id = payload
.get("content_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| PluginError::Message("Missing content_id".into()))?;
let content_type = payload
.get("content_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let reason = payload
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("No reason provided");
let actor_role = payload
.get("actor_role")
.and_then(|v| v.as_str())
.unwrap_or("moderator");
let entry_id = LedgerService::create_entry(
&ctx.pool,
ctx.community_id,
actor_id,
actor_role,
ModerationActionType::ContentRemove,
content_type,
target_id,
reason,
payload.get("rule_reference").and_then(|v| v.as_str()),
payload.get("evidence").cloned(),
payload.get("content_snapshot").cloned(),
None,
"unilateral",
None,
None,
)
.await?;
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
"entry_id": entry_id,
"action_type": "content_remove",
"target_type": content_type,
}),
)
.await;
Ok(())
})
}),
);
// Hook: Log user moderation
let plugin_name2 = self.metadata().name.to_string();
system.add_action(
"moderation.user_action",
plugin_name2.clone(),
1,
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name2.clone();
Box::pin(async move {
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_user_id = payload
.get("target_user_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| PluginError::Message("Missing target_user_id".into()))?;
let action = payload
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("user_warn");
let action_type = match action {
"warn" => ModerationActionType::UserWarn,
"mute" => ModerationActionType::UserMute,
"unmute" => ModerationActionType::UserUnmute,
"suspend" => ModerationActionType::UserSuspend,
"unsuspend" => ModerationActionType::UserUnsuspend,
"ban" => ModerationActionType::UserBan,
"unban" => ModerationActionType::UserUnban,
_ => ModerationActionType::UserWarn,
};
let reason = payload
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("No reason provided");
let actor_role = payload
.get("actor_role")
.and_then(|v| v.as_str())
.unwrap_or("moderator");
let duration = payload
.get("duration_hours")
.and_then(|v| v.as_i64())
.map(|d| d as i32);
let entry_id = LedgerService::create_entry(
&ctx.pool,
ctx.community_id,
actor_id,
actor_role,
action_type,
"user",
target_user_id,
reason,
payload.get("rule_reference").and_then(|v| v.as_str()),
payload.get("evidence").cloned(),
None,
duration,
"unilateral",
None,
None,
)
.await?;
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
"entry_id": entry_id,
"action_type": action,
"target_type": "user",
}),
)
.await;
Ok(())
})
}),
);
// Hook: Log proposal moderation
let plugin_name3 = self.metadata().name.to_string();
system.add_action(
"moderation.proposal_action",
plugin_name3.clone(),
1,
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name3.clone();
Box::pin(async move {
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let proposal_id = payload
.get("proposal_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| PluginError::Message("Missing proposal_id".into()))?;
let action = payload
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("close");
let action_type = match action {
"close" => ModerationActionType::ProposalClose,
"reopen" => ModerationActionType::ProposalReopen,
"archive" => ModerationActionType::ProposalArchive,
_ => ModerationActionType::ProposalClose,
};
let reason = payload
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("No reason provided");
let actor_role = payload
.get("actor_role")
.and_then(|v| v.as_str())
.unwrap_or("moderator");
let decision_type = payload
.get("decision_type")
.and_then(|v| v.as_str())
.unwrap_or("unilateral");
let vote_proposal_id = payload
.get("vote_proposal_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
let entry_id = LedgerService::create_entry(
&ctx.pool,
ctx.community_id,
actor_id,
actor_role,
action_type,
"proposal",
proposal_id,
reason,
payload.get("rule_reference").and_then(|v| v.as_str()),
payload.get("evidence").cloned(),
payload.get("proposal_snapshot").cloned(),
None,
decision_type,
vote_proposal_id,
payload.get("vote_result").cloned(),
)
.await?;
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
"entry_id": entry_id,
"action_type": action,
"target_type": "proposal",
}),
)
.await;
Ok(())
})
}),
);
}
}