From 5577f5e7e54bcedeeb2d29f79b06132a40a13649 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 9 Sep 2026 23:27:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(rules):=20rule=20submissions=20=E2=80=94?= =?UTF-8?q?=20retention=20(withdrawn=2030d,=20declined=2090d),=20contact?= =?UTF-8?q?=20email,=20pending-first=20tenant=20list,=20operator=20hard=20?= =?UTF-8?q?delete=20(D-236=20amended)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed submissions leave through the daily maintenance sweep (RuleSubmissionRetention, one place for rule and test); pending/approved/published never. The submit dialog takes an optional contact email (a UPN is not always a mailbox), stored for the reviewer and never published. The tenant list shows pending rows by default with the rest behind Show all, and the contribute button shows for every writer, also before the first custom rule exists. DELETE global/rule-submissions/{id} (GlobalAdminOnly, MCP delete_rule_submission) removes test/demo rows for operator and tenant alike. Bumps internal and .claude/commands gitlinks. Co-Authored-By: Claude Fable 5.1 --- .claude/commands | 2 +- internal | 2 +- .../RuleSubmissionServiceTests.cs | 38 ++++++++++++++ .../RuleSubmissionsWireParityTests.cs | 2 + .../TableRuleSubmissionRepositoryTests.cs | 36 +++++++++++++ .../TableRuleSubmissionRepository.cs | 52 +++++++++++++++++++ .../DeleteGlobalRuleSubmissionFunction.cs | 52 +++++++++++++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../MaintenanceService.Aggregation.cs | 14 +++++ .../Services/MaintenanceService.cs | 3 ++ .../Services/RuleSubmissionService.cs | 27 ++++++++++ .../src/__tests__/tools-catalog-order.test.ts | 5 +- .../src/generated/wire-types.generated.ts | 3 ++ .../autopilot-monitor-mcp/src/tools/admin.ts | 29 +++++++++++ .../DataAccess/IRuleSubmissionRepository.cs | 7 +++ .../Models/RuleSubmissionApiModels.cs | 5 ++ .../Models/Rules/RuleSubmission.cs | 28 ++++++++++ .../components/RuleSubmissionsSection.tsx | 49 +++++++++++++++-- .../app/analyze-rules/page.tsx | 2 +- .../app/gather-rules/page.tsx | 2 +- .../components/rules/MySubmissionsList.tsx | 37 +++++++++++-- .../components/rules/SubmitRulesModal.tsx | 22 ++++++++ src/Web/autopilot-monitor-web/lib/api.ts | 2 + .../utils/shared-manifests.json | 18 +++++++ .../utils/wire-types.generated.ts | 3 ++ 25 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/DeleteGlobalRuleSubmissionFunction.cs diff --git a/.claude/commands b/.claude/commands index 80ee4efa..79ab9e76 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 80ee4efaf6066b0b720b8d6a498c3586b61d504c +Subproject commit 79ab9e7658029abf4262d2358fde79aec5730bf0 diff --git a/internal b/internal index 8efdea43..84f6aaf7 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 8efdea43fab477dce8cce1abde9ccc7bfe5921a8 +Subproject commit 84f6aaf755c3d0dd62e437b72e0ed8c5df3226ee diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs index a5d05bee..7541801f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs @@ -173,6 +173,22 @@ public class RuleSubmissionServiceTests public void AttributionName_rejects_markup_urls_and_control_characters(string name) => Assert.Throws(() => RuleSubmissionService.ValidateAttributionName(name)); + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" alice@contoso.com ", "alice@contoso.com")] + public void Email_is_trimmed_or_null(string? input, string? expected) + => Assert.Equal(expected, RuleSubmissionService.NormalizeEmail(input)); + + [Theory] + [InlineData("alice")] + [InlineData("@contoso.com")] + [InlineData("alice@")] + [InlineData("a@b@c")] + [InlineData("alice @contoso.com")] + public void Email_rejects_non_addresses(string input) + => Assert.Throws(() => RuleSubmissionService.NormalizeEmail(input)); + // ── review state machine ──────────────────────────────────────────────── private static (RuleSubmissionService service, Mock repo) Build( @@ -320,6 +336,16 @@ public class RuleSubmissionServiceTests new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-NET-003" }, "ga@example.com")); } + [Fact] + public async Task Operator_delete_passes_through_to_the_repository() + { + var (service, repo) = Build(PendingRow()); + repo.Setup(r => r.DeleteAsync("a1b2c3d4e5f6")).ReturnsAsync(true); + + Assert.True(await service.DeleteAsync("a1b2c3d4e5f6")); + repo.Verify(r => r.DeleteAsync("a1b2c3d4e5f6"), Times.Once); + } + [Fact] public async Task Unknown_decision_is_a_bad_request() { @@ -401,6 +427,7 @@ public class RuleSubmissionServiceTests Assert.Equal(12, s.BatchId.Length); Assert.Equal(RuleSubmissionStatuses.Pending, s.Status); Assert.Equal("Community contribution", s.AttributionName); + Assert.Null(s.ContactEmail); Assert.Equal("Proxy PAC unreachable", s.Title); Assert.Equal("network", s.Category); Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId); @@ -477,6 +504,17 @@ public class RuleSubmissionServiceTests await Assert.ThrowsAsync(() => noConfig.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin")); } + [Fact] + public async Task Submit_stores_the_contact_email_when_given() + { + var (service, _) = BuildForSubmit(new List { CustomAnalyzeRule() }); + var request = Request("ANALYZE-CUSTOM-003"); + request.Email = " alice.reply@contoso.com "; + + var created = await service.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin"); + Assert.Equal("alice.reply@contoso.com", created[0].ContactEmail); + } + [Fact] public async Task Submit_person_attribution_freezes_the_requested_name() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionsWireParityTests.cs index 8bca7099..c0ae71c3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionsWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionsWireParityTests.cs @@ -22,6 +22,7 @@ public class RuleSubmissionsWireParityTests Comment = "Fires when the PAC download fails.", SubmittedBy = "alice@contoso.com", SubmittedByName = "Alice Admin", + ContactEmail = "alice.reply@contoso.com", AttributionMode = "anonymous", AttributionName = "Community contribution", SubmittedAt = T0, @@ -48,6 +49,7 @@ public class RuleSubmissionsWireParityTests comment = "Fires when the PAC download fails.", submittedBy = "alice@contoso.com", submittedByName = "Alice Admin", + contactEmail = "alice.reply@contoso.com", attributionMode = "anonymous", attributionName = "Community contribution", submittedAt = T0, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableRuleSubmissionRepositoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableRuleSubmissionRepositoryTests.cs index 92f62f3b..b7738a1c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableRuleSubmissionRepositoryTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableRuleSubmissionRepositoryTests.cs @@ -26,6 +26,7 @@ public class TableRuleSubmissionRepositoryTests Comment = "Fires when the PAC download fails during ESP.", SubmittedBy = "alice@contoso.com", SubmittedByName = "Alice Admin", + ContactEmail = "alice.reply@contoso.com", AttributionMode = RuleAttributionModes.Person, AttributionName = "Alice A.", SubmittedAt = new DateTime(2026, 9, 9, 10, 30, 0, DateTimeKind.Utc), @@ -62,6 +63,7 @@ public class TableRuleSubmissionRepositoryTests Assert.Equal(original.Comment, mapped.Comment); Assert.Equal(original.SubmittedBy, mapped.SubmittedBy); Assert.Equal(original.SubmittedByName, mapped.SubmittedByName); + Assert.Equal(original.ContactEmail, mapped.ContactEmail); Assert.Equal(original.AttributionMode, mapped.AttributionMode); Assert.Equal(original.AttributionName, mapped.AttributionName); Assert.Equal(original.SubmittedAt, mapped.SubmittedAt); @@ -88,6 +90,7 @@ public class TableRuleSubmissionRepositoryTests var original = Full(); original.Status = RuleSubmissionStatuses.Pending; original.Comment = null; + original.ContactEmail = null; original.SourceFireStats = null; original.ValidationFindings = new List(); original.ReviewedBy = null; @@ -101,6 +104,7 @@ public class TableRuleSubmissionRepositoryTests TableRuleSubmissionRepository.StoreSubmission(original)); Assert.Null(mapped.Comment); + Assert.Null(mapped.ContactEmail); Assert.Null(mapped.SourceFireStats); Assert.Empty(mapped.ValidationFindings); Assert.Null(mapped.ReviewedBy); @@ -147,6 +151,38 @@ public class TableRuleSubmissionRepositoryTests Assert.Null(mapped.SourceFireStats); } + // ── retention rule (shared by the sweep and this pin) ── + + private static readonly DateTime Now = new(2026, 12, 1, 12, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData("withdrawn", 29, false)] + [InlineData("withdrawn", 30, true)] + [InlineData("declined", 89, false)] + [InlineData("declined", 90, true)] + [InlineData("pending", 400, false)] + [InlineData("approved", 400, false)] + public void Retention_expires_only_closed_rows_at_their_window(string status, int ageDays, bool expired) + { + var s = Full(); + s.Status = status; + s.SubmittedAt = Now.AddDays(-ageDays); + s.ReviewedAt = status == "declined" ? Now.AddDays(-ageDays) : null; + + Assert.Equal(expired, RuleSubmissionRetention.IsExpired(s, Now)); + } + + [Fact] + public void Retention_of_a_declined_row_counts_from_the_decision_not_the_submission() + { + var s = Full(); + s.Status = RuleSubmissionStatuses.Declined; + s.SubmittedAt = Now.AddDays(-200); + s.ReviewedAt = Now.AddDays(-10); + + Assert.False(RuleSubmissionRetention.IsExpired(s, Now)); + } + [Theory] [InlineData(null, null, "PartitionKey eq 'submissions'")] [InlineData("t'1", null, "PartitionKey eq 'submissions' and TenantId eq 't''1'")] diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleSubmissionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleSubmissionRepository.cs index 87b58d6b..cf9f1feb 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleSubmissionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleSubmissionRepository.cs @@ -137,6 +137,56 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return ids; } + public async Task DeleteExpiredAsync(DateTime nowUtc) + { + var deleted = 0; + try + { + // The expiry rule is the shared RuleSubmissionRetention, so the sweep and its + // test cannot disagree; only the two closed statuses are scanned. + foreach (var status in new[] { RuleSubmissionStatuses.Withdrawn, RuleSubmissionStatuses.Declined }) + { + var expired = new List(); + await foreach (var entity in _tableClient.QueryAsync(filter: BuildFilter(tenantId: null, status))) + { + if (RuleSubmissionRetention.IsExpired(MapSubmission(entity), nowUtc)) expired.Add(entity); + } + foreach (var entity in expired) + { + try + { + await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already gone — a parallel sweep or a tenant offboarding won the race. + } + } + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug("RuleSubmissions table does not exist yet"); + } + return deleted; + } + + public async Task DeleteAsync(string submissionId) + { + var existing = await FindEntityAsync(submissionId); + if (existing == null) return false; + try + { + await _tableClient.DeleteEntityAsync(existing.PartitionKey, existing.RowKey); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + private async Task FindEntityAsync(string submissionId) { try @@ -182,6 +232,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage ["Comment"] = s.Comment ?? string.Empty, ["SubmittedBy"] = s.SubmittedBy, ["SubmittedByName"] = s.SubmittedByName, + ["ContactEmail"] = s.ContactEmail ?? string.Empty, ["AttributionMode"] = s.AttributionMode, ["AttributionName"] = s.AttributionName, ["SubmittedAt"] = s.SubmittedAt, @@ -209,6 +260,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage Comment = NullIfEmpty(e.GetString("Comment")), SubmittedBy = e.GetString("SubmittedBy") ?? string.Empty, SubmittedByName = e.GetString("SubmittedByName") ?? string.Empty, + ContactEmail = NullIfEmpty(e.GetString("ContactEmail")), AttributionMode = e.GetString("AttributionMode") ?? RuleAttributionModes.Anonymous, AttributionName = e.GetString("AttributionName") ?? RuleAttributionModes.AnonymousAuthor, SubmittedAt = e.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/DeleteGlobalRuleSubmissionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/DeleteGlobalRuleSubmissionFunction.cs new file mode 100644 index 00000000..a5a1b164 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/DeleteGlobalRuleSubmissionFunction.cs @@ -0,0 +1,52 @@ +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.Submissions +{ + /// + /// Operator hard delete of a submission (test/demo rows, or a tenant's request). The row + /// vanishes from both lists; a published rule in the repo is untouched. + /// + public class DeleteGlobalRuleSubmissionFunction + { + private readonly ILogger _logger; + private readonly RuleSubmissionService _service; + + public DeleteGlobalRuleSubmissionFunction(ILogger logger, RuleSubmissionService service) + { + _logger = logger; + _service = service; + } + + [Function("DeleteGlobalRuleSubmission")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rule-submissions/{submissionId}")] HttpRequestData req, + string submissionId) + { + try + { + // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware + var user = TenantHelper.GetUserIdentifier(req); + if (string.IsNullOrWhiteSpace(submissionId)) return await req.BadRequestAsync("submissionId is required."); + + var existing = await _service.GetAsync(submissionId); + if (existing == null) return await req.NotFoundAsync("Submission not found."); + + if (!await _service.DeleteAsync(submissionId)) return await req.NotFoundAsync("Submission not found."); + + // Warning on purpose: an operator delete is rare and must be visible in App Insights. + _logger.LogWarning("Rule submission {SubmissionId} ({RuleId}, tenant {TenantId}, status {Status}) deleted by {User}", + submissionId, existing.SourceRuleId, existing.TenantId, existing.Status, user); + return await req.OkAsync(new SuccessMessageResponse { Success = true, Message = "Submission deleted." }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "DeleteGlobalRuleSubmission"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 514cb826..125cfb6c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -522,6 +522,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/rule-submissions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalReadOrAdmin), new("PATCH", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly), + new("DELETE", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly), // Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters). new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 328d11e1..3f08a0bb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -1045,6 +1045,20 @@ namespace AutopilotMonitor.Functions.Services _logger.LogWarning(ex, "Failed to cleanup old tenant notifications"); } + try + { + // Closed community rule submissions: withdrawn 30d, declined 90d (RuleSubmissionRetention); + // pending/approved/published rows are never swept. + var deleted = await _ruleSubmissionRepo.DeleteExpiredAsync(now); + if (deleted > 0) + _logger.LogInformation("Rule submissions cleanup: deleted {Count} closed rows (withdrawn {WithdrawnDays}d / declined {DeclinedDays}d)", + deleted, RuleSubmissionRetention.WithdrawnDays, RuleSubmissionRetention.DeclinedDays); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup closed rule submissions"); + } + try { var deleted = await _hardwareRejectionTracker.DeleteOlderThanAsync(now.AddDays(-hardwareRejectionRetentionDays)); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 67f8bd2d..98f9d15d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -41,6 +41,7 @@ namespace AutopilotMonitor.Functions.Services private readonly INotificationRepository _notificationRepo; private readonly ITenantNotificationRepository _tenantNotificationRepo; private readonly IHardwareRejectionNotificationTracker _hardwareRejectionTracker; + private readonly IRuleSubmissionRepository _ruleSubmissionRepo; private readonly IDelegationInvitationRepository _delegationInvitationRepo; private readonly DataAccess.TableStorage.BackupJobsRepository _backupJobsRepo; private readonly OpsEventService _opsEventService; @@ -75,6 +76,7 @@ namespace AutopilotMonitor.Functions.Services INotificationRepository notificationRepo, ITenantNotificationRepository tenantNotificationRepo, IHardwareRejectionNotificationTracker hardwareRejectionTracker, + IRuleSubmissionRepository ruleSubmissionRepo, IDelegationInvitationRepository delegationInvitationRepo, DataAccess.TableStorage.BackupJobsRepository backupJobsRepo, OpsEventService opsEventService, @@ -103,6 +105,7 @@ namespace AutopilotMonitor.Functions.Services _opsEventRepo = opsEventRepo; _notificationRepo = notificationRepo; _tenantNotificationRepo = tenantNotificationRepo; + _ruleSubmissionRepo = ruleSubmissionRepo; _hardwareRejectionTracker = hardwareRejectionTracker; _delegationInvitationRepo = delegationInvitationRepo; _backupJobsRepo = backupJobsRepo; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleSubmissionService.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleSubmissionService.cs index 029c7932..660e3c3a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleSubmissionService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleSubmissionService.cs @@ -96,6 +96,7 @@ namespace AutopilotMonitor.Functions.Services throw new ArgumentException($"Comment exceeds {SubmitRuleSubmissionsRequest.MaxCommentLength} characters."); var attributionName = await ResolveAttributionNameAsync(tenantId, request.AttributionMode, request.AttributionName); + var contactEmail = NormalizeEmail(request.Email); var distinct = request.Items .Select(i => (Kind: i.Kind?.Trim().ToLowerInvariant() ?? string.Empty, RuleId: i.RuleId?.Trim() ?? string.Empty)) @@ -141,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment!.Trim(), SubmittedBy = submittedBy, SubmittedByName = submittedByName, + ContactEmail = contactEmail, AttributionMode = request.AttributionMode, AttributionName = attributionName, SubmittedAt = now, @@ -228,6 +230,21 @@ namespace AutopilotMonitor.Functions.Services } } + /// Trimmed, or null when empty; the shape check is deliberately loose (exactly one @ with something on both sides, no whitespace, at most 254 characters). + internal static string? NormalizeEmail(string? email) + { + var trimmed = email?.Trim(); + if (string.IsNullOrEmpty(trimmed)) return null; + var at = trimmed!.IndexOf('@'); + if (trimmed.Length > SubmitRuleSubmissionsRequest.MaxEmailLength + || at <= 0 + || at != trimmed.LastIndexOf('@') + || at == trimmed.Length - 1 + || trimmed.Any(char.IsWhiteSpace)) + throw new ArgumentException("email is not a valid address."); + return trimmed; + } + internal static string ValidateAttributionName(string name) { if (!AttributionNameShape.IsMatch(name)) @@ -311,6 +328,7 @@ namespace AutopilotMonitor.Functions.Services Comment = s.Comment, SubmittedBy = s.SubmittedBy, SubmittedByName = s.SubmittedByName, + ContactEmail = s.ContactEmail, AttributionMode = s.AttributionMode, AttributionName = s.AttributionName, SubmittedAt = s.SubmittedAt, @@ -439,6 +457,15 @@ namespace AutopilotMonitor.Functions.Services return await _submissions.UpdateAsync(s); } + /// + /// Operator hard delete — the row disappears from the operator list and the tenant's list + /// alike. Not part of normal operations: for test and demo submissions, or on a tenant's request. + /// + public Task DeleteAsync(string submissionId) => _submissions.DeleteAsync(submissionId); + + /// Daily retention sweep of closed submissions (see ). + public Task SweepExpiredAsync(DateTime nowUtc) => _submissions.DeleteExpiredAsync(nowUtc); + // ── Id suggestion + repo file ─────────────────────────────────────────── private async Task> GetTakenIdsAsync(string kind) diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index e6366fb5..a4cca65b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -110,6 +110,8 @@ describe('role catalog snapshot — privilege-leak guard', () => { // fails — that failure is the deliberate prompt to decide its role placement. const GA_FULL = [ 'annotate_session', + // Community rule submission write surface (strictGa): operator hard delete. + 'delete_rule_submission', 'get_agent_efficiency_metrics', 'get_api_usage', 'get_app_install_metrics', @@ -209,7 +211,7 @@ describe('role catalog snapshot — privilege-leak guard', () => { // Community rule submission write surface: strictGa only — a read-only Global Reader // lists and reads submissions but never decides one or triggers the reseed that publishes. - const RULE_SUBMISSION_WRITE_GA_STRICT = ['reseed_rules_from_github', 'review_rule_submission']; + const RULE_SUBMISSION_WRITE_GA_STRICT = ['delete_rule_submission', 'reseed_rules_from_github', 'review_rule_submission']; // Platform-only tools: a non-platform caller (tenant or delegated) gets no // cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and @@ -217,6 +219,7 @@ describe('role catalog snapshot — privilege-leak guard', () => { // cross-tenant aggregates. const PLATFORM_ONLY = [ 'annotate_session', + 'delete_rule_submission', 'get_agent_efficiency_metrics', 'get_api_usage', 'get_deployment_state', diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 3cce9963..e8c084f7 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -3724,6 +3724,7 @@ export interface RuleSubmissionItem { comment?: string; submittedBy: string; submittedByName: string; + contactEmail?: string; attributionMode: string; attributionName: string; submittedAt: string; @@ -4416,6 +4417,8 @@ export interface SubmitRuleSubmissionsRequest { tenantId: string; items: RuleSubmissionItemRef[]; comment?: string | null; + /** Optional reply address; stored for the reviewer, never published. */ + email?: string | null; /** One of All. */ attributionMode: string; /** Credit text for the person mode; ignored for the other modes. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index bb09fe6e..0d3ce590 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -27,6 +27,7 @@ export const GA_STRICT_TOOL_NAMES: ReadonlySet = new Set([ 'revert_tenant_config', 'review_rule_submission', 'reseed_rules_from_github', + 'delete_rule_submission', ]); /** Default first-page size of get_fleet_overview's session list (a fleet snapshot, not a sweep). */ @@ -102,6 +103,7 @@ import type { ReviewRuleSubmissionResponse, RuleSubmissionDetailResponse, RuleSubmissionListResponse, + SuccessMessageResponse, RuleStatsResponse, RuleStatsRuleAggregate, SessionAnnotationListResponse, @@ -1245,6 +1247,33 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }) ); + // Tool 18h: delete_rule_submission — real Global Admin only (write). Test/demo rows, or a tenant's request. + if (strictGa) server.registerTool( + 'delete_rule_submission', + { + title: 'Delete Rule Submission', + description: + 'Hard-delete one rule submission so it disappears from the operator list and the submitting tenant\'s list ' + + 'alike. Global Admin only; not part of normal operations — for test and demo submissions, or when a tenant asks ' + + 'for removal. A rule already published from the submission stays in the repository and the catalog.', + inputSchema: { + submissionId: z.string().trim().regex(/^[0-9a-f]{12}$/i).describe('The 12-character submission id.'), + }, + annotations: MUTATING, + }, + async (args) => withToolTelemetry('delete_rule_submission', args, async () => { + try { + const data = await apiFetch( + `/api/global/rule-submissions/${encodeURIComponent(args.submissionId)}`, + { method: 'DELETE' }, + ); + return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + } catch (error: unknown) { + return toolError('delete_rule_submission', args, error); + } + }) + ); + // Tool 18g: reseed_rules_from_github — real Global Admin only (write): the publish step. if (strictGa) server.registerTool( 'reseed_rules_from_github', diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleSubmissionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleSubmissionRepository.cs index 4dfe3082..e5876acc 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleSubmissionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleSubmissionRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using AutopilotMonitor.Shared.Models; @@ -28,5 +29,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// The published ids reserved by approved submissions — the third set the id suggestion excludes. Task> GetReservedPublishedRuleIdsAsync(); + + /// Deletes withdrawn and declined rows past ; returns the number deleted. + Task DeleteExpiredAsync(DateTime nowUtc); + + /// Hard delete of one row (operator action); false when the row is gone. + Task DeleteAsync(string submissionId); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/RuleSubmissionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/RuleSubmissionApiModels.cs index 47cacdce..20f85936 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/RuleSubmissionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/RuleSubmissionApiModels.cs @@ -18,11 +18,15 @@ namespace AutopilotMonitor.Shared.Models public const int MaxItems = 10; public const int MaxCommentLength = 4000; public const int MaxAttributionNameLength = 64; + public const int MaxEmailLength = 254; public string TenantId { get; set; } = default!; public List Items { get; set; } = new List(); public string? Comment { get; set; } + /// Optional reply address; stored for the reviewer, never published. + public string? Email { get; set; } + /// One of . public string AttributionMode { get; set; } = RuleAttributionModes.Anonymous; @@ -48,6 +52,7 @@ namespace AutopilotMonitor.Shared.Models public string? Comment { get; set; } public string SubmittedBy { get; set; } = default!; public string SubmittedByName { get; set; } = default!; + public string? ContactEmail { get; set; } public string AttributionMode { get; set; } = default!; public string AttributionName { get; set; } = default!; public DateTime SubmittedAt { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleSubmission.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleSubmission.cs index 9c024090..70b376f4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleSubmission.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleSubmission.cs @@ -35,6 +35,9 @@ namespace AutopilotMonitor.Shared.Models public string SubmittedBy { get; set; } = default!; public string SubmittedByName { get; set; } = default!; + /// Optional reply address the submitter entered — a UPN is not always a mailbox. Never published. + public string? ContactEmail { get; set; } + /// One of . public string AttributionMode { get; set; } = RuleAttributionModes.Anonymous; @@ -118,6 +121,31 @@ namespace AutopilotMonitor.Shared.Models => mode == Anonymous || mode == Organization || mode == Person; } + /// + /// Retention of closed submissions (daily maintenance sweep). Withdrawn rows carry no + /// information beyond "taken back"; declined rows keep the reviewer's reason readable long + /// enough to improve and resubmit. Pending, approved and published rows are never swept — + /// the last two are the provenance of a community rule. + /// + public static class RuleSubmissionRetention + { + public const int WithdrawnDays = 30; + public const int DeclinedDays = 90; + + public static bool IsExpired(RuleSubmission s, DateTime nowUtc) + { + switch (s.Status) + { + case RuleSubmissionStatuses.Withdrawn: + return s.SubmittedAt <= nowUtc.AddDays(-WithdrawnDays); + case RuleSubmissionStatuses.Declined: + return (s.ReviewedAt ?? s.SubmittedAt) <= nowUtc.AddDays(-DeclinedDays); + default: + return false; + } + } + } + public static class RuleSubmissionDecisions { public const string Approve = "approve"; diff --git a/src/Web/autopilot-monitor-web/app/admin/components/RuleSubmissionsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/RuleSubmissionsSection.tsx index 25ee3b8b..da4808b6 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/RuleSubmissionsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/RuleSubmissionsSection.tsx @@ -3,7 +3,7 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, jsonBody, nullOn404 } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; import { extractContinuation } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; @@ -185,6 +185,11 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio void openDetail(updated.submissionId); }; + const handleDeleted = (submissionId: string) => { + setItems((prev) => prev.filter((s) => s.submissionId !== submissionId)); + setDetail(null); + }; + return (
setDetail(null)} onReviewed={handleReviewed} onReload={(id) => openDetail(id)} + onDeleted={handleDeleted} /> )}
@@ -298,7 +304,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio // ── Detail modal ────────────────────────────────────────────────────────── function SubmissionDetailModal({ - detail, loading, canMutate, getAccessToken, onClose, onReviewed, onReload, + detail, loading, canMutate, getAccessToken, onClose, onReviewed, onReload, onDeleted, }: { detail: RuleSubmissionDetailResponse | null; loading: boolean; @@ -307,6 +313,7 @@ function SubmissionDetailModal({ onClose: () => void; onReviewed: (updated: RuleSubmissionItem) => void; onReload: (submissionId: string) => void; + onDeleted: (submissionId: string) => void; }) { const s = detail?.submission ?? null; const [publishedRuleId, setPublishedRuleId] = useState(""); @@ -315,6 +322,7 @@ function SubmissionDetailModal({ const [busy, setBusy] = useState(null); const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null); const [showRepoFile, setShowRepoFile] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); // Seed the decision form from the loaded submission (adjust-during-render on identity change). const [seededFor, setSeededFor] = useState(null); @@ -375,6 +383,23 @@ function SubmissionDetailModal({ } }; + // Operator hard delete (test/demo rows, or a tenant's request): two clicks, no dialog. + const remove = async () => { + if (!s) return; + if (!confirmDelete) { setConfirmDelete(true); return; } + try { + setBusy("delete"); + await fetchOk(api.ruleSubmissions.remove(s.submissionId), getAccessToken, { method: "DELETE" }); + trackEvent("rule_submission_deleted", { kind: s.ruleKind, status: s.status }); + onDeleted(s.submissionId); + } catch (err) { + setMessage({ kind: "error", text: apiErrorText(err, "Delete failed") }); + setConfirmDelete(false); + } finally { + setBusy(null); + } + }; + const downloadRepoFile = () => { if (!detail?.repoFile) return; const blob = new Blob([detail.repoFile.content], { type: "application/json" }); @@ -424,6 +449,14 @@ function SubmissionDetailModal({
Submitted by
{s.submittedByName} ({s.submittedBy}) · {new Date(s.submittedAt).toLocaleString()}
+
+
Contact email
+
+ {s.contactEmail + ? <>{s.contactEmail} + : not provided} +
+
Credit in the published rule
{s.attributionName} ({s.attributionMode})
@@ -538,7 +571,17 @@ function SubmissionDetailModal({
)} -
+
+ {canMutate ? ( + + ) : }
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 1123e815..764b94bf 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -610,7 +610,7 @@ export default function AnalyzeRulesPage() { ) : (
{/* Community contribution: submit own custom rules for the shared pool; the list shows their status. */} - 0 ? () => setShowSubmitModal(true) : undefined} /> + setShowSubmitModal(true)} /> 0 ? () => setShowSubmitModal(true) : undefined} /> + setShowSubmitModal(true)} /> ([]); const [collapsed, setCollapsed] = useState(false); + const [showAll, setShowAll] = useState(false); const [expandedId, setExpandedId] = useState(null); const [withdrawing, setWithdrawing] = useState(null); @@ -73,7 +76,10 @@ export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTe if (items.length === 0) return null; - const pending = items.filter((s) => s.status === "pending").length; + const pendingItems = items.filter((s) => s.status === "pending"); + const pending = pendingItems.length; + const visible = showAll ? items : pendingItems; + const hidden = items.length - visible.length; return (
@@ -88,10 +94,31 @@ export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTe Community submissions ({items.length}{pending > 0 ? `, ${pending} pending` : ""}) + {!collapsed && hidden > 0 && ( + { e.stopPropagation(); setShowAll(true); }} + className="text-xs text-indigo-600 hover:text-indigo-800" + > + Show all ({hidden} more) + + )} + {!collapsed && showAll && items.length > pending && ( + { e.stopPropagation(); setShowAll(false); }} + className="text-xs text-indigo-600 hover:text-indigo-800" + > + Pending only + + )} - {!collapsed && ( + {!collapsed && visible.length === 0 && ( +

Nothing pending.

+ )} + {!collapsed && visible.length > 0 && (
    - {items.map((s) => { + {visible.map((s) => { const badge = submissionStatusBadge(s.status); const open = expandedId === s.submissionId; const hasDetail = !!s.reviewComment || s.willBeAdapted || !!s.publishedRuleId; diff --git a/src/Web/autopilot-monitor-web/components/rules/SubmitRulesModal.tsx b/src/Web/autopilot-monitor-web/components/rules/SubmitRulesModal.tsx index 915ed093..be9697b7 100644 --- a/src/Web/autopilot-monitor-web/components/rules/SubmitRulesModal.tsx +++ b/src/Web/autopilot-monitor-web/components/rules/SubmitRulesModal.tsx @@ -51,6 +51,7 @@ export function SubmitRulesModal({ }: SubmitRulesModalProps) { const [selected, setSelected] = useState>(new Set()); const [comment, setComment] = useState(""); + const [email, setEmail] = useState(""); const [attributionMode, setAttributionMode] = useState("anonymous"); const [creditName, setCreditName] = useState(defaultCreditName); const [consent, setConsent] = useState(false); @@ -65,6 +66,7 @@ export function SubmitRulesModal({ if (show) { setSelected(new Set()); setComment(""); + setEmail(""); setAttributionMode("anonymous"); setCreditName(defaultCreditName); setConsent(false); @@ -101,6 +103,7 @@ export function SubmitRulesModal({ tenantId, items: Array.from(selected).map((ruleId) => ({ kind, ruleId })), comment: comment.trim() || undefined, + email: email.trim() || undefined, attributionMode, attributionName: attributionMode === "person" ? creditName.trim() : undefined, }), @@ -230,6 +233,25 @@ export function SubmitRulesModal({ />
+ {/* Contact email — a UPN is not always a mailbox */} +
+ + setEmail(e.target.value)} + placeholder="your.email@company.com" + maxLength={254} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" + disabled={submitting} + /> +

+ Only for questions about this submission; never published. The decision itself appears on this page. +

+
+ {/* Attribution */}
Credit in the published rule diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 21482e1f..ad56da6b 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -499,6 +499,8 @@ export const api = { `${API_BASE_URL}/api/global/rule-submissions/${encodeURIComponent(submissionId)}`, review: (submissionId: string) => `${API_BASE_URL}/api/global/rule-submissions/${encodeURIComponent(submissionId)}`, + remove: (submissionId: string) => + `${API_BASE_URL}/api/global/rule-submissions/${encodeURIComponent(submissionId)}`, }, // ── Diag Files Reports (no session context) ─────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 171bab03..312c1f5d 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -17198,6 +17198,14 @@ "name": "string" } }, + { + "name": "contactEmail", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, { "name": "attributionMode", "type": { @@ -20384,6 +20392,16 @@ }, "optional": true }, + { + "name": "email", + "type": { + "kind": "primitive", + "name": "string", + "nullable": true + }, + "optional": true, + "doc": "Optional reply address; stored for the reviewer, never published." + }, { "name": "attributionMode", "type": { diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 16e3daea..5edfd9d9 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -3724,6 +3724,7 @@ export interface RuleSubmissionItem { comment?: string; submittedBy: string; submittedByName: string; + contactEmail?: string; attributionMode: string; attributionName: string; submittedAt: string; @@ -4416,6 +4417,8 @@ export interface SubmitRuleSubmissionsRequest { tenantId: string; items: RuleSubmissionItemRef[]; comment?: string | null; + /** Optional reply address; stored for the reviewer, never published. */ + email?: string | null; /** One of All. */ attributionMode: string; /** Credit text for the person mode; ignored for the other modes. */