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
- 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.
1407 lines
53 KiB
C#
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
|
|
};
|
|
}
|