Skip to content

Commit d94e664

Browse files
authored
Merge pull request #5579 from Particular/fix-rolesclaim-default-and-rbac-deny-tests
Fix RolesClaim default docs and cover the RBAC deny path
2 parents bd377f6 + e3be8f6 commit d94e664

5 files changed

Lines changed: 74 additions & 10 deletions

File tree

src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ public async Task Should_reject_requests_with_wrong_issuer()
205205
OpenIdConnectAssertions.AssertUnauthorized(response);
206206
}
207207

208+
[Test]
209+
public async Task Should_forbid_authenticated_user_lacking_required_permission()
210+
{
211+
HttpResponseMessage response = null;
212+
213+
_ = await Define<Context>()
214+
.Done(async ctx =>
215+
{
216+
// The "reader" role grants every :view permission but none of the operate ones.
217+
// Retrying messages requires error:messages:retry (writer/admin only), so an
218+
// authenticated reader must be forbidden (403), not merely unauthenticated (401).
219+
var readerToken = mockOidcServer.GenerateToken(
220+
additionalClaims: new[] { new Claim("roles", "reader") });
221+
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
222+
HttpClient,
223+
HttpMethod.Post,
224+
"/api/errors/retry/all",
225+
readerToken);
226+
return response != null;
227+
})
228+
.Run();
229+
230+
OpenIdConnectAssertions.AssertForbidden(response);
231+
}
232+
208233
class Context : ScenarioContext;
209234
}
210235
}

src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,41 @@ public async Task Should_reject_requests_without_bearer_token()
6363
OpenIdConnectAssertions.AssertUnauthorized(response);
6464
}
6565

66+
[Test]
67+
public async Task Should_return_only_the_routes_the_callers_role_permits()
68+
{
69+
HttpResponseMessage response = null;
70+
71+
_ = await Define<Context>()
72+
.Done(async ctx =>
73+
{
74+
// A reader holds every :view permission but none of the operate ones. The manifest is
75+
// the projection of exactly what the server enforces, so it must advertise a view route
76+
// the reader may call and omit a retry route it may not.
77+
var readerToken = mockOidcServer.GenerateToken(
78+
additionalClaims: new[] { new Claim("roles", "reader") });
79+
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
80+
HttpClient, HttpMethod.Get, "/api/my/routes", readerToken);
81+
return response != null;
82+
})
83+
.Run();
84+
85+
OpenIdConnectAssertions.AssertAuthenticated(response);
86+
87+
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
88+
89+
var roles = root.GetProperty("roles").EnumerateArray().Select(role => role.GetString());
90+
Assert.That(roles, Does.Contain("reader"));
91+
92+
var routes = root.GetProperty("routes").EnumerateArray()
93+
.Select(entry => $"{entry.GetProperty("method").GetString()} {entry.GetProperty("url_template").GetString()}")
94+
.ToArray();
95+
96+
Assert.That(routes, Does.Contain("GET /api/errors"),
97+
"Reader holds error:messages:view, so the errors list route must be advertised.");
98+
Assert.That(routes, Does.Not.Contain("POST /api/errors/retry/all"),
99+
"Reader lacks error:messages:retry, so the retry route must be filtered out.");
100+
}
101+
66102
class Context : ScenarioContext;
67103
}

src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,15 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder
4343
ValidateLifetime = oidcSettings.ValidateLifetime,
4444
ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey,
4545
ValidAudience = oidcSettings.Audience,
46-
ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew
47-
RoleClaimType = oidcSettings.RolesClaim
46+
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
4847
};
4948
options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata;
5049
// Don't map inbound claims to legacy Microsoft claim types
5150
options.MapInboundClaims = false;
51+
// No RoleClaimType is set: it only influences IsInRole()/[Authorize(Roles = ...)],
52+
// which this platform does not use. Roles are read from RolesClaim (which may be a
53+
// nested path such as realm_access.roles) and normalized to ClaimTypes.Role by
54+
// RolesClaimsTransformation, which is what the authorization handlers actually read.
5255

5356
// Custom error response handling for better client experience
5457
options.Events = new JwtBearerEvents

src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ namespace ServiceControl.Hosting.Auth;
1111
/// <summary>
1212
/// Normalises per-IdP role claim shapes into a flat set of <c>roles</c> claims that
1313
/// <see cref="PermissionVerbHandler"/> can read directly. The source path is configured via
14-
/// <c>Authentication.RolesClaim</c> (default <c>realm_access.roles</c> — the Keycloak out-of-box
15-
/// shape). Flat claim names work too (<c>roles</c> for Keycloak with a "User Realm Role" mapper or
16-
/// Microsoft Entra ID app roles, <c>cognito:groups</c> for AWS Cognito).
14+
/// <c>Authentication.RolesClaim</c> (default <c>roles</c> — a flat top-level claim, as emitted by
15+
/// Microsoft Entra ID app roles or Keycloak with a "User Realm Role" mapper). A dotted path reaches
16+
/// into a nested JSON object claim: <c>realm_access.roles</c> for Keycloak's out-of-box shape, or
17+
/// <c>cognito:groups</c> for AWS Cognito.
1718
/// <para>
1819
/// ASP.NET may invoke <see cref="TransformAsync"/> multiple times for the same principal; a sentinel
1920
/// claim makes the transformation idempotent and returns the same principal on subsequent calls.

src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,10 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
139139
public string ServicePulseApiScopes { get; }
140140

141141
/// <summary>
142-
/// Path within the JWT where the user's role values live. Defaults to <c>realm_access.roles</c>
143-
/// to match Keycloak's out-of-box token shape. A flat claim name like <c>roles</c> is used when
144-
/// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role"
145-
/// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates
146-
/// into a nested JSON object claim.
142+
/// Path within the JWT where the user's role values live. Defaults to the flat <c>roles</c>
143+
/// claim, as emitted by Microsoft Entra ID app roles or Keycloak with a "User Realm Role" mapper.
144+
/// A dotted path navigates into a nested JSON object claim: set it to <c>realm_access.roles</c>
145+
/// for Keycloak's out-of-box token shape, or <c>cognito:groups</c> for AWS Cognito.
147146
/// </summary>
148147
public string RolesClaim { get; }
149148

0 commit comments

Comments
 (0)