1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

Merge branch 'main' into PM-11162-assign-to-collection-perm-update

This commit is contained in:
jng 2024-10-02 16:24:01 -04:00
commit c705b404f1
No known key found for this signature in database
GPG Key ID: AF822623CAD19C85
133 changed files with 4296 additions and 605 deletions

View File

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.7.3",
"version": "6.8.0",
"commands": ["swagger"]
},
"dotnet-ef": {

View File

@ -30,7 +30,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -54,7 +54,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
fetch-depth: 0

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -68,7 +68,7 @@ jobs:
node: true
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -173,7 +173,7 @@ jobs:
dotnet: true
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check branch to publish
env:
@ -263,7 +263,7 @@ jobs:
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960 # v6.8.0
with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -282,7 +282,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8
uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -292,7 +292,7 @@ jobs:
needs: build-docker
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -467,7 +467,7 @@ jobs:
- win-x64
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -24,7 +24,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,7 +33,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Collect
id: collect

View File

@ -1,102 +0,0 @@
---
name: Container registry purge
on:
schedule:
- cron: "0 0 * * SUN"
workflow_dispatch:
inputs: {}
jobs:
purge:
name: Purge old images
runs-on: ubuntu-22.04
steps:
- name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Purge images
env:
REGISTRY: bitwardenprod
AGO_DUR_VER: "180d"
AGO_DUR: "30d"
run: |
REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
for REPO in $REPO_LIST
do
PURGE_LATEST=""
PURGE_VERSION=""
PURGE_ELSE=""
TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
for TAG in $TAG_LIST
do
if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
PURGE_LATEST+="--filter '$REPO:$TAG' "
elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
PURGE_VERSION+="--filter '$REPO:$TAG' "
else
PURGE_ELSE+="--filter '$REPO:$TAG' "
fi
done
if [ ! -z "$PURGE_LATEST" ]
then
PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_VERSION" ]
then
PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_ELSE" ]
then
PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
fi
wait
done
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
needs: [purge]
steps:
- name: Check if any job failed
if: |
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
if: failure()
with:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@ -29,7 +29,7 @@ jobs:
label: "DB-migrations-changed"
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
fetch-depth: 2

View File

@ -99,7 +99,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up project name
id: setup

View File

@ -37,7 +37,7 @@ jobs:
fi
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check release version
id: version

View File

@ -26,7 +26,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8
uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with:
sarif_file: cx_result.sarif
@ -66,7 +66,7 @@ jobs:
distribution: "zulu"
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}

View File

@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -147,7 +147,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -46,7 +46,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -39,7 +39,7 @@ jobs:
fi
- name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check if RC branch exists
if: ${{ inputs.cut_rc_branch == true }}
@ -230,7 +230,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
ref: main

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Scim.Context;
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
{
private bool _builtHttpContext;
// See IP list from Ping in docs: https://support.pingidentity.com/s/article/PingOne-IP-Addresses
private static readonly HashSet<string> _pingIpAddresses =
[
"18.217.152.87",
"52.14.10.143",
"13.58.49.148",
"34.211.92.81",
"54.214.158.219",
"34.218.98.164",
"15.223.133.47",
"3.97.84.38",
"15.223.19.71",
"3.97.98.120",
"52.60.115.173",
"3.97.202.223",
"18.184.65.93",
"52.57.244.92",
"18.195.7.252",
"108.128.67.71",
"34.246.158.102",
"108.128.250.27",
"52.63.103.92",
"13.54.131.18",
"52.62.204.36"
];
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
public ScimConfig ScimConfiguration { get; set; }
public Guid? OrganizationId { get; set; }
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
RequestScimProvider = ScimProviderType.Okta;
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.ContainsKey("Adscimversion"))
{
RequestScimProvider = ScimProviderType.AzureAd;
}
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
if (RequestScimProvider == ScimProviderType.Default &&
_pingIpAddresses.Contains(ipAddress))
{
RequestScimProvider = ScimProviderType.Ping;
}
}
}

View File

@ -43,7 +43,8 @@ public class PutGroupCommand : IPutGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
_scimContext.RequestScimProvider != ScimProviderType.Ping)
{
return;
}

View File

@ -20,15 +20,16 @@ public class GetUsersListQuery : IGetUsersListQuery
string externalIdFilter = null;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("userName eq "))
var filterLower = filter.ToLowerInvariant();
if (filterLower.StartsWith("username eq "))
{
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
usernameFilter = filterLower.Substring(12).Trim('"');
if (usernameFilter.Contains("@"))
{
emailFilter = usernameFilter;
}
}
else if (filter.StartsWith("externalId eq "))
else if (filterLower.StartsWith("externalid eq "))
{
externalIdFilter = filter.Substring(14).Trim('"');
}

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -14,6 +14,10 @@
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Billing\Controllers\" />
<Folder Include="Billing\Models\" />
</ItemGroup>
<Choose>
<When Condition="!$(DefineConstants.Contains('OSS'))">

View File

@ -174,18 +174,15 @@
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
{
<div class="ml-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
<div class="ml-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
</div>
}
</div>
</div>
}

View File

@ -0,0 +1,71 @@
using System.Text.Json;
using Bit.Admin.Billing.Models.ProcessStripeEvents;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("process-stripe-events")]
[SelfHosted(NotSelfHostedOnly = true)]
public class ProcessStripeEventsController(
IHttpClientFactory httpClientFactory,
IGlobalSettings globalSettings) : Controller
{
[HttpGet]
public ActionResult Index()
{
return View(new EventsFormModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ProcessAsync([FromForm] EventsFormModel model)
{
var eventIds = model.GetEventIds();
const string baseEndpoint = "stripe/recovery/events";
var endpoint = model.Inspect ? $"{baseEndpoint}/inspect" : $"{baseEndpoint}/process";
var (response, failedResponseMessage) = await PostAsync(endpoint, new EventsRequestBody
{
EventIds = eventIds
});
if (response == null)
{
return StatusCode((int)failedResponseMessage.StatusCode, "An error occurred during your request.");
}
response.ActionType = model.Inspect ? EventActionType.Inspect : EventActionType.Process;
return View("Results", response);
}
private async Task<(EventsResponseBody, HttpResponseMessage)> PostAsync(
string endpoint,
EventsRequestBody requestModel)
{
var client = httpClientFactory.CreateClient("InternalBilling");
client.BaseAddress = new Uri(globalSettings.BaseServiceUri.InternalBilling);
var json = JsonSerializer.Serialize(requestModel);
var requestBody = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var responseMessage = await client.PostAsync(endpoint, requestBody);
if (!responseMessage.IsSuccessStatusCode)
{
return (null, responseMessage);
}
var responseContent = await responseMessage.Content.ReadAsStringAsync();
var response = JsonSerializer.Deserialize<EventsResponseBody>(responseContent);
return (response, null);
}
}

View File

@ -0,0 +1,29 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsFormModel : IValidatableObject
{
[Required]
public string EventIds { get; set; }
[Required]
[DisplayName("Inspect Only")]
public bool Inspect { get; set; }
public List<string> GetEventIds() =>
EventIds?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)
.Select(eventId => eventId.Trim())
.ToList() ?? [];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var eventIds = GetEventIds();
if (eventIds.Any(eventId => !eventId.StartsWith("evt_")))
{
yield return new ValidationResult("Event Ids must start with 'evt_'.");
}
}
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}

View File

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> Events { get; set; }
[JsonIgnore]
public EventActionType ActionType { get; set; }
}
public class EventResponseBody
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("url")]
public string URL { get; set; }
[JsonPropertyName("apiVersion")]
public string APIVersion { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("createdUTC")]
public DateTime CreatedUTC { get; set; }
[JsonPropertyName("processingError")]
public string ProcessingError { get; set; }
}
public enum EventActionType
{
Inspect,
Process
}

View File

@ -0,0 +1,25 @@
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsFormModel
@{
ViewData["Title"] = "Process Stripe Events";
}
<h1>Process Stripe Events</h1>
<form method="post" asp-controller="ProcessStripeEvents" asp-action="Process">
<div class="row">
<div class="col-1">
<div class="form-group">
<input type="submit" value="Process" class="btn btn-primary mb-2"/>
</div>
</div>
<div class="col-2">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" asp-for="Inspect">
<label class="form-check-label" asp-for="Inspect"></label>
</div>
</div>
</div>
<div class="form-group">
<textarea id="event-ids" type="text" class="form-control" rows="100" asp-for="EventIds"></textarea>
</div>
</form>

View File

@ -0,0 +1,49 @@
@using Bit.Admin.Billing.Models.ProcessStripeEvents
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsResponseBody
@{
var title = Model.ActionType == EventActionType.Inspect ? "Inspect Stripe Events" : "Process Stripe Events";
ViewData["Title"] = title;
}
<h1>@title</h1>
<h2>Results</h2>
<div class="table-responsive">
@if (!Model.Events.Any())
{
<p>No data found.</p>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>API Version</th>
<th>Created</th>
@if (Model.ActionType == EventActionType.Process)
{
<th>Processing Error</th>
}
</tr>
</thead>
<tbody>
@foreach (var eventResponseBody in Model.Events)
{
<tr>
<td><a href="@eventResponseBody.URL">@eventResponseBody.Id</a></td>
<td>@eventResponseBody.Type</td>
<td>@eventResponseBody.APIVersion</td>
<td>@eventResponseBody.CreatedUTC</td>
@if (Model.ActionType == EventActionType.Process)
{
<td>@eventResponseBody.ProcessingError</td>
}
</tr>
}
</tbody>
</table>
}
</div>

View File

@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Identity
@using Bit.Admin.AdminConsole
@using Bit.Admin.AdminConsole.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*, Admin"

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -1,11 +1,11 @@
using Bit.Admin.Enums;
#nullable enable
using Bit.Admin.Enums;
using Bit.Admin.Models;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -24,9 +24,9 @@ public class UsersController : Controller
private readonly IPaymentService _paymentService;
private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService;
private readonly IUserService _userService;
public UsersController(
IUserRepository userRepository,
@ -34,18 +34,18 @@ public class UsersController : Controller
IPaymentService paymentService,
GlobalSettings globalSettings,
IAccessControlService accessControlService,
ICurrentContext currentContext,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
IUserService userService)
{
_userRepository = userRepository;
_cipherRepository = cipherRepository;
_paymentService = paymentService;
_globalSettings = globalSettings;
_accessControlService = accessControlService;
_currentContext = currentContext;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService;
_userService = userService;
}
[RequirePermission(Permission.User_List_View)]
@ -64,19 +64,26 @@ public class UsersController : Controller
var skip = (page - 1) * count;
var users = await _userRepository.SearchAsync(email, skip, count);
var userModels = new List<UserViewModel>();
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
var user2Fa = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
// TempDataSerializer is having an issue serializing an empty IEnumerable<Tuple<T1,T2>>, do not set if empty.
if (user2Fa.Count != 0)
var twoFactorAuthLookup = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
userModels = UserViewModel.MapViewModels(users, twoFactorAuthLookup).ToList();
}
else
{
foreach (var user in users)
{
TempData["UsersTwoFactorIsEnabled"] = user2Fa;
var isTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
userModels.Add(UserViewModel.MapViewModel(user, isTwoFactorEnabled));
}
}
return View(new UsersModel
{
Items = users as List<User>,
Items = userModels,
Email = string.IsNullOrWhiteSpace(email) ? null : email,
Page = page,
Count = count,
@ -87,13 +94,17 @@ public class UsersController : Controller
public async Task<IActionResult> View(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
{
return RedirectToAction("Index");
}
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
return View(new UserViewModel(user, ciphers));
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers));
}
[SelfHosted(NotSelfHostedOnly = true)]
@ -108,7 +119,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
}
[HttpPost]

View File

@ -47,5 +47,6 @@ public enum Permission
Tools_GenerateLicenseFile,
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction
Tools_CreateEditTransaction,
Tools_ProcessStripeEvents
}

View File

@ -7,18 +7,23 @@ using Bit.Core.Vault.Entities;
namespace Bit.Admin.Models;
public class UserEditModel : UserViewModel
public class UserEditModel
{
public UserEditModel() { }
public UserEditModel()
{
}
public UserEditModel(
User user,
bool isTwoFactorEnabled,
IEnumerable<Cipher> ciphers,
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings)
: base(user, ciphers)
{
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -35,32 +40,32 @@ public class UserEditModel : UserViewModel
PremiumExpirationDate = user.PremiumExpirationDate;
}
public BillingInfo BillingInfo { get; set; }
public BillingHistoryInfo BillingHistoryInfo { get; set; }
public UserViewModel User { get; init; }
public BillingInfo BillingInfo { get; init; }
public BillingHistoryInfo BillingHistoryInfo { get; init; }
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; set; }
public string BraintreeMerchantId { get; init; }
[Display(Name = "Name")]
public string Name { get; set; }
public string Name { get; init; }
[Required]
[Display(Name = "Email")]
public string Email { get; set; }
public string Email { get; init; }
[Display(Name = "Email Verified")]
public bool EmailVerified { get; set; }
public bool EmailVerified { get; init; }
[Display(Name = "Premium")]
public bool Premium { get; set; }
public bool Premium { get; init; }
[Display(Name = "Max. Storage GB")]
public short? MaxStorageGb { get; set; }
public short? MaxStorageGb { get; init; }
[Display(Name = "Gateway")]
public Core.Enums.GatewayType? Gateway { get; set; }
public Core.Enums.GatewayType? Gateway { get; init; }
[Display(Name = "Gateway Customer Id")]
public string GatewayCustomerId { get; set; }
public string GatewayCustomerId { get; init; }
[Display(Name = "Gateway Subscription Id")]
public string GatewaySubscriptionId { get; set; }
public string GatewaySubscriptionId { get; init; }
[Display(Name = "License Key")]
public string LicenseKey { get; set; }
public string LicenseKey { get; init; }
[Display(Name = "Premium Expiration Date")]
public DateTime? PremiumExpirationDate { get; set; }
public DateTime? PremiumExpirationDate { get; init; }
}

View File

@ -1,18 +1,131 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Vault.Entities;
namespace Bit.Admin.Models;
public class UserViewModel
{
public UserViewModel() { }
public Guid Id { get; }
public string Name { get; }
public string Email { get; }
public DateTime CreationDate { get; }
public DateTime? PremiumExpirationDate { get; }
public bool Premium { get; }
public short? MaxStorageGb { get; }
public bool EmailVerified { get; }
public bool TwoFactorEnabled { get; }
public DateTime AccountRevisionDate { get; }
public DateTime RevisionDate { get; }
public DateTime? LastEmailChangeDate { get; }
public DateTime? LastKdfChangeDate { get; }
public DateTime? LastKeyRotationDate { get; }
public DateTime? LastPasswordChangeDate { get; }
public GatewayType? Gateway { get; }
public string GatewayCustomerId { get; }
public string GatewaySubscriptionId { get; }
public string LicenseKey { get; }
public int CipherCount { get; set; }
public UserViewModel(User user, IEnumerable<Cipher> ciphers)
public UserViewModel(Guid id,
string name,
string email,
DateTime creationDate,
DateTime? premiumExpirationDate,
bool premium,
short? maxStorageGb,
bool emailVerified,
bool twoFactorEnabled,
DateTime accountRevisionDate,
DateTime revisionDate,
DateTime? lastEmailChangeDate,
DateTime? lastKdfChangeDate,
DateTime? lastKeyRotationDate,
DateTime? lastPasswordChangeDate,
GatewayType? gateway,
string gatewayCustomerId,
string gatewaySubscriptionId,
string licenseKey,
IEnumerable<Cipher> ciphers)
{
User = user;
Id = id;
Name = name;
Email = email;
CreationDate = creationDate;
PremiumExpirationDate = premiumExpirationDate;
Premium = premium;
MaxStorageGb = maxStorageGb;
EmailVerified = emailVerified;
TwoFactorEnabled = twoFactorEnabled;
AccountRevisionDate = accountRevisionDate;
RevisionDate = revisionDate;
LastEmailChangeDate = lastEmailChangeDate;
LastKdfChangeDate = lastKdfChangeDate;
LastKeyRotationDate = lastKeyRotationDate;
LastPasswordChangeDate = lastPasswordChangeDate;
Gateway = gateway;
GatewayCustomerId = gatewayCustomerId;
GatewaySubscriptionId = gatewaySubscriptionId;
LicenseKey = licenseKey;
CipherCount = ciphers.Count();
}
public User User { get; set; }
public int CipherCount { get; set; }
public static IEnumerable<UserViewModel> MapViewModels(
IEnumerable<User> users,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
users.Select(user => MapViewModel(user, lookup));
public static UserViewModel MapViewModel(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
new(
user.Id,
user.Name,
user.Email,
user.CreationDate,
user.PremiumExpirationDate,
user.Premium,
user.MaxStorageGb,
user.EmailVerified,
IsTwoFactorEnabled(user, lookup),
user.AccountRevisionDate,
user.RevisionDate,
user.LastEmailChangeDate,
user.LastKdfChangeDate,
user.LastKeyRotationDate,
user.LastPasswordChangeDate,
user.Gateway,
user.GatewayCustomerId ?? string.Empty,
user.GatewaySubscriptionId ?? string.Empty,
user.LicenseKey ?? string.Empty,
Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) =>
new(
user.Id,
user.Name,
user.Email,
user.CreationDate,
user.PremiumExpirationDate,
user.Premium,
user.MaxStorageGb,
user.EmailVerified,
isTwoFactorEnabled,
user.AccountRevisionDate,
user.RevisionDate,
user.LastEmailChangeDate,
user.LastKdfChangeDate,
user.LastKeyRotationDate,
user.LastPasswordChangeDate,
user.Gateway,
user.GatewayCustomerId ?? string.Empty,
user.GatewaySubscriptionId ?? string.Empty,
user.LicenseKey ?? string.Empty,
ciphers);
public static bool IsTwoFactorEnabled(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> twoFactorIsEnabledLookup) =>
twoFactorIsEnabledLookup.FirstOrDefault(x => x.userId == user.Id).twoFactorIsEnabled;
}

View File

@ -1,8 +1,6 @@
using Bit.Core.Entities;
namespace Bit.Admin.Models;
namespace Bit.Admin.Models;
public class UsersModel : PagedModel<User>
public class UsersModel : PagedModel<UserViewModel>
{
public string Email { get; set; }
public string Action { get; set; }

View File

@ -89,6 +89,7 @@ public class Startup
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingOperations();
services.AddHttpClient();
#if OSS
services.AddOosServices();
@ -108,6 +109,7 @@ public class Startup
{
o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml");
o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml");
o.ViewLocationFormats.Add("/Billing/Views/{1}/{0}.cshtml");
});
// Jobs service

View File

@ -161,7 +161,8 @@ public static class RolePermissionMapping
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction
Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents,
}
},
{ "sales", new List<Permission>

View File

@ -14,6 +14,7 @@
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
@ -107,6 +108,12 @@
Manage Stripe Subscriptions
</a>
}
@if (canProcessStripeEvents)
{
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
Process Stripe Events
</a>
}
</div>
</li>
}

View File

@ -86,7 +86,7 @@
@if (canViewUserInformation)
{
<h2>User Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
@await Html.PartialAsync("_ViewInformation", Model.User)
}
@if (canViewBillingInformation)
{

View File

@ -1,6 +1,4 @@
@model UsersModel
@inject Bit.Core.Services.IUserService userService
@inject Bit.Core.Services.IFeatureService featureService
@{
ViewData["Title"] = "Users";
}
@ -16,100 +14,88 @@
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Email</th>
<th style="width: 150px;">Created</th>
<th style="width: 170px; min-width: 170px;">Details</th>
</tr>
<tr>
<th>Email</th>
<th style="width: 150px;">Created</th>
<th style="width: 170px; min-width: 170px;">Details</th>
</tr>
</thead>
<tbody>
@if(!Model.Items.Any())
@if (!Model.Items.Any())
{
<tr>
<td colspan="4">No results to list.</td>
</tr>
}
else
{
@foreach (var user in Model.Items)
{
<tr>
<td colspan="4">No results to list.</td>
<td>
<a asp-action="@Model.Action" asp-route-id="@user.Id">@user.Email</a>
</td>
<td>
<span title="@user.CreationDate.ToString()">
@user.CreationDate.ToShortDateString()
</span>
</td>
<td>
@if (user.Premium)
{
<i class="fa fa-star fa-lg fa-fw"
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")">
</i>
}
else
{
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
}
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
{
<i class="fa fa-plus-square fa-lg fa-fw"
title="Additional Storage, @(user.MaxStorageGb - 1) GB">
</i>
}
else
{
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
title="No Additional Storage">
</i>
}
@if (user.EmailVerified)
{
<i class="fa fa-check-circle fa-lg fa-fw" title="Email Verified"></i>
}
else
{
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
}
@if (user.TwoFactorEnabled)
{
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
}
</td>
</tr>
}
else
{
@foreach(var user in Model.Items)
{
<tr>
<td>
<a asp-action="@Model.Action" asp-route-id="@user.Id">@user.Email</a>
</td>
<td>
<span title="@user.CreationDate.ToString()">
@user.CreationDate.ToShortDateString()
</span>
</td>
<td>
@if(user.Premium)
{
<i class="fa fa-star fa-lg fa-fw"
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")"></i>
}
else
{
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
}
@if(user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
{
<i class="fa fa-plus-square fa-lg fa-fw"
title="Additional Storage, @(user.MaxStorageGb - 1) GB"></i>
}
else
{
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
title="No Additional Storage"></i>
}
@if(user.EmailVerified)
{
<i class="fa fa-check-circle fa-lg fa-fw" title="Email Verified"></i>
}
else
{
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
}
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>;
var matchingUser2Fa = usersTwoFactorIsEnabled?.FirstOrDefault(tuple => tuple.userId == user.Id);
@if(matchingUser2Fa is { twoFactorIsEnabled: true })
{
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
}
}
else
{
@if(await userService.TwoFactorIsEnabledAsync(user))
{
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
}
}
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<nav>
<ul class="pagination">
@if(Model.PreviousPage.HasValue)
@if (Model.PreviousPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@Model.PreviousPage.Value"
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Previous</a>
asp-route-count="@Model.Count" asp-route-email="@Model.Email">
Previous
</a>
</li>
}
else
@ -118,11 +104,13 @@
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if(Model.NextPage.HasValue)
@if (Model.NextPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@Model.NextPage.Value"
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Next</a>
asp-route-count="@Model.Count" asp-route-email="@Model.Email">
Next
</a>
</li>
}
else

View File

@ -1,13 +1,13 @@
@model UserViewModel
@{
ViewData["Title"] = "User: " + Model.User.Email;
ViewData["Title"] = "User: " + Model.Email;
}
<h1>User <small>@Model.User.Email</small></h1>
<h1>User <small>@Model.Email</small></h1>
<h2>Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
<form asp-action="Delete" asp-route-id="@Model.User.Id"
<form asp-action="Delete" asp-route-id="@Model.Id"
onsubmit="return confirm('Are you sure you want to delete this user?')">
<button class="btn btn-danger" type="submit">Delete</button>
</form>

View File

@ -1,43 +1,42 @@
@model UserViewModel
@inject Bit.Core.Services.IUserService userService
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.User.Id</code></dd>
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
<dt class="col-sm-4 col-lg-3">Premium</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.Premium ? "Yes" : "No")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.Premium ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Premium Expires</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.PremiumExpirationDate?.ToString() ?? "-")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.PremiumExpirationDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Email Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.EmailVerified ? "Yes" : "No")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Using 2FA</dt>
<dd class="col-sm-8 col-lg-9">@(await userService.TwoFactorIsEnabledAsync(Model.User) ? "Yes" : "No")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Items</dt>
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
<dt class="col-sm-4 col-lg-3">Vault Modified</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.AccountRevisionDate.ToString()</dd>
<dd class="col-sm-8 col-lg-9">@Model.AccountRevisionDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Created</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.CreationDate.ToString()</dd>
<dd class="col-sm-8 col-lg-9">@Model.CreationDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Modified</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.RevisionDate.ToString()</dd>
<dd class="col-sm-8 col-lg-9">@Model.RevisionDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate?.ToString() ?? "-")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.LastEmailChangeDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate?.ToString() ?? "-")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.LastKdfChangeDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate?.ToString() ?? "-")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.LastKeyRotationDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last Password Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate?.ToString() ?? "-")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.LastPasswordChangeDate?.ToString() ?? "-")</dd>
</dl>

View File

@ -13,7 +13,8 @@
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
"internalScim": "http://localhost:44559",
"internalBilling": "http://localhost:44519"
},
"mail": {
"smtp": {

View File

@ -1,12 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
@ -44,7 +45,6 @@ public class OrganizationUsersController : Controller
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IAuthorizationService _authorizationService;
private readonly IApplicationCacheService _applicationCacheService;
@ -52,6 +52,7 @@ public class OrganizationUsersController : Controller
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
public OrganizationUsersController(
@ -66,14 +67,14 @@ public class OrganizationUsersController : Controller
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IAcceptOrgUserCommand acceptOrgUserCommand,
IAuthorizationService authorizationService,
IApplicationCacheService applicationCacheService,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -86,7 +87,6 @@ public class OrganizationUsersController : Controller
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_updateOrganizationUserCommand = updateOrganizationUserCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_acceptOrgUserCommand = acceptOrgUserCommand;
_authorizationService = authorizationService;
_applicationCacheService = applicationCacheService;
@ -94,6 +94,7 @@ public class OrganizationUsersController : Controller
_ssoConfigRepository = ssoConfigRepository;
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
}
[HttpGet("{id}")]
@ -115,11 +116,27 @@ public class OrganizationUsersController : Controller
return response;
}
[HttpGet("mini-details")]
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
{
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
OrganizationUserUserMiniDetailsOperations.ReadAll);
if (!authorizationResult.Succeeded)
{
throw new NotFoundException();
}
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId);
return new ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>(
organizationUserUserDetails.Select(ou => new OrganizationUserUserMiniDetailsResponseModel(ou)));
}
[HttpGet("")]
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{
var authorized = (await _authorizationService.AuthorizeAsync(
User, OrganizationUserOperations.ReadAll(orgId))).Succeeded;
User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded;
if (!authorized)
{
throw new NotFoundException();
@ -528,6 +545,59 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
}
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model)
{
if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
}
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")]
[HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model)
{
if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
}
[HttpPatch("{id}/revoke")]
[HttpPut("{id}/revoke")]
public async Task RevokeAsync(Guid orgId, Guid id)

View File

@ -171,6 +171,21 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(result.Item1);
}
[HttpPost("create-without-payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationResponseModel> CreateWithoutPaymentAsync([FromBody] OrganizationNoPaymentCreateRequest model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var organizationSignup = model.ToOrganizationSignup(user);
var result = await _organizationService.SignUpAsync(organizationSignup);
return new OrganizationResponseModel(result.Item1);
}
[HttpPut("{id}")]
[HttpPost("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)

View File

@ -14,42 +14,63 @@ public class OrganizationCreateRequestModel : IValidatableObject
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[Required]
[StringLength(256)]
[EmailAddress]
public string BillingEmail { get; set; }
public PlanType PlanType { get; set; }
[Required]
public string Key { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public PaymentMethodType? PaymentMethodType { get; set; }
public string PaymentToken { get; set; }
[Range(0, int.MaxValue)]
public int AdditionalSeats { get; set; }
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string CollectionName { get; set; }
public string TaxIdNumber { get; set; }
public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; }
public string BillingAddressCity { get; set; }
public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; }
[StringLength(2)]
public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
public string InitiationPath { get; set; }
@ -99,16 +120,19 @@ public class OrganizationCreateRequestModel : IValidatableObject
{
yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) });
}
if (PlanType != PlanType.Free && !PaymentMethodType.HasValue)
{
yield return new ValidationResult("Payment method type required.",
new string[] { nameof(PaymentMethodType) });
}
if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry))
{
yield return new ValidationResult("Country required.",
new string[] { nameof(BillingAddressCountry) });
}
if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
{
@ -117,3 +141,4 @@ public class OrganizationCreateRequestModel : IValidatableObject
}
}
}

View File

@ -0,0 +1,116 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationNoPaymentCreateRequest
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[Required]
[StringLength(256)]
[EmailAddress]
public string BillingEmail { get; set; }
public PlanType PlanType { get; set; }
[Required]
public string Key { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public PaymentMethodType? PaymentMethodType { get; set; }
public string PaymentToken { get; set; }
[Range(0, int.MaxValue)]
public int AdditionalSeats { get; set; }
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string CollectionName { get; set; }
public string TaxIdNumber { get; set; }
public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; }
public string BillingAddressCity { get; set; }
public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; }
[StringLength(2)]
public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
public string InitiationPath { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user)
{
var orgSignup = new OrganizationSignup
{
Owner = user,
OwnerKey = Key,
Name = Name,
Plan = PlanType,
PaymentMethodType = PaymentMethodType,
PaymentToken = PaymentToken,
AdditionalSeats = AdditionalSeats,
MaxAutoscaleSeats = MaxAutoscaleSeats,
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0),
PremiumAccessAddon = PremiumAccessAddon,
BillingEmail = BillingEmail,
BusinessName = BusinessName,
CollectionName = CollectionName,
AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(),
AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(),
UseSecretsManager = UseSecretsManager,
IsFromSecretsManagerTrial = IsFromSecretsManagerTrial,
TaxInfo = new TaxInfo
{
TaxIdNumber = TaxIdNumber,
BillingAddressLine1 = BillingAddressLine1,
BillingAddressLine2 = BillingAddressLine2,
BillingAddressCity = BillingAddressCity,
BillingAddressState = BillingAddressState,
BillingAddressPostalCode = BillingAddressPostalCode,
BillingAddressCountry = BillingAddressCountry,
},
InitiationPath = InitiationPath,
};
Keys?.ToOrganizationSignup(orgSignup);
return orgSignup;
}
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class SecureOrganizationUserBulkRequestModel : SecretVerificationRequestModel
{
[Required]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@ -84,6 +84,29 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
public IEnumerable<Guid> Groups { get; set; }
}
#nullable enable
public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
{
public OrganizationUserUserMiniDetailsResponseModel(OrganizationUserUserDetails organizationUser)
: base("organizationUserUserMiniDetails")
{
Id = organizationUser.Id;
UserId = organizationUser.UserId;
Type = organizationUser.Type;
Status = organizationUser.Status;
Name = organizationUser.Name;
Email = organizationUser.Email;
}
public Guid Id { get; }
public Guid? UserId { get; }
public OrganizationUserType Type { get; }
public OrganizationUserStatusType Status { get; }
public string? Name { get; }
public string Email { get; }
}
#nullable disable
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,

View File

@ -35,7 +35,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.0" />
</ItemGroup>
</Project>

View File

@ -443,10 +443,11 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg);
hasPremiumFromOrg, managedByOrganizationId);
return response;
}
@ -471,7 +472,12 @@ public class AccountsController : Controller
}
await _userService.SaveUserAsync(model.ToUser(user));
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, managedByOrganizationId);
return response;
}
@ -485,7 +491,12 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
await _userService.SaveUserAsync(model.ToUser(user), true);
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
return response;
}
@ -633,7 +644,12 @@ public class AccountsController : Controller
BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode,
});
var profile = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
return new PaymentResponseModel
{
UserProfile = profile,
@ -920,4 +936,15 @@ public class AccountsController : Controller
throw new BadRequestException("Token", "Invalid token");
}
}
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return null;
}
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
return organizationManagingUser?.Id;
}
}

View File

@ -14,7 +14,8 @@ public class ProfileResponseModel : ResponseModel
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
bool twoFactorEnabled,
bool premiumFromOrganization) : base("profile")
bool premiumFromOrganization,
Guid? managedByOrganizationId) : base("profile")
{
if (user == null)
{
@ -40,6 +41,7 @@ public class ProfileResponseModel : ResponseModel
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations =
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
ManagedByOrganizationId = managedByOrganizationId;
}
public ProfileResponseModel() : base("profile")
@ -61,6 +63,7 @@ public class ProfileResponseModel : ResponseModel
public bool UsesKeyConnector { get; set; }
public string AvatarColor { get; set; }
public DateTime CreationDate { get; set; }
public Guid? ManagedByOrganizationId { get; set; }
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }

View File

@ -1,6 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@ -100,6 +99,5 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserAuthorizationHandler>();
}
}

View File

@ -1,60 +0,0 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
/// <summary>
/// Handles authorization logic for OrganizationUser objects.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class OrganizationUserAuthorizationHandler : AuthorizationHandler<OrganizationUserOperationRequirement>
{
private readonly ICurrentContext _currentContext;
public OrganizationUserAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserOperationRequirement requirement)
{
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
if (requirement.OrganizationId == default)
{
context.Fail();
return;
}
var org = _currentContext.GetOrganization(requirement.OrganizationId);
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserOperations.ReadAll):
await CanReadAllAsync(context, requirement, org);
break;
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context, OrganizationUserOperationRequirement requirement,
CurrentContextOrganization? org)
{
// All users of an organization can read all other users of that organization for collection access management
if (org is not null)
{
context.Succeed(requirement);
}
// Allow provider users to read all organization users if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{
context.Succeed(requirement);
}
}
}

View File

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
public class OrganizationUserOperationRequirement : OperationAuthorizationRequirement
{
public Guid OrganizationId { get; }
public OrganizationUserOperationRequirement(string name, Guid organizationId)
{
Name = name;
OrganizationId = organizationId;
}
}
public static class OrganizationUserOperations
{
public static OrganizationUserOperationRequirement ReadAll(Guid organizationId)
{
return new OrganizationUserOperationRequirement(nameof(ReadAll), organizationId);
}
}

View File

@ -1,4 +1,5 @@
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
@ -6,6 +7,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -30,6 +32,7 @@ public class SyncController : Controller
private readonly IPolicyRepository _policyRepository;
private readonly ISendRepository _sendRepository;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
public SyncController(
IUserService userService,
@ -41,7 +44,8 @@ public class SyncController : Controller
IProviderUserRepository providerUserRepository,
IPolicyRepository policyRepository,
ISendRepository sendRepository,
GlobalSettings globalSettings)
GlobalSettings globalSettings,
IFeatureService featureService)
{
_userService = userService;
_folderRepository = folderRepository;
@ -53,6 +57,7 @@ public class SyncController : Controller
_policyRepository = policyRepository;
_sendRepository = sendRepository;
_globalSettings = globalSettings;
_featureService = featureService;
}
[HttpGet("")]
@ -90,9 +95,23 @@ public class SyncController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationUserDetails,
providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers,
collectionCiphersGroupDict, excludeDomains, policies, sends);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user, organizationUserDetails);
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization,
managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response;
}
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) ||
!organizationUserDetails.Any(o => o.Enabled && o.UseSso))
{
return null;
}
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
return organizationManagingUser?.Id;
}
}

View File

@ -21,6 +21,7 @@ public class SyncResponseModel : ResponseModel
User user,
bool userTwoFactorEnabled,
bool userHasPremiumFromOrganization,
Guid? managedByOrganizationId,
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
@ -34,7 +35,7 @@ public class SyncResponseModel : ResponseModel
: base("sync")
{
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization);
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
Folders = folders.Select(f => new FolderResponseModel(f));
Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict));
Collections = collections?.Select(

View File

@ -0,0 +1,68 @@
using Bit.Billing.Models.Recovery;
using Bit.Billing.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Billing.Controllers;
[Route("stripe/recovery")]
[SelfHosted(NotSelfHostedOnly = true)]
public class RecoveryController(
IStripeEventProcessor stripeEventProcessor,
IStripeFacade stripeFacade,
IWebHostEnvironment webHostEnvironment) : Controller
{
private readonly string _stripeURL = webHostEnvironment.IsDevelopment() || webHostEnvironment.IsEnvironment("QA")
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com";
// ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute
[HttpPost("events/inspect")]
public async Task<Ok<EventsResponseBody>> InspectEventsAsync([FromBody] EventsRequestBody requestBody)
{
var inspected = await Task.WhenAll(requestBody.EventIds.Select(async eventId =>
{
var @event = await stripeFacade.GetEvent(eventId);
return Map(@event);
}));
var response = new EventsResponseBody { Events = inspected.ToList() };
return TypedResults.Ok(response);
}
// ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute
[HttpPost("events/process")]
public async Task<Ok<EventsResponseBody>> ProcessEventsAsync([FromBody] EventsRequestBody requestBody)
{
var processed = await Task.WhenAll(requestBody.EventIds.Select(async eventId =>
{
var @event = await stripeFacade.GetEvent(eventId);
try
{
await stripeEventProcessor.ProcessEventAsync(@event);
return Map(@event);
}
catch (Exception exception)
{
return Map(@event, exception.Message);
}
}));
var response = new EventsResponseBody { Events = processed.ToList() };
return TypedResults.Ok(response);
}
private EventResponseBody Map(Event @event, string processingError = null) => new()
{
Id = @event.Id,
URL = $"{_stripeURL}/workbench/events/{@event.Id}",
APIVersion = @event.ApiVersion,
Type = @event.Type,
CreatedUTC = @event.Created,
ProcessingError = processingError
};
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models.Recovery;
public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models.Recovery;
public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> Events { get; set; }
}
public class EventResponseBody
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("url")]
public string URL { get; set; }
[JsonPropertyName("apiVersion")]
public string APIVersion { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("createdUTC")]
public DateTime CreatedUTC { get; set; }
[JsonPropertyName("processingError")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ProcessingError { get; set; }
}

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,

View File

@ -2,34 +2,63 @@
namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
public class InvoiceCreatedHandler(
ILogger<InvoiceCreatedHandler> logger,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
: IInvoiceCreatedHandler
{
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IProviderEventService _providerEventService;
public InvoiceCreatedHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_providerEventService = providerEventService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
/// <para>
/// This handler processes the `invoice.created` event in <see href="https://docs.stripe.com/api/events/types#event_types-invoice.created">Stripe</see>. It has
/// two primary responsibilities.
/// </para>
/// <para>
/// 1. Checks to see if the newly created invoice belongs to a PayPal customer. If it does, and the invoice is ready to be paid, it will attempt to pay the invoice
/// with Braintree and then let Stripe know the invoice can be marked as paid.
/// </para>
/// <para>
/// 2. If the invoice is for a provider, it records a point-in-time snapshot of the invoice broken down by the provider's client organizations. This is later used in
/// the provider invoice export.
/// </para>
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
try
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
if (usingPayPal && invoice is
{
AmountDue: > 0,
Paid: false,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
SubscriptionId: not null and not ""
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
}
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to attempt paying for invoice while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
}
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
try
{
await providerEventService.TryRecordInvoiceLineItems(parsedEvent);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to record provider invoice line items while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
}
}
}

View File

@ -206,6 +206,12 @@ public class StripeEventUtilityService : IStripeEventUtilityService
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
}
else if (charge.PaymentMethodDetails.UsBankAccount != null)
{
var usBankAccount = charge.PaymentMethodDetails.UsBankAccount;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{usBankAccount.BankName}, *{usBankAccount.Last4}";
}
else if (charge.PaymentMethodDetails.AchDebit != null)
{
var achDebit = charge.PaymentMethodDetails.AchDebit;

View File

@ -6,6 +6,7 @@ public class StripeFacade : IStripeFacade
{
private readonly ChargeService _chargeService = new();
private readonly CustomerService _customerService = new();
private readonly EventService _eventService = new();
private readonly InvoiceService _invoiceService = new();
private readonly PaymentMethodService _paymentMethodService = new();
private readonly SubscriptionService _subscriptionService = new();
@ -19,6 +20,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
public async Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _eventService.GetAsync(eventId, eventGetOptions, requestOptions, cancellationToken);
public async Task<Customer> GetCustomer(
string customerId,
CustomerGetOptions customerGetOptions = null,

View File

@ -9,4 +9,5 @@ public enum ScimProviderType : byte
JumpCloud = 4,
GoogleWorkspace = 5,
Rippling = 6,
Ping = 7,
}

View File

@ -0,0 +1,23 @@
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
/// <summary>
/// A typed wrapper for an organization Guid. This is used for authorization checks
/// scoped to an organization's resources (e.g. all users for an organization).
/// In these cases, AuthorizationService needs more than just a Guid, but we also don't want to fetch the
/// Organization object from the database each time when it's usually not needed.
/// This should not be used for operations on the organization itself.
/// It implicitly converts to a regular Guid.
/// </summary>
public record OrganizationScope
{
public OrganizationScope(Guid id)
{
Id = id;
}
private Guid Id { get; }
public static implicit operator Guid(OrganizationScope organizationScope) =>
organizationScope.Id;
public override string ToString() => Id.ToString();
}

View File

@ -0,0 +1,77 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserDetailsAuthorizationHandler
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
{
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext, IFeatureService featureService)
{
_currentContext = currentContext;
_featureService = featureService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserUserDetailsOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = false;
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserUserDetailsOperations.ReadAll):
authorized = await CanReadAllAsync(organizationScope);
break;
}
if (authorized)
{
context.Succeed(requirement!);
}
}
private async Task<bool> CanReadAllAsync(Guid organizationId)
{
if (_featureService.IsEnabled(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi))
{
return await CanReadAllAsync_vNext(organizationId);
}
return await CanReadAllAsync_vCurrent(organizationId);
}
private async Task<bool> CanReadAllAsync_vCurrent(Guid organizationId)
{
// All users of an organization can read all other users of that organization for collection access management
var org = _currentContext.GetOrganization(organizationId);
if (org is not null)
{
return true;
}
// Allow provider users to read all organization users if they are a provider for the target organization
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
private async Task<bool> CanReadAllAsync_vNext(Guid organizationId)
{
// Admins can access this for general user management
var organization = _currentContext.GetOrganization(organizationId);
if (organization is
{ Type: OrganizationUserType.Owner } or
{ Type: OrganizationUserType.Admin } or
{ Permissions.ManageUsers: true })
{
return true;
}
// Allow provider users to read all organization users if they are a provider for the target organization
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserDetailsOperationRequirement : OperationAuthorizationRequirement;
public static class OrganizationUserUserDetailsOperations
{
public static OrganizationUserUserDetailsOperationRequirement ReadAll = new() { Name = nameof(ReadAll) };
}

View File

@ -0,0 +1,51 @@
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
{
private readonly IApplicationCacheService _applicationCacheService;
private readonly ICurrentContext _currentContext;
public OrganizationUserUserMiniDetailsAuthorizationHandler(
IApplicationCacheService applicationCacheService,
ICurrentContext currentContext)
{
_applicationCacheService = applicationCacheService;
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserUserMiniDetailsOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = false;
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserUserMiniDetailsOperations.ReadAll):
authorized = await CanReadAllAsync(organizationScope);
break;
}
if (authorized)
{
context.Succeed(requirement);
}
}
private async Task<bool> CanReadAllAsync(Guid organizationId)
{
// All organization users can access this data to manage collection access
var organization = _currentContext.GetOrganization(organizationId);
if (organization != null)
{
return true;
}
// Providers can also access this to manage the organization generally
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserMiniDetailsOperationRequirement : OperationAuthorizationRequirement;
public static class OrganizationUserUserMiniDetailsOperations
{
public static readonly OrganizationUserUserMiniDetailsOperationRequirement ReadAll = new() { Name = nameof(ReadAll) };
}

View File

@ -358,6 +358,11 @@ public class OrganizationService : IOrganizationService
}
}
if (organization.UseSecretsManager && organization.Seats + seatAdjustment < organization.SmSeats)
{
throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats.");
}
var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext)
@ -590,10 +595,20 @@ public class OrganizationService : IOrganizationService
}
else
{
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
if (signup.PaymentMethodType != null)
{
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
}
else
{
await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
}
}
}
@ -1176,12 +1191,7 @@ public class OrganizationService : IOrganizationService
var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
// Revert autoscaling
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
{
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
}
// Revert SmSeat autoscaling
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
{
@ -1192,6 +1202,11 @@ public class OrganizationService : IOrganizationService
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
}
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
{
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
}
exceptions.Add(e);
}

View File

@ -28,6 +28,11 @@ public static class StripeConstants
public const string TaxIdInvalid = "tax_id_invalid";
}
public static class PaymentBehavior
{
public const string DefaultIncomplete = "default_incomplete";
}
public static class PaymentMethodTypes
{
public const string Card = "card";

View File

@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
public static void AddBillingOperations(this IServiceCollection services)
{
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
}

View File

@ -0,0 +1,49 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class PremiumUserSale
{
private PremiumUserSale() { }
public required User User { get; set; }
public required CustomerSetup CustomerSetup { get; set; }
public short? Storage { get; set; }
public void Deconstruct(
out User user,
out CustomerSetup customerSetup,
out short? storage)
{
user = User;
customerSetup = CustomerSetup;
storage = Storage;
}
public static PremiumUserSale From(
User user,
PaymentMethodType paymentMethodType,
string paymentMethodToken,
TaxInfo taxInfo,
short? storage)
{
var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentMethodToken);
var taxInformation = TaxInformation.From(taxInfo);
return new PremiumUserSale
{
User = user,
CustomerSetup = new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation
},
Storage = storage
};
}
}

View File

@ -1,4 +1,5 @@
using Stripe;
using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Billing.Models;
@ -11,6 +12,15 @@ public record TaxInformation(
string City,
string State)
{
public static TaxInformation From(TaxInfo taxInfo) => new(
taxInfo.BillingAddressCountry,
taxInfo.BillingAddressPostalCode,
taxInfo.TaxIdNumber,
taxInfo.BillingAddressLine1,
taxInfo.BillingAddressLine2,
taxInfo.BillingAddressCity,
taxInfo.BillingAddressState);
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
{
var address = new AddressOptions

View File

@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingService
{
/// <summary>
/// <para>Establishes the billing configuration for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
/// <para>
/// The method first checks to see if the
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
@ -17,7 +17,7 @@ public interface IOrganizationBillingService
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
/// </para>
/// </summary>
/// <param name="sale">The purchase details necessary to establish the Stripe entities responsible for billing the organization.</param>
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the organization.</param>
/// <example>
/// <code>
/// var sale = OrganizationSale.From(organization, organizationSignup);

View File

@ -0,0 +1,30 @@
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services;
public interface IPremiumUserBillingService
{
/// <summary>
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para>
/// <para>
/// The method first checks to see if the
/// provided <see cref="PremiumUserSale.User"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="User.GatewayCustomerId"/>.
/// If it doesn't, the method creates one using the <paramref name="sale"/>'s <see cref="PremiumUserSale.CustomerSetup"/>. The method then creates a Stripe <see cref="Stripe.Subscription"/>
/// for the created or existing customer while appending the provided <paramref name="sale"/>'s <see cref="PremiumUserSale.Storage"/>.
/// </para>
/// </summary>
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the premium user.</param>
/// <example>
/// <code>
/// var sale = PremiumUserSale.From(
/// user,
/// paymentMethodType,
/// paymentMethodToken,
/// taxInfo,
/// storage);
/// await premiumUserBillingService.Finalize(sale);
/// </code>
/// </example>
Task Finalize(PremiumUserSale sale);
}

View File

@ -34,11 +34,9 @@ public class OrganizationBillingService(
{
var (organization, customerSetup, subscriptionSetup) = sale;
List<string> expand = ["tax"];
var customer = customerSetup != null
? await CreateCustomerAsync(organization, customerSetup, expand)
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
? await CreateCustomerAsync(organization, customerSetup)
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
@ -111,31 +109,31 @@ public class OrganizationBillingService(
private async Task<Customer> CreateCustomerAsync(
Organization organization,
CustomerSetup customerSetup,
List<string>? expand = null)
CustomerSetup customerSetup)
{
var organizationDisplayName = organization.DisplayName();
var displayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail,
Expand = expand,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30
? organizationDisplayName
: organizationDisplayName[..30]
Value = displayName.Length <= 30
? displayName
: displayName[..30]
}]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
["organizationId"] = organization.Id.ToString(),
["region"] = globalSettings.BaseServiceUri.CloudRegion
}
};
@ -174,46 +172,41 @@ public class OrganizationBillingService(
};
customerCreateOptions.TaxIdData = taxIdData;
var (type, token) = customerSetup.TokenizedPaymentSource;
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (type)
switch (paymentMethodType)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
throw new BillingException();
}
await setupIntentCache.Set(organization.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
customerCreateOptions.PaymentMethod = paymentMethodToken;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, paymentMethodType.ToString());
throw new BillingException();
}
}
@ -227,7 +220,6 @@ public class OrganizationBillingService(
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
await Revert();
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
@ -235,7 +227,6 @@ public class OrganizationBillingService(
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
@ -257,7 +248,7 @@ public class OrganizationBillingService(
await setupIntentCache.Remove(organization.Id);
break;
}
case PaymentMethodType.PayPal:
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;

View File

@ -0,0 +1,260 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
using static Utilities;
public class PremiumUserBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<PremiumUserBillingService> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService
{
public async Task Finalize(PremiumUserSale sale)
{
var (user, customerSetup, storage) = sale;
List<string> expand = ["tax"];
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, customerSetup)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand });
var subscription = await CreateSubscriptionAsync(user.Id, customer, storage);
switch (customerSetup.TokenizedPaymentSource)
{
case { Type: PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
break;
}
}
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
await userRepository.ReplaceAsync(user);
}
private async Task<Customer> CreateCustomerAsync(
User user,
CustomerSetup customerSetup)
{
if (customerSetup.TokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError(
"Cannot create customer for user ({UserID}) without a valid payment source", user.Id);
throw new BillingException();
}
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
{
logger.LogError(
"Cannot create customer for user ({UserID}) without valid tax information", user.Id);
throw new BillingException();
}
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = address,
Description = user.Name,
Email = user.Email,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = user.SubscriberType(),
Value = subscriberName.Length <= 30
? subscriberName
: subscriberName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
["region"] = globalSettings.BaseServiceUri.CloudRegion,
["userId"] = user.Id.ToString()
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = taxIdData
};
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethodType)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id);
throw new BillingException();
}
await setupIntentCache.Set(user.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethodToken;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethodToken);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethodType.ToString());
throw new BillingException();
}
}
try
{
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
await Revert();
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
catch
{
await Revert();
throw;
}
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (customerSetup.TokenizedPaymentSource!.Type)
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(user.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
int? storage)
{
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = "premium-annually",
Quantity = 1
}
};
if (storage is > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = "storage-gb-annually",
Quantity = storage
});
}
var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false;
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["userId"] = userId.ToString()
},
PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete
: null,
OffSession = true
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (usingPayPal)
{
await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
}
return subscription;
}
}

View File

@ -110,7 +110,6 @@ public static class FeatureFlagKeys
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
public const string EnableDeleteProvider = "AC-1218-delete-provider";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
@ -124,7 +123,6 @@ public static class FeatureFlagKeys
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page";
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -143,6 +141,8 @@ public static class FeatureFlagKeys
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment";
public const string Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api";
public static List<string> GetAllKeys()
{

View File

@ -21,8 +21,8 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.11" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.21" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.16" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.26" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" />
@ -34,9 +34,9 @@
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.0" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.1" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
@ -54,8 +54,8 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Braintree" Version="5.26.0" />
<PackageReference Include="Stripe.net" Version="45.13.0" />
<PackageReference Include="Braintree" Version="5.27.0" />
<PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />

View File

@ -0,0 +1,68 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.NotificationCenter.Entities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Authorization;
public class NotificationAuthorizationHandler : AuthorizationHandler<NotificationOperationsRequirement, Notification>
{
private readonly ICurrentContext _currentContext;
public NotificationAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
NotificationOperationsRequirement requirement,
Notification notification)
{
if (!_currentContext.UserId.HasValue)
{
return;
}
var authorized = requirement switch
{
not null when requirement == NotificationOperations.Read => CanRead(notification),
not null when requirement == NotificationOperations.Create => await CanCreate(notification),
not null when requirement == NotificationOperations.Update => await CanUpdate(notification),
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
};
if (authorized)
{
context.Succeed(requirement);
}
}
private bool CanRead(Notification notification)
{
var userMatching = !notification.UserId.HasValue || notification.UserId.Value == _currentContext.UserId!.Value;
var organizationMatching = !notification.OrganizationId.HasValue ||
_currentContext.GetOrganization(notification.OrganizationId.Value) != null;
return notification.Global || (userMatching && organizationMatching);
}
private async Task<bool> CanCreate(Notification notification)
{
var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
await _currentContext.AccessReports(notification.OrganizationId.Value);
var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
notification.UserId.Value == _currentContext.UserId!.Value;
return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
}
private async Task<bool> CanUpdate(Notification notification)
{
var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
await _currentContext.AccessReports(notification.OrganizationId.Value);
var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
notification.UserId.Value == _currentContext.UserId!.Value;
return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
}
}

View File

@ -0,0 +1,19 @@
#nullable enable
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.NotificationCenter.Authorization;
public class NotificationOperationsRequirement : OperationAuthorizationRequirement
{
public NotificationOperationsRequirement(string name)
{
Name = name;
}
}
public static class NotificationOperations
{
public static readonly NotificationOperationsRequirement Read = new(nameof(Read));
public static readonly NotificationOperationsRequirement Create = new(nameof(Create));
public static readonly NotificationOperationsRequirement Update = new(nameof(Update));
}

View File

@ -0,0 +1,57 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.NotificationCenter.Entities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Authorization;
public class NotificationStatusAuthorizationHandler : AuthorizationHandler<NotificationStatusOperationsRequirement,
NotificationStatus>
{
private readonly ICurrentContext _currentContext;
public NotificationStatusAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
NotificationStatusOperationsRequirement requirement,
NotificationStatus notificationStatus)
{
if (!_currentContext.UserId.HasValue)
{
return Task.CompletedTask;
}
var authorized = requirement switch
{
not null when requirement == NotificationStatusOperations.Read => CanRead(notificationStatus),
not null when requirement == NotificationStatusOperations.Create => CanCreate(notificationStatus),
not null when requirement == NotificationStatusOperations.Update => CanUpdate(notificationStatus),
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
};
if (authorized)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool CanRead(NotificationStatus notificationStatus)
{
return notificationStatus.UserId == _currentContext.UserId!.Value;
}
private bool CanCreate(NotificationStatus notificationStatus)
{
return notificationStatus.UserId == _currentContext.UserId!.Value;
}
private bool CanUpdate(NotificationStatus notificationStatus)
{
return notificationStatus.UserId == _currentContext.UserId!.Value;
}
}

View File

@ -0,0 +1,19 @@
#nullable enable
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.NotificationCenter.Authorization;
public class NotificationStatusOperationsRequirement : OperationAuthorizationRequirement
{
public NotificationStatusOperationsRequirement(string name)
{
Name = name;
}
}
public static class NotificationStatusOperations
{
public static readonly NotificationStatusOperationsRequirement Read = new(nameof(Read));
public static readonly NotificationStatusOperationsRequirement Create = new(nameof(Create));
public static readonly NotificationStatusOperationsRequirement Update = new(nameof(Update));
}

View File

@ -0,0 +1,36 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Commands;
public class CreateNotificationCommand : ICreateNotificationCommand
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationRepository _notificationRepository;
public CreateNotificationCommand(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationRepository notificationRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationRepository = notificationRepository;
}
public async Task<Notification> CreateAsync(Notification notification)
{
notification.CreationDate = notification.RevisionDate = DateTime.UtcNow;
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
NotificationOperations.Create);
return await _notificationRepository.CreateAsync(notification);
}
}

View File

@ -0,0 +1,47 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Commands;
public class CreateNotificationStatusCommand : ICreateNotificationStatusCommand
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationRepository _notificationRepository;
private readonly INotificationStatusRepository _notificationStatusRepository;
public CreateNotificationStatusCommand(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationRepository notificationRepository,
INotificationStatusRepository notificationStatusRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationRepository = notificationRepository;
_notificationStatusRepository = notificationStatusRepository;
}
public async Task<NotificationStatus> CreateAsync(NotificationStatus notificationStatus)
{
var notification = await _notificationRepository.GetByIdAsync(notificationStatus.NotificationId);
if (notification == null)
{
throw new NotFoundException();
}
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
NotificationOperations.Read);
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
NotificationStatusOperations.Create);
return await _notificationStatusRepository.CreateAsync(notificationStatus);
}
}

View File

@ -0,0 +1,9 @@
#nullable enable
using Bit.Core.NotificationCenter.Entities;
namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface ICreateNotificationCommand
{
Task<Notification> CreateAsync(Notification notification);
}

View File

@ -0,0 +1,9 @@
#nullable enable
using Bit.Core.NotificationCenter.Entities;
namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface ICreateNotificationStatusCommand
{
Task<NotificationStatus> CreateAsync(NotificationStatus notificationStatus);
}

View File

@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface IMarkNotificationDeletedCommand
{
Task MarkDeletedAsync(Guid notificationId);
}

View File

@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface IMarkNotificationReadCommand
{
Task MarkReadAsync(Guid notificationId);
}

View File

@ -0,0 +1,9 @@
#nullable enable
using Bit.Core.NotificationCenter.Entities;
namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface IUpdateNotificationCommand
{
Task UpdateAsync(Notification notification);
}

View File

@ -0,0 +1,74 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Commands;
public class MarkNotificationDeletedCommand : IMarkNotificationDeletedCommand
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationRepository _notificationRepository;
private readonly INotificationStatusRepository _notificationStatusRepository;
public MarkNotificationDeletedCommand(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationRepository notificationRepository,
INotificationStatusRepository notificationStatusRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationRepository = notificationRepository;
_notificationStatusRepository = notificationStatusRepository;
}
public async Task MarkDeletedAsync(Guid notificationId)
{
if (!_currentContext.UserId.HasValue)
{
throw new NotFoundException();
}
var notification = await _notificationRepository.GetByIdAsync(notificationId);
if (notification == null)
{
throw new NotFoundException();
}
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
NotificationOperations.Read);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
_currentContext.UserId.Value);
if (notificationStatus == null)
{
notificationStatus = new NotificationStatus()
{
NotificationId = notificationId,
UserId = _currentContext.UserId.Value,
DeletedDate = DateTime.Now
};
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
NotificationStatusOperations.Create);
await _notificationStatusRepository.CreateAsync(notificationStatus);
}
else
{
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
NotificationStatusOperations.Update);
notificationStatus.DeletedDate = DateTime.UtcNow;
await _notificationStatusRepository.UpdateAsync(notificationStatus);
}
}
}

View File

@ -0,0 +1,74 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Commands;
public class MarkNotificationReadCommand : IMarkNotificationReadCommand
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationRepository _notificationRepository;
private readonly INotificationStatusRepository _notificationStatusRepository;
public MarkNotificationReadCommand(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationRepository notificationRepository,
INotificationStatusRepository notificationStatusRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationRepository = notificationRepository;
_notificationStatusRepository = notificationStatusRepository;
}
public async Task MarkReadAsync(Guid notificationId)
{
if (!_currentContext.UserId.HasValue)
{
throw new NotFoundException();
}
var notification = await _notificationRepository.GetByIdAsync(notificationId);
if (notification == null)
{
throw new NotFoundException();
}
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
NotificationOperations.Read);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
_currentContext.UserId.Value);
if (notificationStatus == null)
{
notificationStatus = new NotificationStatus()
{
NotificationId = notificationId,
UserId = _currentContext.UserId.Value,
ReadDate = DateTime.Now
};
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
NotificationStatusOperations.Create);
await _notificationStatusRepository.CreateAsync(notificationStatus);
}
else
{
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
notificationStatus, NotificationStatusOperations.Update);
notificationStatus.ReadDate = DateTime.UtcNow;
await _notificationStatusRepository.UpdateAsync(notificationStatus);
}
}
}

View File

@ -0,0 +1,47 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Commands;
public class UpdateNotificationCommand : IUpdateNotificationCommand
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationRepository _notificationRepository;
public UpdateNotificationCommand(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationRepository notificationRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationRepository = notificationRepository;
}
public async Task UpdateAsync(Notification notificationToUpdate)
{
var notification = await _notificationRepository.GetByIdAsync(notificationToUpdate.Id);
if (notification == null)
{
throw new NotFoundException();
}
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
notification, NotificationOperations.Update);
notification.Priority = notificationToUpdate.Priority;
notification.ClientType = notificationToUpdate.ClientType;
notification.Title = notificationToUpdate.Title;
notification.Body = notificationToUpdate.Body;
notification.RevisionDate = DateTime.UtcNow;
await _notificationRepository.ReplaceAsync(notification);
}
}

View File

@ -0,0 +1,47 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Queries.Interfaces;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Queries;
public class GetNotificationStatusForUserQuery : IGetNotificationStatusForUserQuery
{
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly INotificationStatusRepository _notificationStatusRepository;
public GetNotificationStatusForUserQuery(ICurrentContext currentContext,
IAuthorizationService authorizationService,
INotificationStatusRepository notificationStatusRepository)
{
_currentContext = currentContext;
_authorizationService = authorizationService;
_notificationStatusRepository = notificationStatusRepository;
}
public async Task<NotificationStatus> GetByNotificationIdAndUserIdAsync(Guid notificationId)
{
if (!_currentContext.UserId.HasValue)
{
throw new NotFoundException();
}
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
_currentContext.UserId.Value);
if (notificationStatus == null)
{
throw new NotFoundException();
}
await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
notificationStatus, NotificationStatusOperations.Read);
return notificationStatus;
}
}

View File

@ -0,0 +1,37 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries.Interfaces;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Utilities;
namespace Bit.Core.NotificationCenter.Queries;
public class GetNotificationsForUserQuery : IGetNotificationsForUserQuery
{
private readonly ICurrentContext _currentContext;
private readonly INotificationRepository _notificationRepository;
public GetNotificationsForUserQuery(ICurrentContext currentContext,
INotificationRepository notificationRepository)
{
_currentContext = currentContext;
_notificationRepository = notificationRepository;
}
public async Task<IEnumerable<Notification>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter)
{
if (!_currentContext.UserId.HasValue)
{
throw new NotFoundException();
}
var clientType = DeviceTypes.ToClientType(_currentContext.DeviceType);
// Note: only returns the user's notifications - no authorization check needed
return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType,
statusFilter);
}
}

View File

@ -0,0 +1,9 @@
#nullable enable
using Bit.Core.NotificationCenter.Entities;
namespace Bit.Core.NotificationCenter.Queries.Interfaces;
public interface IGetNotificationStatusForUserQuery
{
Task<NotificationStatus> GetByNotificationIdAndUserIdAsync(Guid notificationId);
}

View File

@ -0,0 +1,10 @@
#nullable enable
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Filter;
namespace Bit.Core.NotificationCenter.Queries.Interfaces;
public interface IGetNotificationsForUserQuery
{
Task<IEnumerable<Notification>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter);
}

View File

@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfa
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.OrganizationFeatures.OrganizationCollections;
@ -28,6 +29,7 @@ using Bit.Core.Settings;
using Bit.Core.Tokens;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -141,6 +143,9 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
services.AddScoped<IGetOrganizationUsersManagementStatusQuery, GetOrganizationUsersManagementStatusQuery>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
}
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of

View File

@ -15,6 +15,9 @@ public interface IPaymentService
string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats,
bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0,
int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false);
Task<string> PurchaseOrganizationNoPaymentMethod(Organization org, Plan plan, int additionalSeats,
bool premiumAccessAddon, int additionalSmSeats = 0, int additionalServiceAccount = 0,
bool signupIsFromSecretsManagerTrial = false);
Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship);
Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship);
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade);

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Entities;
@ -95,4 +96,10 @@ public interface IUserService
/// The organization must be enabled and be on an Enterprise plan.
/// </remarks>
Task<bool> IsManagedByAnyOrganizationAsync(Guid userId);
/// <summary>
/// Gets the organization that manages the user.
/// </summary>
/// <inheritdoc cref="IsManagedByAnyOrganizationAsync(Guid)"/>
Task<Organization> GetOrganizationManagingUserAsync(Guid userId);
}

View File

@ -207,6 +207,77 @@ public class StripePaymentService : IPaymentService
}
}
public async Task<string> PurchaseOrganizationNoPaymentMethod(Organization org, StaticStore.Plan plan, int additionalSeats, bool premiumAccessAddon,
int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false)
{
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, new TaxInfo(), additionalSeats, 0, premiumAccessAddon
, additionalSmSeats, additionalServiceAccount);
Customer customer = null;
Subscription subscription;
try
{
var customerCreateOptions = new CustomerCreateOptions
{
Description = org.DisplayBusinessName(),
Email = org.BillingEmail,
Metadata = stripeCustomerMetadata,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = org.SubscriberType(),
Value = GetFirstThirtyCharacters(org.SubscriberName()),
}
],
},
Coupon = signupIsFromSecretsManagerTrial
? SecretsManagerStandaloneDiscountId
: null,
TaxIdData = null,
};
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
subCreateOptions.AddExpand("latest_invoice.payment_intent");
subCreateOptions.Customer = customer.Id;
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating customer, walking back operation.");
if (customer != null)
{
await _stripeAdapter.CustomerDeleteAsync(customer.Id);
}
throw;
}
org.Gateway = GatewayType.Stripe;
org.GatewayCustomerId = customer.Id;
org.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == "incomplete" &&
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
{
org.Enabled = false;
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
org.Enabled = true;
org.ExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
private async Task ChangeOrganizationSponsorship(
Organization org,
OrganizationSponsorship sponsorship,
@ -712,6 +783,11 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Subscription not found.");
}
if (sub.Status == SubscriptionStatuses.Canceled)
{
throw new BadRequestException("You do not have an active subscription. Reinstate your subscription to make changes.");
}
var collectionMethod = sub.CollectionMethod;
var daysUntilDue = sub.DaysUntilDue;
var chargeNow = collectionMethod == "charge_automatically";

Some files were not shown because too many files have changed in this diff Show More