mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(rules): rule submissions — retention (withdrawn 30d, declined 90d), contact email, pending-first tenant list, operator hard delete (D-236 amended)
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 80ee4efaf6...79ab9e7658
+1
-1
Submodule internal updated: 8efdea43fa...84f6aaf755
@@ -173,6 +173,22 @@ public class RuleSubmissionServiceTests
|
||||
public void AttributionName_rejects_markup_urls_and_control_characters(string name)
|
||||
=> Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => RuleSubmissionService.NormalizeEmail(input));
|
||||
|
||||
// ── review state machine ────────────────────────────────────────────────
|
||||
|
||||
private static (RuleSubmissionService service, Mock<IRuleSubmissionRepository> 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<ArgumentException>(() => 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<AnalyzeRule> { 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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RuleSubmissionFinding>();
|
||||
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'")]
|
||||
|
||||
+52
@@ -137,6 +137,56 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<int> 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<TableEntity>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(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<bool> 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<TableEntity?> 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,
|
||||
|
||||
+52
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DeleteGlobalRuleSubmissionFunction
|
||||
{
|
||||
private readonly ILogger<DeleteGlobalRuleSubmissionFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public DeleteGlobalRuleSubmissionFunction(ILogger<DeleteGlobalRuleSubmissionFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("DeleteGlobalRuleSubmission")]
|
||||
public async Task<HttpResponseData> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trimmed, or null when empty; the shape check is deliberately loose (exactly one @ with something on both sides, no whitespace, at most 254 characters).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Task<bool> DeleteAsync(string submissionId) => _submissions.DeleteAsync(submissionId);
|
||||
|
||||
/// <summary>Daily retention sweep of closed submissions (see <see cref="RuleSubmissionRetention"/>).</summary>
|
||||
public Task<int> SweepExpiredAsync(DateTime nowUtc) => _submissions.DeleteExpiredAsync(nowUtc);
|
||||
|
||||
// ── Id suggestion + repo file ───────────────────────────────────────────
|
||||
|
||||
private async Task<HashSet<string>> GetTakenIdsAsync(string kind)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -27,6 +27,7 @@ export const GA_STRICT_TOOL_NAMES: ReadonlySet<string> = 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<SuccessMessageResponse>(
|
||||
`/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',
|
||||
|
||||
@@ -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
|
||||
|
||||
/// <summary>The published ids reserved by approved submissions — the third set the id suggestion excludes.</summary>
|
||||
Task<HashSet<string>> GetReservedPublishedRuleIdsAsync();
|
||||
|
||||
/// <summary>Deletes withdrawn and declined rows past <see cref="RuleSubmissionRetention"/>; returns the number deleted.</summary>
|
||||
Task<int> DeleteExpiredAsync(DateTime nowUtc);
|
||||
|
||||
/// <summary>Hard delete of one row (operator action); false when the row is gone.</summary>
|
||||
Task<bool> DeleteAsync(string submissionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RuleSubmissionItemRef> Items { get; set; } = new List<RuleSubmissionItemRef>();
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>Optional reply address; stored for the reviewer, never published.</summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>One of <see cref="RuleAttributionModes.All"/>.</summary>
|
||||
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; }
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string SubmittedBy { get; set; } = default!;
|
||||
public string SubmittedByName { get; set; } = default!;
|
||||
|
||||
/// <summary>Optional reply address the submitter entered — a UPN is not always a mailbox. Never published.</summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>One of <see cref="RuleAttributionModes.All"/>.</summary>
|
||||
public string AttributionMode { get; set; } = RuleAttributionModes.Anonymous;
|
||||
|
||||
@@ -118,6 +121,31 @@ namespace AutopilotMonitor.Shared.Models
|
||||
=> mode == Anonymous || mode == Organization || mode == Person;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
|
||||
<SectionCardHeader
|
||||
@@ -289,6 +294,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
|
||||
onClose={() => setDetail(null)}
|
||||
onReviewed={handleReviewed}
|
||||
onReload={(id) => openDetail(id)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<string | null>(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<string | null>(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({
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Submitted by</dt>
|
||||
<dd className="mt-0.5 text-gray-900 dark:text-gray-100">{s.submittedByName} <span className="text-gray-500">({s.submittedBy})</span> · {new Date(s.submittedAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Contact email</dt>
|
||||
<dd className="mt-0.5 text-gray-900 dark:text-gray-100 flex items-center">
|
||||
{s.contactEmail
|
||||
? <>{s.contactEmail}<CopyButton value={s.contactEmail} /></>
|
||||
: <span className="text-gray-400 italic">not provided</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Credit in the published rule</dt>
|
||||
<dd className="mt-0.5 text-gray-900 dark:text-gray-100">{s.attributionName} <span className="text-gray-500">({s.attributionMode})</span></dd>
|
||||
@@ -538,7 +571,17 @@ function SubmissionDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
{canMutate ? (
|
||||
<button
|
||||
onClick={remove}
|
||||
disabled={!!busy}
|
||||
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors ${confirmDelete ? "bg-red-600 hover:bg-red-700 text-white" : "text-red-600 hover:bg-red-50"}`}
|
||||
title="Removes the submission for the operator and the tenant alike; a published rule in the repo stays"
|
||||
>
|
||||
{busy === "delete" ? "Deleting..." : confirmDelete ? "Really delete this submission" : "Delete submission"}
|
||||
</button>
|
||||
) : <span />}
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors text-sm">Close</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -610,7 +610,7 @@ export default function AnalyzeRulesPage() {
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Community contribution: submit own custom rules for the shared pool; the list shows their status. */}
|
||||
<CommunityContributionBox onContribute={!isReadOnly && submittableRules.length > 0 ? () => setShowSubmitModal(true) : undefined} />
|
||||
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} />
|
||||
<MySubmissionsList
|
||||
kind="analyze"
|
||||
getAccessToken={getAccessToken}
|
||||
|
||||
@@ -444,7 +444,7 @@ export default function GatherRulesPage() {
|
||||
)}
|
||||
|
||||
{/* Community contribution: submit own custom rules for the shared pool; the list shows their status. */}
|
||||
<CommunityContributionBox onContribute={!isReadOnly && submittableRules.length > 0 ? () => setShowSubmitModal(true) : undefined} />
|
||||
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} />
|
||||
<MySubmissionsList
|
||||
kind="gather"
|
||||
getAccessToken={getAccessToken}
|
||||
|
||||
@@ -31,12 +31,15 @@ export function submissionStatusBadge(status: string): { label: string; cls: str
|
||||
}
|
||||
|
||||
/**
|
||||
* The tenant's own community submissions of one rule kind — a compact, collapsible list that
|
||||
* renders nothing while there is nothing to show, so pages without submissions stay unchanged.
|
||||
* The tenant's own community submissions of one rule kind. Pending rows are the ones an admin
|
||||
* wants to see, so they show by default; decided and withdrawn rows sit behind "Show all". Renders
|
||||
* nothing while there is nothing to show, so pages without submissions stay unchanged. Closed rows
|
||||
* leave on their own: withdrawn after 30 days, declined after 90 (server-side retention).
|
||||
*/
|
||||
export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTenantId, readOnly, onError }: MySubmissionsListProps) {
|
||||
const [items, setItems] = useState<RuleSubmissionItem[]>([]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [withdrawing, setWithdrawing] = useState<string | null>(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 (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
@@ -88,10 +94,31 @@ export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTe
|
||||
Community submissions
|
||||
<span className="text-xs font-normal text-gray-500">({items.length}{pending > 0 ? `, ${pending} pending` : ""})</span>
|
||||
</span>
|
||||
{!collapsed && hidden > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(true); }}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Show all ({hidden} more)
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && showAll && items.length > pending && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(false); }}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Pending only
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
{!collapsed && visible.length === 0 && (
|
||||
<p className="border-t border-gray-100 px-4 py-2 text-xs text-gray-500">Nothing pending.</p>
|
||||
)}
|
||||
{!collapsed && visible.length > 0 && (
|
||||
<ul className="border-t border-gray-100 divide-y divide-gray-100">
|
||||
{items.map((s) => {
|
||||
{visible.map((s) => {
|
||||
const badge = submissionStatusBadge(s.status);
|
||||
const open = expandedId === s.submissionId;
|
||||
const hasDetail = !!s.reviewComment || s.willBeAdapted || !!s.publishedRuleId;
|
||||
|
||||
@@ -51,6 +51,7 @@ export function SubmitRulesModal({
|
||||
}: SubmitRulesModalProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [comment, setComment] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [attributionMode, setAttributionMode] = useState<AttributionMode>("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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact email — a UPN is not always a mailbox */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Communication Email <span className="text-gray-400">(optional but recommended)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Only for questions about this submission; never published. The decision itself appears on this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attribution */}
|
||||
<fieldset className="mb-4">
|
||||
<legend className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Credit in the published rule</legend>
|
||||
|
||||
@@ -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) ───────────────────────────────
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user