feat(#13): S-12c-1 — behandel BFF auth + werkbak (ADR-0013) #85
@@ -349,6 +349,8 @@ services:
|
|||||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
# 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.
|
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
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__Domain__BaseUrl: http://domain:8080/
|
||||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface SubmitAccepted {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WerkbakItem {
|
||||||
|
registrationId: string;
|
||||||
|
bsn: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GetOpenbaarRegisterParams = {
|
export type GetOpenbaarRegisterParams = {
|
||||||
q?: string;
|
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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Bff.Api;
|
using Bff.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||||
?? throw new InvalidOperationException("Missing 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"]
|
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
options.Authority = keycloakAuthority;
|
options.Authority = keycloakAuthority;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.TokenValidationParameters.ValidateAudience = 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).
|
// 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<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||||
@@ -68,7 +94,42 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
|||||||
})
|
})
|
||||||
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
.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();
|
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.IsNullOrEmpty(realmAccess))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var roles = JsonSerializer.Deserialize<RealmAccess>(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<Program>) can boot the app.
|
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Keycloak": {
|
"Keycloak": {
|
||||||
"Authority": "http://localhost:8180/realms/digid"
|
"Authority": "http://localhost:8180/realms/digid",
|
||||||
|
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
|
||||||
},
|
},
|
||||||
"Downstream": {
|
"Downstream": {
|
||||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||||
|
|||||||
@@ -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": {
|
"components": {
|
||||||
@@ -100,6 +128,25 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WerkbakItem": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"bsn",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bsn": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user