Skip to content

Commit

Permalink
Only org policy (#962)
Browse files Browse the repository at this point in the history
* added OnlyOrg to PolicyType enum

* blocked accepting new org invitations if OnlyOrg is relevant to the userOrg

* blocked creating new orgs if already in an org with OnlyOrg enabled

* created email alert for OnlyOrg policy

* removed users & sent alerts when appropriate for the OnlyOrg policy

* added method to noop mail service

* cleanup for OnlyOrg policy server logic

* blocked confirming new org users if they have violated the OnlyOrg policy since accepting

* added localization strings needed for the OnlyOrg policy

* allowed OnlyOrg policy configuration from the portal

* used correct localization key for onlyorg

* formatting and messaging changes for OnlyOrg

* formatting

* messaging change

* code review changes for onlyorg

* slimmed down a conditional

* optimized getting many orgUser records from many userIds

* removed a test file

* sql formatting

* weirdness

* trying to resolve git diff formatting issues
  • Loading branch information
Addison Beck authored Oct 20, 2020
1 parent 50cf16a commit e872b4d
Show file tree
Hide file tree
Showing 18 changed files with 218 additions and 20 deletions.
3 changes: 3 additions & 0 deletions bitwarden_license/src/Portal/Models/PolicyEditModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ public Policy ToPolicy(Policy existingPolicy)
case PolicyType.PasswordGenerator:
existingPolicy.Data = JsonSerializer.Serialize(PasswordGeneratorDataModel, options);
break;
case PolicyType.OnlyOrg:
case PolicyType.TwoFactorAuthentication:
break;
default:
throw new ArgumentOutOfRangeException();
}
Expand Down
5 changes: 5 additions & 0 deletions bitwarden_license/src/Portal/Models/PolicyModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public PolicyModel(PolicyType policyType, bool enabled)
DescriptionKey = "PasswordGeneratorDescription";
break;

case PolicyType.OnlyOrg:
NameKey = "OnlyOrganization";
DescriptionKey = "OnlyOrganizationDescription";
break;

default:
throw new ArgumentOutOfRangeException();
}
Expand Down
11 changes: 11 additions & 0 deletions bitwarden_license/src/Portal/Views/Policies/Edit.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
</div>
}

@if (Model.PolicyType == PolicyType.OnlyOrg)
{
<div class="callout callout-warning" role="alert">
<h3 class="callout-heading">
<i class="fa fa-warning" *ngIf="icon" aria-hidden="true"></i>
@i18nService.T("Warning")
</h3>
@i18nService.T("OnlyOrganizationPolicyWarning")
</div>
}

<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" asp-for="Enabled">
Expand Down
20 changes: 19 additions & 1 deletion src/Api/Controllers/OrganizationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Mvc;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Exceptions;
using Bit.Core.Services;
Expand All @@ -25,6 +26,7 @@ public class OrganizationsController : Controller
private readonly IPaymentService _paymentService;
private readonly CurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IPolicyRepository _policyRepository;

public OrganizationsController(
IOrganizationRepository organizationRepository,
Expand All @@ -33,7 +35,8 @@ public OrganizationsController(
IUserService userService,
IPaymentService paymentService,
CurrentContext currentContext,
GlobalSettings globalSettings)
GlobalSettings globalSettings,
IPolicyRepository policyRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -42,6 +45,7 @@ public OrganizationsController(
_paymentService = paymentService;
_currentContext = currentContext;
_globalSettings = globalSettings;
_policyRepository = policyRepository;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -156,6 +160,13 @@ public async Task<OrganizationResponseModel> Post([FromBody]OrganizationCreateRe
throw new Exception("Invalid plan selected.");
}

var policies = await _policyRepository.GetManyByUserIdAsync(user.Id);
if (policies.Any(policy => policy.Type == PolicyType.OnlyOrg))
{
throw new Exception("You may not create an organization. You belong to an organization " +
"which has a policy that prohibits you from being a member of any other organization.");
}

var organizationSignup = model.ToOrganizationSignup(user);
var result = await _organizationService.SignUpAsync(organizationSignup);
return new OrganizationResponseModel(result.Item1);
Expand All @@ -177,6 +188,13 @@ public async Task<OrganizationResponseModel> PostLicense(OrganizationCreateLicen
throw new BadRequestException("Invalid license");
}

var policies = await _policyRepository.GetManyByUserIdAsync(user.Id);
if (policies.Any(policy => policy.Type == PolicyType.OnlyOrg))
{
throw new Exception("You may not create an organization. You belong to an organization " +
"which has a policy that prohibits you from being a member of any other organization.");
}

var result = await _organizationService.SignUpAsync(license, user, model.Key, model.CollectionName);
return new OrganizationResponseModel(result.Item1);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Enums/PolicyType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum PolicyType : byte
{
TwoFactorAuthentication = 0,
MasterPassword = 1,
PasswordGenerator = 2
PasswordGenerator = 2,
OnlyOrg = 3,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top" align="left">
Your user account has been removed from the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{OrganizationName}}</b> organization because you are a part of another organization. The {{OrganizationName}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations or join with a different account.
</td>
</tr>
</table>
{{/FullHtmlLayout}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your user account has been removed from the {{OrganizationName}} organization because you are a part of another
organization. The {{OrganizationName}} has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations, or join with a
new account.
{{/BasicTextLayout}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail
{
public class OrganizationUserRemovedForPolicyOnlyOrgViewModel : BaseMailModel
{
public string OrganizationName { get; set; }
}
}
1 change: 1 addition & 0 deletions src/Core/Repositories/IOrganizationUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync
Task UpdateGroupsAsync(Guid orgUserId, IEnumerable<Guid> groupIds);
Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
}
}
13 changes: 13 additions & 0 deletions src/Core/Repositories/SqlServer/OrganizationUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,5 +231,18 @@ public class OrganizationUserWithCollections : OrganizationUser
{
public DataTable Collections { get; set; }
}

public async Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUser>(
"[dbo].[OrganizationUser_ReadByUserIds]",
new { UserIds = userIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);

return results.ToList();
}
}
}
}
9 changes: 9 additions & 0 deletions src/Core/Resources/SharedResources.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -539,4 +539,13 @@
<data name="RedirectBehavior" xml:space="preserve">
<value>OIDC Redirect Behavior</value>
</data>
<data name="OnlyOrganization" xml:space="preserve">
<value>Only Organization</value>
</data>
<data name="OnlyOrganizationDescription" xml:space="preserve">
<value>Restrict users from being able to join any other organizations.</value>
</data>
<data name="OnlyOrganizationPolicyWarning" xml:space="preserve">
<value>Organization members who are already a part of another organization will be removed from this organization and will receive an email notifying them about the change.</value>
</data>
</root>
1 change: 1 addition & 0 deletions src/Core/Services/IMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ Task SendInvoiceUpcomingAsync(string email, decimal amount, DateTime dueDate, Li
Task SendLicenseExpiredAsync(IEnumerable<string> emails, string organizationName = null);
Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip);
Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip);
Task SendOrganizationUserRemovedForPolicyOnlyOrgEmailAsync(string organizationName, string email);
}
}
14 changes: 14 additions & 0 deletions src/Core/Services/Implementations/HandlebarsMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,20 @@ public async Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, st
await _mailDeliveryService.SendEmailAsync(message);
}

public async Task SendOrganizationUserRemovedForPolicyOnlyOrgEmailAsync(string organizationName, string email)
{
var message = CreateDefaultMessage($"You have been removed from {organizationName}", email);
var model = new OrganizationUserRemovedForPolicyOnlyOrgViewModel
{
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "OrganizationUserRemovedForPolicyOnlyOrg", model);
message.Category = "OrganizationUserRemovedForPolicyOnlyOrg";
await _mailDeliveryService.SendEmailAsync(message);
}

private MailMessage CreateDefaultMessage(string subject, string toEmail)
{
return CreateDefaultMessage(subject, new List<string> { toEmail });
Expand Down
38 changes: 36 additions & 2 deletions src/Core/Services/Implementations/OrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,10 +1135,34 @@ private async Task<OrganizationUser> AcceptUserAsync(OrganizationUser orgUser, U
}
}

ICollection<Policy> orgPolicies = null;
ICollection<Policy> userPolicies = null;
async Task<bool> hasPolicyAsync(PolicyType policyType, bool useUserPolicies = false)
{
var policies = useUserPolicies ?
userPolicies = userPolicies ?? await _policyRepository.GetManyByUserIdAsync(user.Id) :
orgPolicies = orgPolicies ?? await _policyRepository.GetManyByOrganizationIdAsync(orgUser.OrganizationId);

return policies.Any(p => p.Type == policyType && p.Enabled);
}
var userOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id);
if (userOrgs.Any(ou => ou.OrganizationId != orgUser.OrganizationId && ou.Status != OrganizationUserStatusType.Invited))
{
if (await hasPolicyAsync(PolicyType.OnlyOrg))
{
throw new BadRequestException("You may not join this organization until you leave or remove " +
"all other organizations.");
}
if (await hasPolicyAsync(PolicyType.OnlyOrg, true))
{
throw new BadRequestException("You cannot join this organization because you are a member of " +
"an organization which forbids it");
}
}

if (!await userService.TwoFactorIsEnabledAsync(user))
{
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgUser.OrganizationId);
if (policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled))
if (await hasPolicyAsync(PolicyType.TwoFactorAuthentication))
{
throw new BadRequestException("You cannot join this organization until you enable " +
"two-step login on your user account.");
Expand Down Expand Up @@ -1185,6 +1209,16 @@ public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid o
throw new BadRequestException("User does not have two-step login enabled.");
}

var usingOnlyOrgPolicy = policies.Any(p => p.Type == PolicyType.OnlyOrg && p.Enabled);
if (usingOnlyOrgPolicy)
{
var userOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id);
if (userOrgs.Any(ou => ou.OrganizationId != organizationId && ou.Status != OrganizationUserStatusType.Invited))
{
throw new BadRequestException("User is a member of another organization.");
}
}

orgUser.Status = OrganizationUserStatusType.Confirmed;
orgUser.Key = key;
orgUser.Email = null;
Expand Down
51 changes: 35 additions & 16 deletions src/Core/Services/Implementations/PolicyService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Table;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -53,27 +55,44 @@ public async Task SaveAsync(Policy policy, IUserService userService, IOrganizati
var currentPolicy = await _policyRepository.GetByIdAsync(policy.Id);
if (!currentPolicy?.Enabled ?? true)
{
if (currentPolicy.Type == Enums.PolicyType.TwoFactorAuthentication)
Organization organization = null;
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
policy.OrganizationId);
var removableOrgUsers = orgUsers.Where(ou =>
ou.Status != Enums.OrganizationUserStatusType.Invited &&
ou.Type != Enums.OrganizationUserType.Owner && ou.UserId != savingUserId);
switch (currentPolicy.Type)
{
Organization organization = null;
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
policy.OrganizationId);
foreach (var orgUser in orgUsers.Where(ou =>
ou.Status != Enums.OrganizationUserStatusType.Invited &&
ou.Type != Enums.OrganizationUserType.Owner))
{
if (orgUser.UserId != savingUserId && !await userService.TwoFactorIsEnabledAsync(orgUser))
case Enums.PolicyType.TwoFactorAuthentication:
foreach (var orgUser in removableOrgUsers)
{
if (organization == null)
if (!await userService.TwoFactorIsEnabledAsync(orgUser))
{
organization = await _organizationRepository.GetByIdAsync(policy.OrganizationId);
organization = organization ?? await _organizationRepository.GetByIdAsync(policy.OrganizationId);
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(
organization.Name, orgUser.Email);
}
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(
organization.Name, orgUser.Email);
}
}
break;
case Enums.PolicyType.OnlyOrg:
var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(
removableOrgUsers.Select(ou => ou.UserId.Value));
foreach (var orgUser in removableOrgUsers)
{
if (userOrgs.Any(ou => ou.UserId == orgUser.UserId && ou.Status != OrganizationUserStatusType.Invited))
{
organization = organization ?? await _organizationRepository.GetByIdAsync(policy.OrganizationId);
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicyOnlyOrgEmailAsync(
organization.Name, orgUser.Email);
}
}
break;
default:
break;
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/Core/Services/NoopImplementations/NoopMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,10 @@ public Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string i
{
return Task.FromResult(0);
}

public Task SendOrganizationUserRemovedForPolicyOnlyOrgEmailAsync(string organizationName, string email)
{
return Task.FromResult(0);
}
}
}
18 changes: 18 additions & 0 deletions src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByUserIds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadByUserIds]
@UserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON

IF (SELECT COUNT(1) FROM @UserIds) < 1
BEGIN
RETURN(-1)
END

SELECT
*
FROM
[dbo].[OrganizationUserView]
WHERE
[UserId] IN (SELECT [Id] FROM @UserIds)
END
25 changes: 25 additions & 0 deletions util/Migrator/DbScripts/2020-10-14_00_OrgUserReadByUserIds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
IF OBJECT_ID('[dbo].[OrganizationUser_ReadByUserIds]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_ReadByUserIds]
END
GO

CREATE PROCEDURE [dbo].[OrganizationUser_ReadByUserIds]
@UserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON

IF (SELECT COUNT(1) FROM @UserIds) < 1
BEGIN
RETURN(-1)
END

SELECT
*
FROM
[dbo].[OrganizationUserView]
WHERE
[UserId] IN (SELECT [Id] FROM @UserIds)
END
GO

0 comments on commit e872b4d

Please sign in to comment.