From 9eb51b8b3e4f64288fc6507647c616a30cb9eed2 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 24 Jul 2026 10:51:22 +0200 Subject: [PATCH] feat(bff): GET /beheer/catalogi/zaaktypen proxies the ACL for beheerders (refs #130) --- libs/api-client/src/lib/generated/bff-api.ts | 36 ++++++++++++++++ services/bff/Bff.Api/Program.cs | 35 ++++++++++++++- services/bff/Bff.Api/appsettings.json | 3 +- services/bff/openapi.json | 45 +++++++++++++++++++- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index f0775ad..1828d6e 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -24,6 +24,11 @@ import { Observable } from 'rxjs'; +export interface BeheerZaaktype { + identificatie: string; + omschrijving: string; +} + export interface CurrentRegistration { registrationId: string; status: string; @@ -410,4 +415,35 @@ export class BffApiV1Service { ); } + getBeheerCatalogiZaaktypen( options?: HttpClientBodyOptions): Observable; + getBeheerCatalogiZaaktypen( options?: HttpClientEventOptions): Observable>; + getBeheerCatalogiZaaktypen( options?: HttpClientResponseOptions): Observable>; + getBeheerCatalogiZaaktypen( + options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `/beheer/catalogi/zaaktypen`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.get( + `/beheer/catalogi/zaaktypen`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.get( + `/beheer/catalogi/zaaktypen`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 45e6d13..dd9a8f2 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -40,6 +40,10 @@ 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. @@ -67,14 +71,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }; }); builder.Services.AddAuthorization(options => +{ options.AddPolicy(BehandelAuth.Policy, policy => policy .AddAuthenticationSchemes(BehandelAuth.Scheme) .RequireAuthenticatedUser() - .RequireRole(BehandelAuth.BehandelaarRole))); + .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). +// 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 @@ -205,6 +219,15 @@ app.MapPost("/behandel/registrations/{id}/decide", .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. @@ -257,5 +280,13 @@ internal static class BehandelAuth 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; diff --git a/services/bff/Bff.Api/appsettings.json b/services/bff/Bff.Api/appsettings.json index 489d782..c9026cd 100644 --- a/services/bff/Bff.Api/appsettings.json +++ b/services/bff/Bff.Api/appsettings.json @@ -12,6 +12,7 @@ }, "Downstream": { "Domain": { "BaseUrl": "http://localhost:8130/" }, - "Projection": { "BaseUrl": "http://localhost:8120/" } + "Projection": { "BaseUrl": "http://localhost:8120/" }, + "Acl": { "BaseUrl": "http://localhost:8100/" } } } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index a212961..10b790d 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -227,10 +227,53 @@ } } } + }, + "/beheer/catalogi/zaaktypen": { + "get": { + "tags": [ + "Bff.Api" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BeheerZaaktype" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } } }, "components": { "schemas": { + "BeheerZaaktype": { + "required": [ + "identificatie", + "omschrijving" + ], + "type": "object", + "properties": { + "identificatie": { + "type": "string" + }, + "omschrijving": { + "type": "string" + } + } + }, "CurrentRegistration": { "required": [ "registrationId", @@ -343,4 +386,4 @@ "name": "Bff.Api" } ] -} +} \ No newline at end of file