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. */