diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 167de80..c107ca0 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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: diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 5f76b6b..ca0d967 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -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( options?: HttpClientBodyOptions): Observable; + getBehandelWerkbak( options?: HttpClientEventOptions): Observable>; + getBehandelWerkbak( options?: HttpClientResponseOptions): Observable>; + getBehandelWerkbak( + options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index d705bd5..9823a29 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -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(c => c.BaseAddress = new Uri(domainBaseUrl)); @@ -68,7 +94,42 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, }) .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); + 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"; + + /// 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.IsNullOrEmpty(realmAccess)) + return; + + var roles = JsonSerializer.Deserialize(realmAccess)?.Roles ?? []; + 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) 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 7fbe3c0..489d782 100644 --- a/services/bff/Bff.Api/appsettings.json +++ b/services/bff/Bff.Api/appsettings.json @@ -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/" }, diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 92a9120..e3591c3 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -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" + } + } } } },