Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
dcfe1fd
Initial commit: OrganizationContext class
eliykat Sep 5, 2025
7137dee
Move caching to class level, add tests
eliykat Sep 5, 2025
a8103f6
Tweaks
eliykat Sep 5, 2025
6b233fb
Fix test
eliykat Sep 5, 2025
fab969a
Move existing logic into command
eliykat Aug 9, 2025
8e224f7
Add tests
eliykat Aug 9, 2025
b0adf4a
Add provider check
eliykat Aug 9, 2025
1d41fbf
Fix method name to be consistent with command
eliykat Aug 9, 2025
39e09e5
Fix test naming
eliykat Aug 9, 2025
89053ff
register in DI
eliykat Aug 9, 2025
a094aa6
Remove unnecessary db call
eliykat Aug 12, 2025
1eb1942
Update error string
eliykat Aug 12, 2025
115ddc5
Add missing test coverage for CheckProviderPermissionsAsync logic
eliykat Aug 12, 2025
1f1e358
Make methods static
eliykat Aug 12, 2025
12895fa
WIP refactor: use authorization handler
eliykat Aug 9, 2025
856aa22
Refactor handler to use new OrganizationContext service
eliykat Sep 4, 2025
c37b3b3
Review and refactor tests
eliykat Sep 4, 2025
ab76446
Consolidate test cases
eliykat Sep 4, 2025
ee337e4
Fix merge conflict resolution errors
eliykat Sep 5, 2025
2ade117
Revert old code, feature flag changes
eliykat Sep 5, 2025
fbd479e
Revert unused declaration
eliykat Sep 5, 2025
a91546e
Tweak comment
eliykat Sep 5, 2025
2ffef5c
Merge remote-tracking branch 'origin/main' into ac/pm-24192/server-pr…
eliykat Sep 16, 2025
f70b04c
Fix bug: could be a provider for any org
eliykat Sep 17, 2025
d449e24
Merge remote-tracking branch 'origin/main' into ac/pm-24192/server-pr…
eliykat Sep 25, 2025
563d9e8
Split handlers
eliykat Sep 30, 2025
31fdd9d
Merge remote-tracking branch 'origin/main' into ac/pm-24192/server-pr…
eliykat Oct 9, 2025
c14552c
undo unrelated change
eliykat Oct 9, 2025
deb8dac
tweaks
eliykat Oct 9, 2025
37b751d
Split handlers
eliykat Oct 9, 2025
38daec2
Build out tests
eliykat Oct 9, 2025
684138a
remove unrelated files
eliykat Oct 9, 2025
59dd29d
dotnet format
eliykat Oct 9, 2025
a2a6f38
api integration tests
eliykat Oct 9, 2025
69282c7
dotnet format
eliykat Oct 9, 2025
4936e6c
Merge branch 'main' into ac/pm-24192/server-prevent-organizations-fro…
eliykat Oct 28, 2025
3dcc79a
Rename PutResetPasswordvNext to PutResetPasswordNew and make private …
eliykat Oct 28, 2025
d6d6d99
Replace exceptions with TypedResults in reset password methods
eliykat Oct 28, 2025
585f952
Enable nullable reference types for PutResetPasswordNew method
eliykat Oct 28, 2025
4ef1097
Add unit tests for PutResetPassword method
eliykat Oct 28, 2025
a2b3d7b
Restore [FromBody] attribute to PutResetPasswordNew method
eliykat Oct 28, 2025
e535e12
Fix nullable directive and add partial migration note
eliykat Oct 28, 2025
aea2dab
use example.com domain
eliykat Oct 30, 2025
040ba54
Add ICurrentContext xmldoc
eliykat Oct 30, 2025
c29374b
Add more comments to make Claude happy
eliykat Oct 30, 2025
1b1b7c7
Fix imports
eliykat Oct 30, 2025
85fabd7
Update tests to match expected behavior
eliykat Oct 30, 2025
600186e
Merge remote-tracking branch 'origin/main' into ac/pm-24192/server-pr…
eliykat Oct 30, 2025
aff648e
Fix test failures
eliykat Oct 30, 2025
467e4e5
Combine authorization handlers to clarify code flow
eliykat Oct 31, 2025
5f463fc
Add comment
eliykat Oct 31, 2025
facecce
dotnet format
eliykat Oct 31, 2025
1722a57
Refer to provider member rather than provider
eliykat Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection

services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
]);
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization;

/// <summary>
/// An authorization requirement for recovering an organization member's account.
/// </summary>
/// <remarks>
/// Note: this is different to simply being able to manage account recovery. The user must be recovering
/// a member who has equal or lesser permissions than them.
/// </remarks>
public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement;

/// <summary>
/// Authorizes members and providers to recover a target OrganizationUser's account.
/// </summary>
/// <remarks>
/// This prevents privilege escalation by ensuring that a user cannot recover the account of
/// another user with a higher role or with provider membership.
/// </remarks>
public class RecoverAccountAuthorizationHandler(
IOrganizationContext organizationContext,
ICurrentContext currentContext,
IProviderUserRepository providerUserRepository)
: AuthorizationHandler<RecoverAccountAuthorizationRequirement, OrganizationUser>
{
public const string FailureReason = "You are not permitted to recover this user's account.";
public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account.";

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
RecoverAccountAuthorizationRequirement requirement,
OrganizationUser targetOrganizationUser)
{
// Step 1: check that the User has permissions with respect to the organization.
// This may come from their role in the organization or their provider relationship.
var canRecoverOrganizationMember =
AuthorizeMember(context.User, targetOrganizationUser) ||
await AuthorizeProviderAsync(context.User, targetOrganizationUser);

if (!canRecoverOrganizationMember)
{
context.Fail(new AuthorizationFailureReason(this, FailureReason));
return;
}

// Step 2: check that the User has permissions with respect to any provider the target user is a member of.
// This prevents an organization admin performing privilege escalation into an unrelated provider.
var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser);
if (!canRecoverProviderMember)
{
context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason));
return;
}

context.Succeed(requirement);
}

private async Task<bool> AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId);
}

private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId);
if (currentContextOrganization == null)
{
return false;
}

// Current user must have equal or greater permissions than the user account being recovered
var authorized = targetOrganizationUser.Type switch
{
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner,
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin,
_ => currentContextOrganization is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin }
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true }
};

return authorized;
}

private async Task<bool> CanRecoverProviderAsync(OrganizationUser targetOrganizationUser)
{
if (!targetOrganizationUser.UserId.HasValue)
{
// If an OrganizationUser is not linked to a User then it can't be linked to a Provider either.
// This is invalid but does not pose a privilege escalation risk. Return early and let the command
// handle the invalid input.
return true;
}

var targetUserProviderUsers =
await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value);

// If the target user belongs to any provider that the current user is not a member of,
// deny the action to prevent privilege escalation from organization to provider.
// Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee
// against it; this returns a sequence, so we handle the possibility.
var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId));
return authorized;
}
}

57 changes: 52 additions & 5 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable

using Bit.Api.AdminConsole.Authorization;
Expand All @@ -11,6 +12,7 @@
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
Expand Down Expand Up @@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -97,7 +100,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -126,6 +130,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -474,21 +479,62 @@ await _organizationService.UpdateUserResetPasswordEnrollmentAsync(

[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}

// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
throw new NotFoundException();
return TypedResults.NotFound();
}

var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return;
return TypedResults.Ok();
}

foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}

await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}

#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
{
return TypedResults.NotFound();
}

var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement());
Copy link
Contributor

Choose a reason for hiding this comment

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

Just thinking out loud: this looks like it could be in a middleware that we can attach to the controller method. That way, it's cleaner and we can add it to multiple places. In some languages, you can even hydrate the request object in the middleware so the data can be passed along to other middlewares.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is definitely doable, but at this stage we have no cause to reuse this elsewhere, and the logic is already encapsulated in the handler. I think it's clearer to make the explicit call. As you've found with your logging case, moving logic into the middleware isn't always very clear to the reader.

That said, Billing Team do have a neat attribute that fetches and injects the organization for the current request: https://github.com/bitwarden/server/blob/main/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs#L36-L44. We could do something similar to get the orgUser, make sure they exist, and make sure they match the current organization - given that that is a very common but critical bit of boilerplate. I've already fussed this PR a lot though so I'd prefer to leave it for future experimentation.

if (!authorizationResult.Succeeded)
{
// Return an informative error to show in the UI.
// The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific.
var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason;
// This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead
return TypedResults.BadRequest(new ErrorResponseModel(failureReason));
}

var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}

foreach (var error in result.Errors)
Expand All @@ -497,8 +543,9 @@ public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationU
}

await Task.Delay(2000);
throw new BadRequestException(ModelState);
return TypedResults.BadRequest(ModelState);
}
#nullable disable

[HttpDelete("{id}")]
[Authorize<ManageUsersRequirement>]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;

namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;

public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IUserRepository userRepository,
IMailService mailService,
IEventService eventService,
IPushNotificationService pushNotificationService,
IUserService userService,
TimeProvider timeProvider) : IAdminRecoverAccountCommand
{
public async Task<IdentityResult> RecoverAccountAsync(Guid orgId,
OrganizationUser organizationUser, string newMasterPassword, string key)
{
// Org must be able to use reset password
var org = await organizationRepository.GetByIdAsync(orgId);
if (org == null || !org.UseResetPassword)
{
throw new BadRequestException("Organization does not allow password reset.");
}

// Enterprise policy must be enabled
var resetPasswordPolicy =
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

// Org User must be confirmed and have a ResetPasswordKey
if (organizationUser == null ||
organizationUser.Status != OrganizationUserStatusType.Confirmed ||
organizationUser.OrganizationId != orgId ||
string.IsNullOrEmpty(organizationUser.ResetPasswordKey) ||
!organizationUser.UserId.HasValue)
{
throw new BadRequestException("Organization User not valid");
}

var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value);
if (user == null)
{
throw new NotFoundException();
}

if (user.UsesKeyConnector)
{
throw new BadRequestException("Cannot reset password of a user with Key Connector.");
}

var result = await userService.UpdatePasswordHash(user, newMasterPassword);
if (!result.Succeeded)
{
return result;
}

user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime;
user.LastPasswordChangeDate = user.RevisionDate;
user.ForcePasswordReset = true;
user.Key = key;

await userRepository.ReplaceAsync(user);
await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName());
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword);
await pushNotificationService.PushLogOutAsync(user.Id);

return IdentityResult.Success;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Identity;

namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;

/// <summary>
/// A command used to recover an organization user's account by an organization admin.
/// </summary>
public interface IAdminRecoverAccountCommand
{
/// <summary>
/// Recovers an organization user's account by resetting their master password.
/// </summary>
/// <param name="orgId">The organization the user belongs to.</param>
/// <param name="organizationUser">The organization user being recovered.</param>
/// <param name="newMasterPassword">The user's new master password hash.</param>
/// <param name="key">The user's new master-password-sealed user key.</param>
/// <returns>An IdentityResult indicating success or failure.</returns>
/// <exception cref="BadRequestException">When organization settings, policy, or user state is invalid.</exception>
/// <exception cref="NotFoundException">When the user does not exist.</exception>
Task<IdentityResult> RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser,
string newMasterPassword, string key);
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public static class FeatureFlagKeys
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string AccountRecoveryCommand = "pm-24192-account-recovery-command";

/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
Expand Down
24 changes: 21 additions & 3 deletions src/Core/Context/ICurrentContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
#nullable enable

using System.Security.Claims;
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Identity;
Expand All @@ -12,6 +10,14 @@

namespace Bit.Core.Context;

/// <summary>
/// Provides information about the current HTTP request and the currently authenticated user (if any).
/// This is often (but not exclusively) parsed from the JWT in the current request.
/// </summary>
/// <remarks>
/// This interface suffers from having too much responsibility; consider whether any new code can go in a more
/// specific class rather than adding it here.
/// </remarks>
public interface ICurrentContext
{
HttpContext HttpContext { get; set; }
Expand Down Expand Up @@ -59,8 +65,20 @@ public interface ICurrentContext
Task<bool> EditSubscription(Guid orgId);
Task<bool> EditPaymentMethods(Guid orgId);
Task<bool> ViewBillingHistory(Guid orgId);
/// <summary>
/// Returns true if the current user is a member of a provider that manages the specified organization.
/// This generally gives the user administrative privileges for the organization.
/// </summary>
/// <param name="orgId"></param>
/// <returns></returns>
Task<bool> ProviderUserForOrgAsync(Guid orgId);
/// <summary>
/// Returns true if the current user is a Provider Admin of the specified provider.
/// </summary>
bool ProviderProviderAdmin(Guid providerId);
/// <summary>
/// Returns true if the current user is a member of the specified provider (with any role).
/// </summary>
bool ProviderUser(Guid providerId);
bool ProviderManageUsers(Guid providerId);
bool ProviderAccessEventLogs(Guid providerId);
Expand Down
Loading
Loading