Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,850 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
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.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 StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.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.Notify.WebService.Storage.InMemory;
using StellaOps.Plugin.DependencyInjection;
using MongoDB.Bson;
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>();
if (string.Equals(bootstrapOptions.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddNotifyMongoStorage(builder.Configuration.GetSection("notify:storage"));
}
else
{
builder.Services.AddInMemoryNotifyStorage();
}
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.Read, options.Authority.ReadScope);
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
});
}
else
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwt =>
{
jwt.RequireHttpsMetadata = false;
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.Read,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
HasScope(ctx.User, options.Authority.ReadScope) ||
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();
if (string.Equals(options.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
await RunMongoMigrationsAsync(scope.ServiceProvider);
}
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 async Task RunMongoMigrationsAsync(IServiceProvider services)
{
var initializerType = Type.GetType("StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo");
if (initializerType is null)
{
return;
}
var initializer = services.GetService(initializerType);
if (initializer is null)
{
return;
}
var method = initializerType.GetMethod("EnsureIndexesAsync", new[] { typeof(CancellationToken) });
if (method is null)
{
return;
}
if (method.Invoke(initializer, new object[] { CancellationToken.None }) is Task task)
{
await task.ConfigureAwait(false);
}
}
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 ([FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var rules = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(rules);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, [FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var rule = await repository.GetAsync(tenant, ruleId, cancellationToken);
return rule is null ? Results.NotFound() : JsonResponse(rule);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, INotifyRuleRepository 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 rule = service.UpgradeRule(body);
if (!string.Equals(rule.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(rule, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", rule.RuleId), rule);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, ruleId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/channels", async (INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var channels = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(channels);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, INotifyChannelRepository 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 channel = service.UpgradeChannel(body);
if (!string.Equals(channel.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(channel, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channel.ChannelId), channel);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/channels/{channelId}/test", async (string channelId, [FromBody] ChannelTestSendRequest? request, INotifyChannelRepository repository, INotifyChannelTestService testService, 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." });
}
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
if (channel is null)
{
return Results.NotFound();
}
try
{
var response = await testService.SendAsync(tenant, channel, request, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
return JsonResponse(response, StatusCodes.Status202Accepted);
}
catch (ChannelTestSendValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.RequireAuthorization(NotifyPolicies.Admin)
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
apiGroup.MapGet("/channels/{channelId}/health", async (string channelId, INotifyChannelRepository repository, INotifyChannelHealthService healthService, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
if (channel is null)
{
return Results.NotFound();
}
var response = await healthService.CheckAsync(tenant, channel, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
return JsonResponse(response);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, channelId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/templates", async (INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var templates = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(templates);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, INotifyTemplateRepository 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 template = service.UpgradeTemplate(body);
if (!string.Equals(template.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(template, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", template.TemplateId), template);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, templateId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/deliveries", async (JsonNode? body, INotifyDeliveryRepository 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 delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpdateAsync(delivery, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId), delivery);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/deliveries", async ([FromServices] INotifyDeliveryRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] string? status, [FromQuery] int? limit, [FromQuery] string? continuationToken, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var effectiveLimit = NormalizeLimit(limit);
var result = await repository.QueryAsync(tenant, since, status, effectiveLimit, continuationToken, cancellationToken).ConfigureAwait(false);
var payload = new
{
items = result.Items,
continuationToken = result.ContinuationToken,
count = result.Items.Count
};
return JsonResponse(payload);
})
.RequireAuthorization(NotifyPolicies.Read)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapGet("/deliveries/{deliveryId}", async (string deliveryId, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var delivery = await repository.GetAsync(tenant, deliveryId, cancellationToken);
return delivery is null ? Results.NotFound() : JsonResponse(delivery);
})
.RequireAuthorization(NotifyPolicies.Read)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapPost("/digests", async ([FromBody] NotifyDigestDocument payload, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!string.Equals(payload.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(payload, cancellationToken);
return Results.Ok();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var digest = await repository.GetAsync(tenant, actionKey, cancellationToken);
return digest is null ? Results.NotFound() : JsonResponse(digest);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapDelete("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.RemoveAsync(tenant, actionKey, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, INotifyLockRepository 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);
return JsonResponse(new { acquired });
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
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 NotifyAuditEntryDocument
{
Id = ObjectId.GenerateNewId(),
TenantId = tenant,
Action = action,
Actor = user.Identity?.Name ?? "unknown",
EntityId = body["entityId"]?.GetValue<string>() ?? string.Empty,
EntityType = body["entityType"]?.GetValue<string>() ?? string.Empty,
Timestamp = DateTimeOffset.UtcNow,
Payload = body["payload"] is JsonObject payloadObj
? BsonDocument.Parse(payloadObj.ToJsonString())
: new BsonDocument()
};
await repository.AppendAsync(entry, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", entry.Id.ToString()), new { entry.Id });
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] int? limit, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var entries = await repository.QueryAsync(tenant, since, limit, cancellationToken);
var response = entries.Select(e => new
{
e.Id,
e.TenantId,
e.Actor,
e.Action,
e.EntityId,
e.EntityType,
e.Timestamp,
Payload = JsonNode.Parse(e.Payload.ToJson())
});
return JsonResponse(response);
})
.RequireAuthorization(NotifyPolicies.Read);
}
static int NormalizeLimit(int? value)
{
if (value is null || value <= 0)
{
return 50;
}
return Math.Min(value.Value, 200);
}
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.FindAll("scope"))
{
if (string.Equals(claim.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
};
}