added template vars to analyze rules for better user journey

This commit is contained in:
Oliver Kieselbach
2026-04-03 19:52:58 +02:00
parent 66d9795105
commit ed0e19fa36
26 changed files with 779 additions and 22 deletions
+12 -2
View File
@@ -6,7 +6,7 @@
"severity": "warning",
"category": "identity",
"isCommunity": true,
"enabled": true,
"enabled": false,
"baseConfidence": 75,
"conditions": [
{
@@ -61,5 +61,15 @@
]
}
],
"tags": ["identity", "certificates", "machine-store", "subject-check"]
"tags": ["identity", "certificates", "machine-store", "subject-check"],
"templateVariables": [
{
"name": "cert_subject",
"label": "Certificate Subject",
"description": "The expected certificate subject in your environment (e.g., 'CN=Contoso Root CA'). Case-insensitive substring match against the local machine certificate store.",
"conditionIndex": 1,
"field": "value",
"placeholder": "CN=YOUR-CERTIFICATE-SUBJECT"
}
]
}
+11 -1
View File
@@ -960,7 +960,7 @@
"severity": "warning",
"category": "identity",
"isCommunity": true,
"enabled": true,
"enabled": false,
"baseConfidence": 75,
"conditions": [
{
@@ -1020,6 +1020,16 @@
"certificates",
"machine-store",
"subject-check"
],
"templateVariables": [
{
"name": "cert_subject",
"label": "Certificate Subject",
"description": "The expected certificate subject in your environment (e.g., 'CN=Contoso Root CA'). Case-insensitive substring match against the local machine certificate store.",
"conditionIndex": 1,
"field": "value",
"placeholder": "CN=YOUR-CERTIFICATE-SUBJECT"
}
]
},
{
+50
View File
@@ -122,6 +122,16 @@
"description": "Tags for filtering and categorization.",
"items": { "type": "string" },
"default": []
},
"templateVariables": {
"type": "array",
"description": "Template variables that must be customized per-tenant. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted.",
"items": { "$ref": "#/$defs/templateVariable" },
"default": []
},
"derivedFromTemplateRuleId": {
"type": "string",
"description": "If this custom rule was created from a template, the original template rule's ID. Used to track lineage and prevent duplicate copies."
}
},
"required": ["ruleId", "title", "severity", "category", "conditions", "explanation"],
@@ -247,6 +257,46 @@
},
"required": ["title", "url"],
"additionalProperties": false
},
"templateVariable": {
"type": "object",
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
"properties": {
"name": {
"type": "string",
"description": "Machine name for this variable (e.g., \"cert_subject\")."
},
"label": {
"type": "string",
"description": "Human-readable label shown in the configuration UI."
},
"description": {
"type": "string",
"description": "Help text explaining what value is expected."
},
"conditionIndex": {
"type": "integer",
"description": "Zero-based index into the conditions array where this variable lives.",
"minimum": 0
},
"field": {
"type": "string",
"description": "Which field on the condition to customize.",
"enum": ["value", "eventType", "dataField", "eventAFilterValue"],
"default": "value"
},
"placeholder": {
"type": "string",
"description": "The placeholder value that ships with the template (e.g., \"CN=YOUR-CERTIFICATE-SUBJECT\")."
},
"validation": {
"type": "string",
"description": "Optional regex pattern to validate user input.",
"format": "regex"
}
},
"required": ["name", "label", "conditionIndex", "field", "placeholder"],
"additionalProperties": false
}
}
}
@@ -1 +1 @@
805
823
@@ -45,6 +45,66 @@ public class BuiltInRulesTests
}
}
[Fact]
public void BuiltInAnalyzeRules_TemplateVariablesAreValid()
{
var rules = BuiltInAnalyzeRules.GetAll();
var templateRules = rules.Where(r => r.TemplateVariables != null && r.TemplateVariables.Count > 0).ToList();
foreach (var rule in templateRules)
{
// Template rules should be disabled by default (they need configuration first)
Assert.False(rule.Enabled, $"Template rule {rule.RuleId} should be disabled by default");
foreach (var tv in rule.TemplateVariables)
{
Assert.False(string.IsNullOrEmpty(tv.Name), $"Rule {rule.RuleId}: TemplateVariable is missing Name");
Assert.False(string.IsNullOrEmpty(tv.Label), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Label");
Assert.False(string.IsNullOrEmpty(tv.Placeholder), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Placeholder");
// conditionIndex must reference a valid condition
Assert.True(tv.ConditionIndex >= 0 && tv.ConditionIndex < rule.Conditions.Count,
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has conditionIndex {tv.ConditionIndex} but rule only has {rule.Conditions.Count} conditions");
// Field must be one of the known fields
var validFields = new HashSet<string> { "value", "eventType", "dataField", "eventAFilterValue" };
Assert.True(validFields.Contains(tv.Field),
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has unknown field '{tv.Field}'");
// The placeholder should match the current value in the condition
var condition = rule.Conditions[tv.ConditionIndex];
var actualValue = tv.Field switch
{
"value" => condition.Value,
"eventType" => condition.EventType,
"dataField" => condition.DataField,
"eventAFilterValue" => condition.EventAFilterValue,
_ => null
};
Assert.Equal(tv.Placeholder, actualValue);
}
}
}
[Fact]
public void BuiltInAnalyzeRules_ANALYZE_ID_001_IsTemplate()
{
var rules = BuiltInAnalyzeRules.GetAll();
var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ID-001");
Assert.NotNull(rule);
Assert.False(rule.Enabled, "ANALYZE-ID-001 should be disabled (it's a template)");
Assert.NotNull(rule.TemplateVariables);
Assert.Single(rule.TemplateVariables);
var tv = rule.TemplateVariables[0];
Assert.Equal("cert_subject", tv.Name);
Assert.Equal("Certificate Subject", tv.Label);
Assert.Equal(1, tv.ConditionIndex);
Assert.Equal("value", tv.Field);
Assert.Equal("CN=YOUR-CERTIFICATE-SUBJECT", tv.Placeholder);
}
[Fact]
public void BuiltInImeLogPatterns_LoadsFromEmbeddedResource()
{
@@ -139,6 +139,7 @@ public class EndpointPolicyCatalogCompletenessTests
[InlineData("DELETE", "/api/devices/block/SN123456", "devices/block/{encodedSerialNumber}")]
[InlineData("DELETE", "/api/versions/block/v1.0.*", "versions/block/{encodedPattern}")]
[InlineData("PATCH", "/api/global/session-reports/report-1/note", "global/session-reports/{reportId}/note")]
[InlineData("POST", "/api/rules/analyze/ANALYZE-ID-001/create-from-template", "rules/analyze/{ruleId}/create-from-template")]
public void ParameterizedRoutes_MatchCorrectly(string httpMethod, string requestPath, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
@@ -57,6 +57,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId)
=> _storage.DeleteAnalyzeRuleAsync(tenantId, ruleId);
public Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId)
=> _storage.AnalyzeRuleExistsAsync(partitionKey, ruleId);
public Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId)
=> _storage.GatherRuleExistsAsync(partitionKey, ruleId);
public Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global")
=> _storage.StoreImeLogPatternAsync(pattern, tenantId);
@@ -64,11 +64,20 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
try
{
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
return response;
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
return response;
}
catch (InvalidOperationException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
}
[Function("UpdateAnalyzeRule")]
@@ -105,6 +114,46 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return response;
}
[Function("CreateAnalyzeRuleFromTemplate")]
public async Task<HttpResponseData> CreateFromTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze/{ruleId}/create-from-template")] HttpRequestData req,
string ruleId)
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var body = await new StreamReader(req.Body).ReadToEndAsync();
var variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(body);
if (variables == null || variables.Count == 0)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "Template variable values are required" });
return badRequest;
}
try
{
var newRule = await _ruleService.CreateFromTemplateAsync(tenantId, ruleId, variables);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { success = true, rule = newRule, message = "Custom rule created from template" });
return response;
}
catch (InvalidOperationException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
catch (ArgumentException ex)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = ex.Message });
return badReq;
}
}
[Function("DeleteAnalyzeRule")]
public async Task<HttpResponseData> DeleteRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "rules/analyze/{ruleId}")] HttpRequestData req,
@@ -64,11 +64,20 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
try
{
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
return response;
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
return response;
}
catch (InvalidOperationException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
}
[Function("UpdateGatherRule")]
@@ -158,6 +158,7 @@ public static class EndpointAccessPolicyCatalog
new("PUT", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "rules/analyze", EndpointPolicy.TenantAdminOrGA),
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
@@ -1,6 +1,7 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
@@ -66,10 +67,18 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Creates a custom analyze rule for a tenant
/// Creates a custom analyze rule for a tenant.
/// Throws if a rule with the same ID already exists (global or tenant partition).
/// Uses point queries (O(1)) instead of loading all rules.
/// </summary>
public async Task<bool> CreateRuleAsync(string tenantId, AnalyzeRule rule)
{
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", rule.RuleId)
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, rule.RuleId))
{
throw new InvalidOperationException($"A rule with ID '{rule.RuleId}' already exists.");
}
rule.IsBuiltIn = false;
rule.IsCommunity = false;
rule.CreatedAt = DateTime.UtcNow;
@@ -93,6 +102,94 @@ namespace AutopilotMonitor.Functions.Services
return await _ruleRepo.StoreAnalyzeRuleAsync(rule, tenantId);
}
/// <summary>
/// Creates a tenant custom rule from a template rule, substituting template variables
/// with tenant-specific values. The original template remains disabled for the tenant.
/// </summary>
public async Task<AnalyzeRule> CreateFromTemplateAsync(
string tenantId,
string templateRuleId,
Dictionary<string, string> variableValues)
{
var allRules = await GetAllRulesForTenantAsync(tenantId);
var template = allRules.FirstOrDefault(r => r.RuleId == templateRuleId);
if (template == null)
throw new InvalidOperationException($"Rule '{templateRuleId}' not found.");
if (template.TemplateVariables == null || template.TemplateVariables.Count == 0)
throw new InvalidOperationException($"Rule '{templateRuleId}' is not a template rule.");
// Check if a custom copy already exists for this tenant (by lineage)
var existingCopy = allRules.FirstOrDefault(r => r.DerivedFromTemplateRuleId == templateRuleId);
if (existingCopy != null)
throw new InvalidOperationException($"A custom copy of '{templateRuleId}' already exists: '{existingCopy.RuleId}'.");
// Check for RuleId collision via point query (e.g., someone manually created a rule with the same ID)
var targetRuleId = $"{templateRuleId}-CUSTOM";
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", targetRuleId)
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, targetRuleId))
throw new InvalidOperationException($"A rule with ID '{targetRuleId}' already exists. Delete or rename it first.");
// Validate all template variables have values
foreach (var tv in template.TemplateVariables)
{
if (!variableValues.TryGetValue(tv.Name, out var val) || string.IsNullOrWhiteSpace(val))
throw new ArgumentException($"Missing required value for template variable '{tv.Name}'.");
}
// Deep-clone the template
var customRule = JsonConvert.DeserializeObject<AnalyzeRule>(
JsonConvert.SerializeObject(template))
?? throw new InvalidOperationException("Failed to clone template rule.");
customRule.RuleId = $"{templateRuleId}-CUSTOM";
customRule.IsBuiltIn = false;
customRule.IsCommunity = false;
customRule.Enabled = true;
customRule.DerivedFromTemplateRuleId = templateRuleId;
customRule.TemplateVariables = new List<TemplateVariable>();
customRule.CreatedAt = DateTime.UtcNow;
customRule.UpdatedAt = DateTime.UtcNow;
// Substitute variable values into conditions
foreach (var tv in template.TemplateVariables)
{
var userValue = variableValues[tv.Name];
if (tv.ConditionIndex < 0 || tv.ConditionIndex >= customRule.Conditions.Count)
{
_logger.LogWarning("Template variable '{Name}' has invalid conditionIndex {Index}", tv.Name, tv.ConditionIndex);
continue;
}
var condition = customRule.Conditions[tv.ConditionIndex];
switch (tv.Field?.ToLowerInvariant())
{
case "value": condition.Value = userValue; break;
case "eventtype": condition.EventType = userValue; break;
case "datafield": condition.DataField = userValue; break;
case "eventafiltervalue": condition.EventAFilterValue = userValue; break;
default:
_logger.LogWarning("Template variable '{Name}' has unknown field '{Field}'", tv.Name, tv.Field);
break;
}
}
// Store the custom rule in the tenant partition
var success = await _ruleRepo.StoreAnalyzeRuleAsync(customRule, tenantId);
if (!success)
throw new InvalidOperationException("Failed to store custom rule.");
// Ensure the template rule is disabled for this tenant
await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, false);
_logger.LogInformation("Created custom rule '{CustomRuleId}' from template '{TemplateRuleId}' for tenant '{TenantId}'",
customRule.RuleId, templateRuleId, tenantId);
return customRule;
}
/// <summary>
/// Deletes an analyze rule.
/// For built-in/community rules: removes the tenant's state override (resets to rule default).
@@ -67,10 +67,18 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Creates a custom gather rule for a tenant
/// Creates a custom gather rule for a tenant.
/// Throws if a rule with the same ID already exists (global or tenant partition).
/// Uses point queries (O(1)) instead of loading all rules.
/// </summary>
public async Task<bool> CreateRuleAsync(string tenantId, GatherRule rule)
{
if (await _ruleRepo.GatherRuleExistsAsync("global", rule.RuleId)
|| await _ruleRepo.GatherRuleExistsAsync(tenantId, rule.RuleId))
{
throw new InvalidOperationException($"A rule with ID '{rule.RuleId}' already exists.");
}
rule.IsBuiltIn = false;
rule.IsCommunity = false;
rule.CreatedAt = DateTime.UtcNow;
@@ -388,6 +388,44 @@ namespace AutopilotMonitor.Functions.Services
}
}
// ===== RULE EXISTENCE CHECKS (Point Queries) =====
/// <summary>
/// Checks if an analyze rule exists via point query (PartitionKey + RowKey).
/// O(1) in Table Storage — no partition scan needed.
/// </summary>
public async Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
/// <summary>
/// Checks if a gather rule exists via point query (PartitionKey + RowKey).
/// O(1) in Table Storage — no partition scan needed.
/// </summary>
public async Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
private AnalyzeRule MapToAnalyzeRule(TableEntity entity)
{
return new AnalyzeRule
@@ -29,6 +29,19 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<List<AnalyzeRule>> GetAnalyzeRulesAsync(string partitionKey);
Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId);
// --- Rule Existence Checks (Point Queries) ---
/// <summary>
/// Checks if an analyze rule with the given ID exists in the specified partition.
/// Uses a point query (O(1)) instead of loading the full partition.
/// </summary>
Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId);
/// <summary>
/// Checks if a gather rule with the given ID exists in the specified partition.
/// Uses a point query (O(1)) instead of loading the full partition.
/// </summary>
Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId);
// --- IME Log Patterns ---
Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global");
Task<List<ImeLogPattern>> GetImeLogPatternsAsync(string partitionKey);
@@ -112,6 +112,21 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public List<RelatedDoc> RelatedDocs { get; set; } = new List<RelatedDoc>();
// ===== TEMPLATE =====
/// <summary>
/// Template variables that must be customized per-tenant before the rule can be used.
/// If non-empty, the rule is a template: enabling it creates a tenant custom copy
/// with the user's values substituted into the conditions.
/// </summary>
public List<TemplateVariable> TemplateVariables { get; set; } = new List<TemplateVariable>();
/// <summary>
/// If this custom rule was created from a template, stores the original template rule's ID.
/// Used to track lineage and prevent duplicate copies.
/// </summary>
public string? DerivedFromTemplateRuleId { get; set; }
// ===== METADATA =====
/// <summary>
@@ -268,4 +283,46 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string Url { get; set; } = default!;
}
/// <summary>
/// Defines a template variable in a rule condition that must be customized per-tenant.
/// Points at a specific condition field (by index) and describes what value the user needs to provide.
/// </summary>
public class TemplateVariable
{
/// <summary>
/// Machine name for this variable (e.g., "cert_subject")
/// </summary>
public string Name { get; set; } = default!;
/// <summary>
/// Human-readable label shown in the configuration UI (e.g., "Certificate Subject")
/// </summary>
public string Label { get; set; } = default!;
/// <summary>
/// Help text explaining what value the user should provide
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Zero-based index into the rule's Conditions array where this variable lives
/// </summary>
public int ConditionIndex { get; set; }
/// <summary>
/// Which field on the condition to customize: "value", "eventType", "dataField", "eventAFilterValue"
/// </summary>
public string Field { get; set; } = "value";
/// <summary>
/// The placeholder value that ships with the template (e.g., "CN=YOUR-CERTIFICATE-SUBJECT")
/// </summary>
public string Placeholder { get; set; } = default!;
/// <summary>
/// Optional regex pattern to validate user input
/// </summary>
public string? Validation { get; set; }
}
}
@@ -30,6 +30,10 @@ interface AnalyzeRuleCardProps {
onSetJsonText: (text: string) => void;
onSetJsonError: (error: string | null) => void;
readOnly?: boolean;
onConfigureTemplate?: (rule: AnalyzeRule) => void;
templateCopyExists?: boolean;
templateCopyRuleId?: string;
onScrollToCopy?: (ruleId: string) => void;
}
export default function AnalyzeRuleCard({
@@ -40,11 +44,14 @@ export default function AnalyzeRuleCard({
onDelete, onExport,
onSetJsonModeEdit, onSetJsonText, onSetJsonError,
readOnly = false,
onConfigureTemplate, templateCopyExists, templateCopyRuleId, onScrollToCopy,
}: AnalyzeRuleCardProps) {
const [showJson, setShowJson] = useState(false);
const sevColor = getSeverityColor(rule.severity);
const catColor = getCategoryColor(rule.category);
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
const isTemplate = (rule.templateVariables?.length ?? 0) > 0;
const isDerived = !!rule.derivedFromTemplateRuleId;
return (
<div
@@ -59,6 +66,14 @@ export default function AnalyzeRuleCard({
<span className={`inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full ${rule.enabled ? "bg-green-500" : "bg-gray-300"}`} title={rule.enabled ? "Enabled" : "Disabled"}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
</span>
) : isTemplate && templateCopyExists ? (
<button onClick={(e) => { e.stopPropagation(); if (templateCopyRuleId && onScrollToCopy) onScrollToCopy(templateCopyRuleId); }} className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full bg-gray-300 cursor-pointer" title="Custom copy already exists - click to view">
<span className="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1" />
</button>
) : isTemplate && !rule.enabled ? (
<button onClick={(e) => { e.stopPropagation(); onConfigureTemplate?.(rule); }} className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full bg-amber-400 hover:bg-amber-500 cursor-pointer transition-colors" title="Configure and enable this template rule">
<span className="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1" />
</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onToggleEnabled(rule); }} disabled={togglingRuleId === rule.ruleId} className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${togglingRuleId === rule.ruleId ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${rule.enabled ? "bg-green-500" : "bg-gray-300"}`} title={rule.enabled ? "Disable rule" : "Enable rule"}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
@@ -78,6 +93,16 @@ export default function AnalyzeRuleCard({
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${rule.isBuiltIn ? "bg-blue-50 text-blue-600 border border-blue-200" : rule.isCommunity ? "bg-green-100 text-green-700" : "bg-purple-50 text-purple-600 border border-purple-200"}`}>
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
</span>
{isTemplate && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 bg-amber-100 text-amber-800 border border-amber-200">
Requires Setup
</span>
)}
{isDerived && (
<span className="text-xs text-gray-400 flex-shrink-0 hidden lg:inline" title={`Based on template ${rule.derivedFromTemplateRuleId}`}>
Based on {rule.derivedFromTemplateRuleId}
</span>
)}
<span className="text-xs text-gray-500 flex-shrink-0 hidden md:inline" title="Confidence Threshold">Threshold: {rule.confidenceThreshold}%</span>
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
@@ -10,9 +10,14 @@ interface AnalyzeRuleFormFieldsProps {
form: RuleForm;
setForm: (f: RuleForm) => void;
showRuleId: boolean;
existingRuleIds?: string[];
}
export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: AnalyzeRuleFormFieldsProps) {
export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, existingRuleIds }: AnalyzeRuleFormFieldsProps) {
const ruleIdTrimmed = form.ruleId.trim().toLowerCase();
const isDuplicate = showRuleId && existingRuleIds && ruleIdTrimmed.length > 0
&& existingRuleIds.some(id => id.toLowerCase() === ruleIdTrimmed);
return (
<div className="space-y-5">
{/* Basic Fields */}
@@ -20,7 +25,10 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: Ana
{showRuleId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rule ID <span className="text-red-500">*</span></label>
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`} />
{isDuplicate && (
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
)}
</div>
)}
<div>
@@ -0,0 +1,190 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { AnalyzeRule, TemplateVariable } from "../types";
interface TemplateConfigModalProps {
rule: AnalyzeRule;
saving: boolean;
onSave: (variables: Record<string, string>) => void;
onCancel: () => void;
}
export default function TemplateConfigModal({
rule,
saving,
onSave,
onCancel,
}: TemplateConfigModalProps) {
const templateVars = rule.templateVariables || [];
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const tv of templateVars) {
initial[tv.name] = "";
}
return initial;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Focus the first input on mount
const timer = setTimeout(() => firstInputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}, []);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
for (const tv of templateVars) {
const val = values[tv.name]?.trim();
if (!val) {
newErrors[tv.name] = "This field is required.";
} else if (tv.validation) {
try {
const regex = new RegExp(tv.validation);
if (!regex.test(val)) {
newErrors[tv.name] = "Value does not match the expected format.";
}
} catch {
// Invalid regex in rule definition, skip validation
}
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
const trimmed: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
trimmed[k] = v.trim();
}
onSave(trimmed);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">Configure: {rule.title}</h3>
<div className="flex items-center space-x-2 mt-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Template
</span>
<span className="text-xs text-gray-500">{rule.ruleId}</span>
</div>
</div>
</div>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Info box */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start space-x-2.5">
<svg className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-amber-800">
This rule needs your environment-specific values before it can be activated.
A custom copy will be created for your tenant.
</p>
</div>
{/* Variable fields */}
{templateVars.map((tv, idx) => (
<div key={tv.name}>
<label className="block text-sm font-semibold text-gray-700 mb-1">
{tv.label}
</label>
{tv.description && (
<p className="text-xs text-gray-500 mb-2">{tv.description}</p>
)}
<div className="relative">
<input
ref={idx === 0 ? firstInputRef : undefined}
type="text"
value={values[tv.name] || ""}
onChange={(e) => {
setValues({ ...values, [tv.name]: e.target.value });
if (errors[tv.name]) {
setErrors({ ...errors, [tv.name]: "" });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder={tv.placeholder}
className={`w-full px-3 py-2.5 border-2 rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 ${
errors[tv.name]
? "border-red-300 focus:border-red-400 focus:ring-red-200"
: "border-amber-300 focus:border-amber-400 focus:ring-amber-200 animate-pulse-border"
}`}
/>
</div>
{errors[tv.name] && (
<p className="mt-1 text-xs text-red-600">{errors[tv.name]}</p>
)}
<p className="mt-1 text-xs text-gray-400">
Replaces: <code className="bg-gray-100 px-1 rounded">{tv.placeholder}</code>
</p>
</div>
))}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end space-x-3">
<button
onClick={onCancel}
disabled={saving}
className="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="px-5 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Creating...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Save &amp; Enable</span>
</>
)}
</button>
</div>
</div>
{/* Pulse animation for the amber border */}
<style jsx>{`
@keyframes pulse-border {
0%, 100% { border-color: rgb(252 211 77); }
50% { border-color: rgb(245 158 11); }
}
.animate-pulse-border {
animation: pulse-border 2s ease-in-out 3;
}
`}</style>
</div>
);
}
@@ -20,6 +20,7 @@ import { useAdminMode } from "@/hooks/useAdminMode";
import { AnalyzeRule, RuleForm, EMPTY_FORM, EMPTY_CONDITION, ruleToForm } from "./types";
import AnalyzeRuleFormFields from "./components/AnalyzeRuleFormFields";
import AnalyzeRuleCard from "./components/AnalyzeRuleCard";
import TemplateConfigModal from "./components/TemplateConfigModal";
interface TenantInfo {
tenantId: string;
@@ -71,6 +72,10 @@ export default function AnalyzeRulesPage() {
const [togglingRuleId, setTogglingRuleId] = useState<string | null>(null);
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null);
// Template modal state
const [configureTemplateRule, setConfigureTemplateRule] = useState<AnalyzeRule | null>(null);
const [creatingFromTemplate, setCreatingFromTemplate] = useState(false);
// Global admin mode
const { globalAdminMode } = useAdminMode();
const [tenants, setTenants] = useState<TenantInfo[]>([]);
@@ -129,6 +134,12 @@ export default function AnalyzeRulesPage() {
// Toggle rule enabled/disabled
const handleToggleRule = async (rule: AnalyzeRule) => {
// Intercept: if this is a template rule being enabled, open the config modal instead
if ((rule.templateVariables?.length ?? 0) > 0 && !rule.enabled) {
setConfigureTemplateRule(rule);
return;
}
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
@@ -265,8 +276,40 @@ export default function AnalyzeRulesPage() {
setSaving(false);
};
// Create custom rule from template
const handleCreateFromTemplate = async (variables: Record<string, string>) => {
if (!configureTemplateRule) return;
setCreatingFromTemplate(true);
const result = await mutate(
api.rules.analyzeRuleFromTemplate(configureTemplateRule.ruleId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
}
);
if (result !== null) {
const newRuleId = `${configureTemplateRule.ruleId}-CUSTOM`;
trackEvent("rule_created_from_template", { ruleType: "analyze", templateRuleId: configureTemplateRule.ruleId });
showSuccess(`Rule "${configureTemplateRule.title}" configured and enabled successfully!`);
setConfigureTemplateRule(null);
await fetchRules();
// Auto-expand the newly created custom rule
setExpandedRuleId(newRuleId);
}
setCreatingFromTemplate(false);
};
const rulesList = rules || [];
// Map: templateRuleId -> custom copy ruleId (for template rules that already have a tenant copy)
const templateCopyMap = new Map<string, string>();
for (const r of rulesList) {
if (r.derivedFromTemplateRuleId) {
templateCopyMap.set(r.derivedFromTemplateRuleId, r.ruleId);
}
}
// Filter rules
const filteredRules = rulesList.filter((rule) => {
const matchesSearch =
@@ -506,7 +549,7 @@ export default function AnalyzeRulesPage() {
textareaRows={30}
description={<>Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.</>}
>
<AnalyzeRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} />
<AnalyzeRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} existingRuleIds={rulesList.map(r => r.ruleId)} />
</FormJsonToggle>
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button onClick={() => { setShowCreateForm(false); setJsonModeCreate(false); setJsonError(null); setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} disabled={creating} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium">Cancel</button>
@@ -568,6 +611,10 @@ export default function AnalyzeRulesPage() {
onSetJsonText={setJsonText}
onSetJsonError={setJsonError}
readOnly={isReadOnly}
onConfigureTemplate={(r) => setConfigureTemplateRule(r)}
templateCopyExists={templateCopyMap.has(rule.ruleId)}
templateCopyRuleId={templateCopyMap.get(rule.ruleId)}
onScrollToCopy={(copyId) => setExpandedRuleId(copyId)}
/>
))}
</div>
@@ -575,6 +622,16 @@ export default function AnalyzeRulesPage() {
</div>
)}
</main>
{/* Template Configuration Modal */}
{configureTemplateRule && (
<TemplateConfigModal
rule={configureTemplateRule}
saving={creatingFromTemplate}
onSave={handleCreateFromTemplate}
onCancel={() => setConfigureTemplateRule(null)}
/>
)}
</div>
</ProtectedRoute>
);
@@ -31,6 +31,16 @@ export interface RuleCondition {
eventAFilterValue?: string;
}
export interface TemplateVariable {
name: string;
label: string;
description?: string;
conditionIndex: number;
field: string;
placeholder: string;
validation?: string;
}
export interface AnalyzeRule {
ruleId: string;
title: string;
@@ -51,6 +61,8 @@ export interface AnalyzeRule {
remediation: RemediationStep[];
relatedDocs: RelatedDoc[];
tags: string[];
templateVariables?: TemplateVariable[];
derivedFromTemplateRuleId?: string;
createdAt: string;
updatedAt: string;
}
@@ -317,6 +317,52 @@ export function SectionAnalyzeRules() {
</div>
{/* ── Template Rules ── */}
<h3 className="text-xl font-semibold text-gray-900 pt-4 border-t border-gray-200">Template Rules</h3>
<p className="text-gray-700 text-sm">
Some community rules contain <strong>placeholder values</strong> that must be customized for your environment before
they can work correctly. These rules are marked with a <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200">Requires Setup</span> badge.
</p>
<div className="space-y-4">
<div className="border border-amber-200 rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-amber-50 border-b border-amber-200">
<p className="font-semibold text-sm text-amber-900">How template rules work</p>
</div>
<div className="px-4 py-4 space-y-3 text-sm text-gray-700">
<ol className="list-decimal list-inside space-y-2">
<li>When you click the <strong>enable toggle</strong> on a template rule, a configuration dialog opens instead of directly enabling the rule.</li>
<li>The dialog highlights the fields that need your environment-specific values (e.g., a certificate subject name).</li>
<li>After you enter your values and click <strong>Save &amp; Enable</strong>, a <strong>custom copy</strong> of the rule is created for your tenant with your values applied.</li>
<li>The original template rule remains disabled. Your custom copy is fully editable — you can adjust conditions, confidence, or other settings as needed.</li>
</ol>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs text-blue-900">
<strong>Note:</strong> If you delete the custom copy, the template rule becomes available again and you can re-configure it.
Centrally updated templates (via reseed) do not affect your existing custom copies.
</div>
</div>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-indigo-50 border-b border-indigo-200">
<p className="font-semibold text-sm text-indigo-900">Example — No Machine Certificates Found</p>
<p className="text-xs text-indigo-700 mt-0.5">Template rule that checks for a specific certificate subject in the local machine store</p>
</div>
<div className="px-4 py-4 text-xs space-y-2">
<p className="text-gray-600">This rule ships with the placeholder <code className="bg-gray-100 px-1 rounded">CN=YOUR-CERTIFICATE-SUBJECT</code>.
When you enable it, you are prompted to enter your actual certificate subject (e.g., <code className="bg-gray-100 px-1 rounded">CN=Contoso Root CA</code>).
The system creates a custom rule with your value automatically substituted into the condition.</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
<div><p className="text-gray-500">Rule ID</p><p className="font-medium">ANALYZE-ID-001</p></div>
<div><p className="text-gray-500">Variable</p><p className="font-medium">Certificate Subject</p></div>
<div><p className="text-gray-500">Placeholder</p><p className="font-medium font-mono">CN=YOUR-CERTIFICATE-SUBJECT</p></div>
<div><p className="text-gray-500">Custom Copy ID</p><p className="font-medium font-mono">ANALYZE-ID-001-CUSTOM</p></div>
</div>
</div>
</div>
</div>
{/* ── JSON editing tip ── */}
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700">
<p className="font-semibold text-gray-900 mb-1">JSON editing</p>
@@ -20,13 +20,18 @@ interface GatherRuleFormFieldsProps {
setForm: (f: NewRuleForm) => void;
showRuleId: boolean;
unrestrictedMode?: boolean;
existingRuleIds?: string[];
}
export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMode = false }: GatherRuleFormFieldsProps) {
export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMode = false, existingRuleIds }: GatherRuleFormFieldsProps) {
const targetValidation = useMemo(
() => form.target.trim() ? validateGatherRuleTarget(form.collectorType, form.target, unrestrictedMode) : null,
[form.collectorType, form.target, unrestrictedMode]
);
const ruleIdTrimmed = form.ruleId.trim().toLowerCase();
const isDuplicate = showRuleId && existingRuleIds && ruleIdTrimmed.length > 0
&& existingRuleIds.some(id => id.toLowerCase() === ruleIdTrimmed);
return (
<div className="space-y-5">
{/* Row 1: Rule ID (create only), Title */}
@@ -42,8 +47,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
placeholder="e.g., custom-network-check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`}
/>
{isDuplicate && (
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
)}
</div>
)}
<div>
@@ -575,7 +575,7 @@ export default function GatherRulesPage() {
textareaRows={20}
description={<>Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.</>}
>
<GatherRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} unrestrictedMode={unrestrictedMode} />
<GatherRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} unrestrictedMode={unrestrictedMode} existingRuleIds={rulesList.map(r => r.ruleId)} />
</FormJsonToggle>
{/* Action Buttons */}
+2
View File
@@ -107,6 +107,8 @@ export const api = {
analyze: () => `${API_BASE_URL}/api/rules/analyze`,
analyzeRule: (ruleId: string) =>
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}`,
analyzeRuleFromTemplate: (ruleId: string) =>
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}/create-from-template`,
globalAnalyze: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/analyze${qs({ tenantId })}`,
gather: (tenantId?: string) =>
File diff suppressed because one or more lines are too long