mirror of
https://github.com/bitwarden/server
synced 2025-12-28 06:03:29 +00:00
Merge branch 'arch/seeder-sdk' of github.com:bitwarden/server into arch/seeder-api
# Conflicts: # util/Seeder/Factories/UserSeeder.cs
This commit is contained in:
@@ -32,7 +32,7 @@ public class SlackIntegrationController(
|
||||
}
|
||||
|
||||
string? callbackUrl = Url.RouteUrl(
|
||||
routeName: nameof(CreateAsync),
|
||||
routeName: "SlackIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
@@ -76,7 +76,7 @@ public class SlackIntegrationController(
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[HttpGet("integrations/slack/create", Name = nameof(CreateAsync))]
|
||||
[HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
|
||||
{
|
||||
@@ -103,7 +103,7 @@ public class SlackIntegrationController(
|
||||
|
||||
// Fetch token from Slack and store to DB
|
||||
string? callbackUrl = Url.RouteUrl(
|
||||
routeName: nameof(CreateAsync),
|
||||
routeName: "SlackIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
|
||||
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal file
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
public class TeamsIntegrationController(
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
IBot bot,
|
||||
IBotFrameworkHttpAdapter adapter,
|
||||
ITeamsService teamsService,
|
||||
TimeProvider timeProvider) : Controller
|
||||
{
|
||||
[HttpGet("{organizationId:guid}/integrations/teams/redirect")]
|
||||
public async Task<IActionResult> RedirectAsync(Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var callbackUrl = Url.RouteUrl(
|
||||
routeName: "TeamsIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
);
|
||||
if (string.IsNullOrEmpty(callbackUrl))
|
||||
{
|
||||
throw new BadRequestException("Unable to build callback Url");
|
||||
}
|
||||
|
||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
||||
var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams);
|
||||
|
||||
if (integration is null)
|
||||
{
|
||||
// No teams integration exists, create Initiated version
|
||||
integration = await integrationRepository.CreateAsync(new OrganizationIntegration
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Type = IntegrationType.Teams,
|
||||
Configuration = null,
|
||||
});
|
||||
}
|
||||
else if (integration.Configuration is not null)
|
||||
{
|
||||
// A Completed (fully configured) Teams integration already exists, throw to prevent overriding
|
||||
throw new BadRequestException("There already exists a Teams integration for this organization");
|
||||
|
||||
} // An Initiated teams integration exits, re-use it and kick off a new OAuth flow
|
||||
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
|
||||
var redirectUrl = teamsService.GetRedirectUrl(
|
||||
callbackUrl: callbackUrl,
|
||||
state: state.ToString()
|
||||
);
|
||||
|
||||
if (string.IsNullOrEmpty(redirectUrl))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
|
||||
{
|
||||
var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider);
|
||||
if (oAuthState is null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Fetch existing Initiated record
|
||||
var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId);
|
||||
if (integration is null ||
|
||||
integration.Type != IntegrationType.Teams ||
|
||||
integration.Configuration is not null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Verify Organization matches hash
|
||||
if (!oAuthState.ValidateOrg(integration.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var callbackUrl = Url.RouteUrl(
|
||||
routeName: "TeamsIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
);
|
||||
if (string.IsNullOrEmpty(callbackUrl))
|
||||
{
|
||||
throw new BadRequestException("Unable to build callback Url");
|
||||
}
|
||||
|
||||
var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
throw new BadRequestException("Invalid response from Teams.");
|
||||
}
|
||||
|
||||
var teams = await teamsService.GetJoinedTeamsAsync(token);
|
||||
|
||||
if (!teams.Any())
|
||||
{
|
||||
throw new BadRequestException("No teams were found.");
|
||||
}
|
||||
|
||||
var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams);
|
||||
integration.Configuration = JsonSerializer.Serialize(teamsIntegration);
|
||||
await integrationRepository.UpsertAsync(integration);
|
||||
|
||||
var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}";
|
||||
return Created(location, new OrganizationIntegrationResponseModel(integration));
|
||||
}
|
||||
|
||||
[Route("integrations/teams/incoming")]
|
||||
[AllowAnonymous]
|
||||
[HttpPost]
|
||||
public async Task IncomingPostAsync()
|
||||
{
|
||||
await adapter.ProcessAsync(Request, Response, bot);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
case IntegrationType.Teams:
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
default:
|
||||
return false;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
|
||||
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]);
|
||||
break;
|
||||
case IntegrationType.Slack:
|
||||
case IntegrationType.Slack or IntegrationType.Teams:
|
||||
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]);
|
||||
break;
|
||||
case IntegrationType.Webhook:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
@@ -35,6 +37,16 @@ public class OrganizationIntegrationResponseModel : ResponseModel
|
||||
? OrganizationIntegrationStatus.Initiated
|
||||
: OrganizationIntegrationStatus.Completed,
|
||||
|
||||
// If present and the configuration is null, OAuth has been initiated, and we are
|
||||
// waiting on the return OAuth call. If Configuration is not null and IsCompleted is true,
|
||||
// then we've received the app install bot callback, and it's Completed. Otherwise,
|
||||
// it is In Progress while we await the app install bot callback.
|
||||
IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration)
|
||||
? OrganizationIntegrationStatus.Initiated
|
||||
: (JsonSerializer.Deserialize<TeamsIntegration>(Configuration)?.IsCompleted ?? false)
|
||||
? OrganizationIntegrationStatus.Completed
|
||||
: OrganizationIntegrationStatus.InProgress,
|
||||
|
||||
// HEC and Datadog should only be allowed to be created non-null.
|
||||
// If they are null, they are Invalid
|
||||
IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration)
|
||||
|
||||
@@ -229,8 +229,9 @@ public class Startup
|
||||
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
|
||||
}
|
||||
|
||||
// Add SlackService for OAuth API requests - if configured
|
||||
// Add Slack / Teams Services for OAuth API requests - if configured
|
||||
services.AddSlackService(globalSettings);
|
||||
services.AddTeamsService(globalSettings);
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
|
||||
@@ -7,7 +7,8 @@ public enum IntegrationType : int
|
||||
Slack = 3,
|
||||
Webhook = 4,
|
||||
Hec = 5,
|
||||
Datadog = 6
|
||||
Datadog = 6,
|
||||
Teams = 7
|
||||
}
|
||||
|
||||
public static class IntegrationTypeExtensions
|
||||
@@ -24,6 +25,8 @@ public static class IntegrationTypeExtensions
|
||||
return "hec";
|
||||
case IntegrationType.Datadog:
|
||||
return "datadog";
|
||||
case IntegrationType.Teams:
|
||||
return "teams";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegration(
|
||||
string TenantId,
|
||||
IReadOnlyList<TeamInfo> Teams,
|
||||
string? ChannelId = null,
|
||||
Uri? ServiceUrl = null)
|
||||
{
|
||||
public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);
|
||||
@@ -0,0 +1,38 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class TeamsListenerConfiguration(GlobalSettings globalSettings) :
|
||||
ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Teams;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Slack;
|
||||
|
||||
|
||||
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal file
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Teams;
|
||||
|
||||
/// <summary>Represents the response returned by the Microsoft OAuth 2.0 token endpoint.
|
||||
/// See <see href="https://learn.microsoft.com/graph/auth-v2-user">Microsoft identity platform and OAuth 2.0
|
||||
/// authorization code flow</see>.</summary>
|
||||
public class TeamsOAuthResponse
|
||||
{
|
||||
/// <summary>The access token issued by Microsoft, used to call the Microsoft Graph API.</summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Represents the response from the <c>/me/joinedTeams</c> Microsoft Graph API call.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/user-list-joinedteams">List joined teams -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class JoinedTeamsResponse
|
||||
{
|
||||
/// <summary>The collection of teams that the user has joined.</summary>
|
||||
[JsonPropertyName("value")]
|
||||
public List<TeamInfo> Value { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Represents a Microsoft Teams team returned by the Graph API.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/resources/team">Team resource type -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class TeamInfo
|
||||
{
|
||||
/// <summary>The unique identifier of the team.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The name of the team.</summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The ID of the Microsoft Entra tenant for this team.</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Teams;
|
||||
|
||||
public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider
|
||||
{
|
||||
private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer;
|
||||
|
||||
public Task<bool> IsValidAppIdAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId);
|
||||
}
|
||||
|
||||
public Task<string?> GetAppPasswordAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId ? clientSecret : null);
|
||||
}
|
||||
|
||||
public Task<bool> IsAuthenticationDisabledAsync()
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateIssuerAsync(string issuer)
|
||||
{
|
||||
return Task.FromResult(issuer == _microsoftBotFrameworkIssuer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
|
||||
public class VNextSavePolicyCommand(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IEventService eventService,
|
||||
IPolicyRepository policyRepository,
|
||||
IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers,
|
||||
TimeProvider timeProvider,
|
||||
IPolicyEventHandlerFactory policyEventHandlerFactory)
|
||||
: IVNextSavePolicyCommand
|
||||
{
|
||||
private readonly IReadOnlyDictionary<PolicyType, IEnforceDependentPoliciesEvent> _policyValidationEvents = MapToDictionary(policyValidationEventHandlers);
|
||||
|
||||
private static Dictionary<PolicyType, IEnforceDependentPoliciesEvent> MapToDictionary(IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers)
|
||||
{
|
||||
var policyValidationEventsDict = new Dictionary<PolicyType, IEnforceDependentPoliciesEvent>();
|
||||
foreach (var policyValidationEvent in policyValidationEventHandlers)
|
||||
{
|
||||
if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent))
|
||||
{
|
||||
throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy.");
|
||||
}
|
||||
}
|
||||
return policyValidationEventsDict;
|
||||
}
|
||||
|
||||
public async Task<Policy> SaveAsync(SavePolicyModel policyRequest)
|
||||
{
|
||||
var policyUpdateRequest = policyRequest.PolicyUpdate;
|
||||
var organizationId = policyUpdateRequest.OrganizationId;
|
||||
|
||||
await EnsureOrganizationCanUsePolicyAsync(organizationId);
|
||||
|
||||
var savedPoliciesDict = await GetCurrentPolicyStateAsync(organizationId);
|
||||
|
||||
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdateRequest.Type);
|
||||
|
||||
ValidatePolicyDependencies(policyUpdateRequest, currentPolicy, savedPoliciesDict);
|
||||
|
||||
await ValidateTargetedPolicyAsync(policyRequest, currentPolicy);
|
||||
|
||||
await ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy);
|
||||
|
||||
var upsertedPolicy = await UpsertPolicyAsync(policyUpdateRequest);
|
||||
|
||||
await eventService.LogPolicyEventAsync(upsertedPolicy, EventType.Policy_Updated);
|
||||
|
||||
await ExecutePostUpsertSideEffectAsync(policyRequest, upsertedPolicy, currentPolicy);
|
||||
|
||||
return upsertedPolicy;
|
||||
}
|
||||
|
||||
private async Task EnsureOrganizationCanUsePolicyAsync(Guid organizationId)
|
||||
{
|
||||
var org = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||
if (org == null)
|
||||
{
|
||||
throw new BadRequestException("Organization not found");
|
||||
}
|
||||
|
||||
if (!org.UsePolicies)
|
||||
{
|
||||
throw new BadRequestException("This organization cannot use policies.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Policy> UpsertPolicyAsync(PolicyUpdate policyUpdateRequest)
|
||||
{
|
||||
var policy = await policyRepository.GetByOrganizationIdTypeAsync(policyUpdateRequest.OrganizationId, policyUpdateRequest.Type)
|
||||
?? new Policy
|
||||
{
|
||||
OrganizationId = policyUpdateRequest.OrganizationId,
|
||||
Type = policyUpdateRequest.Type,
|
||||
CreationDate = timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
|
||||
policy.Enabled = policyUpdateRequest.Enabled;
|
||||
policy.Data = policyUpdateRequest.Data;
|
||||
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
await policyRepository.UpsertAsync(policy);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private async Task ValidateTargetedPolicyAsync(SavePolicyModel policyRequest,
|
||||
Policy? currentPolicy)
|
||||
{
|
||||
await ExecutePolicyEventAsync<IPolicyValidationEvent>(
|
||||
policyRequest.PolicyUpdate.Type,
|
||||
async validator =>
|
||||
{
|
||||
var validationError = await validator.ValidateAsync(policyRequest, currentPolicy);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
throw new BadRequestException(validationError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ValidatePolicyDependencies(
|
||||
PolicyUpdate policyUpdateRequest,
|
||||
Policy? currentPolicy,
|
||||
Dictionary<PolicyType, Policy> savedPoliciesDict)
|
||||
{
|
||||
var result = policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(policyUpdateRequest.Type);
|
||||
|
||||
result.Switch(
|
||||
validator =>
|
||||
{
|
||||
var isCurrentlyEnabled = currentPolicy?.Enabled == true;
|
||||
|
||||
switch (policyUpdateRequest.Enabled)
|
||||
{
|
||||
case true when !isCurrentlyEnabled:
|
||||
ValidateEnablingRequirements(validator, savedPoliciesDict);
|
||||
return;
|
||||
case false when isCurrentlyEnabled:
|
||||
ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict);
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ => { });
|
||||
}
|
||||
|
||||
private void ValidateDisablingRequirements(
|
||||
IEnforceDependentPoliciesEvent validator,
|
||||
PolicyType policyType,
|
||||
Dictionary<PolicyType, Policy> savedPoliciesDict)
|
||||
{
|
||||
var dependentPolicyTypes = _policyValidationEvents.Values
|
||||
.Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType))
|
||||
.Select(otherValidator => otherValidator.Type)
|
||||
.Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) &&
|
||||
savedPolicy.Enabled)
|
||||
.ToList();
|
||||
|
||||
switch (dependentPolicyTypes)
|
||||
{
|
||||
case { Count: 1 }:
|
||||
throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy.");
|
||||
case { Count: > 1 }:
|
||||
throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEnablingRequirements(
|
||||
IEnforceDependentPoliciesEvent validator,
|
||||
Dictionary<PolicyType, Policy> savedPoliciesDict)
|
||||
{
|
||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.ToList();
|
||||
|
||||
if (missingRequiredPolicyTypes.Count != 0)
|
||||
{
|
||||
throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecutePreUpsertSideEffectAsync(
|
||||
SavePolicyModel policyRequest,
|
||||
Policy? currentPolicy)
|
||||
{
|
||||
await ExecutePolicyEventAsync<IOnPolicyPreUpdateEvent>(
|
||||
policyRequest.PolicyUpdate.Type,
|
||||
handler => handler.ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy));
|
||||
}
|
||||
private async Task ExecutePostUpsertSideEffectAsync(
|
||||
SavePolicyModel policyRequest,
|
||||
Policy postUpsertedPolicyState,
|
||||
Policy? previousPolicyState)
|
||||
{
|
||||
await ExecutePolicyEventAsync<IOnPolicyPostUpdateEvent>(
|
||||
policyRequest.PolicyUpdate.Type,
|
||||
handler => handler.ExecutePostUpsertSideEffectAsync(
|
||||
policyRequest,
|
||||
postUpsertedPolicyState,
|
||||
previousPolicyState));
|
||||
}
|
||||
|
||||
private async Task ExecutePolicyEventAsync<T>(PolicyType type, Func<T, Task> func) where T : IPolicyUpdateEvent
|
||||
{
|
||||
var handler = policyEventHandlerFactory.GetHandler<T>(type);
|
||||
|
||||
await handler.Match(
|
||||
async h => await func(h),
|
||||
_ => Task.CompletedTask
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<PolicyType, Policy>> GetCurrentPolicyStateAsync(Guid organizationId)
|
||||
{
|
||||
var savedPolicies = await policyRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
// Note: policies may be missing from this dict if they have never been enabled
|
||||
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
|
||||
return savedPoliciesDict;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public record PolicyUpdate
|
||||
public PolicyType Type { get; set; }
|
||||
public string? Data { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
[Obsolete("Please use SavePolicyModel.PerformedBy instead.")]
|
||||
public IActingUser? PerformedBy { get; set; }
|
||||
|
||||
public T GetDataModel<T>() where T : IPolicyDataModel, new()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.AdminConsole.Services.Implementations;
|
||||
@@ -13,7 +15,9 @@ public static class PolicyServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<IPolicyService, PolicyService>();
|
||||
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
|
||||
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
|
||||
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
|
||||
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
|
||||
|
||||
services.AddPolicyValidators();
|
||||
services.AddPolicyRequirements();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// PolicyTypes that must be enabled before this policy can be enabled, if any.
|
||||
/// These dependencies will be checked when this policy is enabled and when any required policy is disabled.
|
||||
/// </summary>
|
||||
public IEnumerable<PolicyType> RequiredPolicies { get; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs side effects after a policy has been upserted.
|
||||
/// For example, this can be used for cleanup tasks or notifications.
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">The policy save request</param>
|
||||
/// <param name="postUpsertedPolicyState">The policy after it was upserted</param>
|
||||
/// <param name="previousPolicyState">The policy state before it was updated, if any</param>
|
||||
public Task ExecutePostUpsertSideEffectAsync(
|
||||
SavePolicyModel policyRequest,
|
||||
Policy postUpsertedPolicyState,
|
||||
Policy? previousPolicyState);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs side effects before a policy is upserted.
|
||||
/// For example, this can be used to remove non-compliant users from the organization.
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
|
||||
/// <param name="currentPolicy">The current policy, if any</param>
|
||||
public Task ExecutePreUpsertSideEffectAsync(
|
||||
SavePolicyModel policyRequest,
|
||||
Policy? currentPolicy);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy-specific event handlers used during the save workflow in <see cref="IVNextSavePolicyCommand"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported handlers:
|
||||
/// - <see cref="IEnforceDependentPoliciesEvent"/> for dependency checks
|
||||
/// - <see cref="IPolicyValidationEvent"/> for custom validation
|
||||
/// - <see cref="IOnPolicyPreUpdateEvent"/> for pre-save logic
|
||||
/// - <see cref="IOnPolicyPostUpdateEvent"/> for post-save logic
|
||||
/// </remarks>
|
||||
public interface IPolicyEventHandlerFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event handler for the given policy type and handler interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Handler type implementing <see cref="IPolicyUpdateEvent"/>.</typeparam>
|
||||
/// <param name="policyType">The policy type to resolve.</param>
|
||||
/// <returns>
|
||||
/// <see cref="OneOf{T, None}"/> — the handler if available, or None if not implemented.
|
||||
/// </returns>
|
||||
OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
public interface IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy type that the associated handler will handle.
|
||||
/// </summary>
|
||||
public PolicyType Type { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
public interface IPolicyValidationEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs side effects after a policy is validated but before it is saved.
|
||||
/// For example, this can be used to remove non-compliant users from the organization.
|
||||
/// Implementation is optional; by default, it will not perform any side effects.
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
|
||||
/// <param name="currentPolicy">The current policy, if any</param>
|
||||
public Task<string> ValidateAsync(
|
||||
SavePolicyModel policyRequest,
|
||||
Policy? currentPolicy);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Microsoft.Azure.NotificationHubs.Messaging;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Handles creating or updating organization policies with validation and side effect execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Workflow:
|
||||
/// 1. Validates organization can use policies
|
||||
/// 2. Validates required and dependent policies
|
||||
/// 3. Runs policy-specific validation (<see cref="IPolicyValidationEvent"/>)
|
||||
/// 4. Executes pre-save logic (<see cref="IOnPolicyPreUpdateEvent"/>)
|
||||
/// 5. Saves the policy
|
||||
/// 6. Logs the event
|
||||
/// 7. Executes post-save logic (<see cref="IOnPolicyPostUpdateEvent"/>)
|
||||
/// </remarks>
|
||||
public interface IVNextSavePolicyCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs the necessary validations, saves the policy and any side effects
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">Policy data, acting user, and metadata.</param>
|
||||
/// <returns>The saved policy with updated revision and applied changes.</returns>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown if:
|
||||
/// - The organization can’t use policies
|
||||
/// - Dependent policies are missing or block changes
|
||||
/// - Custom validation fails
|
||||
/// </exception>
|
||||
Task<Policy> SaveAsync(SavePolicyModel policyRequest);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
|
||||
|
||||
public class PolicyEventHandlerHandlerFactory(
|
||||
IEnumerable<IPolicyUpdateEvent> allEventHandlers) : IPolicyEventHandlerFactory
|
||||
{
|
||||
public OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent
|
||||
{
|
||||
var tEventHandlers = allEventHandlers.OfType<T>().ToList();
|
||||
|
||||
var matchingHandlers = tEventHandlers.Where(h => h.Type == policyType).ToList();
|
||||
|
||||
if (matchingHandlers.Count > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Multiple {nameof(IPolicyUpdateEvent)} handlers of type {typeof(T).Name} found for {nameof(PolicyType)} {policyType}. " +
|
||||
$"Expected one {typeof(T).Name} handler per {nameof(PolicyType)}.");
|
||||
}
|
||||
|
||||
var policyTEventHandler = matchingHandlers.SingleOrDefault();
|
||||
if (policyTEventHandler is null)
|
||||
{
|
||||
return new None();
|
||||
}
|
||||
|
||||
return policyTEventHandler;
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,6 @@ namespace Bit.Core.Repositories;
|
||||
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||
{
|
||||
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
|
||||
|
||||
Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
|
||||
/// and sending messages.</summary>
|
||||
public interface ISlackService
|
||||
{
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the ID of a Slack channel by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelName">The name of the channel to look up.</param>
|
||||
/// <returns>The channel ID if found; otherwise, an empty string.</returns>
|
||||
Task<string> GetChannelIdAsync(string token, string channelName);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the IDs of multiple Slack channels by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelNames">A list of channel names to look up.</param>
|
||||
/// <returns>A list of matching channel IDs. Channels that cannot be found are omitted.</returns>
|
||||
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up a user by their email address.</remarks>
|
||||
/// <summary>Retrieves the DM channel ID for a Slack user by email.
|
||||
/// See <see href="https://api.slack.com/methods/users.lookupByEmail">users.lookupByEmail API</see> and
|
||||
/// <see href="https://api.slack.com/methods/conversations.open">conversations.open API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="email">The email address of the user to open a DM with.</param>
|
||||
/// <returns>The DM channel ID if successful; otherwise, an empty string.</returns>
|
||||
Task<string> GetDmChannelByEmailAsync(string token, string email);
|
||||
|
||||
/// <summary>Builds the Slack OAuth 2.0 authorization URL for the app.
|
||||
/// See <see href="https://api.slack.com/authentication/oauth-v2">Slack OAuth v2 documentation</see>.</summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Slack will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>Exchanges a Slack OAuth code for an access token.
|
||||
/// See <see href="https://api.slack.com/methods/oauth.v2.access">oauth.v2.access API</see>.</summary>
|
||||
/// <param name="code">The authorization code returned by Slack via the callback URL after user authorization.</param>
|
||||
/// <param name="redirectUrl">The redirect URI that was used in the authorization request.</param>
|
||||
/// <returns>A valid Slack access token if successful; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>Sends a message to a Slack channel by ID.
|
||||
/// See <see href="https://api.slack.com/methods/chat.postMessage">chat.postMessage API</see>.</summary>
|
||||
/// <remarks>This is used primarily by the <see cref="SlackIntegrationHandler"/> to send events to the
|
||||
/// Slack channel.</remarks>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="message">The message text to send.</param>
|
||||
/// <param name="channelId">The channel ID to send the message to.</param>
|
||||
/// <returns>A task that completes when the message has been sent.</returns>
|
||||
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
|
||||
}
|
||||
|
||||
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal file
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides functionality relating to the Microsoft Teams integration including OAuth,
|
||||
/// team discovery and sending a message to a channel in Teams.
|
||||
/// </summary>
|
||||
public interface ITeamsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow.
|
||||
/// </summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Microsoft will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>
|
||||
/// Exchange the OAuth code for a Microsoft Graph API access token.
|
||||
/// </summary>
|
||||
/// <param name="code">The code returned from Microsoft via the OAuth callback Url.</param>
|
||||
/// <param name="redirectUrl">The same redirect URI that was passed to the authorization request.</param>
|
||||
/// <returns>A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Get the Teams to which the authenticated user belongs via Microsoft Graph API.
|
||||
/// </summary>
|
||||
/// <param name="accessToken">A valid Microsoft Graph access token for the user (obtained via OAuth).</param>
|
||||
/// <returns>A read-only list of <see cref="TeamInfo"/> objects representing the user’s joined teams.
|
||||
/// Returns an empty list if the request fails or if the token is invalid.</returns>
|
||||
Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Send a message to a specific channel in Teams.
|
||||
/// </summary>
|
||||
/// <remarks>This is used primarily by the <see cref="TeamsIntegrationHandler"/> to send events to the
|
||||
/// Teams channel.</remarks>
|
||||
/// <param name="serviceUri">The service URI associated with the Microsoft Bot Framework connector for the target
|
||||
/// team. Obtained via the bot framework callback.</param>
|
||||
/// <param name="channelId"> The conversation or channel ID where the message should be delivered. Obtained via
|
||||
/// the bot framework callback.</param>
|
||||
/// <param name="message">The message text to post to the channel.</param>
|
||||
/// <returns>A task that completes when the message has been sent. Errors during message delivery are surfaced
|
||||
/// as exceptions from the underlying connector client.</returns>
|
||||
Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message);
|
||||
}
|
||||
@@ -203,31 +203,17 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
|
||||
|
||||
- The top-level object that enables a specific integration for the organization.
|
||||
- Includes any properties that apply to the entire integration across all events.
|
||||
- For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`.
|
||||
- For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level,
|
||||
but the configuration level takes precedence. However, even though it is optional, an organization must
|
||||
have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration
|
||||
via `OrganizationIntegrationConfiguration`.
|
||||
- For HEC, it consists of the scheme, token, and URI:
|
||||
|
||||
```json
|
||||
{
|
||||
"Scheme": "Bearer",
|
||||
"Token": "Auth-token-from-HEC-service",
|
||||
"Uri": "https://example.com/api"
|
||||
}
|
||||
```
|
||||
- For example, Slack stores the token in the `Configuration` which applies to every event, but stores the
|
||||
channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack
|
||||
integration, but the channel could be configured differently depending on event type.
|
||||
- See the table below for more examples / details on what is stored at which level.
|
||||
|
||||
### `OrganizationIntegrationConfiguration`
|
||||
|
||||
- This contains the configurations specific to each `EventType` for the integration.
|
||||
- `Configuration` contains the event-specific configuration.
|
||||
- For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }`
|
||||
- For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }`
|
||||
- Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication.
|
||||
- As stated above, all of this information can be specified here or at the `OrganizationIntegration`
|
||||
level, but any properties declared here will take precedence over the ones above.
|
||||
- For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level.
|
||||
- Any properties at this level override the `Configuration` form the `OrganizationIntegration`.
|
||||
- See the table below for examples of specific integrations.
|
||||
- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
|
||||
- The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
|
||||
- The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
|
||||
@@ -245,6 +231,23 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
|
||||
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
|
||||
the database to determine what to publish at the integration level.
|
||||
|
||||
### Existing integrations and the configurations at each level
|
||||
|
||||
The following table illustrates how each integration is configured and what exactly is stored in the `Configuration`
|
||||
property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under
|
||||
`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be
|
||||
stored at each status.
|
||||
|
||||
| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
|
||||
| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
|
||||
| Slack | **Initiated**: `null`<br/>**Completed**:<br/>`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` |
|
||||
| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`<br/><br/>Whatever is defined at this level takes precedence |
|
||||
| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` |
|
||||
| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` |
|
||||
| Teams | **Initiated**: `null`<br/>**In Progress**: <br/> `{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`<br/>**Completed**: <br/>`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` |
|
||||
|
||||
## Filtering
|
||||
|
||||
In addition to the ability to configure integrations mentioned above, organization admins can
|
||||
@@ -349,10 +352,20 @@ and event type.
|
||||
- This will be the deserialized version of the `MergedConfiguration` in
|
||||
`OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
A new row with the new integration should be added to this doc in the table above [Existing integrations
|
||||
and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level).
|
||||
|
||||
## Request Models
|
||||
|
||||
1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
|
||||
- Additionally, add tests in `OrganizationIntegrationRequestModelTests`
|
||||
2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
|
||||
- Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests`
|
||||
|
||||
## Response Model
|
||||
|
||||
1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`.
|
||||
- Additionally, add / update tests in `OrganizationIntegrationResponseModelTests`
|
||||
|
||||
## Integration Handler
|
||||
|
||||
|
||||
@@ -90,6 +90,12 @@ public class SlackService(
|
||||
|
||||
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
|
||||
new FormUrlEncodedContent(new[]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Rest;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class TeamsIntegrationHandler(
|
||||
ITeamsService teamsService)
|
||||
: IntegrationHandlerBase<TeamsIntegrationConfigurationDetails>
|
||||
{
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(
|
||||
IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await teamsService.SendMessageToChannelAsync(
|
||||
serviceUri: message.Configuration.ServiceUrl,
|
||||
message: message.RenderedTemplate,
|
||||
channelId: message.Configuration.ChannelId
|
||||
);
|
||||
|
||||
return new IntegrationHandlerResult(success: true, message: message);
|
||||
}
|
||||
catch (HttpOperationException ex)
|
||||
{
|
||||
var result = new IntegrationHandlerResult(success: false, message: message);
|
||||
var statusCode = (int)ex.Response.StatusCode;
|
||||
result.Retryable = statusCode is 429 or >= 500 and < 600;
|
||||
result.FailureReason = ex.Message;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var result = new IntegrationHandlerResult(success: false, message: message);
|
||||
result.Retryable = false;
|
||||
result.FailureReason = ex.Message;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Teams;
|
||||
using Microsoft.Bot.Connector;
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
using Microsoft.Bot.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamInfo = Bit.Core.Models.Teams.TeamInfo;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class TeamsService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<TeamsService> logger) : ActivityHandler, ITeamsService
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
private readonly string _clientId = globalSettings.Teams.ClientId;
|
||||
private readonly string _clientSecret = globalSettings.Teams.ClientSecret;
|
||||
private readonly string _scopes = globalSettings.Teams.Scopes;
|
||||
private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl;
|
||||
private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl;
|
||||
|
||||
public const string HttpClientName = "TeamsServiceHttpClient";
|
||||
|
||||
public string GetRedirectUrl(string redirectUrl, string state)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
query["client_id"] = _clientId;
|
||||
query["response_type"] = "code";
|
||||
query["redirect_uri"] = redirectUrl;
|
||||
query["response_mode"] = "query";
|
||||
query["scope"] = string.Join(" ", _scopes);
|
||||
query["state"] = state;
|
||||
|
||||
return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}";
|
||||
}
|
||||
|
||||
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post,
|
||||
$"{_loginBaseUrl}/common/oauth2/v2.0/token");
|
||||
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", _clientId },
|
||||
{ "client_secret", _clientSecret },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUrl },
|
||||
{ "grant_type", "authorization_code" }
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
TeamsOAuthResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<TeamsOAuthResponse>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{_graphBaseUrl}/me/joinedTeams");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Get Teams request failed: {errorText}", errorText);
|
||||
return new List<TeamInfo>();
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<JoinedTeamsResponse>();
|
||||
|
||||
return result?.Value ?? [];
|
||||
}
|
||||
|
||||
public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
|
||||
{
|
||||
var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret);
|
||||
using var connectorClient = new ConnectorClient(serviceUri, credentials);
|
||||
|
||||
var activity = new Activity
|
||||
{
|
||||
Type = ActivityTypes.Message,
|
||||
Text = message
|
||||
};
|
||||
|
||||
await connectorClient.Conversations.SendToConversationAsync(channelId, activity);
|
||||
}
|
||||
|
||||
protected override async Task OnInstallationUpdateAddAsync(ITurnContext<IInstallationUpdateActivity> turnContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var conversationId = turnContext.Activity.Conversation.Id;
|
||||
var serviceUrl = turnContext.Activity.ServiceUrl;
|
||||
var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId;
|
||||
var tenantId = turnContext.Activity.Conversation.TenantId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(conversationId) &&
|
||||
!string.IsNullOrWhiteSpace(serviceUrl) &&
|
||||
Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) &&
|
||||
!string.IsNullOrWhiteSpace(teamId) &&
|
||||
!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
await HandleIncomingAppInstallAsync(
|
||||
conversationId: conversationId,
|
||||
serviceUrl: parsedUri,
|
||||
teamId: teamId,
|
||||
tenantId: tenantId
|
||||
);
|
||||
}
|
||||
|
||||
await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken);
|
||||
}
|
||||
|
||||
internal async Task HandleIncomingAppInstallAsync(
|
||||
string conversationId,
|
||||
Uri serviceUrl,
|
||||
string teamId,
|
||||
string tenantId)
|
||||
{
|
||||
var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId(
|
||||
tenantId: tenantId,
|
||||
teamId: teamId);
|
||||
|
||||
if (integration?.Configuration is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var teamsConfig = JsonSerializer.Deserialize<TeamsIntegration>(integration.Configuration);
|
||||
if (teamsConfig is null || teamsConfig.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
integration.Configuration = JsonSerializer.Serialize(teamsConfig with
|
||||
{
|
||||
ChannelId = conversationId,
|
||||
ServiceUrl = serviceUrl
|
||||
});
|
||||
|
||||
await integrationRepository.UpsertAsync(integration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopTeamsService : ITeamsService
|
||||
{
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TeamInfo>>(Array.Empty<TeamInfo>());
|
||||
}
|
||||
|
||||
public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,8 @@ public class PreviewOrganizationTaxCommand(
|
||||
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType);
|
||||
|
||||
var quantity = newPlan.HasNonSeatBasedPasswordManagerPlan() ? 1 : 2;
|
||||
|
||||
var items = new List<InvoiceSubscriptionDetailsItemOptions>
|
||||
{
|
||||
new ()
|
||||
@@ -142,7 +144,7 @@ public class PreviewOrganizationTaxCommand(
|
||||
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? newPlan.PasswordManager.StripePlanId
|
||||
: newPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = 2
|
||||
Quantity = quantity
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,12 +196,17 @@ public class PreviewOrganizationTaxCommand(
|
||||
? currentPlan.PasswordManager.StripePlanId
|
||||
: currentPlan.PasswordManager.StripeSeatPlanId];
|
||||
|
||||
var quantity = currentPlan.HasNonSeatBasedPasswordManagerPlan() &&
|
||||
!newPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? (long)organization.Seats!
|
||||
: passwordManagerSeats.Quantity;
|
||||
|
||||
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? newPlan.PasswordManager.StripePlanId
|
||||
: newPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = passwordManagerSeats.Quantity
|
||||
Quantity = quantity
|
||||
});
|
||||
|
||||
var hasStorage =
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
@@ -30,8 +30,8 @@ public interface IGetOrganizationWarningsQuery
|
||||
|
||||
public class GetOrganizationWarningsQuery(
|
||||
ICurrentContext currentContext,
|
||||
IHasPaymentMethodQuery hasPaymentMethodQuery,
|
||||
IProviderRepository providerRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IGetOrganizationWarningsQuery
|
||||
{
|
||||
@@ -81,15 +81,7 @@ public class GetOrganizationWarningsQuery(
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = subscription.Customer;
|
||||
|
||||
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
|
||||
|
||||
var hasPaymentMethod =
|
||||
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
|
||||
hasUnverifiedBankAccount ||
|
||||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
|
||||
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
|
||||
|
||||
if (hasPaymentMethod)
|
||||
{
|
||||
@@ -287,22 +279,4 @@ public class GetOrganizationWarningsQuery(
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> HasUnverifiedBankAccountAsync(
|
||||
Organization organization)
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
|
||||
|
||||
if (string.IsNullOrEmpty(setupIntentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
Expand = ["payment_method"]
|
||||
});
|
||||
|
||||
return setupIntent.IsUnverifiedBankAccount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
@@ -27,6 +28,7 @@ namespace Bit.Core.Billing.Organizations.Services;
|
||||
public class OrganizationBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
IHasPaymentMethodQuery hasPaymentMethodQuery,
|
||||
ILogger<OrganizationBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPricingClient pricingClient,
|
||||
@@ -43,7 +45,7 @@ public class OrganizationBillingService(
|
||||
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
|
||||
: await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||
var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup);
|
||||
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
|
||||
{
|
||||
@@ -120,8 +122,7 @@ public class OrganizationBillingService(
|
||||
orgOccupiedSeats.Total);
|
||||
}
|
||||
|
||||
public async Task
|
||||
UpdatePaymentMethod(
|
||||
public async Task UpdatePaymentMethod(
|
||||
Organization organization,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
TaxInformation taxInformation)
|
||||
@@ -397,7 +398,7 @@ public class OrganizationBillingService(
|
||||
}
|
||||
|
||||
private async Task<Subscription> CreateSubscriptionAsync(
|
||||
Guid organizationId,
|
||||
Organization organization,
|
||||
Customer customer,
|
||||
SubscriptionSetup subscriptionSetup)
|
||||
{
|
||||
@@ -465,7 +466,7 @@ public class OrganizationBillingService(
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["organizationId"] = organizationId.ToString(),
|
||||
["organizationId"] = organization.Id.ToString(),
|
||||
["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) &&
|
||||
subscriptionSetup.InitiationPath.Contains("trial from marketing website")
|
||||
? "marketing-initiated"
|
||||
@@ -475,9 +476,10 @@ public class OrganizationBillingService(
|
||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||
};
|
||||
|
||||
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
|
||||
|
||||
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
|
||||
if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) &&
|
||||
!customer.Metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
if (!hasPaymentMethod)
|
||||
{
|
||||
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
|
||||
{
|
||||
|
||||
58
src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs
Normal file
58
src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Payment.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public interface IHasPaymentMethodQuery
|
||||
{
|
||||
Task<bool> Run(ISubscriber subscriber);
|
||||
}
|
||||
|
||||
public class HasPaymentMethodQuery(
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IHasPaymentMethodQuery
|
||||
{
|
||||
public async Task<bool> Run(ISubscriber subscriber)
|
||||
{
|
||||
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(subscriber);
|
||||
|
||||
var customer = await subscriberService.GetCustomer(subscriber);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
return hasUnverifiedBankAccount;
|
||||
}
|
||||
|
||||
return
|
||||
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
|
||||
hasUnverifiedBankAccount ||
|
||||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
|
||||
}
|
||||
|
||||
private async Task<bool> HasUnverifiedBankAccountAsync(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
|
||||
|
||||
if (string.IsNullOrEmpty(setupIntentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
Expand = ["payment_method"]
|
||||
});
|
||||
|
||||
return setupIntent.IsUnverifiedBankAccount();
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,6 @@ public static class Registrations
|
||||
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
|
||||
services.AddTransient<IGetCreditQuery, GetCreditQuery>();
|
||||
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
|
||||
services.AddTransient<IHasPaymentMethodQuery, HasPaymentMethodQuery>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,6 @@ public static class FeatureFlagKeys
|
||||
/* Tools Team */
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
|
||||
public const string UseChromiumImporter = "pm-23982-chromium-importer";
|
||||
|
||||
/* Vault Team */
|
||||
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.52.0" />
|
||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.Bot.Builder" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
|
||||
|
||||
@@ -1,19 +1,112 @@
|
||||
# Email templates
|
||||
# MJML email templating
|
||||
|
||||
This directory contains MJML templates for emails sent by the application. MJML is a markup language designed to reduce the pain of coding responsive email templates.
|
||||
This directory contains MJML templates for emails. MJML is a markup language designed to reduce the pain of coding responsive email templates. Component based development features in MJML improve code quality and reusability.
|
||||
|
||||
## Usage
|
||||
MJML stands for MailJet Markup Language.
|
||||
|
||||
```bash
|
||||
## Implementation considerations
|
||||
|
||||
These `MJML` templates are compiled into HTML which will then be further consumed by our Handlebars mail service. We can continue to use this service to assign values from our View Models. This leverages the existing infrastructure. It also means we can continue to use the double brace (`{{}}`) syntax within MJML since Handlebars can be used to assign values to those `{{variables}}`.
|
||||
|
||||
There is no change on how we interact with our view models.
|
||||
|
||||
There is an added step where we compile `*.mjml` to `*.html.hbs`. `*.html.hbs` is the format we use so the handlebars service can apply the variables. This build pipeline process is in progress and may need to be manually done at times.
|
||||
|
||||
### `*.txt.hbs`
|
||||
|
||||
There is no change to how we create the `txt.hbs`. MJML does not impact how we create these artifacts.
|
||||
|
||||
## Building `MJML` files
|
||||
|
||||
```shell
|
||||
npm ci
|
||||
|
||||
# Build once
|
||||
# Build *.html to ./out directory
|
||||
npm run build
|
||||
|
||||
# To build on changes
|
||||
npm run watch
|
||||
# To build on changes to *.mjml and *.js files, new files will not be tracked, you will need to run again
|
||||
npm run build:watch
|
||||
|
||||
# Build *.html.hbs to ./out directory
|
||||
npm run build:hbs
|
||||
|
||||
# Build minified *.html.hbs to ./out directory
|
||||
npm run build:minify
|
||||
|
||||
# apply prettier formatting
|
||||
npm run prettier
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
MJML supports components and you can create your own components by adding them to `.mjmlconfig`.
|
||||
MJML supports components and you can create your own components by adding them to `.mjmlconfig`. Components are simple JavaScript that return MJML markup based on the attributes assigned, see components/mj-bw-hero.js. The markup is not a proper object, but contained in a string.
|
||||
|
||||
When using MJML templating you can use the above [commands](#building-mjml-files) to compile the template and view it in a web browser.
|
||||
|
||||
Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags.
|
||||
|
||||
### Recommended development
|
||||
|
||||
#### Mjml email template development
|
||||
|
||||
1. create `cool-email.mjml` in appropriate team directory
|
||||
2. run `npm run build:watch`
|
||||
3. view compiled `HTML` output in a web browser
|
||||
4. iterate -> while `build:watch`'ing you should be able to refresh the browser page after the mjml/js re-compile to see the changes
|
||||
|
||||
#### Testing with `IMailService`
|
||||
|
||||
After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation.
|
||||
|
||||
1. run `npm run build:minify`
|
||||
2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them
|
||||
3. run code that will send the email
|
||||
|
||||
The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations.
|
||||
|
||||
### Custom tags
|
||||
|
||||
There is currently a `mj-bw-hero` tag you can use within your `*.mjml` templates. This is a good example of how to create a component that takes in attribute values allowing us to be more DRY in our development of emails. Since the attribute's input is a string we are able to define whatever we need into the component, in this case `mj-bw-hero`.
|
||||
|
||||
In order to view the custom component you have written you will need to include it in the `.mjmlconfig` and reference it in an `mjml` template file.
|
||||
|
||||
```html
|
||||
<!-- Custom component implementation-->
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/business.png"
|
||||
title="Verify your email to access this Bitwarden Send"
|
||||
/>
|
||||
```
|
||||
|
||||
Attributes in Custom Components are defined by the developer. They can be required or optional depending on implementation. See the official MJML documentation for more information.
|
||||
|
||||
```js
|
||||
static allowedAttributes = {
|
||||
"img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area
|
||||
title: "string", // REQUIRED: large text stating primary purpose of the email
|
||||
"button-text": "string", // OPTIONAL: text to display in the button
|
||||
"button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked
|
||||
"sub-title": "string", // OPTIONAL: smaller text providing additional context for the title
|
||||
};
|
||||
|
||||
static defaultAttributes = {};
|
||||
```
|
||||
|
||||
Custom components, such as `mj-bw-hero`, must be defined in the `.mjmlconfig` in order for them to be compiled and rendered properly in the templates.
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": ["components/mj-bw-hero"]
|
||||
}
|
||||
```
|
||||
|
||||
### `mj-include`
|
||||
|
||||
You are also able to reference other more static MJML templates in your MJML file simply by referencing the file within the MJML template.
|
||||
|
||||
```html
|
||||
<!-- Example of reference to mjml template -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-include path="../../components/learn-more-footer.mjml" />
|
||||
</mj-wrapper>
|
||||
```
|
||||
|
||||
128
src/Core/MailTemplates/Mjml/build.js
Normal file
128
src/Core/MailTemplates/Mjml/build.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const mjml2html = require("mjml");
|
||||
const { registerComponent } = require("mjml-core");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const glob = require("glob");
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2); // Remove 'node' and script path
|
||||
|
||||
// Parse flags
|
||||
const flags = {
|
||||
minify: args.includes("--minify") || args.includes("-m"),
|
||||
watch: args.includes("--watch") || args.includes("-w"),
|
||||
hbs: args.includes("--hbs") || args.includes("-h"),
|
||||
trace: args.includes("--trace") || args.includes("-t"),
|
||||
clean: args.includes("--clean") || args.includes("-c"),
|
||||
help: args.includes("--help"),
|
||||
};
|
||||
|
||||
// Use __dirname to get absolute paths relative to the script location
|
||||
const config = {
|
||||
inputDir: path.join(__dirname, "emails"),
|
||||
outputDir: path.join(__dirname, "out"),
|
||||
minify: flags.minify,
|
||||
validationLevel: "strict",
|
||||
hbsOutput: flags.hbs,
|
||||
};
|
||||
|
||||
// Debug output
|
||||
if (flags.trace) {
|
||||
console.log("[DEBUG] Script location:", __dirname);
|
||||
console.log("[DEBUG] Input directory:", config.inputDir);
|
||||
console.log("[DEBUG] Output directory:", config.outputDir);
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(config.outputDir)) {
|
||||
fs.mkdirSync(config.outputDir, { recursive: true });
|
||||
if (flags.trace) {
|
||||
console.log("[INFO] Created output directory:", config.outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Find all MJML files with absolute path
|
||||
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`);
|
||||
|
||||
console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`);
|
||||
|
||||
if (mjmlFiles.length === 0) {
|
||||
console.error("[ERROR] No MJML files found!");
|
||||
console.error("[ERROR] Looked in:", config.inputDir);
|
||||
console.error(
|
||||
"[ERROR] Does this directory exist?",
|
||||
fs.existsSync(config.inputDir),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Compile each MJML file
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
mjmlFiles.forEach((filePath) => {
|
||||
try {
|
||||
const mjmlContent = fs.readFileSync(filePath, "utf8");
|
||||
const fileName = path.basename(filePath, ".mjml");
|
||||
const relativePath = path.relative(config.inputDir, filePath);
|
||||
|
||||
console.log(`\n[BUILD] Compiling: ${relativePath}`);
|
||||
|
||||
// Compile MJML to HTML
|
||||
const result = mjml2html(mjmlContent, {
|
||||
minify: config.minify,
|
||||
validationLevel: config.validationLevel,
|
||||
filePath: filePath, // Important: tells MJML where the file is for resolving includes
|
||||
mjmlConfigPath: __dirname, // Point to the directory with .mjmlconfig
|
||||
});
|
||||
|
||||
// Check for errors
|
||||
if (result.errors.length > 0) {
|
||||
console.error(`[ERROR] Failed to compile ${fileName}.mjml:`);
|
||||
result.errors.forEach((err) =>
|
||||
console.error(` ${err.formattedMessage}`),
|
||||
);
|
||||
errorCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate output path preserving directory structure
|
||||
const relativeDir = path.dirname(relativePath);
|
||||
const outputDir = path.join(config.outputDir, relativeDir);
|
||||
|
||||
// Ensure subdirectory exists
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const outputExtension = config.hbsOutput ? ".html.hbs" : ".html";
|
||||
const outputPath = path.join(outputDir, `${fileName}${outputExtension}`);
|
||||
fs.writeFileSync(outputPath, result.html);
|
||||
|
||||
console.log(
|
||||
`[OK] Built: ${fileName}.mjml → ${path.relative(__dirname, outputPath)}`,
|
||||
);
|
||||
successCount++;
|
||||
|
||||
// Log warnings if any
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
console.warn(`[WARN] Warnings for ${fileName}.mjml:`);
|
||||
result.warnings.forEach((warn) =>
|
||||
console.warn(` ${warn.formattedMessage}`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Exception processing ${path.basename(filePath)}:`);
|
||||
console.error(` ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n[SUMMARY] Compilation complete!`);
|
||||
console.log(` Success: ${successCount}`);
|
||||
console.log(` Failed: ${errorCount}`);
|
||||
console.log(` Output: ${config.outputDir}`);
|
||||
|
||||
if (errorCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# TODO: This should probably be replaced with a node script building every file in `emails/`
|
||||
|
||||
npx mjml emails/invite.mjml -o out/invite.html
|
||||
npx mjml emails/two-factor.mjml -o out/two-factor.html
|
||||
@@ -8,9 +8,17 @@
|
||||
<mj-body background-color="#e6e9ef" width="660px" />
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.link { text-decoration: none; color: #175ddc; font-weight: 600 }
|
||||
.link {
|
||||
text-decoration: none;
|
||||
color: #175ddc;
|
||||
font-weight: 600;
|
||||
}
|
||||
</mj-style>
|
||||
<mj-style>
|
||||
.border-fix > table { border-collapse:separate !important; } .border-fix >
|
||||
table > tbody > tr > td { border-radius: 3px; }
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</mj-style>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>Your two-step verification code is: <b>{{Token}}</b></p>
|
||||
<p>
|
||||
Your two-step verification code is: <b>{{ Token }}</b>
|
||||
</p>
|
||||
<p>Use this code to complete logging in with Bitwarden.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
},
|
||||
"homepage": "https://bitwarden.com",
|
||||
"scripts": {
|
||||
"build": "./build.sh",
|
||||
"watch": "nodemon --exec ./build.sh --watch ./components --watch ./emails --ext js,mjml",
|
||||
"build": "node ./build.js",
|
||||
"build:hbs": "node ./build.js --hbs",
|
||||
"build:minify": "node ./build.js --hbs --minify",
|
||||
"build:watch": "nodemon ./build.js --watch emails --watch components --ext mjml,js",
|
||||
"prettier": "prettier --cache --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
78
src/Core/MailTemplates/README.md
Normal file
78
src/Core/MailTemplates/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
Email templating
|
||||
================
|
||||
|
||||
We use MJML to generate the HTML that our mail services use to send emails to users. To accomplish this, we use different file types depending on which part of the email generation process we're working with.
|
||||
|
||||
# File Types
|
||||
|
||||
## `*.html.hbs`
|
||||
These are the compiled HTML email templates that serve as the foundation for all HTML emails sent by the Bitwarden platform. They are generated from MJML source files and enhanced with Handlebars templating capabilities.
|
||||
|
||||
### Generation Process
|
||||
- **Source**: Built from `*.mjml` files in the `./mjml` directory.
|
||||
- The MJML source acts as a toolkit for developers to generate HTML. It is the developers responsibility to generate the HTML and then ensure it is accessible to `IMailService` implementations.
|
||||
- **Build Tool**: Generated via node build scripts: `npm run build`.
|
||||
- The build script definitions can be viewed in the `Mjml/package.json` as well as in `Mjml/build.js`.
|
||||
- **Output**: Cross-client compatible HTML with embedded CSS for maximum email client support
|
||||
- **Template Engine**: Enhanced with Handlebars syntax for dynamic content injection
|
||||
|
||||
### Handlebars Integration
|
||||
The templates use Handlebars templating syntax for dynamic content replacement:
|
||||
|
||||
```html
|
||||
<!-- Example Handlebars usage -->
|
||||
<h1>Welcome {{userName}}!</h1>
|
||||
<p>Your organization {{organizationName}} has invited you to join.</p>
|
||||
<a href="{{actionUrl}}">Accept Invitation</a>
|
||||
```
|
||||
|
||||
**Variable Types:**
|
||||
- **Simple Variables**: `{{userName}}`, `{{email}}`, `{{organizationName}}`
|
||||
|
||||
### Email Service Integration
|
||||
The `IMailService` consumes these templates through the following process:
|
||||
|
||||
1. **Template Selection**: Service selects appropriate `.html.hbs` template based on email type
|
||||
2. **Model Binding**: View model properties are mapped to Handlebars variables
|
||||
3. **Compilation**: Handlebars engine processes variables and generates final HTML
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
**Variable Naming:**
|
||||
- Use camelCase for consistency: `{{userName}}`, `{{organizationName}}`
|
||||
- Prefix URLs with descriptive names: `{{actionUrl}}`, `{{logoUrl}}`
|
||||
|
||||
**Testing Considerations:**
|
||||
- Verify Handlebars variable replacement with actual view model data
|
||||
- Ensure graceful degradation when variables are missing or null, if necessary
|
||||
- Validate HTML structure and accessibility compliance
|
||||
|
||||
## `*.txt.hbs`
|
||||
These files provide plain text versions of emails and are essential for email accessibility and deliverability. They serve several important purposes:
|
||||
|
||||
### Purpose and Usage
|
||||
- **Accessibility**: Screen readers and assistive technologies often work better with plain text versions
|
||||
- **Email Client Compatibility**: Some email clients prefer or only display plain text versions
|
||||
- **Fallback Content**: When HTML rendering fails, the plain text version ensures the message is still readable
|
||||
|
||||
### Structure
|
||||
Plain text email templates use the same Handlebars syntax (`{{variable}}`) as HTML templates for dynamic content replacement. They should:
|
||||
|
||||
- Contain the core message content without HTML formatting
|
||||
- Use line breaks and spacing for readability
|
||||
- Include all important links as full URLs
|
||||
- Maintain logical content hierarchy using spacing and simple text formatting
|
||||
|
||||
### Email Service Integration
|
||||
The `IMailService` automatically uses both versions when sending emails:
|
||||
- The HTML version (from `*.html.hbs`) provides rich formatting and styling
|
||||
- The plain text version (from `*.txt.hbs`) serves as the text alternative
|
||||
- Email clients can choose which version to display based on user preferences and capabilities
|
||||
|
||||
### Development Guidelines
|
||||
- Always create a corresponding `*.txt.hbs` file for each `*.html.hbs` template
|
||||
- Keep the content concise but complete - include all essential information from the HTML version
|
||||
- Test plain text templates to ensure they're readable and convey the same message
|
||||
|
||||
## `*.mjml`
|
||||
This is a templating language we use to increase efficiency when creating email content. See the readme within the `./mjml` directory for more comprehensive information.
|
||||
@@ -62,6 +62,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual SqlSettings MySql { get; set; } = new SqlSettings();
|
||||
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
|
||||
public virtual SlackSettings Slack { get; set; } = new SlackSettings();
|
||||
public virtual TeamsSettings Teams { get; set; } = new TeamsSettings();
|
||||
public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings();
|
||||
public virtual MailSettings Mail { get; set; } = new MailSettings();
|
||||
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
|
||||
@@ -295,6 +296,15 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string Scopes { get; set; }
|
||||
}
|
||||
|
||||
public class TeamsSettings
|
||||
{
|
||||
public virtual string LoginBaseUrl { get; set; } = "https://login.microsoftonline.com";
|
||||
public virtual string GraphBaseUrl { get; set; } = "https://graph.microsoft.com/v1.0";
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual string ClientSecret { get; set; }
|
||||
public virtual string Scopes { get; set; }
|
||||
}
|
||||
|
||||
public class EventLoggingSettings
|
||||
{
|
||||
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
|
||||
@@ -320,6 +330,8 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription";
|
||||
public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription";
|
||||
public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription";
|
||||
public virtual string TeamsEventSubscriptionName { get; set; } = "events-teams-subscription";
|
||||
public virtual string TeamsIntegrationSubscriptionName { get; set; } = "integration-teams-subscription";
|
||||
|
||||
public string ConnectionString
|
||||
{
|
||||
@@ -364,6 +376,9 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue";
|
||||
public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue";
|
||||
public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue";
|
||||
public virtual string TeamsEventsQueueName { get; set; } = "events-teams-queue";
|
||||
public virtual string TeamsIntegrationQueueName { get; set; } = "integration-teams-queue";
|
||||
public virtual string TeamsIntegrationRetryQueueName { get; set; } = "integration-teams-retry-queue";
|
||||
|
||||
public string HostName
|
||||
{
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
|
||||
public static class AssemblyHelpers
|
||||
{
|
||||
private static readonly IEnumerable<AssemblyMetadataAttribute> _assemblyMetadataAttributes;
|
||||
private static readonly AssemblyInformationalVersionAttribute _assemblyInformationalVersionAttributes;
|
||||
private const string GIT_HASH_ASSEMBLY_KEY = "GitHash";
|
||||
private static string _version;
|
||||
private static string _gitHash;
|
||||
private static string? _version;
|
||||
private static string? _gitHash;
|
||||
|
||||
static AssemblyHelpers()
|
||||
{
|
||||
_assemblyMetadataAttributes = Assembly.GetEntryAssembly().GetCustomAttributes<AssemblyMetadataAttribute>();
|
||||
_assemblyInformationalVersionAttributes = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
}
|
||||
|
||||
public static string GetVersion()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_version))
|
||||
var assemblyInformationalVersionAttribute = typeof(AssemblyHelpers).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
if (assemblyInformationalVersionAttribute == null)
|
||||
{
|
||||
_version = _assemblyInformationalVersionAttributes.InformationalVersion;
|
||||
Debug.Fail("The AssemblyInformationalVersionAttribute is expected to exist in this assembly, possibly its generation was turned off.");
|
||||
return;
|
||||
}
|
||||
|
||||
var informationalVersion = assemblyInformationalVersionAttribute.InformationalVersion.AsSpan();
|
||||
|
||||
if (!informationalVersion.TrySplitBy('+', out var version, out var gitHash))
|
||||
{
|
||||
// Treat the whole thing as the version
|
||||
_version = informationalVersion.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
_version = version.ToString();
|
||||
if (gitHash.Length < 8)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_gitHash = gitHash[..8].ToString();
|
||||
}
|
||||
|
||||
public static string? GetVersion()
|
||||
{
|
||||
return _version;
|
||||
}
|
||||
|
||||
public static string GetGitHash()
|
||||
public static string? GetGitHash()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_gitHash))
|
||||
{
|
||||
try
|
||||
{
|
||||
_gitHash = _assemblyMetadataAttributes.Where(i => i.Key == GIT_HASH_ASSEMBLY_KEY).First().Value;
|
||||
}
|
||||
catch (Exception)
|
||||
{ }
|
||||
}
|
||||
|
||||
return _gitHash;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,17 @@ public class OrganizationIntegrationRepository : Repository<OrganizationIntegrat
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var result = await connection.QuerySingleOrDefaultAsync<OrganizationIntegration>(
|
||||
"[dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]",
|
||||
new { TenantId = tenantId, TeamId = teamId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,16 @@ public class OrganizationIntegrationRepository :
|
||||
return await query.Run(dbContext).ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Core.AdminConsole.Entities.OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(
|
||||
string tenantId,
|
||||
string teamId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = new OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(tenantId: tenantId, teamId: teamId);
|
||||
return await query.Run(dbContext).SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries;
|
||||
|
||||
public class OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery : IQuery<OrganizationIntegration>
|
||||
{
|
||||
private readonly string _tenantId;
|
||||
private readonly string _teamId;
|
||||
|
||||
public OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(string tenantId, string teamId)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_teamId = teamId;
|
||||
}
|
||||
|
||||
public IQueryable<OrganizationIntegration> Run(DatabaseContext dbContext)
|
||||
{
|
||||
var query =
|
||||
from oi in dbContext.OrganizationIntegrations
|
||||
where oi.Type == IntegrationType.Teams &&
|
||||
oi.Configuration != null &&
|
||||
oi.Configuration.Contains($"\"TenantId\":\"{_tenantId}\"") &&
|
||||
oi.Configuration.Contains($"\"id\":\"{_teamId}\"")
|
||||
select new OrganizationIntegration()
|
||||
{
|
||||
Id = oi.Id,
|
||||
OrganizationId = oi.OrganizationId,
|
||||
Type = oi.Type,
|
||||
Configuration = oi.Configuration,
|
||||
};
|
||||
return query;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Bit.Core;
|
||||
using Bit.Core.AdminConsole.AbilitiesCache;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.AdminConsole.Models.Teams;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.AdminConsole.Services.Implementations;
|
||||
@@ -69,6 +70,8 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Localization;
|
||||
using Microsoft.Azure.Cosmos.Fluent;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
using Microsoft.Extensions.Caching.Cosmos;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -604,6 +607,33 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes))
|
||||
{
|
||||
services.AddHttpClient(TeamsService.HttpClientName);
|
||||
services.TryAddSingleton<TeamsService>();
|
||||
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
|
||||
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
|
||||
services.TryAddSingleton<IBotFrameworkHttpAdapter>(sp =>
|
||||
new BotFrameworkHttpAdapter(
|
||||
new TeamsBotCredentialProvider(
|
||||
clientId: globalSettings.Teams.ClientId,
|
||||
clientSecret: globalSettings.Teams.ClientSecret
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ITeamsService, NoopTeamsService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static void UseDefaultMiddleware(this IApplicationBuilder app,
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
@@ -913,6 +943,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// Add services in support of handlers
|
||||
services.AddSlackService(globalSettings);
|
||||
services.AddTeamsService(globalSettings);
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
|
||||
services.AddHttpClient(DatadogIntegrationHandler.HttpClientName);
|
||||
@@ -921,12 +952,14 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<WebhookIntegrationConfigurationDetails>, WebhookIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<DatadogIntegrationConfigurationDetails>, DatadogIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<TeamsIntegrationConfigurationDetails>, TeamsIntegrationHandler>();
|
||||
|
||||
var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings);
|
||||
var slackConfiguration = new SlackListenerConfiguration(globalSettings);
|
||||
var webhookConfiguration = new WebhookListenerConfiguration(globalSettings);
|
||||
var hecConfiguration = new HecListenerConfiguration(globalSettings);
|
||||
var datadogConfiguration = new DatadogListenerConfiguration(globalSettings);
|
||||
var teamsConfiguration = new TeamsListenerConfiguration(globalSettings);
|
||||
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
@@ -944,6 +977,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
services.AddRabbitMqIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
|
||||
}
|
||||
|
||||
if (IsAzureServiceBusEnabled(globalSettings))
|
||||
@@ -967,6 +1001,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
services.AddAzureServiceBusIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
|
||||
}
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]
|
||||
@TenantId NVARCHAR(200),
|
||||
@TeamId NVARCHAR(200)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP 1 *
|
||||
FROM [dbo].[OrganizationIntegrationView]
|
||||
CROSS APPLY OPENJSON([Configuration], '$.Teams')
|
||||
WITH ( TeamId NVARCHAR(MAX) '$.id' ) t
|
||||
WHERE [Type] = 7
|
||||
AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId
|
||||
AND t.TeamId = @TeamId
|
||||
AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL
|
||||
AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL;
|
||||
END
|
||||
Reference in New Issue
Block a user