Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Rules/AnalyzeRulesFunction.cs
T
Oliver Kieselbach 61892592e4 feat(rules): community rule submissions — submit custom rules from the portal, review via GA page or MCP, publish by repo commit + reseed (D-236)
Tenant admins submit references to their own gather/analyze rules; the server freezes the rule, records pre-flight findings and fire stats, and stores it under a 12-character id (RuleSubmissions, SessionReports layout). Attribution is the submitter's frozen choice (anonymous by default) and becomes the published author. Review (approve with reserved id / decline with comment) through /admin/reports/rule-submissions or the MCP tools list_rule_submissions, get_rule_submission, review_rule_submission, reseed_rules_from_github; published is derived from the global catalog, never stored. Analyze rules now get the same server-side author stamp as gather rules; ANALYZE-ID-001 carries author Community. Privacy page names the credit; bumps internal and .claude/commands gitlinks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:53:32 +02:00

177 lines
7.7 KiB
C#

using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// CRUD API for managing analyze rules (portal-facing, JWT auth)
/// </summary>
public class AnalyzeRulesFunction
{
private readonly ILogger<AnalyzeRulesFunction> _logger;
private readonly AnalyzeRuleService _ruleService;
public AnalyzeRulesFunction(
ILogger<AnalyzeRulesFunction> logger,
AnalyzeRuleService ruleService)
{
_logger = logger;
_ruleService = ruleService;
}
[Function("GetAnalyzeRules")]
public async Task<HttpResponseData> GetRules(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "rules/analyze")] HttpRequestData req)
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var rules = await _ruleService.GetAllRulesForTenantAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AnalyzeRuleListResponse { Success = true, Rules = rules });
return response;
}
[Function("CreateAnalyzeRule")]
public async Task<HttpResponseData> CreateRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze")] HttpRequestData req)
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
// Newtonsoft on purpose: rule documents stay on the Newtonsoft path (TypedRequestGuardTests baseline).
var read = await req.ReadNewtonsoftAsync<AnalyzeRule>(1_048_576);
if (read.Error != null) return read.Error;
var rule = read.Value!;
if (string.IsNullOrEmpty(rule.RuleId))
{
return await req.BadRequestAsync("Invalid rule data");
}
// Author is stamped from the creator's token, never from the payload
// (anti-spoof), and stays immutable through every later update.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = success, Message = success ? "Rule created" : "Failed to create rule" });
return response;
}
catch (InvalidOperationException ex)
{
return await req.ConflictAsync(ex.Message);
}
catch (ArgumentException ex)
{
return await req.BadRequestAsync(ex.Message);
}
}
[Function("UpdateAnalyzeRule")]
public async Task<HttpResponseData> UpdateRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
// Newtonsoft on purpose: rule documents stay on the Newtonsoft path (TypedRequestGuardTests baseline).
var read = await req.ReadNewtonsoftAsync<AnalyzeRule>(1_048_576);
if (read.Error != null) return read.Error;
var rule = read.Value!;
rule.RuleId = ruleId;
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
// this with the original author (immutable attribution), but a full-payload PUT
// that upserts a rule with no existing row must not store the payload's author.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
catch (InvalidOperationException ex)
{
return await req.ConflictAsync(ex.Message);
}
catch (ArgumentException ex)
{
return await req.BadRequestAsync(ex.Message);
}
}
[Function("CreateAnalyzeRuleFromTemplate")]
public async Task<HttpResponseData> CreateFromTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze/{ruleId}/create-from-template")] HttpRequestData req,
string ruleId)
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var read = await req.ReadAsync<CreateAnalyzeRuleFromTemplateRequest>();
if (read.Error != null) return read.Error;
var variables = read.Value!.Variables;
if (variables == null || variables.Count == 0)
{
return await req.BadRequestAsync("Template variable values are required");
}
try
{
var author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
var newRule = await _ruleService.CreateFromTemplateAsync(tenantId, ruleId, variables, author);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new CreateAnalyzeRuleFromTemplateResponse { Success = true, Rule = newRule, Message = "Custom rule created from template" });
return response;
}
catch (InvalidOperationException ex)
{
return await req.ConflictAsync(ex.Message);
}
catch (ArgumentException ex)
{
return await req.BadRequestAsync(ex.Message);
}
}
[Function("DeleteAnalyzeRule")]
public async Task<HttpResponseData> DeleteRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
// Load the rule to determine its type (built-in/community vs. custom)
var rules = await _ruleService.GetAllRulesForTenantAsync(tenantId);
var rule = rules.FirstOrDefault(r => r.RuleId == ruleId);
if (rule == null)
{
return await req.NotFoundAsync("Rule not found");
}
var success = await _ruleService.DeleteRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = success, Message = success ? "Rule deleted" : "Failed to delete rule" });
return response;
}
}
}