Add topology auth policies + journey findings notes

Concelier:
- Register Topology.Read, Topology.Manage, Topology.Admin authorization
  policies mapped to OrchRead/OrchOperate/PlatformContextRead/IntegrationWrite
  scopes. Previously these policies were referenced by endpoints but never
  registered, causing System.InvalidOperationException on every topology
  API call.

Gateway routes:
- Simplified targets/environments routes (removed specific sub-path routes,
  use catch-all patterns instead)
- Changed environments base route to JobEngine (where CRUD lives)
- Changed to ReverseProxy type for all topology routes

KNOWN ISSUE (not yet fixed):
- ReverseProxy routes don't forward the gateway's identity envelope to
  Concelier. The regions/targets/bindings endpoints return 401 because
  hasPrincipal=False — the gateway authenticates the user but doesn't
  pass the identity to the backend via ReverseProxy. Microservice routes
  use Valkey transport which includes envelope headers. Topology endpoints
  need either: (a) Valkey transport registration in Concelier, or
  (b) Concelier configured to accept raw bearer tokens on ReverseProxy paths.
  This is an architecture-level fix.

Journey findings collected so far:
- Integration wizard (Harbor + GitHub App): works end-to-end
- Advisory Check All: fixed (parallel individual checks)
- Mirror domain creation: works, generate-immediately fails silently
- Topology wizard Step 1 (Region): blocked by auth passthrough issue
- Topology wizard Step 2 (Environment): POST to JobEngine needs verify
- User ID resolution: raw hashes shown everywhere

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 08:12:39 +02:00
parent 602df77467
commit da76d6e93e
223 changed files with 24763 additions and 489 deletions

View File

@@ -0,0 +1,410 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// Compatibility endpoints for Notify sub-resources that the frontend (WEB-NOTIFY-39/40)
/// expects at /api/v1/notify/* but are not yet served by the Notify microservice.
/// The gateway routes these specific sub-paths to Platform while channels/rules/deliveries
/// continue to be served by the Notify service.
/// </summary>
public static class NotifyCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapNotifyCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/notify")
.WithTags("Notify Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.NotifyViewer))
.RequireTenant();
// ── Digest Schedules ──────────────────────────────────────────
group.MapGet("/digest-schedules", (HttpContext ctx, [FromQuery] string? pageToken, [FromQuery] int? pageSize) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
items = new[]
{
new
{
scheduleId = "digest-daily",
tenantId = tenant,
name = "Daily Digest",
frequency = "daily",
timezone = "UTC",
hour = 8,
enabled = true,
createdAt = "2025-10-01T00:00:00Z"
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListDigestSchedules");
group.MapPost("/digest-schedules", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
scheduleId = $"digest-{Guid.NewGuid():N}".Substring(0, 20),
tenantId = tenant,
name = "New Schedule",
frequency = "daily",
timezone = "UTC",
hour = 8,
enabled = true,
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.SaveDigestSchedule");
group.MapDelete("/digest-schedules/{scheduleId}", (string scheduleId) =>
Results.NoContent())
.WithName("NotifyCompat.DeleteDigestSchedule");
// ── Quiet Hours ───────────────────────────────────────────────
group.MapGet("/quiet-hours", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
items = new[]
{
new
{
quietHoursId = "qh-default",
tenantId = tenant,
name = "Weeknight Quiet",
windows = new[]
{
new
{
timezone = "UTC",
days = new[] { "Mon", "Tue", "Wed", "Thu", "Fri" },
start = "22:00",
end = "06:00"
}
},
exemptions = new[]
{
new
{
eventKinds = new[] { "attestor.verification.failed" },
reason = "Always alert on attestation failures"
}
},
enabled = true,
createdAt = "2025-10-01T00:00:00Z"
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListQuietHours");
group.MapPost("/quiet-hours", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
quietHoursId = $"qh-{Guid.NewGuid():N}".Substring(0, 16),
tenantId = tenant,
name = "New Quiet Hours",
windows = Array.Empty<object>(),
exemptions = Array.Empty<object>(),
enabled = true,
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.SaveQuietHours");
group.MapDelete("/quiet-hours/{quietHoursId}", (string quietHoursId) =>
Results.NoContent())
.WithName("NotifyCompat.DeleteQuietHours");
// ── Throttle Configs ──────────────────────────────────────────
group.MapGet("/throttle-configs", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
items = new[]
{
new
{
throttleId = "throttle-default",
tenantId = tenant,
name = "Default Throttle",
windowSeconds = 60,
maxEvents = 50,
burstLimit = 100,
enabled = true,
createdAt = "2025-10-01T00:00:00Z"
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListThrottleConfigs");
group.MapPost("/throttle-configs", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
throttleId = $"throttle-{Guid.NewGuid():N}".Substring(0, 20),
tenantId = tenant,
name = "New Throttle",
windowSeconds = 60,
maxEvents = 50,
burstLimit = 100,
enabled = true,
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.SaveThrottleConfig");
group.MapDelete("/throttle-configs/{throttleId}", (string throttleId) =>
Results.NoContent())
.WithName("NotifyCompat.DeleteThrottleConfig");
// ── Simulate ──────────────────────────────────────────────────
group.MapPost("/simulate", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
simulationId = $"sim-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
matchedRules = new[] { "rule-critical-vulns" },
wouldNotify = new[]
{
new
{
channelId = "chn-soc-webhook",
actionId = "act-soc",
template = "tmpl-default",
digest = "instant"
}
},
throttled = false,
quietHoursActive = false,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.Simulate");
// ── Escalation Policies ───────────────────────────────────────
group.MapGet("/escalation-policies", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
items = new[]
{
new
{
policyId = "escalate-critical",
tenantId = tenant,
name = "Critical Escalation",
levels = new[]
{
new { level = 1, delayMinutes = 0, channels = new[] { "chn-soc-webhook" }, notifyOnAck = false },
new { level = 2, delayMinutes = 15, channels = new[] { "chn-slack-dev" }, notifyOnAck = true }
},
enabled = true,
createdAt = "2025-10-01T00:00:00Z"
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListEscalationPolicies");
group.MapPost("/escalation-policies", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
policyId = $"escalate-{Guid.NewGuid():N}".Substring(0, 20),
tenantId = tenant,
name = "New Policy",
levels = Array.Empty<object>(),
enabled = true,
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.SaveEscalationPolicy");
group.MapDelete("/escalation-policies/{policyId}", (string policyId) =>
Results.NoContent())
.WithName("NotifyCompat.DeleteEscalationPolicy");
// ── Localizations ─────────────────────────────────────────────
group.MapGet("/localizations", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
items = new[]
{
new
{
localeId = "loc-en-us",
tenantId = tenant,
locale = "en-US",
name = "English (US)",
templates = new Dictionary<string, string>(StringComparer.Ordinal)
{
["vuln.critical"] = "Critical vulnerability detected: {{title}}"
},
dateFormat = "MM/DD/YYYY",
timeFormat = "HH:mm:ss",
enabled = true,
createdAt = "2025-10-01T00:00:00Z"
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListLocalizations");
group.MapPost("/localizations", (HttpContext ctx) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
return Results.Ok(new
{
localeId = $"loc-{Guid.NewGuid():N}".Substring(0, 16),
tenantId = tenant,
locale = "en-US",
name = "New Locale",
templates = new Dictionary<string, string>(StringComparer.Ordinal),
dateFormat = "MM/DD/YYYY",
timeFormat = "HH:mm:ss",
enabled = true,
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.SaveLocalization");
group.MapDelete("/localizations/{localeId}", (string localeId) =>
Results.NoContent())
.WithName("NotifyCompat.DeleteLocalization");
// ── Incidents ─────────────────────────────────────────────────
group.MapGet("/incidents", (HttpContext ctx, TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
items = new[]
{
new
{
incidentId = "inc-001",
tenantId = tenant,
title = "Critical vulnerability CVE-2021-44228",
severity = "critical",
status = "open",
eventIds = new[] { "evt-001", "evt-002" },
escalationLevel = 1,
escalationPolicyId = "escalate-critical",
createdAt = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
}
},
total = 1,
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.ListIncidents");
group.MapGet("/incidents/{incidentId}", (HttpContext ctx, string incidentId, TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
incidentId,
tenantId = tenant,
title = "Critical vulnerability CVE-2021-44228",
severity = "critical",
status = "open",
eventIds = new[] { "evt-001", "evt-002" },
escalationLevel = 1,
escalationPolicyId = "escalate-critical",
createdAt = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
});
}).WithName("NotifyCompat.GetIncident");
group.MapPost("/incidents/{incidentId}/ack", (HttpContext ctx, string incidentId, TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(ctx, null);
if (string.IsNullOrWhiteSpace(tenant))
return Results.BadRequest(new { error = "tenant_required" });
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
incidentId,
acknowledged = true,
acknowledgedAt = now.ToString("O", CultureInfo.InvariantCulture),
acknowledgedBy = "admin",
traceId = ctx.TraceIdentifier
});
}).WithName("NotifyCompat.AckIncident");
return app;
}
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
=> tenantId?.Trim()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.User.Claims.FirstOrDefault(static claim =>
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
}

View File

@@ -0,0 +1,457 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
public static class SignalsCompatibilityEndpoints
{
private static readonly SignalRecord[] SeedSignals =
[
new(
"sig-001",
"ci_build",
"gitea",
"completed",
new Dictionary<string, object?>
{
["host"] = "build-agent-01",
["runtime"] = "ebpf",
["probeStatus"] = "healthy",
["latencyMs"] = 41
},
"corr-001",
"sha256:001",
new[] { "update-runtime-health" },
"2026-03-09T08:10:00Z",
"2026-03-09T08:10:02Z",
null),
new(
"sig-002",
"ci_deploy",
"internal",
"processing",
new Dictionary<string, object?>
{
["host"] = "deploy-stage-02",
["runtime"] = "etw",
["probeStatus"] = "degraded",
["latencyMs"] = 84
},
"corr-002",
"sha256:002",
new[] { "refresh-rollout-state" },
"2026-03-09T08:12:00Z",
null,
null),
new(
"sig-003",
"registry_push",
"harbor",
"failed",
new Dictionary<string, object?>
{
["host"] = "registry-sync-01",
["runtime"] = "dyld",
["probeStatus"] = "failed",
["latencyMs"] = 132
},
"corr-003",
"sha256:003",
new[] { "retry-mirror" },
"2026-03-09T08:13:00Z",
"2026-03-09T08:13:05Z",
"Registry callback timed out."),
new(
"sig-004",
"scan_complete",
"internal",
"completed",
new Dictionary<string, object?>
{
["host"] = "scanner-03",
["runtime"] = "ebpf",
["probeStatus"] = "healthy",
["latencyMs"] = 58
},
"corr-004",
"sha256:004",
new[] { "refresh-risk-snapshot" },
"2026-03-09T08:16:00Z",
"2026-03-09T08:16:01Z",
null),
new(
"sig-005",
"policy_eval",
"internal",
"received",
new Dictionary<string, object?>
{
["host"] = "policy-runner-01",
["runtime"] = "unknown",
["probeStatus"] = "degraded",
["latencyMs"] = 73
},
"corr-005",
"sha256:005",
new[] { "await-policy-evaluation" },
"2026-03-09T08:18:00Z",
null,
null),
new(
"sig-006",
"scm_push",
"github",
"completed",
new Dictionary<string, object?>
{
["host"] = "webhook-ingress-01",
["branch"] = "main",
["commitCount"] = 3,
["latencyMs"] = 22
},
"corr-006",
"sha256:006",
new[] { "trigger-ci-build" },
"2026-03-09T08:20:00Z",
"2026-03-09T08:20:01Z",
null),
new(
"sig-007",
"scm_pr",
"gitlab",
"completed",
new Dictionary<string, object?>
{
["host"] = "webhook-ingress-02",
["action"] = "merged",
["targetBranch"] = "release/1.4",
["latencyMs"] = 35
},
"corr-007",
"sha256:007",
new[] { "trigger-release-pipeline" },
"2026-03-09T08:22:00Z",
"2026-03-09T08:22:02Z",
null)
];
private static readonly TriggerRecord[] SeedTriggers =
[
new("trg-001", "CI Build Gate", "ci_build", "status == 'failed'", "notify-team", true, "2026-03-09T08:14:00Z", 42),
new("trg-002", "Registry Push Mirror", "registry_push", "provider == 'harbor'", "sync-mirror", true, "2026-03-09T08:13:05Z", 18),
new("trg-003", "Policy Eval Alert", "policy_eval", "payload.decision == 'deny'", "block-release", true, null, 0),
new("trg-004", "SCM Push CI Trigger", "scm_push", "branch == 'main'", "trigger-ci-build", true, "2026-03-09T08:20:00Z", 127)
];
public static IEndpointRouteBuilder MapSignalsCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/signals")
.WithTags("Signals Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.SignalsRead))
.RequireTenant();
// GET /api/v1/signals
group.MapGet("", (
[FromQuery] string? type,
[FromQuery] string? status,
[FromQuery] string? provider,
[FromQuery] int? limit,
[FromQuery] string? cursor) =>
{
var filtered = ApplyFilters(type, status, provider);
var offset = ParseCursor(cursor);
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
var items = filtered.Skip(offset).Take(pageSize).ToArray();
var nextCursor = offset + pageSize < filtered.Length
? (offset + pageSize).ToString(CultureInfo.InvariantCulture)
: null;
return Results.Ok(new
{
items,
total = filtered.Length,
cursor = nextCursor
});
})
.WithName("SignalsCompatibility.List");
// GET /api/v1/signals/stats
group.MapGet("/stats", () => Results.Ok(BuildStats(SeedSignals)))
.WithName("SignalsCompatibility.Stats");
// GET /api/v1/signals/triggers
group.MapGet("/triggers", () => Results.Ok(SeedTriggers))
.WithName("SignalsCompatibility.ListTriggers");
// POST /api/v1/signals/triggers
group.MapPost("/triggers", (TriggerCreateRequest request) =>
{
var id = $"trg-{Guid.NewGuid().ToString("N")[..6]}";
var trigger = new TriggerRecord(
id,
request.Name ?? "New Trigger",
request.SignalType ?? "ci_build",
request.Condition ?? "true",
request.Action ?? "notify",
request.Enabled ?? true,
null,
0);
return Results.Ok(trigger);
})
.WithName("SignalsCompatibility.CreateTrigger");
// PUT /api/v1/signals/triggers/{id}
group.MapPut("/triggers/{id}", (string id, TriggerCreateRequest request) =>
{
var existing = SeedTriggers.FirstOrDefault(t =>
string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
var trigger = new TriggerRecord(
id,
request.Name ?? existing?.Name ?? "Updated Trigger",
request.SignalType ?? existing?.SignalType ?? "ci_build",
request.Condition ?? existing?.Condition ?? "true",
request.Action ?? existing?.Action ?? "notify",
request.Enabled ?? existing?.Enabled ?? true,
existing?.LastTriggered,
existing?.TriggerCount ?? 0);
return Results.Ok(trigger);
})
.WithName("SignalsCompatibility.UpdateTrigger");
// DELETE /api/v1/signals/triggers/{id}
group.MapDelete("/triggers/{id}", (string id) => Results.NoContent())
.WithName("SignalsCompatibility.DeleteTrigger");
// PATCH /api/v1/signals/triggers/{id}
group.MapPatch("/triggers/{id}", (string id, TriggerToggleRequest request) =>
{
var existing = SeedTriggers.FirstOrDefault(t =>
string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
var trigger = new TriggerRecord(
id,
existing?.Name ?? "Trigger",
existing?.SignalType ?? "ci_build",
existing?.Condition ?? "true",
existing?.Action ?? "notify",
request.Enabled,
existing?.LastTriggered,
existing?.TriggerCount ?? 0);
return Results.Ok(trigger);
})
.WithName("SignalsCompatibility.ToggleTrigger");
// GET /api/v1/signals/reachability/facts
group.MapGet("/reachability/facts", (
[FromQuery] string? tenantId,
[FromQuery] string? projectId,
[FromQuery] string? assetId,
[FromQuery] string? component,
[FromQuery] string? traceId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
facts = new[]
{
new
{
component = component ?? "org.apache.logging.log4j:log4j-core",
status = "reachable",
confidence = 0.92,
callDepth = (int?)3,
function = (string?)"org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
signalsVersion = "1.4.0",
observedAt = now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture),
evidenceTraceIds = new[] { "trace-a1b2c3", "trace-d4e5f6" }
},
new
{
component = component ?? "com.fasterxml.jackson.databind:jackson-databind",
status = "unreachable",
confidence = 0.87,
callDepth = (int?)null,
function = (string?)null,
signalsVersion = "1.4.0",
observedAt = now.AddMinutes(-8).ToString("O", CultureInfo.InvariantCulture),
evidenceTraceIds = new[] { "trace-g7h8i9" }
}
}
});
})
.WithName("SignalsCompatibility.GetReachabilityFacts");
// GET /api/v1/signals/reachability/call-graphs
group.MapGet("/reachability/call-graphs", (
[FromQuery] string? tenantId,
[FromQuery] string? projectId,
[FromQuery] string? assetId,
[FromQuery] string? component,
[FromQuery] string? traceId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
paths = new[]
{
new
{
id = "path-001",
source = "com.example.app.Main",
target = "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
lastObserved = now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture),
hops = new[]
{
new
{
service = "api-gateway",
endpoint = "/api/v1/process",
timestamp = now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture)
},
new
{
service = "order-service",
endpoint = "OrderProcessor.handle",
timestamp = now.AddMinutes(-12).AddSeconds(1).ToString("O", CultureInfo.InvariantCulture)
},
new
{
service = "logging-framework",
endpoint = "JndiLookup.lookup",
timestamp = now.AddMinutes(-12).AddSeconds(2).ToString("O", CultureInfo.InvariantCulture)
}
},
evidence = new
{
score = 0.92,
traceId = "trace-a1b2c3"
}
}
}
});
})
.WithName("SignalsCompatibility.GetCallGraphs");
// GET /api/v1/signals/{id}
group.MapGet("/{id}", (string id) =>
{
var signal = SeedSignals.FirstOrDefault(s =>
string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
return signal is not null
? Results.Ok(signal)
: Results.NotFound(new { error = "signal_not_found", id });
})
.WithName("SignalsCompatibility.GetDetail");
// POST /api/v1/signals/{id}/retry
group.MapPost("/{id}/retry", (string id, TimeProvider timeProvider) =>
{
var existing = SeedSignals.FirstOrDefault(s =>
string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
if (existing is null)
{
return Results.NotFound(new { error = "signal_not_found", id });
}
var retried = existing with
{
Status = "processing",
ProcessedAt = null,
Error = null
};
return Results.Ok(retried);
})
.WithName("SignalsCompatibility.Retry");
return app;
}
private static SignalRecord[] ApplyFilters(string? type, string? status, string? provider) =>
SeedSignals
.Where(s => string.IsNullOrWhiteSpace(type) || string.Equals(s.Type, type, StringComparison.OrdinalIgnoreCase))
.Where(s => string.IsNullOrWhiteSpace(status) || string.Equals(s.Status, status, StringComparison.OrdinalIgnoreCase))
.Where(s => string.IsNullOrWhiteSpace(provider) || string.Equals(s.Provider, provider, StringComparison.OrdinalIgnoreCase))
.ToArray();
private static int ParseCursor(string? cursor) =>
int.TryParse(cursor, out var offset) && offset >= 0 ? offset : 0;
private static object BuildStats(IReadOnlyCollection<SignalRecord> signals)
{
var byType = signals
.GroupBy(s => s.Type, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var byStatus = signals
.GroupBy(s => s.Status, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var byProvider = signals
.GroupBy(s => s.Provider, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var successful = signals.Count(s =>
string.Equals(s.Status, "completed", StringComparison.OrdinalIgnoreCase));
var latencySamples = signals
.Select(s => s.Payload.TryGetValue("latencyMs", out var v) ? v : null)
.OfType<int>()
.ToArray();
return new
{
total = signals.Count,
byType,
byStatus,
byProvider,
lastHourCount = signals.Count,
successRate = signals.Count == 0 ? 100.0 : Math.Round((successful / (double)signals.Count) * 100, 2),
avgProcessingMs = latencySamples.Length == 0 ? 0.0 : Math.Round(latencySamples.Average(), 2)
};
}
private sealed record SignalRecord(
string Id,
string Type,
string Provider,
string Status,
IReadOnlyDictionary<string, object?> Payload,
string? CorrelationId,
string? ArtifactRef,
IReadOnlyCollection<string> TriggeredActions,
string ReceivedAt,
string? ProcessedAt,
string? Error);
private sealed record TriggerRecord(
string Id,
string Name,
string SignalType,
string Condition,
string Action,
bool Enabled,
string? LastTriggered,
int TriggerCount);
private sealed record TriggerCreateRequest(
string? Name,
string? SignalType,
string? Condition,
string? Action,
bool? Enabled);
private sealed record TriggerToggleRequest(bool Enabled);
}

View File

@@ -177,7 +177,7 @@ public sealed class PlatformEnvironmentSettingsOptions
public string RedirectUri { get; set; } = string.Empty;
public string? SilentRefreshRedirectUri { get; set; }
public string? PostLogoutRedirectUri { get; set; }
public string Scope { get; set; } = "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit";
public string Scope { get; set; } = "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit signer:read signer:sign signer:rotate signer:admin trust:read trust:write trust:admin";
public string? Audience { get; set; }
public List<string> DpopAlgorithms { get; set; } = new() { "ES256" };
public int RefreshLeewaySeconds { get; set; } = 60;

View File

@@ -338,6 +338,9 @@ app.MapLegacyAliasEndpoints();
app.MapPackAdapterEndpoints();
app.MapConsoleCompatibilityEndpoints();
app.MapAocCompatibilityEndpoints();
app.MapNotifyCompatibilityEndpoints();
app.MapSignalsCompatibilityEndpoints();
app.MapQuotaCompatibilityEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();