## What & why First half of **S-12c** (behandel-portal backend), per **ADR-0013** (decisions recorded in #84): - **BFF multi-realm auth.** A second JWT bearer scheme (`medewerker`) alongside the default `digid` scheme. On validation it lifts Keycloak's `realm_access.roles` onto the principal, and a `behandelaar` policy (medewerker scheme + `behandelaar` role) gates `/behandel/*`. Self-service keeps the digid scheme. - **Werkbak = Flowable tasks.** The domain `Werkbak` query reads the open `Beoordelen` tasks (§8.2, S-12b's `IUserTaskClient`) and enriches each with its aggregate's bsn + status; `GET /behandel/werkbak` (domain) is proxied by the BFF `GET /behandel/werkbak` behind the behandelaar policy. The read projection stays the anonymous openbaar model (no premature `IN_BEHANDELING`/personal-data plumbing — deferred in ADR-0008). Behavior: `/behandel/werkbak` is **401** without a token, **403** for a medewerker lacking the role, **200 + werkbak** for a behandelaar. **S-12c-2** (next): `POST /behandel/registrations/{id}/decide` → domain decision + complete the Flowable task. ## Definition of Done - [x] Linked issue: #13 (umbrella, `refs`); closes the adr-proposal #84 - [x] Tests first; red → green per layer - [x] Unit + acceptance green (`make unit`): domain 78, bff 23, acceptance 9 (+ acl/event-subscriber unaffected) - [x] api-client `test` green; openapi.json regenerated (drift guard passes) - [x] Mutation ≥ break(90): **domain 100%, bff 100%** - [x] ADR-0013 added; `Keycloak__MedewerkerAuthority` wired into compose - [ ] CI green (pending) Part of #13. closes #84 Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-15
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
|
||||
|
||||
## Context
|
||||
|
||||
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
|
||||
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
|
||||
the whole slice.
|
||||
|
||||
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
|
||||
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
|
||||
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
|
||||
`digid`-realm JWT validation rejects a medewerker token outright.
|
||||
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
|
||||
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
|
||||
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
|
||||
task; the decision must advance it, and also apply the domain transition (ADR-0011).
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
|
||||
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
|
||||
completes the Flowable task.**
|
||||
|
||||
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
|
||||
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
|
||||
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
|
||||
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
|
||||
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
|
||||
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
|
||||
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
|
||||
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
|
||||
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
|
||||
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
|
||||
decision acts on a real task with no separate correlation store. The read projection stays the
|
||||
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
|
||||
data (both deferred in ADR-0008) just to render a staff view.
|
||||
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
|
||||
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
|
||||
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
|
||||
here so the boundary is decided up front.
|
||||
|
||||
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
|
||||
endpoint + task completion.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
|
||||
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
|
||||
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
|
||||
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
|
||||
(§8.3).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
|
||||
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
|
||||
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
|
||||
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
|
||||
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
|
||||
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
|
||||
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
|
||||
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
|
||||
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
|
||||
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
|
||||
enough at this size and keeps §8.3 simple.
|
||||
@@ -349,6 +349,8 @@ services:
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
ports:
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface SubmitAccepted {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface WerkbakItem {
|
||||
registrationId: string;
|
||||
bsn: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type GetOpenbaarRegisterParams = {
|
||||
q?: string;
|
||||
};
|
||||
@@ -215,4 +221,35 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -13,10 +13,17 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference
|
||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
||||
|
||||
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
|
||||
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||
public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Port to the read projection.</summary>
|
||||
@@ -37,6 +44,9 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
@@ -6,6 +8,10 @@ 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"]
|
||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
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();
|
||||
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));
|
||||
@@ -68,7 +94,53 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
||||
})
|
||||
.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);
|
||||
|
||||
app.Run();
|
||||
|
||||
// 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>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;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Keycloak": {
|
||||
"Authority": "http://localhost:8180/realms/digid"
|
||||
"Authority": "http://localhost:8180/realms/digid",
|
||||
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
|
||||
},
|
||||
"Downstream": {
|
||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||
|
||||
57
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
57
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
|
||||
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
|
||||
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
|
||||
/// </summary>
|
||||
public class BehandelEndpointTests
|
||||
{
|
||||
private static HttpRequestMessage Werkbak(string? bearer)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_the_werkbak_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Serves_the_werkbak_to_a_behandelaar()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
|
||||
var item = Assert.Single(items!);
|
||||
Assert.Equal("reg-1", item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
@@ -23,9 +24,34 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||
public FakeDomainClient Domain { get; } = new();
|
||||
public FakeProjectionClient Projection { get; } = new();
|
||||
|
||||
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
||||
services.Configure<JwtBearerOptions>(scheme, options =>
|
||||
{
|
||||
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
||||
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
||||
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
||||
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
|
||||
options.Authority = null;
|
||||
options.MetadataAddress = null!;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration();
|
||||
options.ConfigurationManager =
|
||||
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestSigningKey,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||
|
||||
@@ -34,23 +60,11 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||
services.AddSingleton<IDomainClient>(Domain);
|
||||
services.AddSingleton<IProjectionClient>(Projection);
|
||||
|
||||
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
// Validate locally against the test key; never reach out for OIDC metadata.
|
||||
options.Authority = null;
|
||||
options.MetadataAddress = null!;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration();
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestSigningKey,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
||||
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
||||
// parameters are swapped here.
|
||||
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
|
||||
ValidateWithTestKey(services, "medewerker");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,12 +74,16 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
{
|
||||
public string? SubmittedBsn { get; private set; }
|
||||
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||
public List<WerkbakItem> Werkbak { get; } = [];
|
||||
|
||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
||||
}
|
||||
|
||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||
|
||||
@@ -17,6 +17,22 @@ internal static class TestTokens
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||
expired: false);
|
||||
|
||||
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
|
||||
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
|
||||
public static string Medewerker(params string[] roles)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
return handler.CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
Claims = new Dictionary<string, object>
|
||||
{
|
||||
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
|
||||
},
|
||||
Expires = DateTime.UtcNow.AddMinutes(30),
|
||||
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
|
||||
});
|
||||
}
|
||||
|
||||
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
|
||||
@@ -60,6 +60,34 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/behandel/werkbak": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WerkbakItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -100,6 +128,25 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WerkbakItem": {
|
||||
"required": [
|
||||
"registrationId",
|
||||
"bsn",
|
||||
"status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registrationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"bsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||
builder.Services.AddScoped<Werkbak>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
@@ -73,6 +74,12 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
||||
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
||||
Results.Ok(await werkbak.GetAsync(ct)));
|
||||
|
||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
32
services/domain/Big.Application/Werkbak.cs
Normal file
32
services/domain/Big.Application/Werkbak.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
|
||||
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>
|
||||
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
|
||||
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
|
||||
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
|
||||
/// whose registration the domain doesn't know is skipped rather than invented.
|
||||
/// </summary>
|
||||
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
{
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
|
||||
var items = new List<WerkbakItem>(open.Count);
|
||||
foreach (var task in open)
|
||||
{
|
||||
var registration = await store.GetAsync(task.RegistrationId, ct);
|
||||
if (registration is null)
|
||||
continue;
|
||||
|
||||
items.Add(new WerkbakItem(
|
||||
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||
/// open beoordeling tasks and records claim/complete calls.</summary>
|
||||
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
|
||||
{
|
||||
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(open);
|
||||
|
||||
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||
{
|
||||
Claimed = (taskId, behandelaar);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||
{
|
||||
Completed = (taskId, besluit);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
/// fixed zaak URL.</summary>
|
||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
|
||||
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads
|
||||
/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration
|
||||
/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively.
|
||||
/// </summary>
|
||||
public class WerkbakTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
|
||||
var werkbak = new Werkbak(tasks, store);
|
||||
|
||||
var items = await werkbak.GetAsync();
|
||||
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(registration.Id.ToString(), item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
Assert.Equal("InBehandeling", item.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Is_empty_when_no_beoordelingen_are_open()
|
||||
{
|
||||
var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore());
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_a_task_whose_registration_is_unknown()
|
||||
{
|
||||
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
|
||||
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||
}
|
||||
|
||||
/// <summary>Serves configurable projection rows.</summary>
|
||||
|
||||
Reference in New Issue
Block a user