## What & why S-10a, the **workflow/timeout spine** of the (split) document-upload slice: the registratie process now parks at a **`WachtOpDocumenten`** user task with an **interrupting `P30D` boundary timer**. When the documents arrive the task completes and the process continues into the diploma routing (S-13) → Beoordelen; if the 30 days lapse, the timer cancels the wait, runs a `RegistratieVerlopen` external-worker task, and the domain expires the aggregate to a new terminal status **`Verlopen`**. Backend only — the real upload trigger (portal → BFF → ACL → Documenten API) is S-10b (#103). Closes #102 Mechanism recorded in **ADR-0017**; opened as proposal #104. Mirrors the S-14 escalation (boundary-timer + external-worker) and S-11 withdrawal (interrupting cancel) patterns. ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (red→green pairs per layer). - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issue (`refs #102`). - [ ] CI green — all Gitea Actions jobs (pending on this PR). - [x] `docker compose up` health unaffected (no new services; deploy path unchanged). - [x] Docs updated (ADR-0017, demo-script, BACKLOG split). - [x] ADR added (`docs/architecture/adr-0017-document-wait-timeout-cancellation.md`). - [x] Demo note in `docs/demo-script.md`. ## Notes for reviewers - **Domain** (`Registration.Expire()` + `Verlopen`), **application** (`ExpireRegistrationWorker`), **infra** (`RegistratieVerlopenProcessor`/`Pump`, `IRegistratieVerlopenClient`, Flowable acquire/complete + `CompleteDocumentWaitAsync`) — the timeout counterpart to the OpenZaak/escalation worker trios; idempotent per §8.6. - **BPMN** verified live against a `flowable-rest` probe: complete `WachtOpDocumenten` → routes to Beoordelen; fire the P30D timer → `RegistratieVerlopen` job (carrying `registrationId`) + the wait task cancelled. `verify-domain` exercises both branches in-stack (completes the wait in every existing block; fires the timer and asserts `Verlopen` in a new block). - **Scope boundary:** on expiry the aggregate goes `Verlopen` and the process ends, but the ZGW *zaak* is not yet set to a cancellation status — that needs a new ACL method + statustype seeding and is folded into S-10b (noted in ADR-0017). - `CompleteDocumentWaitAsync` is built and HTTP-tested here but not yet called from a domain endpoint; S-10b wires the upload trigger to it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #105
211 lines
9.9 KiB
C#
211 lines
9.9 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, 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 provided = await domain.ProvideDocumentsAsync(id, bsn, 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);
|
|
|
|
// 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;
|