1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 08:02:49 -05:00

[Provider] Server entities and models (#1370)

* Mock out provider models and service

* Implement CreateAsync, CompleteSetupAsync, UpdateAsync, InviteUserAsync and ResendInvitesAsync

* Implement AcceptUserAsync and ConfirmUsersAsync

* Implement SaveUserAsync and DeleteUserAsync

* Add email templates

* Add admin operations for providers

* Fix mail template names

* Rename roles

* Verify provider has provideradmin

* Add self hosted check to admin controller

* Resolve review comments

* Update sql queries

* Change create provider to use email instead of userId
This commit is contained in:
Oscar Hinton
2021-06-03 18:58:29 +02:00
committed by GitHub
parent 58954f161e
commit fe1ffb6a22
58 changed files with 2110 additions and 6 deletions

View File

@ -53,5 +53,10 @@
// Organization_ClientExportedVault = 1602,
Policy_Updated = 1700,
ProviderUser_Invited = 1800,
ProviderUser_Confirmed = 1801,
ProviderUser_Updated = 1802,
ProviderUser_Removed = 1803,
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Enums.Provider
{
public enum ProviderOrganizationProviderUserType : byte
{
Administrator = 0,
ServiceAdmin = 1,
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Enums.Provider
{
public enum ProviderStatusType : byte
{
Pending = 0,
Created = 1,
}
}

View File

@ -0,0 +1,9 @@
namespace Bit.Core.Enums.Provider
{
public enum ProviderUserStatusType : byte
{
Invited = 0,
Accepted = 1,
Confirmed = 2,
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Enums.Provider
{
public enum ProviderUserType : byte
{
ProviderAdmin = 0,
ServiceUser = 1,
}
}

View File

@ -0,0 +1,16 @@
{{#>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; text-align: center;" valign="top" align="center">
You have been invited to setup a new Provider within Bitwarden.
</td>
</tr>
<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; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Setup Provider Now
</a>
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
You have been invited to setup a new Provider within Bitwarden. To continue, click the following link:
{{{Url}}}
{{/BasicTextLayout}}

View File

@ -0,0 +1,14 @@
{{#>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">
This email is to notify you that you have been confirmed as a user of <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;">{{ProviderName}}</b>.
</td>
</tr>
<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 last" 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; -webkit-text-size-adjust: none;" valign="top">
You may now access the provider and manage the connected organizations.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
This email is to notify you that you have been confirmed as a user of {{ProviderName}}.
You may now access the provider and manage the connected organizations.
{{/BasicTextLayout}}

View File

@ -0,0 +1,21 @@
{{#>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; text-align: center;" valign="top" align="center">
You have been invited to join the provider, <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;">{{ProviderName}}</b>.
</td>
</tr>
<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; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Join Provider Now
</a>
</td>
</tr>
<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 last" 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; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you do not wish to join this provider, you can safely ignore this email.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,7 @@
{{#>BasicTextLayout}}
You have been invited to join the provider, {{ProviderName}}. To accept this invite, click the following link:
{{{Url}}}
If you do not wish to join this provider, you can safely ignore this email.
{{/BasicTextLayout}}

View File

@ -0,0 +1,15 @@
using System.Collections.Generic;
using Bit.Core.Enums.Provider;
using Bit.Core.Models.Data;
namespace Bit.Core.Models.Business.Provider
{
public class ProviderUserInvite
{
public IEnumerable<string> Emails { get; set; }
public ProviderUserType Type { get; set; }
public Permissions Permissions { get; set; }
public ProviderUserInvite() {}
}
}

View File

@ -0,0 +1,14 @@
namespace Bit.Core.Models.Mail.Provider
{
public class ProviderSetupInviteViewModel : BaseMailModel
{
public string ProviderId { get; set; }
public string Email { get; set; }
public string Token { get; set; }
public string Url => string.Format("{0}/setup-provider?providerId={1}&email={2}&token={3}",
WebVaultUrl,
ProviderId,
Email,
Token);
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail.Provider
{
public class ProviderUserConfirmedViewModel : BaseMailModel
{
public string ProviderName { get; set; }
}
}

View File

@ -0,0 +1,20 @@
namespace Bit.Core.Models.Mail.Provider
{
public class ProviderUserInvitedViewModel : BaseMailModel
{
public string ProviderName { get; set; }
public string ProviderId { get; set; }
public string ProviderUserId { get; set; }
public string Email { get; set; }
public string ProviderNameUrlEncoded { get; set; }
public string Token { get; set; }
public string Url => string.Format("{0}/accept-provider?providerId={1}&" +
"providerUserId={2}&email={3}&providerName={4}&token={5}",
WebVaultUrl,
ProviderId,
ProviderUserId,
Email,
ProviderNameUrlEncoded,
Token);
}
}

View File

@ -0,0 +1,31 @@
using System;
using Bit.Core.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Table.Provider
{
public class Provider : ITableObject<Guid>
{
public Guid Id { get; set; }
public string Name { get; set; }
public string BusinessName { get; set; }
public string BusinessAddress1 { get; set; }
public string BusinessAddress2 { get; set; }
public string BusinessAddress3 { get; set; }
public string BusinessCountry { get; set; }
public string BusinessTaxNumber { get; set; }
public string BillingEmail { get; set; }
public ProviderStatusType Status { get; set; }
public bool Enabled { get; set; } = true;
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}
}

View File

@ -0,0 +1,24 @@
using System;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Table.Provider
{
public class ProviderOrganization : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
public string Key { get; set; }
public string Settings { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using Bit.Core.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Table.Provider
{
public class ProviderOrganizationProviderUser : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid ProviderOrganizationId { get; set; }
public Guid ProviderUserId { get; set; }
public ProviderOrganizationProviderUserType Type { get; set; }
public string Permissions { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}
}

View File

@ -0,0 +1,28 @@
using System;
using Bit.Core.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Table.Provider
{
public class ProviderUser : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid? UserId { get; set; }
public string Email { get; set; }
public string Key { get; set; }
public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; }
public string Permissions { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}
}

View File

@ -0,0 +1,9 @@
using System;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Repositories
{
public interface IProviderOrganizationProviderUserRepository : IRepository<Provider, Guid>
{
}
}

View File

@ -0,0 +1,9 @@
using System;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Repositories
{
public interface IProviderOrganizationRepository : IRepository<Provider, Guid>
{
}
}

View File

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Repositories
{
public interface IProviderRepository : IRepository<Provider, Guid>
{
Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take);
}
}

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Enums.Provider;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Repositories
{
public interface IProviderUserRepository : IRepository<ProviderUser, Guid>
{
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
Task DeleteManyAsync(IEnumerable<Guid> userIds);
}
}

View File

@ -0,0 +1,17 @@
using System;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Settings;
namespace Bit.Core.Repositories.SqlServer
{
public class ProviderOrganizationProviderUserRepository : Repository<Provider, Guid>, IProviderOrganizationProviderUserRepository
{
public ProviderOrganizationProviderUserRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public ProviderOrganizationProviderUserRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
}
}

View File

@ -0,0 +1,17 @@
using System;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Settings;
namespace Bit.Core.Repositories.SqlServer
{
public class ProviderOrganizationRepository : Repository<Provider, Guid>, IProviderOrganizationRepository
{
public ProviderOrganizationRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public ProviderOrganizationRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
}
}

View File

@ -0,0 +1,38 @@
using System;
using Bit.Core.Models.Table;
using System.Threading.Tasks;
using System.Data.SqlClient;
using System.Data;
using Dapper;
using System.Linq;
using System.Collections.Generic;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Settings;
namespace Bit.Core.Repositories.SqlServer
{
public class ProviderRepository : Repository<Provider, Guid>, IProviderRepository
{
public ProviderRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public ProviderRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
{
var results = await connection.QueryAsync<Provider>(
"[dbo].[Provider_Search]",
new { Name = name, UserEmail = userEmail, Skip = skip, Take = take },
commandType: CommandType.StoredProcedure,
commandTimeout: 120);
return results.ToList();
}
}
}
}

View File

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Bit.Core.Enums.Provider;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Dapper;
using Microsoft.Data.SqlClient;
namespace Bit.Core.Repositories.SqlServer
{
public class ProviderUserRepository : Repository<ProviderUser, Guid>, IProviderUserRepository
{
public ProviderUserRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public ProviderUserRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers)
{
using (var connection = new SqlConnection(ConnectionString))
{
var result = await connection.ExecuteScalarAsync<int>(
"[dbo].[ProviderUser_ReadCountByProviderIdEmail]",
new { ProviderId = providerId, Email = email, OnlyUsers = onlyRegisteredUsers },
commandType: CommandType.StoredProcedure);
return result;
}
}
public async Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<ProviderUser>(
"[dbo].[ProviderUser_ReadByIds]",
new { Ids = ids.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<ProviderUser>(
"[dbo].[ProviderUser_ReadByProviderId]",
new { ProviderId = providerId, Type = type },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task DeleteManyAsync(IEnumerable<Guid> providerUserIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
await connection.ExecuteAsync("[dbo].[ProviderUser_DeleteByIds]",
new { Ids = providerUserIds.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure);
}
}
}
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Services
{
@ -17,5 +18,8 @@ namespace Bit.Core.Services
Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type, DateTime? date = null);
Task LogOrganizationUserEventsAsync(IEnumerable<(OrganizationUser, EventType, DateTime?)> events);
Task LogOrganizationEventAsync(Organization organization, EventType type, DateTime? date = null);
Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null);
Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events);
}
}

View File

@ -3,6 +3,7 @@ using Bit.Core.Models.Table;
using System.Collections.Generic;
using System;
using Bit.Core.Models.Mail;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Services
{
@ -41,5 +42,8 @@ namespace Bit.Core.Services
Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email);
Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage);
Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName);
Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email);
Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email);
Task SendProviderConfirmedEmailAsync(string providerName, string email);
}
}

View File

@ -0,0 +1,31 @@
using System.Threading.Tasks;
using Bit.Core.Models.Table;
using System.Collections.Generic;
using System;
using Bit.Core.Models.Business.Provider;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Services
{
public interface IProviderService
{
Task CreateAsync(string ownerEmail);
Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key);
Task UpdateAsync(Provider provider, bool updateBilling = false);
Task<List<ProviderUser>> InviteUserAsync(Guid providerId, Guid invitingUserId, ProviderUserInvite providerUserInvite);
Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(Guid providerId, Guid invitingUserId,
IEnumerable<Guid> providerUsersId);
Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token);
Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys, Guid confirmingUserId);
Task SaveUserAsync(ProviderUser user, Guid savingUserId);
Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId, IEnumerable<Guid> providerUserIds,
Guid deletingUserId);
Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key);
Task RemoveOrganization(Guid providerOrganizationId, Guid removingUserId);
// TODO: Figure out how ProviderOrganizationProviderUsers should be managed
}
}

View File

@ -7,6 +7,7 @@ using System.Linq;
using System.Collections.Generic;
using Bit.Core.Models.Table;
using Bit.Core.Context;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Settings;
namespace Bit.Core.Services
@ -222,6 +223,12 @@ namespace Bit.Core.Services
await _eventWriteService.CreateAsync(e);
}
// TODO: Implement this
public Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null) => throw new NotImplementedException();
// TODO: Implement this
public Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events) => throw new NotImplementedException();
private bool CanUseEvents(IDictionary<Guid, OrganizationAbility> orgAbilities, Guid orgId)
{
return orgAbilities != null && orgAbilities.ContainsKey(orgId) &&

View File

@ -9,6 +9,8 @@ using System.Net;
using Bit.Core.Utilities;
using System.Linq;
using System.Reflection;
using Bit.Core.Models.Mail.Provider;
using Bit.Core.Models.Table.Provider;
using HandlebarsDotNet;
namespace Bit.Core.Services
@ -646,5 +648,53 @@ namespace Bit.Core.Services
message.Category = "EmergencyAccessRecoveryTimedOut";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email)
{
var message = CreateDefaultMessage($"Create a Provider", email);
var model = new ProviderSetupInviteViewModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
ProviderId = provider.Id.ToString(),
Email = email,
Token = token,
};
await AddMessageContentAsync(message, "Provider.ProviderSetupInvite", model);
message.Category = "ProviderSetupInvite";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
{
var message = CreateDefaultMessage($"Join {providerName}", email);
var model = new ProviderUserInvitedViewModel
{
ProviderName = CoreHelpers.SanitizeForEmail(providerName),
Email = WebUtility.UrlDecode(providerUser.Email),
ProviderId = providerUser.ProviderId.ToString(),
ProviderUserId = providerUser.Id.ToString(),
ProviderNameUrlEncoded = WebUtility.UrlEncode(providerName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Provider.ProviderUserInvited", model);
message.Category = "ProviderSetupInvite";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendProviderConfirmedEmailAsync(string providerName, string email)
{
var message = CreateDefaultMessage($"You Have Been Confirmed To {providerName}", email);
var model = new ProviderUserConfirmedViewModel
{
ProviderName = CoreHelpers.SanitizeForEmail(providerName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "Provider.ProviderUserConfirmed", model);
message.Category = "ProviderUserConfirmed";
await _mailDeliveryService.SendEmailAsync(message);
}
}
}

View File

@ -0,0 +1,348 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Enums.Provider;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business.Provider;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
namespace Bit.Core.Services
{
public class ProviderService : IProviderService
{
private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService;
private readonly IEventService _eventService;
private readonly GlobalSettings _globalSettings;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IUserRepository userRepository, IUserService userService, IMailService mailService,
IDataProtectionProvider dataProtectionProvider, IEventService eventService, GlobalSettings globalSettings)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_userRepository = userRepository;
_userService = userService;
_mailService = mailService;
_eventService = eventService;
_globalSettings = globalSettings;
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
}
public async Task CreateAsync(string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
var provider = new Provider
{
Status = ProviderStatusType.Pending,
Enabled = true,
};
await _providerRepository.CreateAsync(provider);
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {owner.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, owner.Email);
}
public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id, _globalSettings))
{
throw new BadRequestException("Invalid token.");
}
await _providerRepository.UpsertAsync(provider);
var providerUser = new ProviderUser
{
ProviderId = provider.Id,
UserId = owner.Id,
Key = key,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin,
};
await _providerUserRepository.CreateAsync(providerUser);
}
public async Task UpdateAsync(Provider provider, bool updateBilling = false)
{
if (provider.Id == default)
{
throw new ApplicationException("Cannot create provider this way.");
}
await _providerRepository.ReplaceAsync(provider);
}
public async Task<List<ProviderUser>> InviteUserAsync(Guid providerId, Guid invitingUserId,
ProviderUserInvite invite)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null || invite?.Emails == null || !invite.Emails.Any())
{
throw new NotFoundException();
}
var providerUsers = new List<ProviderUser>();
foreach (var email in invite.Emails)
{
// Make sure user is not already invited
var existingProviderUserCount =
await _providerUserRepository.GetCountByProviderAsync(providerId, email, false);
if (existingProviderUserCount > 0)
{
continue;
}
var providerUser = new ProviderUser
{
ProviderId = providerId,
UserId = null,
Email = email.ToLowerInvariant(),
Key = null,
Type = invite.Type,
Status = ProviderUserStatusType.Invited,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
if (invite.Permissions != null)
{
providerUser.Permissions = JsonSerializer.Serialize(invite.Permissions, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});
}
await _providerUserRepository.CreateAsync(providerUser);
await SendInviteAsync(providerUser, provider);
providerUsers.Add(providerUser);
}
await _eventService.LogProviderUsersEventAsync(providerUsers.Select(pu => (pu, EventType.ProviderUser_Invited, null as DateTime?)));
return providerUsers;
}
public async Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(Guid providerId, Guid invitingUserId,
IEnumerable<Guid> providerUsersId)
{
var providerUsers = await _providerUserRepository.GetManyAsync(providerUsersId);
var provider = await _providerRepository.GetByIdAsync(providerId);
var result = new List<Tuple<ProviderUser, string>>();
foreach (var providerUser in providerUsers)
{
if (providerUser.Status != ProviderUserStatusType.Invited || providerUser.ProviderId != providerId)
{
result.Add(Tuple.Create(providerUser, "User invalid."));
continue;
}
await SendInviteAsync(providerUser, provider);
result.Add(Tuple.Create(providerUser, ""));
}
return result;
}
public async Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token)
{
var providerUser = await _providerUserRepository.GetByIdAsync(providerUserId);
if (providerUser == null)
{
throw new BadRequestException("User invalid.");
}
if (providerUser.Status != ProviderUserStatusType.Invited)
{
throw new BadRequestException("Already accepted.");
}
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id, _globalSettings))
{
throw new BadRequestException("Invalid token.");
}
if (string.IsNullOrWhiteSpace(providerUser.Email) ||
!providerUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id;
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
return providerUser;
}
public async Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var providerUsers = await _providerUserRepository.GetManyAsync(keys.Keys);
var validProviderUsers = providerUsers
.Where(u => u.UserId != null)
.ToList();
if (!validProviderUsers.Any())
{
return new List<Tuple<ProviderUser, string>>();
}
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
var provider = await _providerRepository.GetByIdAsync(providerId);
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
var keyedFilteredUsers = validProviderUsers.ToDictionary(u => u.UserId.Value, u => u);
var result = new List<Tuple<ProviderUser, string>>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var user in users)
{
if (!keyedFilteredUsers.ContainsKey(user.Id))
{
continue;
}
var providerUser = keyedFilteredUsers[user.Id];
try
{
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id];
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
await _mailService.SendOrganizationConfirmedEmailAsync(provider.Name, user.Email);
result.Add(Tuple.Create(providerUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
public async Task SaveUserAsync(ProviderUser user, Guid savingUserId)
{
if (user.Id.Equals(default))
{
throw new BadRequestException("Invite the user first.");
}
if (user.Type != ProviderUserType.ProviderAdmin &&
!await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] {user.Id}))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
await _providerUserRepository.ReplaceAsync(user);
await _eventService.LogProviderUserEventAsync(user, EventType.ProviderUser_Updated);
}
public async Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId,
IEnumerable<Guid> providerUserIds, Guid deletingUserId)
{
var providerUsers = await _providerUserRepository.GetManyAsync(providerUserIds);
if (!await HasConfirmedProviderAdminExceptAsync(providerId, providerUserIds))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
var result = new List<Tuple<ProviderUser, string>>();
var deletedUserIds = new List<Guid>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var providerUser in providerUsers)
{
try
{
if (providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
if (providerUser.UserId == deletingUserId)
{
throw new BadRequestException("You cannot remove yourself.");
}
events.Add((providerUser, EventType.ProviderUser_Removed, null));
result.Add(Tuple.Create(providerUser, ""));
deletedUserIds.Add(providerUser.Id);
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
await _providerUserRepository.DeleteManyAsync(deletedUserIds);
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
// TODO: Implement this
public Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key) => throw new NotImplementedException();
// TODO: Implement this
public Task RemoveOrganization(Guid providerOrganizationId, Guid removingUserId) => throw new NotImplementedException();
private async Task SendInviteAsync(ProviderUser providerUser, Provider provider)
{
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
var token = _dataProtector.Protect(
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
}
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)
{
var providerAdmins = await _providerUserRepository.GetManyByProviderAsync(providerId,
ProviderUserType.ProviderAdmin);
var confirmedOwners = providerAdmins.Where(o => o.Status == ProviderUserStatusType.Confirmed);
var confirmedOwnersIds = confirmedOwners.Select(u => u.Id);
return confirmedOwnersIds.Except(providerUserIds).Any();
}
}
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Services
{
@ -38,6 +39,16 @@ namespace Bit.Core.Services
return Task.FromResult(0);
}
public Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null)
{
return Task.FromResult(0);
}
public Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events)
{
return Task.FromResult(0);
}
public Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type,
DateTime? date = null)
{

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Models.Mail;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table.Provider;
namespace Bit.Core.Services
{
@ -158,10 +159,25 @@ namespace Bit.Core.Services
{
return Task.FromResult(0);
}
public Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName)
{
return Task.FromResult(0);
}
public Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email)
{
return Task.FromResult(0);
}
public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
{
return Task.FromResult(0);
}
public Task SendProviderConfirmedEmailAsync(string providerName, string email)
{
return Task.FromResult(0);
}
}
}

View File

@ -85,6 +85,10 @@ namespace Bit.Core.Utilities
services.AddSingleton<ISendRepository, SqlServerRepos.SendRepository>();
services.AddSingleton<ITaxRateRepository, SqlServerRepos.TaxRateRepository>();
services.AddSingleton<IEmergencyAccessRepository, SqlServerRepos.EmergencyAccessRepository>();
services.AddSingleton<IProviderRepository, SqlServerRepos.ProviderRepository>();
services.AddSingleton<IProviderUserRepository, SqlServerRepos.ProviderUserRepository>();
services.AddSingleton<IProviderOrganizationRepository, SqlServerRepos.ProviderOrganizationRepository>();
services.AddSingleton<IProviderOrganizationProviderUserRepository, SqlServerRepos.ProviderOrganizationProviderUserRepository>();
}
if (globalSettings.SelfHosted)
@ -116,12 +120,13 @@ namespace Bit.Core.Utilities
services.AddScoped<ICollectionService, CollectionService>();
services.AddScoped<IGroupService, GroupService>();
services.AddScoped<IPolicyService, PolicyService>();
services.AddScoped<Services.IEventService, EventService>();
services.AddScoped<IEventService, EventService>();
services.AddScoped<IEmergencyAccessService, EmergencyAccessService>();
services.AddSingleton<IDeviceService, DeviceService>();
services.AddSingleton<IAppleIapService, AppleIapService>();
services.AddSingleton<ISsoConfigService, SsoConfigService>();
services.AddScoped<ISendService, SendService>();
services.AddScoped<IProviderService, ProviderService>();
}
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)