using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; using Bff.Api; using Microsoft.AspNetCore.Authentication.JwtBearer; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); // OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and // outgoing HttpClient calls (BFF → Domain, BFF → projection-api), exported over OTLP to Tempo, so a // portal request is one connected trace across the services. Service name + OTLP endpoint come from // OTEL_* env (compose); the exporter no-ops when Tempo is unreachable. /health is filtered out. builder.Services.AddOpenTelemetry() .ConfigureResource(r => r.AddService( builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName)) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health") .AddHttpClientInstrumentation() .AddOtlpExporter()) // OpenTelemetry metrics (S-16c, ADR-0023): the golden signals for the request path — // http.server.request.duration (traffic/errors/latency) + http.client.* for the downstream hops, // plus the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes // these from /metrics (mapped below); no OTLP push for metrics, so no collector hop (ADR-0023). .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddMeter("System.Runtime") .AddPrometheusExporter()); var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); // Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid), // so the BFF validates a second issuer for the behandel endpoints (ADR-0013). var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"] ?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'"); var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'"); // The beheer portal's read-only catalogus view reaches the ACL directly (ADR-0025): the catalogus is // not a domain concern, and only the ACL may read the ZGW Catalogi API (§8.1). var aclBaseUrl = builder.Configuration["Downstream:Acl:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Acl:BaseUrl'"); // Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton — // Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated. builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = keycloakAuthority; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; }) // The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles // (the nested realm_access.roles claim) into role claims so authorization policies can require them. .AddJwtBearer(BehandelAuth.Scheme, options => { options.Authority = medewerkerAuthority; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; options.Events = new JwtBearerEvents { OnTokenValidated = context => { BehandelAuth.AddRealmRoles(context.Principal); return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(options => { options.AddPolicy(BehandelAuth.Policy, policy => policy .AddAuthenticationSchemes(BehandelAuth.Scheme) .RequireAuthenticatedUser() .RequireRole(BehandelAuth.BehandelaarRole)); // Beheer endpoints reuse the medewerker scheme (same realm, same realm-role lifting) but require the // beheerder role rather than behandelaar (S-15a). options.AddPolicy(BeheerAuth.Policy, policy => policy .AddAuthenticationSchemes(BehandelAuth.Scheme) .RequireAuthenticatedUser() .RequireRole(BeheerAuth.BeheerderRole)); }); // The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches // the ACL for the beheer catalogus read (ADR-0025). builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(domainBaseUrl)); builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(projectionBaseUrl)); builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(aclBaseUrl)); builder.Services.AddHealthChecks(); // Clear the auto-populated `servers` block so the committed spec is stable regardless of the host // the doc was generated from (the client sets its own base URL). Keeps the drift guard deterministic. builder.Services.AddOpenApi(options => options.AddDocumentTransformer((document, _, _) => { document.Servers?.Clear(); return Task.CompletedTask; })); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapHealthChecks("/health"); // Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format. app.MapPrometheusScrapingEndpoint(); app.MapOpenApi(); // Self-service submit: requires a valid digid token; the bsn comes from the token, not the body, // and is forwarded to the domain (ADR-0010). Returns 202 — the zaak is opened asynchronously (S-05). app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); if (string.IsNullOrWhiteSpace(bsn)) return Results.BadRequest("The token carries no bsn claim."); var accepted = await domain.SubmitRegistrationAsync(bsn, ct); return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted); }) .RequireAuthorization() .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized); // Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the // portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token; // 204 when the citizen has none in flight (so the portal shows the submit form). app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); if (string.IsNullOrWhiteSpace(bsn)) return Results.BadRequest("The token carries no bsn claim."); var current = await domain.GetCurrentRegistrationAsync(bsn, ct); return current is null ? Results.NoContent() : Results.Ok(current); }) .RequireAuthorization() .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized); // Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration. // The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action; // a registration that is unknown or not the caller's comes back 404 (ownership is not revealed). app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); if (string.IsNullOrWhiteSpace(bsn)) return Results.BadRequest("The token carries no bsn claim."); var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct); return withdrawn ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); // Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their // registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is // forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a // registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage // is S-10b — this is the trigger that unblocks the process. app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); if (string.IsNullOrWhiteSpace(bsn)) return Results.BadRequest("The token carries no bsn claim."); if (string.IsNullOrWhiteSpace(body?.ContentBase64)) return Results.BadRequest("A document is required."); var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct); return provided ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); // Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09). app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) => { var entries = await projection.GetRegisterAsync(ct); return Results.Ok(OpenbaarProjection.PublicView(entries, q)); }) .Produces>(StatusCodes.Status200OK); // Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm // token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013). app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) => Results.Ok(await domain.GetWerkbakAsync(ct))) .RequireAuthorization(BehandelAuth.Policy) .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); // A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which // applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate. app.MapPost("/behandel/registrations/{id}/decide", async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) => { if (!BehandelAuth.IsKnownBesluit(body.Besluit)) return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." }); await domain.DecideAsync(id, body.Besluit, ct); return Results.NoContent(); }) .RequireAuthorization(BehandelAuth.Policy) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); // Beheer catalogus viewer (S-15a): the published zaaktypen, read-only. Reached only with a medewerker- // realm token carrying the beheerder role; the BFF proxies the ACL's read (ADR-0025). Public-safe. app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToken ct) => Results.Ok(await acl.GetZaaktypenAsync(ct))) .RequireAuthorization(BeheerAuth.Policy) .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); app.Run(); /// The behandelaar's decision on a registration. public sealed record DecideRequest(string Besluit); /// A diploma upload from the self-service portal — the file base64-encoded client-side, with /// its name and MIME type. The bsn is taken from the DigiD token, not this body. public sealed record ProvideDocumentsRequest(string ContentBase64, string? FileName = null, string? ContentType = null); // Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013). internal static class BehandelAuth { public const string Scheme = "medewerker"; public const string Policy = "behandelaar"; public const string BehandelaarRole = "behandelaar"; /// The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a /// 400 without troubling the domain. Mirrors the domain's BeoordelingsBesluit. public static bool IsKnownBesluit(string? besluit) => string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) || string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase); /// Lift Keycloak's realm roles (the nested realm_access.roles claim) onto the /// principal as role claims, so RequireRole can authorize on them. public static void AddRealmRoles(ClaimsPrincipal? principal) { if (principal?.Identity is not ClaimsIdentity identity) return; var realmAccess = principal.FindFirst("realm_access")?.Value; if (string.IsNullOrWhiteSpace(realmAccess)) return; // A malformed realm_access claim must not fail authentication (a throw here becomes a 401); // it simply yields no roles, so the authorization policy answers 403. string[] roles; try { roles = JsonSerializer.Deserialize(realmAccess)?.Roles ?? []; } catch (JsonException) { return; } foreach (var role in roles) identity.AddClaim(new Claim(identity.RoleClaimType, role)); } private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles); } // Beheer (medewerker-realm) authorization wiring (S-15a). Reuses the "medewerker" bearer scheme // (BehandelAuth.Scheme) and its realm-role lifting; only the required role differs. internal static class BeheerAuth { public const string Policy = "beheerder"; public const string BeheerderRole = "beheerder"; } // Exposed so the test host (WebApplicationFactory) can boot the app. public partial class Program;