Files
register-referentie/services/bff/Bff.Api/Program.cs
not 0904df8db0
CI / lint (push) Successful in 1m21s
CI / build (push) Successful in 1m4s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m40s
CI / mutation (push) Successful in 5m31s
CI / verify-stack (push) Successful in 7m56s
feat(acl): diploma upload stored in the ZGW Documenten API (S-10b, closes #103) (#108)
## What & why

S-10b: the self-service **diploma upload** is now real. After submitting, the citizen picks a PDF and
uploads it; the portal base64-encodes it client-side → BFF → domain → **ACL**, which stores it in the
ZGW **Documenten (DRC) API** as an `enkelvoudiginformatieobject` and relates it to the zaak, then the
`WachtOpDocumenten` wait completes and the case advances to beoordeling. Per §8.1 only the ACL talks to
ZGW.

Closes #103

Mechanism in **ADR-0018** (proposal #107). Builds on S-10a (#102). The zaak-close-on-expiry item is
carved to **#106 (S-10c)**.

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer).
- [x] Conventional Commits referencing the issue (`refs #103`).
- [ ] CI green — all Gitea Actions jobs (pending on this PR).
- [x] `docker compose up` health unaffected (ACL boots on a placeholder informatieobjecttype URL; the real one is injected by verify-domain).
- [x] Docs updated (ADR-0018, demo-script, BACKLOG + S-10c).
- [x] ADR added (`docs/architecture/adr-0018-diploma-upload-via-acl-documenten.md`).
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **ACL** (`OpenZaakGateway.StoreDocumentAsync` + `AclService.StoreDiplomaAsync` + `POST /documenten`) reuses the existing gateway patterns (ZGW Bearer, buffered non-chunked body, **no CRS** — Documenten isn't geo). Unit-tested via the stub handler; an **integration test** stores a real document against live OpenZaak (verify-acl).
- **Transport:** base64 JSON on every hop (portal encodes client-side) — I deviated from proposal #107's multipart to keep one contract shape and avoid `IFormFile`/antiforgery/multipart-client plumbing; fine at diploma size (ADR-0018 §Alternatives).
- **Infra:** `seed_catalogus.py` seeds + publishes a "Diploma" `informatieobjecttype` and relates it to the zaaktype (while both concept); `verify-domain` injects its URL into the ACL. No new ZGW scopes (seed applicatie has `heeft_alle_autorisaties`).
- **e2e:** uploads a real PDF (`setInputFiles`) after the openbaar INGEDIEND row confirms the zaak is open (so storage doesn't race the OpenZaak worker).
- **Scope boundary:** the ZGW zaak is not set to a cancellation status on 30-day expiry — that's #106 (S-10c).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #108
2026-07-21 12:15:33 +00:00

217 lines
10 KiB
C#

using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
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'");
// 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)));
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
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");
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<SubmitAccepted>(StatusCodes.Status202Accepted)
.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<IReadOnlyList<OpenbaarEntry>>(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<IReadOnlyList<WerkbakItem>>(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);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit);
/// <summary>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.</summary>
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";
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
public static bool IsKnownBesluit(string? besluit) =>
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
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>(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);
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;