Files
git.stella-ops.org/src/Notify/StellaOps.Notify.WebService/Program.cs
StellaOps Bot e923880694
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
feat: Add DigestUpsertRequest and LockEntity models
- Introduced DigestUpsertRequest for handling digest upsert requests with properties like ChannelId, Recipient, DigestKey, Events, and CollectUntil.
- Created LockEntity to represent a lightweight distributed lock entry with properties such as Id, TenantId, Resource, Owner, ExpiresAt, and CreatedAt.

feat: Implement ILockRepository interface and LockRepository class

- Defined ILockRepository interface with methods for acquiring and releasing locks.
- Implemented LockRepository class with methods to try acquiring a lock and releasing it, using SQL for upsert operations.

feat: Add SurfaceManifestPointer record for manifest pointers

- Introduced SurfaceManifestPointer to represent a minimal pointer to a Surface.FS manifest associated with an image digest.

feat: Create PolicySimulationInputLock and related validation logic

- Added PolicySimulationInputLock record to describe policy simulation inputs and expected digests.
- Implemented validation logic for policy simulation inputs, including checks for digest drift and shadow mode requirements.

test: Add unit tests for ReplayVerificationService and ReplayVerifier

- Created ReplayVerificationServiceTests to validate the behavior of the ReplayVerificationService under various scenarios.
- Developed ReplayVerifierTests to ensure the correctness of the ReplayVerifier logic.

test: Implement PolicySimulationInputLockValidatorTests

- Added tests for PolicySimulationInputLockValidator to verify the validation logic against expected inputs and conditions.

chore: Add cosign key example and signing scripts

- Included a placeholder cosign key example for development purposes.
- Added a script for signing Signals artifacts using cosign with support for both v2 and v3.

chore: Create script for uploading evidence to the evidence locker

- Developed a script to upload evidence to the evidence locker, ensuring required environment variables are set.
2025-12-03 07:51:50 +02:00

1407 lines
53 KiB
C#

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Globalization;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Postgres;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.WebService.Diagnostics;
using StellaOps.Notify.WebService.Extensions;
using StellaOps.Notify.WebService.Hosting;
using StellaOps.Notify.WebService.Options;
using StellaOps.Notify.WebService.Plugins;
using StellaOps.Notify.WebService.Security;
using StellaOps.Notify.WebService.Services;
using StellaOps.Notify.WebService.Internal;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Notify.WebService.Contracts;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "NOTIFY_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml"));
};
});
var contentRootPath = builder.Environment.ContentRootPath;
var bootstrapOptions = builder.Configuration.BindOptions<NotifyWebServiceOptions>(
NotifyWebServiceOptions.SectionName,
(opts, _) =>
{
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
NotifyWebServiceOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<NotifyWebServiceOptions>()
.Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName))
.PostConfigure(options =>
{
NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath);
NotifyWebServiceOptionsValidator.Validate(options);
})
.ValidateOnStart();
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
loggerConfiguration
.MinimumLevel.Is(minimumLevel)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ServiceStatus>();
builder.Services.AddSingleton<NotifySchemaMigrationService>();
// PostgreSQL is the canonical Notify storage; enable Postgres-backed repositories.
builder.Services.AddNotifyPostgresStorage(builder.Configuration, sectionName: "Postgres:Notify");
var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath);
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureRateLimiting(builder, bootstrapOptions);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
ConfigureRequestPipeline(app, bootstrapOptions);
ConfigureEndpoints(app);
await app.RunAsync();
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options)
{
if (options.Authority.Enabled)
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = options.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = options.Authority.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds);
resourceOptions.Audiences.Clear();
foreach (var audience in options.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
});
builder.Services.AddAuthorization(auth =>
{
auth.AddStellaOpsScopePolicy(NotifyPolicies.Viewer, options.Authority.ViewerScope);
auth.AddPolicy(
NotifyPolicies.Operator,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
HasScope(ctx.User, options.Authority.OperatorScope) ||
HasScope(ctx.User, options.Authority.AdminScope)));
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
});
}
else
{
if (options.Authority.AllowAnonymousFallback)
{
builder.Services.AddAuthentication(authOptions =>
{
authOptions.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName;
authOptions.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
AllowAllAuthenticationHandler.SchemeName,
static _ => { });
builder.Services.AddAuthorization(auth =>
{
auth.AddPolicy(
NotifyPolicies.Viewer,
policy => policy.RequireAssertion(_ => true));
auth.AddPolicy(
NotifyPolicies.Operator,
policy => policy.RequireAssertion(_ => true));
auth.AddPolicy(
NotifyPolicies.Admin,
policy => policy.RequireAssertion(_ => true));
});
}
else
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwt =>
{
jwt.RequireHttpsMetadata = false;
jwt.IncludeErrorDetails = true;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = options.Authority.Issuer,
ValidateAudience = options.Authority.Audiences.Count > 0,
ValidAudiences = options.Authority.Audiences,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
NameClaimType = ClaimTypes.Name
};
});
builder.Services.AddAuthorization(auth =>
{
auth.AddPolicy(
NotifyPolicies.Viewer,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
HasScope(ctx.User, options.Authority.ViewerScope) ||
HasScope(ctx.User, options.Authority.OperatorScope) ||
HasScope(ctx.User, options.Authority.AdminScope)));
auth.AddPolicy(
NotifyPolicies.Operator,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
HasScope(ctx.User, options.Authority.OperatorScope) ||
HasScope(ctx.User, options.Authority.AdminScope)));
auth.AddPolicy(
NotifyPolicies.Admin,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope)));
});
}
}
}
static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var tenantHeader = options.Api.TenantHeader;
var limits = options.Api.RateLimits;
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
rateLimiterOptions.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, limits.DeliveryHistory, tenantHeader, "deliveries");
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, limits.TestSend, tenantHeader, "channel-test");
});
static void ConfigurePolicy(
RateLimiterOptions rateLimiterOptions,
string policyName,
NotifyWebServiceOptions.RateLimitPolicyOptions policy,
string tenantHeader,
string prefix)
{
rateLimiterOptions.AddPolicy(policyName, httpContext =>
{
if (policy is null || !policy.Enabled)
{
return RateLimitPartition.GetNoLimiter("notify-disabled");
}
var identity = ResolveIdentity(httpContext, tenantHeader, prefix);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = policy.TokenLimit,
TokensPerPeriod = policy.TokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds),
QueueLimit = policy.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
});
}
static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix)
{
var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header)
? header.ToString().Trim()
: "anonymous";
var subject = httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.Identity?.Name
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return string.Concat(prefix, ':', tenant, ':', subject);
}
}
static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options)
{
var stopwatch = Stopwatch.StartNew();
try
{
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<INotifyPluginRegistry>();
var count = await registry.WarmupAsync();
stopwatch.Stop();
status.RecordReadyCheck(success: true, stopwatch.Elapsed);
logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message);
logger.LogError(ex, "Failed to initialise Notify WebService.");
throw;
}
}
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options)
{
if (options.Telemetry.EnableRequestLogging)
{
app.UseSerilogRequestLogging(c =>
{
c.IncludeQueryInRequestPath = true;
c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
});
}
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
}
static void ConfigureEndpoints(WebApplication app)
{
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", (ServiceStatus status) =>
{
var snapshot = status.CreateSnapshot();
if (snapshot.Ready.IsReady)
{
return Results.Ok(new
{
status = "ready",
checkedAt = snapshot.Ready.CheckedAt,
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds,
snapshot.StartedAt
});
}
return JsonResponse(
new
{
status = "unready",
snapshot.Ready.Error,
checkedAt = snapshot.Ready.CheckedAt,
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
},
StatusCodes.Status503ServiceUnavailable);
});
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
var tenantHeader = options.Api.TenantHeader;
var apiBasePath = options.Api.BasePath.TrimEnd('/');
var apiGroup = app.MapGroup(options.Api.BasePath);
var internalGroup = app.MapGroup(options.Api.InternalBasePath);
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
.WithName("notify.rules.normalize")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
.WithName("notify.channels.normalize");
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
.WithName("notify.templates.normalize");
apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var rules = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
return JsonResponse(rules.Select(ToNotifyRule));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(ruleId, out var id))
{
return Results.BadRequest(new { error = "ruleId must be a GUID." });
}
var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var ruleModel = service.UpgradeRule(body);
if (!string.Equals(ruleModel.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
if (!TryParseGuid(ruleModel.RuleId, out var ruleGuid))
{
return Results.BadRequest(new { error = "ruleId must be a GUID." });
}
var entity = ToRuleEntity(ruleModel);
var existing = await repository.GetByIdAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", ruleModel.RuleId), ruleModel);
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(ruleId, out var ruleGuid))
{
return Results.BadRequest(new { error = "ruleId must be a GUID." });
}
await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var channels = await repository.GetAllAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
return JsonResponse(channels.Select(ToNotifyChannel));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(channelId, out var id))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var channelModel = service.UpgradeChannel(body);
if (!string.Equals(channelModel.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
if (!TryParseGuid(channelModel.ChannelId, out var channelGuid))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
var entity = ToChannelEntity(channelModel);
var existing = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channelModel.ChannelId), channelModel);
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(channelId, out var channelGuid))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var templates = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
return JsonResponse(templates.Select(ToNotifyTemplate));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(templateId, out var templateGuid))
{
return Results.BadRequest(new { error = "templateId must be a GUID." });
}
var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var templateModel = service.UpgradeTemplate(body);
if (!string.Equals(templateModel.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
if (!TryParseGuid(templateModel.TemplateId, out var templateGuid))
{
return Results.BadRequest(new { error = "templateId must be a GUID." });
}
var entity = ToTemplateEntity(templateModel);
var existing = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", templateModel.TemplateId), templateModel);
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(templateId, out var templateGuid))
{
return Results.BadRequest(new { error = "templateId must be a GUID." });
}
await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
NotifyDelivery delivery;
try
{
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
}
catch (Exception ex)
{
return Results.BadRequest(new { error = $"Invalid delivery payload: {ex.Message}" });
}
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
if (!TryParseGuid(delivery.DeliveryId, out var deliveryId))
{
return Results.BadRequest(new { error = "deliveryId must be a GUID." });
}
if (!TryParseGuid(delivery.ActionId, out var channelId))
{
return Results.BadRequest(new { error = "actionId must be a GUID representing the channel." });
}
if (!TryParseGuid(delivery.RuleId, out var ruleId))
{
return Results.BadRequest(new { error = "ruleId must be a GUID." });
}
var entity = ToDeliveryEntity(delivery, deliveryId, channelId, ruleId, body);
var saved = await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
return CreatedJson(
BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId),
ToDeliveryDetail(saved, channelName: null, channelType: null));
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/deliveries", async (
IDeliveryRepository repository,
IChannelRepository channelRepository,
HttpContext context,
[FromQuery] string? status,
[FromQuery] string? channelId,
[FromQuery] string? eventType,
[FromQuery] DateTimeOffset? since,
[FromQuery] DateTimeOffset? until,
[FromQuery] int? limit,
[FromQuery] int? offset,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
DeliveryStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<DeliveryStatus>(status, ignoreCase: true, out var parsed))
{
return Results.BadRequest(new { error = "Unknown delivery status." });
}
statusFilter = parsed;
}
Guid? channelGuid = null;
if (!string.IsNullOrWhiteSpace(channelId))
{
if (!Guid.TryParse(channelId, out var parsedChannel))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
channelGuid = parsedChannel;
}
var take = Math.Clamp(limit ?? 100, 1, 500);
var skip = Math.Max(0, offset ?? 0);
var deliveries = await repository
.QueryAsync(tenant, statusFilter, channelGuid, eventType, since, until, take, skip, cancellationToken)
.ConfigureAwait(false);
var summaries = new List<object>(deliveries.Count);
foreach (var delivery in deliveries)
{
string? channelName = null;
string? channelType = null;
var channel = await channelRepository.GetByIdAsync(delivery.TenantId, delivery.ChannelId, cancellationToken).ConfigureAwait(false);
if (channel is not null)
{
channelName = channel.Name;
channelType = channel.ChannelType.ToString().ToLowerInvariant();
}
summaries.Add(ToDeliverySummary(delivery, channelName, channelType));
}
var hasMore = deliveries.Count == take;
var nextCursor = hasMore ? (skip + deliveries.Count).ToString(CultureInfo.InvariantCulture) : null;
return JsonResponse(new
{
items = summaries,
count = summaries.Count,
total = summaries.Count,
hasMore,
nextCursor,
continuationToken = nextCursor
});
})
.RequireAuthorization(NotifyPolicies.Viewer)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapGet("/deliveries/{deliveryId}", async (
string deliveryId,
IDeliveryRepository repository,
IChannelRepository channelRepository,
HttpContext context,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
var headerResolved = TryResolveTenant(context, tenantHeader, out var tenantFromHeader, out var error);
var effectiveTenant = tenant ?? tenantFromHeader;
if (effectiveTenant is null && !headerResolved)
{
return error!;
}
if (effectiveTenant is null)
{
return Results.BadRequest(new { error = "Tenant must be provided via header or query string." });
}
if (!TryParseGuid(deliveryId, out var deliveryGuid))
{
return Results.BadRequest(new { error = "deliveryId must be a GUID." });
}
var delivery = await repository.GetByIdAsync(effectiveTenant, deliveryGuid, cancellationToken).ConfigureAwait(false);
if (delivery is null)
{
return Results.NotFound();
}
string? channelName = null;
string? channelType = null;
var channel = await channelRepository.GetByIdAsync(effectiveTenant, delivery.ChannelId, cancellationToken).ConfigureAwait(false);
if (channel is not null)
{
channelName = channel.Name;
channelType = channel.ChannelType.ToString().ToLowerInvariant();
}
return JsonResponse(ToDeliveryDetail(delivery, channelName, channelType));
})
.RequireAuthorization(NotifyPolicies.Viewer)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapPost("/digests", async ([FromBody] DigestUpsertRequest? request, IDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (request is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
if (!TryParseGuid(request.ChannelId, out var channelIdGuid))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
if (string.IsNullOrWhiteSpace(request.Recipient))
{
return Results.BadRequest(new { error = "recipient is required." });
}
if (string.IsNullOrWhiteSpace(request.DigestKey))
{
return Results.BadRequest(new { error = "digestKey is required." });
}
var collectUntil = request.CollectUntil ?? DateTimeOffset.UtcNow.AddHours(1);
var eventsJson = request.Events?.ToJsonString() ?? "[]";
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = tenant,
ChannelId = channelIdGuid,
Recipient = request.Recipient,
DigestKey = request.DigestKey,
EventCount = request.Events?.Count ?? 0,
Events = eventsJson,
Status = DigestStatus.Collecting,
CollectUntil = collectUntil,
SentAt = null,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var saved = await repository.UpsertAsync(digest, cancellationToken).ConfigureAwait(false);
return CreatedJson(
BuildResourceLocation(apiBasePath, "digests", request.DigestKey),
ToDigestResponse(saved));
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/digests/{actionKey}", async (
string actionKey,
[FromQuery] string channelId,
[FromQuery] string recipient,
IDigestRepository repository,
HttpContext context,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(channelId, out var channelGuid))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
if (string.IsNullOrWhiteSpace(recipient))
{
return Results.BadRequest(new { error = "recipient is required." });
}
var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest));
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapDelete("/digests/{actionKey}", async (
string actionKey,
[FromQuery] string channelId,
[FromQuery] string recipient,
IDigestRepository repository,
HttpContext context,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(channelId, out var channelGuid))
{
return Results.BadRequest(new { error = "channelId must be a GUID." });
}
if (string.IsNullOrWhiteSpace(recipient))
{
return Results.BadRequest(new { error = "recipient is required." });
}
var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
return deleted ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var action = body["action"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(action))
{
return Results.BadRequest(new { error = "Action is required." });
}
var entry = new NotifyAuditEntity
{
TenantId = tenant,
UserId = Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) ? userId : null,
Action = action,
ResourceType = body["entityType"]?.GetValue<string>() ?? string.Empty,
ResourceId = body["entityId"]?.GetValue<string>(),
Details = body["payload"]?.ToJsonString(),
CorrelationId = context.TraceIdentifier,
CreatedAt = DateTimeOffset.UtcNow
};
var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false);
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id });
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var results = await repository.ListAsync(tenant, limit ?? 100, offset ?? 0, cancellationToken).ConfigureAwait(false);
var payload = results.Select(a => new
{
a.Id,
a.TenantId,
a.UserId,
a.Action,
a.ResourceType,
a.ResourceId,
a.CorrelationId,
a.CreatedAt,
Details = string.IsNullOrWhiteSpace(a.Details) ? null : JsonNode.Parse(a.Details)
});
return JsonResponse(payload);
})
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken).ConfigureAwait(false);
return JsonResponse(new { acquired });
})
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var released = await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken).ConfigureAwait(false);
return released ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization(NotifyPolicies.Operator);
}
static bool TryParseGuid(string value, out Guid guid) => Guid.TryParse(value, out guid);
static RuleEntity ToRuleEntity(NotifyRule rule)
{
var channelIds = rule.Actions.Select(action => ParseRequiredGuid(action.Channel, "channel"))
.ToArray();
var templateId = rule.Actions
.Select(action => action.Template)
.Where(id => id is not null)
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : (Guid?)null)
.FirstOrDefault();
return new RuleEntity
{
Id = ParseRequiredGuid(rule.RuleId, "ruleId"),
TenantId = rule.TenantId,
Name = rule.Name,
Description = rule.Description ?? string.Empty,
Enabled = rule.Enabled,
Priority = 0,
EventTypes = rule.Match.EventKinds.ToArray(),
Filter = NotifyCanonicalJsonSerializer.Serialize(rule.Match),
ChannelIds = channelIds,
TemplateId = templateId,
Metadata = NotifyCanonicalJsonSerializer.Serialize(rule),
CreatedAt = rule.CreatedAt,
UpdatedAt = rule.UpdatedAt
};
}
static NotifyRule ToNotifyRule(RuleEntity entity)
{
var match = string.IsNullOrWhiteSpace(entity.Filter)
? NotifyRuleMatch.Create()
: NotifyCanonicalJsonSerializer.Deserialize<NotifyRuleMatch>(entity.Filter);
var actions = entity.ChannelIds.Select((channelId, index) =>
NotifyRuleAction.Create(
actionId: $"action-{index + 1}",
channel: channelId.ToString(),
template: entity.TemplateId?.ToString()))
.ToImmutableArray();
var metadata = DeserializeMetadata(entity.Metadata);
return NotifyRule.Create(
entity.Id.ToString(),
entity.TenantId,
entity.Name,
match,
actions,
enabled: entity.Enabled,
description: entity.Description,
metadata: metadata,
createdAt: entity.CreatedAt,
updatedAt: entity.UpdatedAt);
}
static ChannelEntity ToChannelEntity(NotifyChannel channel)
{
return new ChannelEntity
{
Id = ParseRequiredGuid(channel.ChannelId, "channelId"),
TenantId = channel.TenantId,
Name = channel.Name,
ChannelType = ToStorageChannelType(channel.Type),
Enabled = channel.Enabled,
Config = NotifyCanonicalJsonSerializer.Serialize(channel.Config),
Metadata = NotifyCanonicalJsonSerializer.Serialize(channel),
CreatedAt = channel.CreatedAt,
UpdatedAt = channel.UpdatedAt,
CreatedBy = channel.CreatedBy
};
}
static NotifyChannel ToNotifyChannel(ChannelEntity entity)
{
var config = string.IsNullOrWhiteSpace(entity.Config)
? NotifyChannelConfig.Create("inline-default")
: NotifyCanonicalJsonSerializer.Deserialize<NotifyChannelConfig>(entity.Config);
var metadataModel = TryDeserialize<NotifyChannel>(entity.Metadata);
return metadataModel ??
NotifyChannel.Create(
entity.Id.ToString(),
entity.TenantId,
entity.Name,
ToModelChannelType(entity.ChannelType),
config,
enabled: entity.Enabled,
createdBy: entity.CreatedBy,
createdAt: entity.CreatedAt,
updatedAt: entity.UpdatedAt);
}
static TemplateEntity ToTemplateEntity(NotifyTemplate template)
{
return new TemplateEntity
{
Id = ParseRequiredGuid(template.TemplateId, "templateId"),
TenantId = template.TenantId,
Name = template.Key,
ChannelType = ToStorageChannelType(template.ChannelType),
BodyTemplate = template.Body,
Locale = template.Locale,
Metadata = NotifyCanonicalJsonSerializer.Serialize(template),
CreatedAt = template.CreatedAt,
UpdatedAt = template.UpdatedAt,
SubjectTemplate = template.Description
};
}
static NotifyTemplate ToNotifyTemplate(TemplateEntity entity)
{
var metadataModel = TryDeserialize<NotifyTemplate>(entity.Metadata);
if (metadataModel is not null)
{
return metadataModel;
}
return NotifyTemplate.Create(
entity.Id.ToString(),
entity.TenantId,
ToModelChannelType(entity.ChannelType),
entity.Name,
entity.Locale,
entity.BodyTemplate,
description: entity.SubjectTemplate,
metadata: DeserializeMetadata(entity.Metadata),
createdAt: entity.CreatedAt,
updatedAt: entity.UpdatedAt);
}
static ChannelType ToStorageChannelType(NotifyChannelType type) => type switch
{
NotifyChannelType.Email => ChannelType.Email,
NotifyChannelType.Slack => ChannelType.Slack,
NotifyChannelType.Teams => ChannelType.Teams,
NotifyChannelType.Webhook => ChannelType.Webhook,
NotifyChannelType.PagerDuty => ChannelType.PagerDuty,
NotifyChannelType.OpsGenie => ChannelType.OpsGenie,
_ => ChannelType.Webhook
};
static NotifyChannelType ToModelChannelType(ChannelType type) => type switch
{
ChannelType.Email => NotifyChannelType.Email,
ChannelType.Slack => NotifyChannelType.Slack,
ChannelType.Teams => NotifyChannelType.Teams,
ChannelType.Webhook => NotifyChannelType.Webhook,
ChannelType.PagerDuty => NotifyChannelType.PagerDuty,
ChannelType.OpsGenie => NotifyChannelType.OpsGenie,
_ => NotifyChannelType.Webhook
};
static Guid ParseRequiredGuid(string value, string field)
{
if (Guid.TryParse(value, out var guid))
{
return guid;
}
throw new InvalidOperationException($"{field} must be a GUID.");
}
static ImmutableDictionary<string, string>? DeserializeMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var dictionary = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
return dictionary.ToImmutableDictionary(StringComparer.Ordinal);
}
catch
{
return null;
}
}
static DeliveryEntity ToDeliveryEntity(NotifyDelivery delivery, Guid deliveryId, Guid channelId, Guid ruleId, JsonNode rawPayload)
{
var correlationId = delivery.Metadata.TryGetValue("traceId", out var trace) ? trace : null;
var recipient = delivery.Rendered?.Target;
if (string.IsNullOrWhiteSpace(recipient) && delivery.Metadata.TryGetValue("target", out var target))
{
recipient = target;
}
if (string.IsNullOrWhiteSpace(recipient))
{
recipient = "unknown";
}
return new DeliveryEntity
{
Id = deliveryId,
TenantId = delivery.TenantId,
ChannelId = channelId,
RuleId = ruleId,
TemplateId = null,
Status = ToStorageDeliveryStatus(delivery.Status),
Recipient = recipient,
Subject = delivery.Rendered?.Title,
Body = delivery.Rendered?.Body,
EventType = delivery.Kind,
EventPayload = rawPayload.ToJsonString(),
Attempt = delivery.Attempts.Length,
MaxAttempts = 3,
NextRetryAt = null,
ErrorMessage = delivery.StatusReason,
ExternalId = null,
CorrelationId = correlationId,
CreatedAt = delivery.CreatedAt,
QueuedAt = null,
SentAt = delivery.SentAt,
DeliveredAt = delivery.CompletedAt,
FailedAt = delivery.Status == NotifyDeliveryStatus.Failed ? delivery.CompletedAt : null
};
}
static object ToDeliverySummary(DeliveryEntity entity, string? channelName, string? channelType)
=> new
{
deliveryId = entity.Id.ToString(),
channelId = entity.ChannelId.ToString(),
channelName,
channelType,
eventType = entity.EventType,
status = DeliveryStatusToString(entity.Status),
attemptCount = entity.Attempt,
createdAt = entity.CreatedAt,
sentAt = entity.SentAt,
latencyMs = entity.SentAt is { } sent ? (long?)(sent - entity.CreatedAt).TotalMilliseconds : null
};
static object ToDeliveryDetail(DeliveryEntity entity, string? channelName, string? channelType)
=> new
{
deliveryId = entity.Id.ToString(),
tenantId = entity.TenantId,
channelId = entity.ChannelId.ToString(),
channelName,
channelType,
ruleId = entity.RuleId?.ToString(),
eventType = entity.EventType,
status = DeliveryStatusToString(entity.Status),
subject = entity.Subject,
attemptCount = entity.Attempt,
attempts = Array.Empty<object>(),
createdAt = entity.CreatedAt,
sentAt = entity.SentAt,
failedAt = entity.FailedAt,
errorMessage = entity.ErrorMessage,
idempotencyKey = entity.CorrelationId
};
static string DeliveryStatusToString(DeliveryStatus status) => status.ToString().ToLowerInvariant();
static DeliveryStatus ToStorageDeliveryStatus(NotifyDeliveryStatus status) => status switch
{
NotifyDeliveryStatus.Pending => DeliveryStatus.Pending,
NotifyDeliveryStatus.Sent => DeliveryStatus.Sent,
NotifyDeliveryStatus.Failed => DeliveryStatus.Failed,
NotifyDeliveryStatus.Throttled => DeliveryStatus.Queued,
NotifyDeliveryStatus.Digested => DeliveryStatus.Delivered,
NotifyDeliveryStatus.Dropped => DeliveryStatus.Failed,
_ => DeliveryStatus.Pending
};
static object ToDigestResponse(DigestEntity entity) => new
{
id = entity.Id,
tenantId = entity.TenantId,
channelId = entity.ChannelId,
recipient = entity.Recipient,
digestKey = entity.DigestKey,
eventCount = entity.EventCount,
events = string.IsNullOrWhiteSpace(entity.Events) ? new JsonArray() : JsonNode.Parse(entity.Events),
status = entity.Status,
collectUntil = entity.CollectUntil,
sentAt = entity.SentAt,
createdAt = entity.CreatedAt,
updatedAt = entity.UpdatedAt
};
static T? TryDeserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return NotifyCanonicalJsonSerializer.Deserialize<T>(json);
}
catch
{
return default;
}
}
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
{
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
{
tenant = string.Empty;
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
return false;
}
tenant = header.ToString().Trim();
error = null;
return true;
}
static string BuildResourceLocation(string basePath, params string[] segments)
{
if (segments.Length == 0)
{
return basePath;
}
var builder = new StringBuilder(basePath);
foreach (var segment in segments)
{
builder.Append('/');
builder.Append(Uri.EscapeDataString(segment));
}
return builder.ToString();
}
static IResult JsonResponse<T>(T value, int statusCode = StatusCodes.Status200OK, string? location = null)
{
var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new JsonHttpResult(payload, statusCode, location);
}
static IResult CreatedJson<T>(string location, T value)
=> JsonResponse(value, StatusCodes.Status201Created, location);
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
{
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
try
{
var model = upgrade(body);
var json = NotifyCanonicalJsonSerializer.Serialize(model);
return Results.Content(json, "application/json");
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
static bool HasScope(ClaimsPrincipal principal, string scope)
{
if (principal is null || string.IsNullOrWhiteSpace(scope))
{
return false;
}
foreach (var claim in principal.Claims)
{
if (!string.Equals(claim.Type, "scope", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(claim.Type, "http://schemas.microsoft.com/identity/claims/scope", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var values = claim.Value.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var value in values)
{
if (string.Equals(value, scope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
static LogEventLevel MapLogLevel(string configuredLevel)
{
return configuredLevel?.ToLowerInvariant() switch
{
"verbose" => LogEventLevel.Verbose,
"debug" => LogEventLevel.Debug,
"warning" => LogEventLevel.Warning,
"error" => LogEventLevel.Error,
"fatal" => LogEventLevel.Fatal,
_ => LogEventLevel.Information
};
}