Skip to content

[PM-44143][BEEEP] feat: add RBAC from IdP group claims - #8587

Draft
trmartin4 wants to merge 9 commits into
admin/upstream-oidc-minimalfrom
admin/add-rbac-from-idp
Draft

trmartin4 wants to merge 9 commits into
admin/upstream-oidc-minimalfrom
admin/add-rbac-from-idp

Conversation

@trmartin4

@trmartin4 trmartin4 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

🎟️ Tracking

PM-44143

📔 Objective

Builds on top of the minimal upstream-OIDC work (#8508) to derive Admin Portal role claims from the IdP's groups claim at SSO sign-in time, route SSO sessions through a dedicated cookie scheme so the Identity cookie's SecurityStampValidator does not reject them within 30 seconds, and enforce a deployment-driven tenancy gate at sign-in so principals who have no admin membership on their deployment never receive a session.

What's new

  • GroupClaimRoleMapper strips the configured AdminSettings:Oidc:GroupPrefix off each groups claim and keeps the suffix only if it matches a key in RolePermissionMapping (owner, admin, billing, cs, sales). Prefix match is case-insensitive. Multiple groups union into a role set.
  • SsoSignInPrincipalBuilder assembles the ClaimsPrincipal written to the SSO cookie on sign-in. Bypasses SignInManager/UserManager - no local user record, no SecurityStamp, no periodic re-fetch. The IdP owns identity, session lifetime is bounded by cookie expiry and RP-initiated logout.
  • AccessControlService.UserHasPermission now unions across every role claim the principal holds. If any role grants the permission, the user has it.
  • LoginController.SsoSignIn enforces a deployment-driven tenancy gate: on cloud, the principal must carry at least one role claim; on self-hosted, the email must be on AdminSettings:Admins. AddAdminUpstreamOidc fail-louds at boot when the deployment-required config is missing.
  • src/Admin/Auth/SSO.md documents the deployment-driven model.

🧩 Two cookie schemes, resolved by the authorization policy

The Admin app now has two auth cookies:

Scheme Who writes it SecurityStampValidator Scenario
IdentityConstants.ApplicationScheme passwordless email path (SignInManager) yes, 30s re-check against AdminSettings:Admins CSV self-hosted passwordless
AdminAuthenticationSchemes.SsoCookie (new) SSO path (SsoSignInPrincipalBuilder → HttpContext.SignInAsync) no (IdP owns identity, no local user to re-fetch) self-hosted SSO, cloud SSO

Why two schemes: the Identity cookie's SecurityStampValidator re-fetches the user from the user store every 30 seconds and signs them out on mismatch. The SSO principal has no backing user record, so Identity's cookie would eject it. A dedicated cookie scheme with no validator wired up is the honest fit for IdP-owned sessions.

How bare [Authorize] resolves across both:

The authorization DefaultPolicy lists both schemes. ASP.NET Core's PolicyEvaluator calls AuthenticateAsync on each scheme in order and sets HttpContext.User to the first successful result, so both passwordless and SSO sessions satisfy bare [Authorize] without each controller having to list schemes. Each scheme is actually decrypted and validated - no cookie-name heuristic that would short-circuit a valid session when a stale cookie from the other scheme is on the request. FallbackPolicy is deliberately not set, so endpoints without an explicit [Authorize] stay anonymous (the login pages, /alive, /error, and the version endpoint). DefaultAuthenticateScheme and DefaultChallengeScheme stay at Identity's application cookie (the AddIdentity default), so bare [Authorize] still redirects to /login via Identity and SignInManager still writes to the Identity cookie.

⚠️ Behavior change - SSO tenancy restructured

The old SSO path required the authenticated email to also appear in the AdminSettings:Admins CSV allowlist (via UserManager.FindByEmailAsync). This PR replaces that uniform check with a deployment-driven split.

Cloud Self-hosted
Tenancy gate Principal has at least one ClaimTypes.Role claim Email is on AdminSettings:Admins
Authorization Union of permissions across role claims via RolePermissionMapping SelfHosted short-circuit grants all
Role claims come from AdminSettings:Oidc:GroupPrefix → GroupClaimRoleMapper n/a
Boot-time required config AdminSettings:Oidc:GroupPrefix AdminSettings:Admins

The passwordless email flow is unchanged and continues to use the AdminSettings:Admins allowlist with the 30s re-check.

🤖 AI-assisted review

  • Review path: Standard (bitwarden-code-review:code-review-local) against base admin/upstream-oidc-minimal
  • Reviewer flagged the SecurityStampValidator rejection on the original one-cookie design; addressed by the two-scheme split above
  • Reviewer flagged an initial FallbackPolicy = DefaultPolicy that would have locked out the login pages and health probes; dropped so only [Authorize]-decorated endpoints require authentication
  • Reviewer flagged that bare [Authorize] actions on UsersController/OrganizationsController were reachable by role-less SSO principals; addressed by the deployment-driven tenancy gate above. Adding [RequirePermission] to those ungated actions remains a desirable defense-in-depth follow-up and is tracked separately

SSO sign-in now maps the IdP `groups` claim onto Admin Portal role
claims via GroupClaimRoleMapper and signs the resulting principal into
the application cookie through SsoSignInPrincipalBuilder, bypassing
SignInManager/UserManager. AccessControlService.UserHasPermission takes
the union across every role the principal holds.

The AdminSettings:Admins CSV allowlist is no longer the gate for the
SSO path - IdP group membership is. Passwordless sign-in still uses the
allowlist. AddAdminUpstreamOidc fail-louds on an empty
AdminSettings:Oidc:GroupPrefix.
@trmartin4 trmartin4 added the ai-review Request a Claude code review label Oct 13, 2026
@trmartin4 trmartin4 changed the title Add RBAC from IdP group claims to Admin Portal [PM-44143][BEEEP] feat: add RBAC from IdP group claims Oct 13, 2026
@trmartin4 trmartin4 added the t:feature Change Type - Feature Development label Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Re-reviewed through 9d24324. That commit replaces the dual-gate SSO tenancy check with one that depends on the deployment type: on cloud the principal must hold a mapped role claim, and on self-hosted the email must be on AdminSettings:Admins. Boot now fails when the setting that deployment needs is missing. The SSO.md adminSettings:role:{roleName} thread is resolved, because cloud now requires GroupPrefix at boot. The SignInManager.IsSignedIn thread is not resolved. SsoSignInPrincipalBuilder still stamps the sso-cookie authentication type, so _Layout.cshtml still hides the nav and the Log Out button for SSO sessions.

Code Review Details
  • ⚠️ : SSO sessions fail SignInManager.IsSignedIn, so the layout hides the nav and Log Out (open thread, unchanged)
    • src/Admin/Auth/Services/SsoSignInPrincipalBuilder.cs:38
  • ♻️ : The Admins comment says the list does not gate SSO sign-in, but on self-hosted it is now the only SSO gate. The GroupPrefix "still signs in" comment and the GroupClaimRoleMapper null-prefix reasoning are also stale
    • src/Admin/AdminSettings.cs:8-11

Comment thread src/Admin/Auth/Services/SsoSignInPrincipalBuilder.cs
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 53.75000% with 74 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.56%. Comparing base (746cfdf) to head (9d24324).

Files with missing lines Patch % Lines
src/Admin/Startup.cs 0.00% 62 Missing ⚠️
src/Admin/Services/AccessControlService.cs 61.53% 2 Missing and 3 partials ⚠️
src/Admin/Auth/Services/GroupClaimRoleMapper.cs 84.00% 2 Missing and 2 partials ⚠️
src/Admin/Auth/Controllers/LoginController.cs 91.66% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           admin/upstream-oidc-minimal    #8587      +/-   ##
===============================================================
- Coverage                        66.90%   65.56%   -1.35%     
===============================================================
  Files                             2591     2548      -43     
  Lines                           111391   109729    -1662     
  Branches                         10154     9982     -172     
===============================================================
- Hits                             74523    71939    -2584     
- Misses                           34382    35421    +1039     
+ Partials                          2486     2369     -117     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/Admin/Startup.cs Outdated
AdminAuthenticationSchemes.SsoCookie)
.RequireAuthenticatedUser()
.Build();
options.FallbackPolicy = options.DefaultPolicy;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CRITICAL: The new FallbackPolicy requires sign-in on every endpoint that has no [Authorize]/[AllowAnonymous], which includes the /login page itself, so no one can sign in.

Details and fix

Before this PR FallbackPolicy was null. ASP.NET Core only applies it to endpoints that carry no IAuthorizeData, and several Admin endpoints carry none:

  • LoginController.Index (GET and POST) and LoginController.Confirm: only Sso and SsoSignIn are [AllowAnonymous]. An anonymous GET /login is challenged by the default scheme (Identity's app cookie, LoginPath = "/login"). That redirects to /login?ReturnUrl=%2Flogin, which is challenged again, so the browser loops until it gives up. The passwordless magic link (/login/confirm) is challenged the same way, so neither sign-in path can be reached.
  • InfoController /alive and /now: health probes now get a 302 instead of a 200.
  • ErrorController /error: UseExceptionHandler("/error") re-executes into an endpoint that challenges.
  • MapVersionEndpoint() /version.

The tests don't catch this because they build controllers directly and never run the authorization middleware.

Making bare [Authorize] accept both cookies only needs DefaultPolicy. The fallback isn't part of that goal:

Suggested change
options.FallbackPolicy = options.DefaultPolicy;
// FallbackPolicy deliberately left unset: LoginController.Index/Confirm, /alive,
// /error and /version have no [Authorize]/[AllowAnonymous] and must stay reachable.

If you do want deny-by-default, add [AllowAnonymous] to LoginController (class level), InfoController, and ErrorController, and call .AllowAnonymous() on the version endpoint. Then add a WebApplicationFactory test that checks GET /login returns 200.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in f4955f9. FallbackPolicy is no longer set, and only DefaultPolicy lists both cookie schemes. LoginController.Index/Confirm, /alive, /error, and /version are anonymous again.

The previous commit set FallbackPolicy = DefaultPolicy, which required
authentication on every endpoint without an explicit [Authorize] or
[AllowAnonymous]. That locked out the login pages (LoginController.Index
and Confirm have no attributes), the /alive health probe, /error, and
the version endpoint. The login page would 302 to itself; health probes
would 302 to the login page.

Keep DefaultPolicy so [Authorize] still accepts either cookie scheme,
but let endpoints without [Authorize] stay anonymous.
// is bounded by the cookie expiry. A separate scheme (not the Identity application
// cookie) is required because Identity's SecurityStampValidator does a 30s re-fetch
// against the user store, which would reject the SSO principal within 30 seconds.
var principal = _ssoSignInPrincipalBuilder.Build(email, external.Principal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: An SSO user with no mapped groups still gets a cookie, and several [Authorize] actions have no RequirePermission gate, so in cloud that user can read and change customer data.

Details and fix

The allowlist check was removed and nothing replaced it. In cloud, SsoSignIn now signs in any IdP-authenticated user with a verified email, even when MapRoles returns nothing. The PR description says such a user is denied "every gated action". But these actions under [Authorize] are not gated by RequirePermission:

  • UsersController.View / Edit (GET): user email, ciphers metadata, billing and billing history
  • OrganizationsController.View / Edit (GET), plus TriggerBillingSync and ResendOwnerInvite (POST)

Before this change, only operators on the AdminSettings:Admins allowlist could reach those actions. Now anyone the IdP authenticates for the app can reach them.

The comment added in AddAdminUpstreamOidc already assumes this case is refused ("every SSO sign-in would be rejected as 'no mapped group'"), but the code never refuses it. Suggested fix: when not self-hosted and the mapper returns no roles, reject the sign-in the same way the other branches do:

var principal = _ssoSignInPrincipalBuilder.Build(email, external.Principal);
if (!_globalSettings.SelfHosted && !principal.HasClaim(c => c.Type == ClaimTypes.Role))
{
    _logger.LogWarning("SSO sign-in rejected: principal holds no mapped Admin Portal group.");
    await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
    return RedirectToAction("Index", new { error = 5 });
}

Add a test for the rejection, and update the PR description and the AdminSettings.GroupPrefix comment, which say no-group principals "still sign in".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 1dfda58. SsoSignIn now rejects any principal with no ClaimTypes.Role claim before SignInAsync: it signs out the external scheme and redirects with the generic error 5. SsoSignIn_RedirectsError5_WhenPrincipalHasNoRoleClaims covers this. The gate runs on both self-hosted and cloud, which is stricter than what I suggested and fine.

One leftover: the GroupPrefix comment at src/Admin/AdminSettings.cs:83 still says a principal with no mapped groups "still signs in", and the Build_StampsNoRoleClaims_WhenNoGroupsMap test comment says the same. Both now contradict the gate and should be updated.

…cheme

The ClaimsIdentity was declaring IdentityConstants.ApplicationScheme as its
AuthenticationType, carried over from when the principal was signed into
the Identity cookie. Now that it signs into AdminAuthenticationSchemes.
SsoCookie, Identity.AuthenticationType was lying about the actual scheme -
not a functional bug (IsAuthenticated just requires non-null), but anything
keying off AuthenticationType for middleware traces or debug tooling would
have reported the wrong scheme.
Several controller actions in UsersController and OrganizationsController
carry only bare [Authorize] and no [RequirePermission] gate (View, Edit,
TriggerBillingSync, ResendOwnerInvite). On cloud, with the AdminSettings:
Admins allowlist removed from the SSO path, an IdP-authenticated user whose
groups do not map to any admin role would receive an application cookie
and be able to reach those actions.

Add a tenancy gate at sign-in: after SsoSignInPrincipalBuilder.Build
returns, verify the principal carries at least one ClaimTypes.Role claim.
If none, log a warning, sign out the external scheme, and redirect with
the generic SSO-failed error. The gate is independent of per-action
[RequirePermission] checks, which remain a desirable defense-in-depth
follow-up.

Design rationale and the considered alternatives are captured in
src/Admin/Auth/SSO.md, following the ADR format from the engineering
documentation standard (ADR-0034).
Drop the ADR-style frontmatter and the Considered Options / Decision
Outcome / Positive-Negative-Plan structure. Document the SSO sign-in path
as it is: the two sign-in paths, the SSO flow, the tenancy gate, the
authn/tenancy/authz separation, the cookie schemes, logout, and operator
configuration. Audience is engineers modifying the SSO code and operators
provisioning Admin Portal access, not reviewers evaluating a design
decision.
// AuthenticationType is required for ClaimsIdentity.IsAuthenticated to return true.
// Match the scheme the controller signs this principal into so middleware traces and
// any code keying off Identity.AuthenticationType report the actual scheme.
var identity = new ClaimsIdentity(AdminAuthenticationSchemes.SsoCookie);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: After this change, SignInManager.IsSignedIn returns false for SSO sessions. The layout hides the whole nav and shows "Log In" instead of "Log Out", so SSO users have no way to start RP-initiated logout from the UI.

Details and fix

_Layout.cshtml gates both the nav menu (line 51) and the Log Out form (line 150) on SignInManager.IsSignedIn(User). The stock implementation (not overridden by PasswordlessSignInManager) is:

return principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); // IdentityConstants.ApplicationScheme

The cookie ticket serializer keeps ClaimsIdentity.AuthenticationType across the round trip. The SSO identity now comes back as "sso-cookie", so the check fails even on [Authorize] pages where PolicyEvaluator has already authenticated the user. Before e7d6e7d the identity carried IdentityConstants.ApplicationScheme, which is why this worked. So the commit message's "not a functional bug" doesn't hold for this caller.

Either fix works:

  • Revert to new ClaimsIdentity(IdentityConstants.ApplicationScheme) and restore the matching test assertion.
  • Keep SsoCookie and change both layout checks to User.Identity?.IsAuthenticated == true, which covers both cookie schemes.

Whichever you choose, add a test that pins the contract, so the layout's sign-in signal can't silently drift from the principal again.

Rewrite SSO.md to describe what the system does today without rehashing
alternatives considered or temporal references to in-flight work. Shorten
the tenancy-gate comment in LoginController.cs for the same reason - the
"this PR removed" phrasing is meaningless once the PR is merged and the
list of currently-ungated [Authorize] actions is accurate only until the
first [RequirePermission] follow-up lands.
Operators can now configure the SSO sign-in path with either the
AdminSettings:Admins allowlist, the AdminSettings:Oidc:GroupPrefix group
mapping, or both. Each configured signal is a tenancy gate; every
configured gate must admit the user. Signals that are not configured are
skipped.

- AddAdminUpstreamOidc fails loud at boot when SSO is enabled with
  neither Admins nor GroupPrefix set.
- LoginController.SsoSignIn runs each configured gate and rejects with
  the generic SSO-failed error when any gate denies.
- SSO.md documents the three configurations (CSV only, groups only,
  both) and what each means for sign-in, role claims, and authorization
  on cloud vs self-hosted.
- LoginControllerTests cover every pass/deny combination across the
  three configurations. ServiceCollectionExtensionsTests covers the
  loosened boot-time fail-loud and the CSV-only configuration.
Comment thread src/Admin/Auth/SSO.md Outdated
Comment on lines +41 to +44
The `Admins`-only configuration suits operators who already use an IdP for authentication but
prefer to manage the admin list in config rather than in IdP groups. On cloud, those operators
also need to populate `adminSettings:role:{roleName}` entries if they want anyone to have
permissions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Filling in adminSettings:role:{roleName} gives SSO users no permissions. The SSO path never reads that config, so this guidance is wrong.

Details and fix

adminSettings:role:{roleName} is read only by AccessControlService.GetUserRole. Its only caller is CustomClaimsPrincipalFactory, which runs on the passwordless SignInManager path. SsoSignInPrincipalBuilder.Build gets role claims only from GroupClaimRoleMapper.MapRoles. UserHasPermission checks only the role claims on the principal. So on cloud, an Admins-only SSO user has zero permissions no matter what the operator puts in adminSettings:role:*. The matrix row at line 37 says the same thing.

An operator who follows this paragraph will end up with admitted SSO users who can't use any gated feature, and nothing will tell them why. The PR description makes the same claim.

Fix one of these two ways:

  • Have the SSO principal builder also add GetUserRole(email) as a role claim when GroupPrefix is unset, and add a test for it. Or:
  • Rewrite this paragraph to say that, on cloud, Admins-only SSO grants no gated permissions and operators need GroupPrefix to get any. Update the PR description to match.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 9d24324. SSO.md no longer mentions adminSettings:role:{roleName}. The case it described, an operator on cloud configured with Admins only, can no longer happen: AddAdminUpstreamOidc now throws at boot on cloud when GroupPrefix is unset, so SSO role claims always come from GroupClaimRoleMapper. On self-hosted, AccessControlService.UserHasPermission short-circuits on SelfHosted, so role claims are never consulted there.

Cloud and self-hosted have different tenancy and authorization models;
expose that in the sign-in path rather than making operators configure
their way to it.

- Cloud: tenancy = principal has at least one role claim. Authorization =
  union of permissions from role claims via RolePermissionMapping. Role
  claims come from AdminSettings:Oidc:GroupPrefix mapping.
- Self-hosted: tenancy = email on AdminSettings:Admins. Authorization =
  SelfHosted short-circuit grants everything. Role claims are irrelevant;
  GroupPrefix is not required.

LoginController.SsoSignIn branches on IGlobalSettings.SelfHosted and
applies the appropriate gate. AddAdminUpstreamOidc fail-louds at boot
when the deployment-required config is missing (Admins on self-hosted,
GroupPrefix on cloud). SSO.md replaces the dual-gate configuration
matrix with a cloud-vs-self-hosted table. Dual-gate tests replaced with
deployment-type-aware tests covering both the pass and deny paths of
each deployment.
Comment on lines +8 to +11
// Comma-separated allowlist of email addresses permitted to sign in to the Admin Portal
// via the passwordless email flow. Authentication proves identity, this list decides who
// is an admin on the passwordless path. The SSO path uses IdP group claims mapped via
// AdminSettings:Oidc:GroupPrefix instead, so this list does not gate SSO sign-in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: After 9d24324, this comment says Admins "does not gate SSO sign-in". On self-hosted it is now the only SSO gate, and boot fails if it is empty.

Details and fix

LoginController.SsoSignIn checks IsOnAdminAllowlist(email) when _globalSettings.SelfHosted, and AddAdminUpstreamOidc throws on a self-hosted deployment when Admins is empty. A self-hosted operator who reads this comment would conclude that SSO admins are managed through IdP groups and leave them off this list. They would then be locked out of SSO, or the app would fail to boot.

The comments elsewhere in this change describe the earlier design and should change with it:

  • AdminSettings.cs:82-84 (GroupPrefix): "A principal with no mapped groups still signs in". On cloud that principal is now rejected. This leftover was already pointed out on the LoginController thread and is still here.
  • GroupClaimRoleMapper.cs constructor: "when OIDC is enabled, AddAdminUpstreamOidc fail-louds on an empty prefix ... so a null here means OIDC is off". On self-hosted, OIDC can be enabled with no prefix set. The behavior is still correct (it returns an empty set), but the reasoning in the comment is wrong. The MapRoles_ReturnsEmpty_WhenPrefixUnset test comment has the same problem.

Suggested wording for this comment:

// Comma-separated allowlist of email addresses ("email" or "email:stamp").
// Passwordless path: gates sign-in on every deployment.
// SSO path: gates sign-in on self-hosted only (required at boot when SSO is enabled there).
// Cloud SSO uses IdP group claims mapped via AdminSettings:Oidc:GroupPrefix instead.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant