Repository navigation
[PM-44143][BEEEP] feat: add RBAC from IdP group claims #8587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: admin/upstream-oidc-minimal
Are you sure you want to change the base?
Changes from 3 commits
3531933
65cb422
f4955f9
e7d6e7d
1dfda58
4293d43
3ebcc4b
5e6af3b
9d24324
d54125f
92ff0e7
7379247
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| using Bit.Admin.Auth.Filters; | ||
| using Bit.Admin.Auth.IdentityServer; | ||
| using Bit.Admin.Auth.Models; | ||
| using Bit.Admin.Auth.Services; | ||
| using Microsoft.AspNetCore.Authentication; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Identity; | ||
|
|
@@ -16,19 +17,19 @@ namespace Bit.Admin.Auth.Controllers; | |
| public class LoginController : Controller | ||
| { | ||
| private readonly PasswordlessSignInManager<IdentityUser> _signInManager; | ||
| private readonly UserManager<IdentityUser> _userManager; | ||
| private readonly AdminSettings _adminSettings; | ||
| private readonly ISsoSignInPrincipalBuilder _ssoSignInPrincipalBuilder; | ||
| private readonly ILogger<LoginController> _logger; | ||
|
|
||
| public LoginController( | ||
| PasswordlessSignInManager<IdentityUser> signInManager, | ||
| UserManager<IdentityUser> userManager, | ||
| IOptions<AdminSettings> adminSettings, | ||
| ISsoSignInPrincipalBuilder ssoSignInPrincipalBuilder, | ||
| ILogger<LoginController> logger) | ||
| { | ||
| _signInManager = signInManager; | ||
| _userManager = userManager; | ||
| _adminSettings = adminSettings.Value; | ||
| _ssoSignInPrincipalBuilder = ssoSignInPrincipalBuilder; | ||
| _logger = logger; | ||
| } | ||
|
|
||
|
|
@@ -143,16 +144,6 @@ public async Task<IActionResult> SsoSignIn(string returnUrl = null, string remot | |
| return RedirectToAction("Index", new { error = 5 }); | ||
| } | ||
|
|
||
| var user = await _userManager.FindByEmailAsync(email); | ||
| if (user == null) | ||
| { | ||
| // Same error code as other reject branches. Distinct codes would let any authenticated | ||
| // IdP user enumerate which emails are on the admin allowlist. | ||
| _logger.LogWarning("SSO sign-in rejected: principal not on admin allowlist."); | ||
| await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); | ||
| return RedirectToAction("Index", new { error = 5 }); | ||
| } | ||
|
|
||
| // Set IsPersistent = false, so SSO sessions die when the browser closes. | ||
| // The passwordless flow uses IsPersistent = true and survives a browser restart. | ||
| var props = new AuthenticationProperties { IsPersistent = false }; | ||
|
|
@@ -171,7 +162,14 @@ public async Task<IActionResult> SsoSignIn(string returnUrl = null, string remot | |
| props.StoreTokens([new AuthenticationToken { Name = "id_token", Value = idToken }]); | ||
| } | ||
|
|
||
| await _signInManager.SignInWithClaimsAsync(user, props, Array.Empty<Claim>()); | ||
| // SSO signs in to its own dedicated cookie scheme with a hand-built principal rather | ||
| // than routing through SignInManager / UserManager. There's no local user record to | ||
| // look up and no SecurityStamp to track - the IdP owns identity, and session lifetime | ||
| // 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Details and fixThe allowlist check was removed and nothing replaced it. In cloud,
Before this change, only operators on the The comment added in 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved in 1dfda58. One leftover: the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| await HttpContext.SignInAsync(AdminAuthenticationSchemes.SsoCookie, principal, props); | ||
| await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); | ||
| _logger.LogInformation("SSO sign-in succeeded."); | ||
|
|
||
|
|
@@ -187,12 +185,16 @@ public async Task<IActionResult> SsoSignIn(string returnUrl = null, string remot | |
| [ValidateAntiForgeryToken] | ||
| public async Task<IActionResult> Logout() | ||
| { | ||
| // Presence of `id_token` on the app cookie means this is an SSO session (the | ||
| // passwordless flow never stores tokens), so it's both the "was SSO" signal and the | ||
| // value we need for id_token_hint. Read before SignOutAsync clears the cookie. | ||
| var idToken = await HttpContext.GetTokenAsync("id_token"); | ||
|
|
||
| // Presence of `id_token` on the SSO cookie is both (a) the marker that this is an SSO | ||
| // session (the passwordless flow writes to a different cookie and stores no tokens) | ||
| // and (b) the value we need as id_token_hint on RP-initiated logout. Read before the | ||
| // sign-outs below clear the cookie. | ||
| var idToken = await HttpContext.GetTokenAsync(AdminAuthenticationSchemes.SsoCookie, "id_token"); | ||
|
|
||
| // Sign out of both cookie schemes. SignOutAsync on a scheme the user isn't currently | ||
| // signed in to is a no-op, so this covers both passwordless and SSO sessions cleanly. | ||
| await _signInManager.SignOutAsync(); | ||
| await HttpContext.SignOutAsync(AdminAuthenticationSchemes.SsoCookie); | ||
|
|
||
| var loggedOutRedirect = Url.Action(nameof(Index), "Login", new { success = 1 }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,17 @@ | ||
| ο»Ώnamespace Bit.Admin.Auth.IdentityServer; | ||
| namespace Bit.Admin.Auth.IdentityServer; | ||
|
|
||
| public static class AdminAuthenticationSchemes | ||
| { | ||
| // Registered scheme name for the upstream federated OIDC handler. Used by controller | ||
| // actions to Challenge/SignOut the correct handler when the app has multiple auth | ||
| // schemes registered (this one plus ASP.NET Identity's application/external cookies). | ||
| public const string UpstreamOidc = "sso"; | ||
|
|
||
| // Application cookie issued at the end of a successful SSO sign-in. Separate from the | ||
| // Identity application cookie by design: the SSO path has no local user store and no | ||
| // SecurityStamp to track - the IdP owns identity, so routing it through the Identity | ||
| // cookie (whose SecurityStampValidator does a 30s re-fetch against the user store) would | ||
| // sign the session out within 30 seconds. Startup's authorization DefaultPolicy lists | ||
| // both this scheme and IdentityConstants.ApplicationScheme so [Authorize] accepts either. | ||
| public const string SsoCookie = "sso-cookie"; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| ο»Ώusing System.Security.Claims; | ||
| using Bit.Admin.Utilities; | ||
|
|
||
| namespace Bit.Admin.Auth.Services; | ||
|
|
||
| // Translates IdP group claims into Admin Portal role names. | ||
| // | ||
| // An IdP ships group memberships in the `groups` claim of the ID token. By | ||
| // convention each Admin Portal group is named `<GroupPrefix><role>` (e.g. | ||
| // `us-prd-cloud-adminportal-admin`); the mapper strips the prefix and keeps the suffix only if | ||
| // it matches one of the role names in RolePermissionMapping. The suffix match is | ||
| // case-insensitive. A principal in multiple matched groups gets the union of their roles. | ||
| // | ||
| // Decoupled from the ClaimsPrincipal source so the SSO sign-in controller and the AccessControl | ||
| // pipeline can share the same mapper and unit tests do not need to spin up ASP.NET. | ||
| public interface IGroupClaimRoleMapper | ||
| { | ||
| IReadOnlyCollection<string> MapRoles(ClaimsPrincipal principal); | ||
| } | ||
|
|
||
| public class GroupClaimRoleMapper : IGroupClaimRoleMapper | ||
| { | ||
| // The "groups" claim is an OIDC convention currently supported by all major IdPs. | ||
| public const string GroupsClaimType = "groups"; | ||
|
|
||
| private readonly string? _groupPrefix; | ||
|
|
||
| public GroupClaimRoleMapper(AdminSettings adminSettings) | ||
| { | ||
| // Tolerant of an unset prefix so the type can be registered unconditionally. The SSO path | ||
| // is the only caller; when OIDC is enabled, AddAdminUpstreamOidc fail-louds on an empty | ||
| // prefix at startup, so a null here means OIDC is off and the mapper will never be hit. | ||
| _groupPrefix = adminSettings?.Oidc?.GroupPrefix; | ||
| } | ||
|
|
||
| public IReadOnlyCollection<string> MapRoles(ClaimsPrincipal principal) | ||
| { | ||
| if (principal is null || string.IsNullOrWhiteSpace(_groupPrefix)) | ||
| { | ||
| return Array.Empty<string>(); | ||
| } | ||
|
|
||
| var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| foreach (var claim in principal.FindAll(GroupsClaimType)) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(claim.Value)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (!claim.Value.StartsWith(_groupPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var suffix = claim.Value.Substring(_groupPrefix.Length).Trim().ToLowerInvariant(); | ||
| if (RolePermissionMapping.RolePermissions.ContainsKey(suffix)) | ||
| { | ||
| roles.Add(suffix); | ||
| } | ||
| } | ||
|
|
||
| return roles; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| ο»Ώ// FIXME: Update this file to be null safe and then delete the line below | ||
| #nullable disable | ||
|
|
||
| using System.Security.Claims; | ||
| using Microsoft.AspNetCore.Identity; | ||
|
|
||
| namespace Bit.Admin.Auth.Services; | ||
|
|
||
| // Assembles the ClaimsPrincipal that gets written to the Admin Portal SSO cookie on sign-in. | ||
| // The caller hands over the verified external principal and the email, and gets back a | ||
| // principal ready for HttpContext.SignInAsync. | ||
| // | ||
| // Deliberately bypasses SignInManager / UserManager so the SSO path does not need a backing | ||
| // user store entry and does not stamp a SecurityStamp claim. The companion to that choice is | ||
| // that the controller signs the result into the dedicated AdminAuthenticationSchemes.SsoCookie | ||
| // scheme (not Identity's application cookie); the Identity cookie's SecurityStampValidator | ||
| // would otherwise reject this principal within 30 seconds because no matching user exists in | ||
| // the store. | ||
| public interface ISsoSignInPrincipalBuilder | ||
| { | ||
| ClaimsPrincipal Build(string email, ClaimsPrincipal externalPrincipal); | ||
| } | ||
|
|
||
| public class SsoSignInPrincipalBuilder : ISsoSignInPrincipalBuilder | ||
| { | ||
| private readonly IGroupClaimRoleMapper _groupClaimRoleMapper; | ||
|
|
||
| public SsoSignInPrincipalBuilder(IGroupClaimRoleMapper groupClaimRoleMapper) | ||
| { | ||
| _groupClaimRoleMapper = groupClaimRoleMapper; | ||
| } | ||
|
|
||
| public ClaimsPrincipal Build(string email, ClaimsPrincipal externalPrincipal) | ||
| { | ||
| // AuthenticationType is required for ClaimsIdentity.IsAuthenticated to return true. | ||
| // Using the application scheme name is conventional and makes the identity recognizable | ||
| // in middleware traces. | ||
| var identity = new ClaimsIdentity(IdentityConstants.ApplicationScheme); | ||
|
|
||
| identity.AddClaim(new Claim(ClaimTypes.Name, email)); | ||
| identity.AddClaim(new Claim(ClaimTypes.Email, email)); | ||
| identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, email)); | ||
|
trmartin4 marked this conversation as resolved.
|
||
|
|
||
| foreach (var role in _groupClaimRoleMapper.MapRoles(externalPrincipal)) | ||
| { | ||
| identity.AddClaim(new Claim(ClaimTypes.Role, role)); | ||
| } | ||
|
|
||
| return new ClaimsPrincipal(identity); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.SsoSignInchecksIsOnAdminAllowlist(email)when_globalSettings.SelfHosted, andAddAdminUpstreamOidcthrows on a self-hosted deployment whenAdminsis 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 theLoginControllerthread and is still here.GroupClaimRoleMapper.csconstructor: "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. TheMapRoles_ReturnsEmpty_WhenPrefixUnsettest comment has the same problem.Suggested wording for this comment:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
92ff0e7 and 7379247 fix most of this. The
Admins,GroupPrefix, andGroupClaimRoleMapperconstructor comments now describe the deployment-driven split. Two test comments are still stale:test/Admin.Test/Auth/Services/GroupClaimRoleMapperTests.cs:114-116(MapRoles_ReturnsEmpty_WhenPrefixUnset) says a null prefix only happens "in the OIDC-off topology". On self-hosted SSO, a null prefix is now a supported configuration.test/Admin.Test/Auth/Services/SsoSignInPrincipalBuilderTests.cs:54-56(Build_StampsNoRoleClaims_WhenNoGroupsMap) says a user with no mapped groups "still gets a valid principal". On cloud,LoginController.SsoSignInnow rejects that principal before sign-in. The builder still returns the principal, but the controller does not sign it in.These are minor and do not block merge.