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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -338,6 +338,9 @@ app.MapLegacyAliasEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
app.MapConsoleCompatibilityEndpoints();
|
||||
app.MapAocCompatibilityEndpoints();
|
||||
app.MapNotifyCompatibilityEndpoints();
|
||||
app.MapSignalsCompatibilityEndpoints();
|
||||
app.MapQuotaCompatibilityEndpoints();
|
||||
app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
app.MapSeedEndpoints();
|
||||
|
||||
Reference in New Issue
Block a user