mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
Merge branch 'main' into ac/ac-1682/ef-migrations
This commit is contained in:
@ -4,27 +4,11 @@
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "6.5.0",
|
||||
"commands": [
|
||||
"swagger"
|
||||
]
|
||||
},
|
||||
"coverlet.console": {
|
||||
"version": "3.1.2",
|
||||
"commands": [
|
||||
"coverlet"
|
||||
]
|
||||
},
|
||||
"dotnet-reportgenerator-globaltool": {
|
||||
"version": "5.1.6",
|
||||
"commands": [
|
||||
"reportgenerator"
|
||||
]
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "7.0.14",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
"commands": ["dotnet-ef"]
|
||||
}
|
||||
}
|
||||
}
|
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@ -14,7 +14,12 @@
|
||||
|
||||
# Database Operations for database changes
|
||||
src/Sql/** @bitwarden/dept-dbops
|
||||
util/EfShared/** @bitwarden/dept-dbops
|
||||
util/Migrator/** @bitwarden/dept-dbops
|
||||
util/MySqlMigrations/** @bitwarden/dept-dbops
|
||||
util/PostgresMigrations/** @bitwarden/dept-dbops
|
||||
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
||||
util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||
|
||||
# Auth team
|
||||
**/Auth @bitwarden/team-auth-dev
|
||||
|
7
.github/renovate.json
vendored
7
.github/renovate.json
vendored
@ -37,6 +37,10 @@
|
||||
"matchManagers": ["github-actions"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions", "dockerfile", "docker-compose"],
|
||||
"commitMessagePrefix": "[deps] DevOps:"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["DnsClient", "Quartz"],
|
||||
"description": "Admin Console owned dependencies",
|
||||
@ -59,8 +63,7 @@
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"Fido2.AspNet",
|
||||
"IdentityServer4",
|
||||
"IdentityServer4.AccessTokenValidation",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Azure.Cosmos.Table",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
|
87
.github/workflows/build.yml
vendored
87
.github/workflows/build.yml
vendored
@ -61,28 +61,14 @@ jobs:
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore --locked-mode
|
||||
shell: pwsh
|
||||
|
||||
- name: Remove SQL proj
|
||||
run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj
|
||||
|
||||
- name: Build OSS solution
|
||||
run: dotnet build bitwarden-server.sln -p:Configuration=Debug -p:DefineConstants="OSS" --verbosity minimal
|
||||
shell: pwsh
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build bitwarden-server.sln -p:Configuration=Debug --verbosity minimal
|
||||
shell: pwsh
|
||||
|
||||
- name: Test OSS solution
|
||||
run: dotnet test ./test --configuration Debug --no-build --logger "trx;LogFileName=oss-test-results.trx"
|
||||
shell: pwsh
|
||||
run: dotnet test ./test --configuration Release --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Test Bitwarden solution
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --no-build --logger "trx;LogFileName=bw-test-results.trx"
|
||||
shell: pwsh
|
||||
run: dotnet test ./bitwarden_license/test --configuration Release --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
|
||||
@ -93,6 +79,11 @@ jobs:
|
||||
reporter: dotnet-trx
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-artifacts:
|
||||
name: Build artifacts
|
||||
runs-on: ubuntu-22.04
|
||||
@ -156,14 +147,6 @@ jobs:
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
|
||||
- name: Restore/Clean project
|
||||
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
run: |
|
||||
echo "Restore"
|
||||
dotnet restore
|
||||
echo "Clean"
|
||||
dotnet clean -c "Release" -o obj/build-output/publish
|
||||
|
||||
- name: Build node
|
||||
if: ${{ matrix.node }}
|
||||
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
@ -357,9 +340,6 @@ jobs:
|
||||
- name: Login to PROD ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
|
||||
- name: Restore
|
||||
run: dotnet tool restore
|
||||
|
||||
- name: Make Docker stubs
|
||||
if: github.ref == 'refs/heads/main' ||
|
||||
github.ref == 'refs/heads/rc' ||
|
||||
@ -443,10 +423,8 @@ jobs:
|
||||
- name: Build Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore"
|
||||
dotnet restore
|
||||
echo "Clean"
|
||||
dotnet clean -c "Release" -o obj/build-output/publish
|
||||
echo "Restore tools"
|
||||
dotnet tool restore
|
||||
echo "Publish"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
@ -495,11 +473,6 @@ jobs:
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
|
||||
- name: Restore project
|
||||
run: |
|
||||
echo "Restore"
|
||||
dotnet restore
|
||||
|
||||
- name: Publish project
|
||||
run: |
|
||||
dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \
|
||||
@ -521,12 +494,10 @@ jobs:
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
self-host-build:
|
||||
name: Trigger self-host build
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-docker
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
@ -555,6 +526,40 @@ jobs:
|
||||
}
|
||||
})
|
||||
|
||||
trigger-k8s-deploy:
|
||||
name: Trigger k8s deploy
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve github PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'bitwarden',
|
||||
repo: 'devops',
|
||||
workflow_id: 'deploy-k8s.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
environment: 'US-DEV Cloud',
|
||||
tag: 'main'
|
||||
}
|
||||
})
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
if: always()
|
||||
@ -568,6 +573,7 @@ jobs:
|
||||
- upload
|
||||
- build-mssqlmigratorutility
|
||||
- self-host-build
|
||||
- trigger-k8s-deploy
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
@ -583,6 +589,7 @@ jobs:
|
||||
UPLOAD_STATUS: ${{ needs.upload.result }}
|
||||
BUILD_MSSQLMIGRATORUTILITY_STATUS: ${{ needs.build-mssqlmigratorutility.result }}
|
||||
TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }}
|
||||
TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }}
|
||||
run: |
|
||||
if [ "$CLOC_STATUS" = "failure" ]; then
|
||||
exit 1
|
||||
@ -600,6 +607,8 @@ jobs:
|
||||
exit 1
|
||||
elif [ "$TRIGGER_SELF_HOST_BUILD_STATUS" = "failure" ]; then
|
||||
exit 1
|
||||
elif [ "$TRIGGER_K8S_DEPLOY_STATUS" = "failure" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Login to Azure - CI subscription
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>2023.12.1</Version>
|
||||
<Version>2024.1.0</Version>
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
|
@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -33,13 +36,14 @@ public class ProviderService : IProviderService
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
IUserService userService, IOrganizationService organizationService, IMailService mailService,
|
||||
IDataProtectionProvider dataProtectionProvider, IEventService eventService,
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext)
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@ -53,6 +57,7 @@ public class ProviderService : IProviderService
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
_currentContext = currentContext;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
|
||||
@ -369,6 +374,7 @@ public class ProviderService : IProviderService
|
||||
Key = key,
|
||||
};
|
||||
|
||||
await ApplyProviderPriceRateAsync(organizationId, providerId);
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
|
||||
}
|
||||
@ -381,18 +387,110 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Provider must be of type Reseller in order to assign Organizations to it.");
|
||||
}
|
||||
|
||||
var existingProviderOrganizationsCount = await _providerOrganizationRepository.GetCountByOrganizationIdsAsync(organizationIds);
|
||||
var orgIdsList = organizationIds.ToList();
|
||||
var existingProviderOrganizationsCount = await _providerOrganizationRepository.GetCountByOrganizationIdsAsync(orgIdsList);
|
||||
if (existingProviderOrganizationsCount > 0)
|
||||
{
|
||||
throw new BadRequestException("Organizations must not be assigned to any Provider.");
|
||||
}
|
||||
|
||||
var providerOrganizationsToInsert = organizationIds.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId });
|
||||
var providerOrganizationsToInsert = orgIdsList.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId });
|
||||
var insertedProviderOrganizations = await _providerOrganizationRepository.CreateManyAsync(providerOrganizationsToInsert);
|
||||
|
||||
await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null)));
|
||||
}
|
||||
|
||||
private async Task ApplyProviderPriceRateAsync(Guid organizationId, Guid providerId)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
// if a provider was created before Nov 6, 2023.If true, the organization plan assigned to that provider is updated to a 2020 plan.
|
||||
if (provider.CreationDate >= Constants.ProviderCreatedPriorNov62023)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
|
||||
var extractedPlanType = PlanTypeMappings(organization);
|
||||
if (subscriptionItem != null)
|
||||
{
|
||||
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
}
|
||||
|
||||
await _organizationRepository.UpsertAsync(organization);
|
||||
}
|
||||
|
||||
private async Task<Stripe.SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
|
||||
{
|
||||
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
|
||||
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
|
||||
}
|
||||
|
||||
private static string GetStripeSeatPlanId(PlanType planType)
|
||||
{
|
||||
return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId;
|
||||
}
|
||||
|
||||
private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (subscriptionItem.Price.Id != extractedPlanType)
|
||||
{
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription,
|
||||
new Stripe.SubscriptionUpdateOptions
|
||||
{
|
||||
Items = new List<Stripe.SubscriptionItemOptions>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = extractedPlanType,
|
||||
Quantity = organization.Seats.Value,
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw new Exception("Unable to update existing plan on stripe");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static PlanType PlanTypeMappings(Organization organization)
|
||||
{
|
||||
var planTypeMappings = new Dictionary<PlanType, string>
|
||||
{
|
||||
{ PlanType.EnterpriseAnnually2020, GetEnumDisplayName(PlanType.EnterpriseAnnually2020) },
|
||||
{ PlanType.EnterpriseMonthly2020, GetEnumDisplayName(PlanType.EnterpriseMonthly2020) },
|
||||
{ PlanType.TeamsMonthly2020, GetEnumDisplayName(PlanType.TeamsMonthly2020) },
|
||||
{ PlanType.TeamsAnnually2020, GetEnumDisplayName(PlanType.TeamsAnnually2020) }
|
||||
};
|
||||
|
||||
foreach (var mapping in planTypeMappings)
|
||||
{
|
||||
if (mapping.Value.IndexOf(organization.Plan, StringComparison.Ordinal) != -1)
|
||||
{
|
||||
organization.PlanType = mapping.Key;
|
||||
organization.Plan = mapping.Value;
|
||||
return organization.PlanType;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException("Invalid PlanType selected");
|
||||
}
|
||||
|
||||
private static string GetEnumDisplayName(Enum value)
|
||||
{
|
||||
var fieldInfo = value.GetType().GetField(value.ToString());
|
||||
|
||||
var displayAttribute = (DisplayAttribute)Attribute.GetCustomAttribute(fieldInfo!, typeof(DisplayAttribute));
|
||||
|
||||
return displayAttribute?.Name ?? value.ToString();
|
||||
}
|
||||
|
||||
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
|
||||
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
|
||||
{
|
||||
|
@ -18,6 +18,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using Provider = Bit.Core.AdminConsole.Entities.Provider.Provider;
|
||||
using ProviderUser = Bit.Core.AdminConsole.Entities.Provider.ProviderUser;
|
||||
@ -598,4 +599,98 @@ public class ProviderServiceTests
|
||||
await sutProvider.GetDependency<IEventService>().Received()
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var expectedPlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
||||
|
||||
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received().LogProviderOrganizationEventAsync(Arg.Any<ProviderOrganization>(),
|
||||
EventType.ProviderOrganization_Added);
|
||||
Assert.Equal(organization.PlanType, expectedPlanType);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AddOrganization_CreateBeforeNov162023_PlanTypeUpdated(Provider provider, Organization organization, string key,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
var newCreationDate = DateTime.UtcNow.AddMonths(-3);
|
||||
BackdateProviderCreationDate(provider, newCreationDate);
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Plan = "Enterprise (Annually)";
|
||||
|
||||
var expectedPlanType = PlanType.EnterpriseAnnually2020;
|
||||
|
||||
var expectedPlanId = "2020-enterprise-org-seat-annually";
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull();
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId);
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
await sutProvider.GetDependency<IStripeAdapter>().SubscriptionUpdateAsync(
|
||||
organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem));
|
||||
|
||||
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
||||
|
||||
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received().LogProviderOrganizationEventAsync(Arg.Any<ProviderOrganization>(),
|
||||
EventType.ProviderOrganization_Added);
|
||||
|
||||
Assert.Equal(organization.PlanType, expectedPlanType);
|
||||
}
|
||||
|
||||
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
|
||||
new()
|
||||
{
|
||||
Items = new List<Stripe.SubscriptionItemOptions>
|
||||
{
|
||||
new() { Id = subscriptionItem.Id, Price = expectedPlanId },
|
||||
}
|
||||
};
|
||||
|
||||
private static Subscription GetSubscription(string subscriptionId) =>
|
||||
new()
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "sub_item_123",
|
||||
Price = new Price()
|
||||
{
|
||||
Id = "2023-enterprise-org-seat-annually"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static void BackdateProviderCreationDate(Provider provider, DateTime newCreationDate)
|
||||
{
|
||||
// Set the CreationDate to the desired value
|
||||
provider.GetType().GetProperty("CreationDate")?.SetValue(provider, newCreationDate, null);
|
||||
}
|
||||
}
|
||||
|
2
dev/.gitignore
vendored
2
dev/.gitignore
vendored
@ -1,6 +1,6 @@
|
||||
.data
|
||||
secrets.json
|
||||
db
|
||||
*.db
|
||||
|
||||
# Docker container configurations
|
||||
.env
|
||||
|
@ -13,8 +13,11 @@ public class RedisPersistedGrantStoreTests
|
||||
{
|
||||
const string SQL = nameof(SQL);
|
||||
const string Redis = nameof(Redis);
|
||||
const string Cosmos = nameof(Cosmos);
|
||||
|
||||
private readonly IPersistedGrantStore _redisGrantStore;
|
||||
private readonly IPersistedGrantStore _sqlGrantStore;
|
||||
private readonly IPersistedGrantStore _cosmosGrantStore;
|
||||
private readonly PersistedGrant _updateGrant;
|
||||
|
||||
private IPersistedGrantStore _grantStore = null!;
|
||||
@ -45,12 +48,18 @@ public class RedisPersistedGrantStoreTests
|
||||
);
|
||||
|
||||
var sqlConnectionString = "YOUR CONNECTION STRING HERE";
|
||||
|
||||
_sqlGrantStore = new PersistedGrantStore(
|
||||
new GrantRepository(
|
||||
sqlConnectionString,
|
||||
sqlConnectionString
|
||||
)
|
||||
),
|
||||
g => new Bit.Core.Auth.Entities.Grant(g)
|
||||
);
|
||||
|
||||
var cosmosConnectionString = "YOUR CONNECTION STRING HERE";
|
||||
_cosmosGrantStore = new PersistedGrantStore(
|
||||
new Bit.Core.Auth.Repositories.Cosmos.GrantRepository(cosmosConnectionString),
|
||||
g => new Bit.Core.Auth.Models.Data.GrantItem(g)
|
||||
);
|
||||
|
||||
var creationTime = new DateTime(638350407400000000, DateTimeKind.Utc);
|
||||
@ -69,7 +78,7 @@ public class RedisPersistedGrantStoreTests
|
||||
};
|
||||
}
|
||||
|
||||
[Params(Redis, SQL)]
|
||||
[Params(Redis, SQL, Cosmos)]
|
||||
public string StoreType { get; set; } = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
@ -83,6 +92,10 @@ public class RedisPersistedGrantStoreTests
|
||||
{
|
||||
_grantStore = _sqlGrantStore;
|
||||
}
|
||||
else if (StoreType == Cosmos)
|
||||
{
|
||||
_grantStore = _cosmosGrantStore;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidProgramException();
|
||||
|
@ -6,12 +6,12 @@
|
||||
@section Scripts {
|
||||
<script>
|
||||
function onRowSelect(selectingPage = false) {
|
||||
var checkboxes = document.getElementsByClassName('row-check');
|
||||
var checkedCheckboxCount = 0;
|
||||
var bulkActions = document.getElementById('bulkActions');
|
||||
let checkboxes = document.getElementsByClassName('row-check');
|
||||
let checkedCheckboxCount = 0;
|
||||
let bulkActions = document.getElementById('bulkActions');
|
||||
|
||||
var selectPage = document.getElementById('selectPage');
|
||||
for(var i = 0; i < checkboxes.length; i++){
|
||||
let selectPage = document.getElementById('selectPage');
|
||||
for(let i = 0; i < checkboxes.length; i++){
|
||||
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
|
||||
checkboxes[i].checked = true;
|
||||
checkedCheckboxCount += 1;
|
||||
@ -26,40 +26,39 @@
|
||||
bulkActions.classList.add("d-none");
|
||||
}
|
||||
|
||||
var selectPage = document.getElementById('selectPage');
|
||||
var selectAll = document.getElementById('selectAll');
|
||||
if (checkedCheckboxCount == checkboxes.length) {
|
||||
let selectAll = document.getElementById('selectAll');
|
||||
if (checkedCheckboxCount === checkboxes.length) {
|
||||
selectPage.checked = true;
|
||||
selectAll.classList.remove("d-none");
|
||||
|
||||
var selectAllElement = document.getElementById('selectAllElement');
|
||||
let selectAllElement = document.getElementById('selectAllElement');
|
||||
selectAllElement.classList.remove('d-none');
|
||||
|
||||
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
selectedAllConfirmation.classList.add('d-none');
|
||||
} else {
|
||||
selectPage.checked = false;
|
||||
selectAll.classList.add("d-none");
|
||||
var selectAllInput = document.getElementById('selectAllInput');
|
||||
let selectAllInput = document.getElementById('selectAllInput');
|
||||
selectAllInput.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
var selectAllInput = document.getElementById('selectAllInput');
|
||||
let selectAllInput = document.getElementById('selectAllInput');
|
||||
selectAllInput.checked = true;
|
||||
|
||||
var selectAllElement = document.getElementById('selectAllElement');
|
||||
let selectAllElement = document.getElementById('selectAllElement');
|
||||
selectAllElement.classList.add('d-none');
|
||||
|
||||
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
selectedAllConfirmation.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function exportSelectedSubscriptions() {
|
||||
var selectAll = document.getElementById('selectAll');
|
||||
var httpRequest = new XMLHttpRequest();
|
||||
httpRequest.open('POST');
|
||||
let selectAll = document.getElementById('selectAll');
|
||||
let httpRequest = new XMLHttpRequest();
|
||||
httpRequest.open("POST");
|
||||
httpRequest.send();
|
||||
}
|
||||
|
||||
@ -109,7 +108,9 @@
|
||||
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>}
|
||||
{
|
||||
<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
@ -117,7 +118,9 @@
|
||||
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2">
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{<option asp-selected='@Model.Filter.TestClock == @clock.Id' value="@clock.Id">@clock.Name</option>}
|
||||
{
|
||||
<option asp-selected='@Model.Filter.TestClock == @clock.Id' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -125,7 +128,7 @@
|
||||
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button>
|
||||
</div>
|
||||
<hr/>
|
||||
<input type="checkbox" class="d-none" asp-for="Filter.SelectAll" name="filter.SelectAll" id="selectAllInput" asp-for="Model.Filter.SelectAll">
|
||||
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
||||
<div class="text-center row d-flex justify-content-center">
|
||||
<div id="selectAll" class="d-none col-8">
|
||||
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
||||
@ -163,23 +166,32 @@
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.Id" value="@Model.Items[i].Subscription.Id">
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.Status" value="@Model.Items[i].Subscription.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
|
||||
|
||||
@{
|
||||
var i0 = i;
|
||||
}
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
|
||||
|
||||
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
|
||||
{
|
||||
var i1 = i;
|
||||
var j1 = j;
|
||||
<input
|
||||
type="hidden"
|
||||
asp-for="@Model.Items[i].Subscription.Items.Data[j].Plan.Id"
|
||||
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id"
|
||||
>
|
||||
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
|
||||
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
|
||||
}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i].Selected">
|
||||
|
||||
@{
|
||||
var i2 = i;
|
||||
}
|
||||
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -214,8 +226,7 @@
|
||||
class="page-link"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.PreviousPage"
|
||||
>
|
||||
value="@StripeSubscriptionsAction.PreviousPage">
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
@ -234,8 +245,7 @@
|
||||
type="submit"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.NextPage"
|
||||
>
|
||||
value="@StripeSubscriptionsAction.NextPage">
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
@ -254,8 +264,7 @@
|
||||
class="btn btn-primary mr-1"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.Export"
|
||||
>
|
||||
value="@StripeSubscriptionsAction.Export">
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
@ -263,8 +272,7 @@
|
||||
class="btn btn-danger"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.BulkCancel"
|
||||
>
|
||||
value="@StripeSubscriptionsAction.BulkCancel">
|
||||
Bulk Cancel
|
||||
</button>
|
||||
</span>
|
||||
|
@ -1,14 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -16,7 +14,6 @@ namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[Route("organizations/{orgId}/auth-requests")]
|
||||
[Authorize("Application")]
|
||||
[RequireFeature(FeatureFlagKeys.TrustedDeviceEncryption)]
|
||||
public class OrganizationAuthRequestsController : Controller
|
||||
{
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
|
@ -764,12 +764,6 @@ public class OrganizationsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (model.Data.MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption &&
|
||||
!_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext))
|
||||
{
|
||||
throw new BadRequestException(nameof(model.Data.MemberDecryptionType), "Invalid member decryption type.");
|
||||
}
|
||||
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(id);
|
||||
ssoConfig = ssoConfig == null ? model.ToSsoConfig(id) : model.ToSsoConfig(ssoConfig);
|
||||
organization.Identifier = model.Identifier;
|
||||
|
@ -56,6 +56,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -97,6 +98,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
|
@ -61,6 +61,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
AccessSecretsManager = organization.AccessSecretsManager;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
|
||||
if (organization.SsoConfig != null)
|
||||
{
|
||||
@ -116,4 +117,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ public class ProviderResponseModel : ResponseModel
|
||||
BusinessCountry = provider.BusinessCountry;
|
||||
BusinessTaxNumber = provider.BusinessTaxNumber;
|
||||
BillingEmail = provider.BillingEmail;
|
||||
CreationDate = provider.CreationDate;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -32,4 +33,5 @@ public class ProviderResponseModel : ResponseModel
|
||||
public string BusinessCountry { get; set; }
|
||||
public string BusinessTaxNumber { get; set; }
|
||||
public string BillingEmail { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
|
@ -110,7 +110,7 @@ public class GroupsController : Controller
|
||||
public async Task<IActionResult> Post([FromBody] GroupCreateUpdateRequestModel model)
|
||||
{
|
||||
var group = model.ToGroup(_currentContext.OrganizationId.Value);
|
||||
var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
|
||||
var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
|
||||
await _createGroupCommand.CreateGroupAsync(group, organization, associations);
|
||||
var response = new GroupResponseModel(group, associations);
|
||||
@ -139,7 +139,7 @@ public class GroupsController : Controller
|
||||
}
|
||||
|
||||
var updatedGroup = model.ToGroup(existingGroup);
|
||||
var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
|
||||
var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
|
||||
await _updateGroupCommand.UpdateGroupAsync(updatedGroup, organization, associations);
|
||||
var response = new GroupResponseModel(updatedGroup, associations);
|
||||
|
@ -119,7 +119,7 @@ public class MembersController : Controller
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
|
||||
{
|
||||
var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
|
||||
var invite = new OrganizationUserInvite
|
||||
{
|
||||
Emails = new List<string> { model.Email },
|
||||
@ -154,7 +154,7 @@ public class MembersController : Controller
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var updatedUser = model.ToOrganizationUser(existingUser);
|
||||
var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
|
||||
await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups);
|
||||
MemberResponseModel response = null;
|
||||
if (existingUser.UserId.HasValue)
|
||||
|
@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Public;
|
||||
namespace Bit.Api.AdminConsole.Public.Models;
|
||||
|
||||
public abstract class AssociationWithPermissionsBaseModel
|
||||
{
|
||||
@ -15,4 +15,9 @@ public abstract class AssociationWithPermissionsBaseModel
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool? ReadOnly { get; set; }
|
||||
/// <summary>
|
||||
/// When true, the hide passwords permission will not allow the user or group to view passwords.
|
||||
/// This prevents easy copy-and-paste of hidden items, however it may not completely prevent user access.
|
||||
/// </summary>
|
||||
public bool? HidePasswords { get; set; }
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Public.Request;
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
|
||||
public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel
|
||||
{
|
||||
public CollectionAccessSelection ToSelectionReadOnly()
|
||||
public CollectionAccessSelection ToCollectionAccessSelection()
|
||||
{
|
||||
return new CollectionAccessSelection
|
||||
{
|
||||
Id = Id.Value,
|
||||
ReadOnly = ReadOnly.Value
|
||||
ReadOnly = ReadOnly.Value,
|
||||
HidePasswords = HidePasswords.GetValueOrDefault()
|
||||
};
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
using Bit.Api.Auth.Models.Public.Request;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Api.Auth.Models.Public.Request;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Public.Response;
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Response;
|
||||
|
||||
public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
|
||||
{
|
||||
@ -12,5 +12,6 @@ public class AssociationWithPermissionsResponseModel : AssociationWithPermission
|
||||
}
|
||||
Id = selection.Id;
|
||||
ReadOnly = selection.ReadOnly;
|
||||
HidePasswords = selection.HidePasswords;
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Auth.Models.Public.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
|
@ -1,5 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Auth.Models.Public.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
@ -684,17 +684,6 @@ public class AccountsController : Controller
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("iap-check")]
|
||||
public async Task PostIapCheck([FromBody] IapCheckRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
await _userService.IapCheckAsync(user, model.PaymentMethodType.Value);
|
||||
}
|
||||
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremium(PremiumRequestModel model)
|
||||
{
|
||||
|
100
src/Api/Billing/Public/Controllers/OrganizationController.cs
Normal file
100
src/Api/Billing/Public/Controllers/OrganizationController.cs
Normal file
@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OrganizationSubscriptionUpdateRequestModel = Bit.Api.Billing.Public.Models.OrganizationSubscriptionUpdateRequestModel;
|
||||
|
||||
namespace Bit.Api.Billing.Public.Controllers;
|
||||
|
||||
[Route("public/organization")]
|
||||
[Authorize("Organization")]
|
||||
public class OrganizationController : Controller
|
||||
{
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
|
||||
public OrganizationController(
|
||||
IOrganizationService organizationService,
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_currentContext = currentContext;
|
||||
_organizationRepository = organizationRepository;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the organization's current subscription for Password Manager and/or Secrets Manager.
|
||||
/// </summary>
|
||||
/// <param name="model">The request model containing the updated subscription information.</param>
|
||||
[HttpPut("subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> PostSubscriptionAsync([FromBody] OrganizationSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UpdatePasswordManagerAsync(model, _currentContext.OrganizationId.Value);
|
||||
|
||||
var secretsManagerResult = await UpdateSecretsManagerAsync(model, _currentContext.OrganizationId.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(secretsManagerResult))
|
||||
{
|
||||
return Ok(new { Message = secretsManagerResult });
|
||||
}
|
||||
|
||||
return Ok(new { Message = "Subscription updated successfully." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { Message = "An error occurred while updating the subscription." });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdatePasswordManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId)
|
||||
{
|
||||
if (model.PasswordManager != null)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
model.PasswordManager.ToPasswordManagerSubscriptionUpdate(organization);
|
||||
await _organizationService.UpdateSubscription(organization.Id, (int)model.PasswordManager.Seats,
|
||||
model.PasswordManager.MaxAutoScaleSeats);
|
||||
if (model.PasswordManager.Storage.HasValue)
|
||||
{
|
||||
await _organizationService.AdjustStorageAsync(organization.Id, (short)model.PasswordManager.Storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> UpdateSecretsManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId)
|
||||
{
|
||||
if (model.SecretsManager == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (!organization.UseSecretsManager)
|
||||
{
|
||||
return "Organization has no access to Secrets Manager.";
|
||||
}
|
||||
|
||||
var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization);
|
||||
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(secretsManagerUpdate);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Api.Billing.Public.Models;
|
||||
|
||||
public class OrganizationSubscriptionUpdateRequestModel : IValidatableObject
|
||||
{
|
||||
public PasswordManagerSubscriptionUpdateModel PasswordManager { get; set; }
|
||||
public SecretsManagerSubscriptionUpdateModel SecretsManager { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (PasswordManager == null && SecretsManager == null)
|
||||
{
|
||||
yield return new ValidationResult("At least one of PasswordManager or SecretsManager must be provided.");
|
||||
}
|
||||
|
||||
yield return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
public class PasswordManagerSubscriptionUpdateModel
|
||||
{
|
||||
public int? Seats { get; set; }
|
||||
public int? Storage { get; set; }
|
||||
private int? _maxAutoScaleSeats;
|
||||
public int? MaxAutoScaleSeats
|
||||
{
|
||||
get { return _maxAutoScaleSeats; }
|
||||
set { _maxAutoScaleSeats = value < 0 ? null : value; }
|
||||
}
|
||||
|
||||
public virtual void ToPasswordManagerSubscriptionUpdate(Organization organization)
|
||||
{
|
||||
UpdateMaxAutoScaleSeats(organization);
|
||||
|
||||
UpdateSeats(organization);
|
||||
|
||||
UpdateStorage(organization);
|
||||
}
|
||||
|
||||
private void UpdateMaxAutoScaleSeats(Organization organization)
|
||||
{
|
||||
MaxAutoScaleSeats ??= organization.MaxAutoscaleSeats;
|
||||
}
|
||||
|
||||
private void UpdateSeats(Organization organization)
|
||||
{
|
||||
if (Seats is > 0)
|
||||
{
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
Seats = Seats.Value - organization.Seats.Value;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Seats = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStorage(Organization organization)
|
||||
{
|
||||
if (Storage is > 0)
|
||||
{
|
||||
if (organization.MaxStorageGb.HasValue)
|
||||
{
|
||||
Storage = (short?)(Storage - organization.MaxStorageGb.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Storage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class SecretsManagerSubscriptionUpdateModel
|
||||
{
|
||||
public int? Seats { get; set; }
|
||||
private int? _maxAutoScaleSeats;
|
||||
public int? MaxAutoScaleSeats
|
||||
{
|
||||
get { return _maxAutoScaleSeats; }
|
||||
set { _maxAutoScaleSeats = value < 0 ? null : value; }
|
||||
}
|
||||
public int? ServiceAccounts { get; set; }
|
||||
private int? _maxAutoScaleServiceAccounts;
|
||||
public int? MaxAutoScaleServiceAccounts
|
||||
{
|
||||
get { return _maxAutoScaleServiceAccounts; }
|
||||
set { _maxAutoScaleServiceAccounts = value < 0 ? null : value; }
|
||||
}
|
||||
|
||||
public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization)
|
||||
{
|
||||
var update = UpdateUpdateMaxAutoScale(organization);
|
||||
UpdateSeats(organization, update);
|
||||
UpdateServiceAccounts(organization, update);
|
||||
return update;
|
||||
}
|
||||
|
||||
private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization)
|
||||
{
|
||||
var update = new SecretsManagerSubscriptionUpdate(organization, false)
|
||||
{
|
||||
MaxAutoscaleSmSeats = MaxAutoScaleSeats ?? organization.MaxAutoscaleSmSeats,
|
||||
MaxAutoscaleSmServiceAccounts = MaxAutoScaleServiceAccounts ?? organization.MaxAutoscaleSmServiceAccounts
|
||||
};
|
||||
return update;
|
||||
}
|
||||
|
||||
private void UpdateSeats(Organization organization, SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (Seats is > 0)
|
||||
{
|
||||
if (organization.SmSeats.HasValue)
|
||||
{
|
||||
Seats = Seats.Value - organization.SmSeats.Value;
|
||||
|
||||
}
|
||||
update.AdjustSeats(Seats.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateServiceAccounts(Organization organization, SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (ServiceAccounts is > 0)
|
||||
{
|
||||
if (organization.SmServiceAccounts.HasValue)
|
||||
{
|
||||
ServiceAccounts = ServiceAccounts.Value - organization.SmServiceAccounts.Value;
|
||||
}
|
||||
update.AdjustServiceAccounts(ServiceAccounts.Value);
|
||||
}
|
||||
}
|
||||
}
|
@ -584,11 +584,6 @@ public class CollectionsController : Controller
|
||||
|
||||
// Filter the assigned collections to only return those where the user has Manage permission
|
||||
var manageableOrgCollections = assignedOrgCollections.Where(c => c.Item1.Manage).ToList();
|
||||
var readAssignedAuthorized = await _authorizationService.AuthorizeAsync(User, manageableOrgCollections.Select(c => c.Item1), BulkCollectionOperations.ReadWithAccess);
|
||||
if (!readAssignedAuthorized.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new ListResponseModel<CollectionAccessDetailsResponseModel>(manageableOrgCollections.Select(c =>
|
||||
new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users)
|
||||
@ -609,16 +604,8 @@ public class CollectionsController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
|
||||
var readAuthorized = (await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Read)).Succeeded;
|
||||
if (readAuthorized)
|
||||
{
|
||||
orgCollections = collections.Where(c => c.OrganizationId == orgId);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
|
||||
orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList();
|
||||
}
|
||||
|
||||
var responses = orgCollections.Select(c => new CollectionResponseModel(c));
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Api.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
@ -206,10 +207,8 @@ public class DevicesController : Controller
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("knowndevice")]
|
||||
public async Task<bool> GetByIdentifierQuery(
|
||||
[FromHeader(Name = "X-Request-Email")] string email,
|
||||
[FromHeader(Name = "X-Device-Identifier")] string deviceIdentifier)
|
||||
=> await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(email), deviceIdentifier);
|
||||
public async Task<bool> GetByIdentifierQuery([FromHeader] KnownDeviceRequestModel request)
|
||||
=> await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(request.Email), request.DeviceIdentifier);
|
||||
|
||||
[Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")]
|
||||
[AllowAnonymous]
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Api.Auth.Models.Public.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Api.Models.Public.Request;
|
||||
|
@ -1,5 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Auth.Models.Public.Response;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
|
@ -1,19 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Enums = Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Models.Request;
|
||||
|
||||
public class IapCheckRequestModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public Enums.PaymentMethodType? PaymentMethodType { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (PaymentMethodType != Enums.PaymentMethodType.AppleInApp)
|
||||
{
|
||||
yield return new ValidationResult("Not a supported in-app purchase payment method.",
|
||||
new string[] { nameof(PaymentMethodType) });
|
||||
}
|
||||
}
|
||||
}
|
16
src/Api/Models/Request/KnownDeviceRequestModel.cs
Normal file
16
src/Api/Models/Request/KnownDeviceRequestModel.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Api.Models.Request;
|
||||
|
||||
public class KnownDeviceRequestModel
|
||||
{
|
||||
[Required]
|
||||
[FromHeader(Name = "X-Request-Email")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required]
|
||||
[FromHeader(Name = "X-Device-Identifier")]
|
||||
public string DeviceIdentifier { get; set; }
|
||||
|
||||
}
|
@ -36,6 +36,7 @@ public class ProfileResponseModel : ResponseModel
|
||||
ForcePasswordReset = user.ForcePasswordReset;
|
||||
UsesKeyConnector = user.UsesKeyConnector;
|
||||
AvatarColor = user.AvatarColor;
|
||||
CreationDate = user.CreationDate;
|
||||
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o));
|
||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||
ProviderOrganizations =
|
||||
@ -61,6 +62,7 @@ public class ProfileResponseModel : ResponseModel
|
||||
public bool ForcePasswordReset { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public string AvatarColor { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
|
||||
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
|
||||
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }
|
||||
|
@ -18,7 +18,6 @@ public class SubscriptionResponseModel : ResponseModel
|
||||
MaxStorageGb = user.MaxStorageGb;
|
||||
License = license;
|
||||
Expiration = License.Expires;
|
||||
UsingInAppPurchase = subscription.UsingInAppPurchase;
|
||||
}
|
||||
|
||||
public SubscriptionResponseModel(User user, UserLicense license = null)
|
||||
@ -42,7 +41,6 @@ public class SubscriptionResponseModel : ResponseModel
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public UserLicense License { get; set; }
|
||||
public DateTime? Expiration { get; set; }
|
||||
public bool UsingInAppPurchase { get; set; }
|
||||
}
|
||||
|
||||
public class BillingCustomerDiscount
|
||||
|
@ -89,7 +89,7 @@ public class CollectionsController : Controller
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var updatedCollection = model.ToCollection(existingCollection);
|
||||
var associations = model.Groups?.Select(c => c.ToSelectionReadOnly());
|
||||
var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection());
|
||||
await _collectionService.SaveAsync(updatedCollection, associations);
|
||||
var response = new CollectionResponseModel(updatedCollection, associations);
|
||||
return new JsonResult(response);
|
||||
|
@ -288,6 +288,12 @@ public class Startup
|
||||
"Bitwarden Public API");
|
||||
config.OAuthClientId("accountType.id");
|
||||
config.OAuthClientSecret("secretKey");
|
||||
|
||||
// Persist authorization on page refresh - for development use only
|
||||
if (Environment.IsDevelopment())
|
||||
{
|
||||
config.EnablePersistAuthorization();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
@ -20,6 +21,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private Guid _targetOrganizationId;
|
||||
|
||||
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
@ -27,11 +29,13 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
public BulkCollectionAuthorizationHandler(
|
||||
ICurrentContext currentContext,
|
||||
ICollectionRepository collectionRepository,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_collectionRepository = collectionRepository;
|
||||
_featureService = featureService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
@ -84,7 +88,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
|
||||
case not null when requirement == BulkCollectionOperations.Update:
|
||||
case not null when requirement == BulkCollectionOperations.ModifyAccess:
|
||||
await CanUpdateCollection(context, requirement, resources, org);
|
||||
await CanUpdateCollectionAsync(context, requirement, resources, org);
|
||||
break;
|
||||
|
||||
case not null when requirement == BulkCollectionOperations.Delete:
|
||||
@ -96,10 +100,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
private async Task CanCreateAsync(AuthorizationHandlerContext context, IAuthorizationRequirement requirement,
|
||||
CurrentContextOrganization? org)
|
||||
{
|
||||
// If the limit collection management setting is disabled, allow any user to create collections
|
||||
// Otherwise, Owners, Admins, and users with CreateNewCollections permission can always create collections
|
||||
// Owners, Admins, and users with CreateNewCollections permission can always create collections
|
||||
if (org is
|
||||
{ LimitCollectionCreationDeletion: false } or
|
||||
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.CreateNewCollections: true })
|
||||
{
|
||||
@ -107,6 +109,13 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
return;
|
||||
}
|
||||
|
||||
// If the limit collection management setting is disabled, allow any user to create collections
|
||||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false })
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow provider users to create collections if they are a provider for the target organization
|
||||
if (await _currentContext.ProviderUserForOrgAsync(_targetOrganizationId))
|
||||
{
|
||||
@ -131,8 +140,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
// ensure they have access for the collection being read
|
||||
if (org is not null)
|
||||
{
|
||||
var isAssignedToCollections = await IsAssignedToCollectionsAsync(resources, org, false);
|
||||
if (isAssignedToCollections)
|
||||
var canManageCollections = await CanManageCollectionsAsync(resources, org);
|
||||
if (canManageCollections)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
@ -164,8 +173,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
// ensure they have access with manage permission for the collection being read
|
||||
if (org is not null)
|
||||
{
|
||||
var isAssignedToCollections = await IsAssignedToCollectionsAsync(resources, org, true);
|
||||
if (isAssignedToCollections)
|
||||
var canManageCollections = await CanManageCollectionsAsync(resources, org);
|
||||
if (canManageCollections)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
@ -182,7 +191,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
/// <summary>
|
||||
/// Ensures the acting user is allowed to update the target collections or manage access permissions for them.
|
||||
/// </summary>
|
||||
private async Task CanUpdateCollection(AuthorizationHandlerContext context,
|
||||
private async Task CanUpdateCollectionAsync(AuthorizationHandlerContext context,
|
||||
IAuthorizationRequirement requirement, ICollection<Collection> resources,
|
||||
CurrentContextOrganization? org)
|
||||
{
|
||||
@ -199,7 +208,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
// ensure they have manage permission for the collection being managed
|
||||
if (org is not null)
|
||||
{
|
||||
var canManageCollections = await IsAssignedToCollectionsAsync(resources, org, true);
|
||||
var canManageCollections = await CanManageCollectionsAsync(resources, org);
|
||||
if (canManageCollections)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
@ -226,11 +235,12 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for non-null org here: the user must be apart of the organization for this setting to take affect
|
||||
// The limit collection management setting is disabled,
|
||||
// ensure acting user has manage permissions for all collections being deleted
|
||||
if (org is { LimitCollectionCreationDeletion: false })
|
||||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false })
|
||||
{
|
||||
var canManageCollections = await IsAssignedToCollectionsAsync(resources, org, true);
|
||||
var canManageCollections = await CanManageCollectionsAsync(resources, org);
|
||||
if (canManageCollections)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
@ -245,21 +255,35 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsAssignedToCollectionsAsync(
|
||||
private async Task<bool> CanManageCollectionsAsync(
|
||||
ICollection<Collection> targetCollections,
|
||||
CurrentContextOrganization org,
|
||||
bool requireManagePermission)
|
||||
CurrentContextOrganization org)
|
||||
{
|
||||
// List of collection Ids the acting user has access to
|
||||
var assignedCollectionIds =
|
||||
(await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value, useFlexibleCollections: true))
|
||||
.Where(c =>
|
||||
// Check Collections with Manage permission
|
||||
(!requireManagePermission || c.Manage) && c.OrganizationId == org.Id)
|
||||
c.Manage && c.OrganizationId == org.Id)
|
||||
.Select(c => c.Id)
|
||||
.ToHashSet();
|
||||
|
||||
// Check if the acting user has access to all target collections
|
||||
return targetCollections.All(tc => assignedCollectionIds.Contains(tc.Id));
|
||||
}
|
||||
|
||||
private async Task<OrganizationAbility?> GetOrganizationAbilityAsync(CurrentContextOrganization? organization)
|
||||
{
|
||||
// If the CurrentContextOrganization is null, then the user isn't a member of the org so the setting is
|
||||
// irrelevant
|
||||
if (organization == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
(await _applicationCacheService.GetOrganizationAbilitiesAsync())
|
||||
.TryGetValue(organization.Id, out var organizationAbility);
|
||||
|
||||
return organizationAbility;
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@ -16,15 +17,18 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
|
||||
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
|
||||
public GroupAuthorizationHandler(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
@ -62,10 +66,8 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
|
||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
|
||||
CurrentContextOrganization? org)
|
||||
{
|
||||
// If the limit collection management setting is disabled, allow any user to read all groups
|
||||
// Otherwise, Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all groups
|
||||
// Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all groups
|
||||
if (org is
|
||||
{ LimitCollectionCreationDeletion: false } or
|
||||
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.ManageGroups: true } or
|
||||
{ Permissions.ManageUsers: true } or
|
||||
@ -77,10 +79,33 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for non-null org here: the user must be apart of the organization for this setting to take affect
|
||||
// If the limit collection management setting is disabled, allow any user to read all groups
|
||||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false })
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow provider users to read all groups if they are a provider for the target organization
|
||||
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OrganizationAbility?> GetOrganizationAbilityAsync(CurrentContextOrganization? organization)
|
||||
{
|
||||
// If the CurrentContextOrganization is null, then the user isn't a member of the org so the setting is
|
||||
// irrelevant
|
||||
if (organization == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
(await _applicationCacheService.GetOrganizationAbilitiesAsync())
|
||||
.TryGetValue(organization.Id, out var organizationAbility);
|
||||
|
||||
return organizationAbility;
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@ -16,15 +17,18 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler<Organiz
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
|
||||
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
|
||||
public OrganizationUserAuthorizationHandler(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
@ -64,7 +68,6 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler<Organiz
|
||||
// If the limit collection management setting is disabled, allow any user to read all organization users
|
||||
// Otherwise, Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all organization users
|
||||
if (org is
|
||||
{ LimitCollectionCreationDeletion: false } or
|
||||
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.ManageGroups: true } or
|
||||
{ Permissions.ManageUsers: true } or
|
||||
@ -76,10 +79,33 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler<Organiz
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for non-null org here: the user must be apart of the organization for this setting to take affect
|
||||
// If the limit collection management setting is disabled, allow any user to read all organization users
|
||||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false })
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OrganizationAbility?> GetOrganizationAbilityAsync(CurrentContextOrganization? organization)
|
||||
{
|
||||
// If the CurrentContextOrganization is null, then the user isn't a member of the org so the setting is
|
||||
// irrelevant
|
||||
if (organization == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
(await _applicationCacheService.GetOrganizationAbilitiesAsync())
|
||||
.TryGetValue(organization.Id, out var organizationAbility);
|
||||
|
||||
return organizationAbility;
|
||||
}
|
||||
}
|
||||
|
@ -96,6 +96,7 @@ public class SyncController : Controller
|
||||
{
|
||||
collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
|
||||
collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
}
|
||||
|
||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||
|
@ -30,7 +30,6 @@ namespace Bit.Billing.Controllers;
|
||||
[Route("stripe")]
|
||||
public class StripeController : Controller
|
||||
{
|
||||
private const decimal PremiumPlanAppleIapPrice = 14.99M;
|
||||
private const string PremiumPlanId = "premium-annually";
|
||||
private const string PremiumPlanIdAppStore = "premium-annually-app";
|
||||
|
||||
@ -42,7 +41,6 @@ public class StripeController : Controller
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAppleIapService _appleIapService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly ILogger<StripeController> _logger;
|
||||
private readonly BraintreeGateway _btGateway;
|
||||
@ -64,7 +62,6 @@ public class StripeController : Controller
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITransactionRepository transactionRepository,
|
||||
IUserService userService,
|
||||
IAppleIapService appleIapService,
|
||||
IMailService mailService,
|
||||
IReferenceEventService referenceEventService,
|
||||
ILogger<StripeController> logger,
|
||||
@ -82,7 +79,6 @@ public class StripeController : Controller
|
||||
_organizationRepository = organizationRepository;
|
||||
_transactionRepository = transactionRepository;
|
||||
_userService = userService;
|
||||
_appleIapService = appleIapService;
|
||||
_mailService = mailService;
|
||||
_referenceEventService = referenceEventService;
|
||||
_taxRateRepository = taxRateRepository;
|
||||
@ -681,10 +677,6 @@ public class StripeController : Controller
|
||||
{
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.GetAsync(invoice.CustomerId);
|
||||
if (customer?.Metadata?.ContainsKey("appleReceipt") ?? false)
|
||||
{
|
||||
return await AttemptToPayInvoiceWithAppleReceiptAsync(invoice, customer);
|
||||
}
|
||||
|
||||
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
|
||||
{
|
||||
@ -699,99 +691,6 @@ public class StripeController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceWithAppleReceiptAsync(Invoice invoice, Customer customer)
|
||||
{
|
||||
if (!customer?.Metadata?.ContainsKey("appleReceipt") ?? true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var originalAppleReceiptTransactionId = customer.Metadata["appleReceipt"];
|
||||
var appleReceiptRecord = await _appleIapService.GetReceiptAsync(originalAppleReceiptTransactionId);
|
||||
if (string.IsNullOrWhiteSpace(appleReceiptRecord?.Item1) || !appleReceiptRecord.Item2.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId);
|
||||
var ids = GetIdsFromMetaData(subscription?.Metadata);
|
||||
if (!ids.Item2.HasValue)
|
||||
{
|
||||
// Apple receipt is only for user subscriptions
|
||||
return false;
|
||||
}
|
||||
|
||||
if (appleReceiptRecord.Item2.Value != ids.Item2.Value)
|
||||
{
|
||||
_logger.LogError("User Ids for Apple Receipt and subscription do not match: {0} != {1}.",
|
||||
appleReceiptRecord.Item2.Value, ids.Item2.Value);
|
||||
return false;
|
||||
}
|
||||
|
||||
var appleReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(appleReceiptRecord.Item1);
|
||||
if (appleReceiptStatus == null)
|
||||
{
|
||||
// TODO: cancel sub if receipt is cancelled?
|
||||
return false;
|
||||
}
|
||||
|
||||
var receiptExpiration = appleReceiptStatus.GetLastExpiresDate().GetValueOrDefault(DateTime.MinValue);
|
||||
var invoiceDue = invoice.DueDate.GetValueOrDefault(DateTime.MinValue);
|
||||
if (receiptExpiration <= invoiceDue)
|
||||
{
|
||||
_logger.LogWarning("Apple receipt expiration is before invoice due date. {0} <= {1}",
|
||||
receiptExpiration, invoiceDue);
|
||||
return false;
|
||||
}
|
||||
|
||||
var receiptLastTransactionId = appleReceiptStatus.GetLastTransactionId();
|
||||
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.AppStore, receiptLastTransactionId);
|
||||
if (existingTransaction != null)
|
||||
{
|
||||
_logger.LogWarning("There is already an existing transaction for this Apple receipt.",
|
||||
receiptLastTransactionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var appleTransaction = appleReceiptStatus.BuildTransactionFromLastTransaction(
|
||||
PremiumPlanAppleIapPrice, ids.Item2.Value);
|
||||
appleTransaction.Type = TransactionType.Charge;
|
||||
|
||||
var invoiceService = new InvoiceService();
|
||||
try
|
||||
{
|
||||
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["appleReceipt"] = appleReceiptStatus.GetOriginalTransactionId(),
|
||||
["appleReceiptTransactionId"] = receiptLastTransactionId
|
||||
}
|
||||
});
|
||||
|
||||
await _transactionRepository.CreateAsync(appleTransaction);
|
||||
await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e.Message.Contains("Invoice is already paid"))
|
||||
{
|
||||
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = invoice.Metadata
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
|
||||
{
|
||||
_logger.LogDebug("Attempting to pay invoice with Braintree");
|
||||
|
@ -15,14 +15,10 @@ public class CurrentContextOrganization
|
||||
Type = orgUser.Type;
|
||||
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(orgUser.Permissions);
|
||||
AccessSecretsManager = orgUser.AccessSecretsManager && orgUser.UseSecretsManager && orgUser.Enabled;
|
||||
LimitCollectionCreationDeletion = orgUser.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = orgUser.AllowAdminAccessToAllCollectionItems;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public OrganizationUserType Type { get; set; }
|
||||
public Permissions Permissions { get; set; } = new();
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
}
|
||||
|
@ -91,6 +91,11 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
/// <summary>
|
||||
/// True if the organization is using the Flexible Collections permission changes, false otherwise.
|
||||
/// For existing organizations, this must only be set to true once data migrations have been run for this organization.
|
||||
/// </summary>
|
||||
public bool FlexibleCollections { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
@ -236,7 +241,10 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
|
||||
return providers[provider];
|
||||
}
|
||||
|
||||
public void UpdateFromLicense(OrganizationLicense license, bool flexibleCollectionsIsEnabled)
|
||||
public void UpdateFromLicense(
|
||||
OrganizationLicense license,
|
||||
bool flexibleCollectionsMvpIsEnabled,
|
||||
bool flexibleCollectionsV1IsEnabled)
|
||||
{
|
||||
Name = license.Name;
|
||||
BusinessName = license.BusinessName;
|
||||
@ -267,6 +275,7 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
|
||||
UseSecretsManager = license.UseSecretsManager;
|
||||
SmSeats = license.SmSeats;
|
||||
SmServiceAccounts = license.SmServiceAccounts;
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled || license.LimitCollectionCreationDeletion;
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems;
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,9 @@ public class OrganizationAbility
|
||||
UseResetPassword = organization.UseResetPassword;
|
||||
UseCustomPermissions = organization.UseCustomPermissions;
|
||||
UsePolicies = organization.UsePolicies;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -35,4 +38,7 @@ public class OrganizationAbility
|
||||
public bool UseResetPassword { get; set; }
|
||||
public bool UseCustomPermissions { get; set; }
|
||||
public bool UsePolicies { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
@ -50,4 +50,5 @@ public class OrganizationUserOrganizationDetails
|
||||
public int? SmServiceAccounts { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
@ -558,8 +558,10 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
await ValidateSignUpPoliciesAsync(owner.Id);
|
||||
|
||||
var flexibleCollectionsIsEnabled =
|
||||
var flexibleCollectionsMvpIsEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
var flexibleCollectionsV1IsEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
@ -601,7 +603,8 @@ public class OrganizationService : IOrganizationService
|
||||
UseSecretsManager = license.UseSecretsManager,
|
||||
SmSeats = license.SmSeats,
|
||||
SmServiceAccounts = license.SmServiceAccounts,
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled || license.LimitCollectionCreationDeletion
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion,
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems
|
||||
};
|
||||
|
||||
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
|
||||
@ -1118,12 +1121,12 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
// Determine if org has SSO enabled and if user is required to login with SSO
|
||||
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
||||
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id)).Enabled;
|
||||
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
||||
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
||||
// need to check the policy if the org has SSO enabled.
|
||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||
organization.UsePolicies &&
|
||||
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso)).Enabled;
|
||||
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||
|
||||
// Generate the list of org users and expiring tokens
|
||||
// create helper function to create expiring tokens
|
||||
@ -1890,11 +1893,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
public void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade)
|
||||
{
|
||||
if (plan is not { LegacyYear: null })
|
||||
{
|
||||
throw new BadRequestException("Invalid Password Manager plan selected.");
|
||||
}
|
||||
|
||||
ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager");
|
||||
|
||||
if (plan.PasswordManager.BaseSeats + upgrade.AdditionalSeats <= 0)
|
||||
@ -2409,12 +2407,8 @@ public class OrganizationService : IOrganizationService
|
||||
public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
|
||||
{
|
||||
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
|
||||
if (plan is not { LegacyYear: null })
|
||||
{
|
||||
throw new BadRequestException("Invalid plan selected.");
|
||||
}
|
||||
|
||||
if (plan.Disabled)
|
||||
if (plan!.Disabled)
|
||||
{
|
||||
throw new BadRequestException("Plan not found.");
|
||||
}
|
||||
|
@ -1,23 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Bit.Core.Auth.Entities;
|
||||
|
||||
public class Grant
|
||||
public class Grant : IGrant
|
||||
{
|
||||
public Grant() { }
|
||||
|
||||
public Grant(PersistedGrant pGrant)
|
||||
{
|
||||
Key = pGrant.Key;
|
||||
Type = pGrant.Type;
|
||||
SubjectId = pGrant.SubjectId;
|
||||
SessionId = pGrant.SessionId;
|
||||
ClientId = pGrant.ClientId;
|
||||
Description = pGrant.Description;
|
||||
CreationDate = pGrant.CreationTime;
|
||||
ExpirationDate = pGrant.Expiration;
|
||||
ConsumedDate = pGrant.ConsumedTime;
|
||||
Data = pGrant.Data;
|
||||
}
|
||||
|
||||
public int Id { get; set; }
|
||||
[MaxLength(200)]
|
||||
public string Key { get; set; }
|
||||
public string Key { get; set; } = null!;
|
||||
[MaxLength(50)]
|
||||
public string Type { get; set; }
|
||||
public string Type { get; set; } = null!;
|
||||
[MaxLength(200)]
|
||||
public string SubjectId { get; set; }
|
||||
public string? SubjectId { get; set; }
|
||||
[MaxLength(100)]
|
||||
public string SessionId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
[MaxLength(200)]
|
||||
public string ClientId { get; set; }
|
||||
public string ClientId { get; set; } = null!;
|
||||
[MaxLength(200)]
|
||||
public string Description { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
public DateTime? ConsumedDate { get; set; }
|
||||
public string Data { get; set; }
|
||||
public string Data { get; set; } = null!;
|
||||
}
|
||||
|
77
src/Core/Auth/Models/Data/GrantItem.cs
Normal file
77
src/Core/Auth/Models/Data/GrantItem.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Auth.Repositories.Cosmos;
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public class GrantItem : IGrant
|
||||
{
|
||||
public GrantItem() { }
|
||||
|
||||
public GrantItem(PersistedGrant pGrant)
|
||||
{
|
||||
Key = pGrant.Key;
|
||||
Type = pGrant.Type;
|
||||
SubjectId = pGrant.SubjectId;
|
||||
SessionId = pGrant.SessionId;
|
||||
ClientId = pGrant.ClientId;
|
||||
Description = pGrant.Description;
|
||||
CreationDate = pGrant.CreationTime;
|
||||
ExpirationDate = pGrant.Expiration;
|
||||
ConsumedDate = pGrant.ConsumedTime;
|
||||
Data = pGrant.Data;
|
||||
SetTtl();
|
||||
}
|
||||
|
||||
public GrantItem(IGrant g)
|
||||
{
|
||||
Key = g.Key;
|
||||
Type = g.Type;
|
||||
SubjectId = g.SubjectId;
|
||||
SessionId = g.SessionId;
|
||||
ClientId = g.ClientId;
|
||||
Description = g.Description;
|
||||
CreationDate = g.CreationDate;
|
||||
ExpirationDate = g.ExpirationDate;
|
||||
ConsumedDate = g.ConsumedDate;
|
||||
Data = g.Data;
|
||||
SetTtl();
|
||||
}
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
[JsonConverter(typeof(Base64IdStringConverter))]
|
||||
public string Key { get; set; }
|
||||
[JsonPropertyName("typ")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("sub")]
|
||||
public string SubjectId { get; set; }
|
||||
[JsonPropertyName("sid")]
|
||||
public string SessionId { get; set; }
|
||||
[JsonPropertyName("cid")]
|
||||
public string ClientId { get; set; }
|
||||
[JsonPropertyName("des")]
|
||||
public string Description { get; set; }
|
||||
[JsonPropertyName("cre")]
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
[JsonPropertyName("exp")]
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
[JsonPropertyName("con")]
|
||||
public DateTime? ConsumedDate { get; set; }
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; }
|
||||
// https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-time-to-live?tabs=dotnet-sdk-v3#set-time-to-live-on-an-item-using-an-sdk
|
||||
[JsonPropertyName("ttl")]
|
||||
public int Ttl { get; set; } = -1;
|
||||
|
||||
public void SetTtl()
|
||||
{
|
||||
if (ExpirationDate != null)
|
||||
{
|
||||
var sec = (ExpirationDate.Value - DateTime.UtcNow).TotalSeconds;
|
||||
if (sec > 0)
|
||||
{
|
||||
Ttl = (int)sec;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
src/Core/Auth/Models/Data/IGrant.cs
Normal file
15
src/Core/Auth/Models/Data/IGrant.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public interface IGrant
|
||||
{
|
||||
string Key { get; set; }
|
||||
string Type { get; set; }
|
||||
string SubjectId { get; set; }
|
||||
string SessionId { get; set; }
|
||||
string ClientId { get; set; }
|
||||
string Description { get; set; }
|
||||
DateTime CreationDate { get; set; }
|
||||
DateTime? ExpirationDate { get; set; }
|
||||
DateTime? ConsumedDate { get; set; }
|
||||
string Data { get; set; }
|
||||
}
|
32
src/Core/Auth/Repositories/Cosmos/Base64IdStringConverter.cs
Normal file
32
src/Core/Auth/Repositories/Cosmos/Base64IdStringConverter.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Auth.Repositories.Cosmos;
|
||||
|
||||
public class Base64IdStringConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
ToKey(reader.GetString());
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(ToId(value));
|
||||
|
||||
public static string ToId(string key)
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return CoreHelpers.TransformToBase64Url(key);
|
||||
}
|
||||
|
||||
public static string ToKey(string id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return CoreHelpers.TransformFromBase64Url(id);
|
||||
}
|
||||
}
|
81
src/Core/Auth/Repositories/Cosmos/GrantRepository.cs
Normal file
81
src/Core/Auth/Repositories/Cosmos/GrantRepository.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Bit.Core.Auth.Repositories.Cosmos;
|
||||
|
||||
public class GrantRepository : IGrantRepository
|
||||
{
|
||||
private readonly CosmosClient _client;
|
||||
private readonly Database _database;
|
||||
private readonly Container _container;
|
||||
|
||||
public GrantRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.IdentityServer.CosmosConnectionString)
|
||||
{ }
|
||||
|
||||
public GrantRepository(string cosmosConnectionString)
|
||||
{
|
||||
var options = new CosmosClientOptions
|
||||
{
|
||||
Serializer = new SystemTextJsonCosmosSerializer(new JsonSerializerOptions
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
})
|
||||
};
|
||||
// TODO: Perhaps we want to evaluate moving this to DI as a keyed service singleton in .NET 8
|
||||
_client = new CosmosClient(cosmosConnectionString, options);
|
||||
_database = _client.GetDatabase("identity");
|
||||
_container = _database.GetContainer("grant");
|
||||
}
|
||||
|
||||
public async Task<IGrant> GetByKeyAsync(string key)
|
||||
{
|
||||
var id = Base64IdStringConverter.ToId(key);
|
||||
try
|
||||
{
|
||||
var response = await _container.ReadItemAsync<GrantItem>(id, new PartitionKey(id));
|
||||
return response.Resource;
|
||||
}
|
||||
catch (CosmosException e)
|
||||
{
|
||||
if (e.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ICollection<IGrant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public async Task SaveAsync(IGrant obj)
|
||||
{
|
||||
if (obj is not GrantItem item)
|
||||
{
|
||||
item = new GrantItem(obj);
|
||||
}
|
||||
item.SetTtl();
|
||||
var id = Base64IdStringConverter.ToId(item.Key);
|
||||
await _container.UpsertItemAsync(item, new PartitionKey(id), new ItemRequestOptions
|
||||
{
|
||||
// ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/best-practice-dotnet#best-practices-for-write-heavy-workloads
|
||||
EnableContentResponseOnWrite = false
|
||||
});
|
||||
}
|
||||
|
||||
public async Task DeleteByKeyAsync(string key)
|
||||
{
|
||||
var id = Base64IdStringConverter.ToId(key);
|
||||
await _container.DeleteItemAsync<IGrant>(id, new PartitionKey(id));
|
||||
}
|
||||
|
||||
public Task DeleteManyAsync(string subjectId, string sessionId, string clientId, string type)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
|
||||
namespace Bit.Core.Auth.Repositories;
|
||||
|
||||
public interface IGrantRepository
|
||||
{
|
||||
Task<Grant> GetByKeyAsync(string key);
|
||||
Task<ICollection<Grant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type);
|
||||
Task SaveAsync(Grant obj);
|
||||
Task<IGrant> GetByKeyAsync(string key);
|
||||
Task<ICollection<IGrant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type);
|
||||
Task SaveAsync(IGrant obj);
|
||||
Task DeleteByKeyAsync(string key);
|
||||
Task DeleteManyAsync(string subjectId, string sessionId, string clientId, string type);
|
||||
}
|
||||
|
@ -29,6 +29,12 @@ public static class Constants
|
||||
/// Used by IdentityServer to identify our own provider.
|
||||
/// </summary>
|
||||
public const string IdentityProvider = "bitwarden";
|
||||
|
||||
/// <summary>
|
||||
/// Date identifier used in ProviderService to determine if a provider was created before Nov 6, 2023.
|
||||
/// If true, the organization plan assigned to that provider is updated to a 2020 plan.
|
||||
/// </summary>
|
||||
public static readonly DateTime ProviderCreatedPriorNov62023 = new DateTime(2023, 11, 6);
|
||||
}
|
||||
|
||||
public static class AuthConstants
|
||||
|
@ -54,7 +54,7 @@ public class CurrentContext : ICurrentContext
|
||||
{
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_featureService = featureService;
|
||||
_featureService = featureService; ;
|
||||
}
|
||||
|
||||
public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings)
|
||||
@ -383,15 +383,10 @@ public class CurrentContext : ICurrentContext
|
||||
throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF.");
|
||||
}
|
||||
|
||||
var canCreateNewCollections = false;
|
||||
var org = GetOrganization(orgId);
|
||||
if (org != null)
|
||||
{
|
||||
canCreateNewCollections = !org.LimitCollectionCreationDeletion || org.Permissions.CreateNewCollections;
|
||||
}
|
||||
return await EditAssignedCollections(orgId)
|
||||
|| await DeleteAssignedCollections(orgId)
|
||||
|| canCreateNewCollections;
|
||||
|| (org != null && org.Permissions.CreateNewCollections);
|
||||
}
|
||||
|
||||
public async Task<bool> ManageGroups(Guid orgId)
|
||||
|
@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="1.0.1" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.0.150" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.2.47" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.31" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.31" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" />
|
||||
@ -47,7 +47,7 @@
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
|
||||
<PackageReference Include="Sentry.Serilog" Version="3.41.3" />
|
||||
<PackageReference Include="Duende.IdentityServer" Version="6.0.4" />
|
||||
<PackageReference Include="Duende.IdentityServer" Version="6.3.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.AzureCosmosDB" Version="2.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="2.0.9" />
|
||||
|
@ -16,10 +16,6 @@ public enum PaymentMethodType : byte
|
||||
Credit = 4,
|
||||
[Display(Name = "Wire Transfer")]
|
||||
WireTransfer = 5,
|
||||
[Display(Name = "Apple In-App Purchase")]
|
||||
AppleInApp = 6,
|
||||
[Display(Name = "Google In-App Purchase")]
|
||||
GoogleInApp = 7,
|
||||
[Display(Name = "Check")]
|
||||
Check = 8,
|
||||
[Display(Name = "None")]
|
||||
|
@ -1,134 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class AppleReceiptStatus
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int? Status { get; set; }
|
||||
[JsonPropertyName("environment")]
|
||||
public string Environment { get; set; }
|
||||
[JsonPropertyName("latest_receipt")]
|
||||
public string LatestReceipt { get; set; }
|
||||
[JsonPropertyName("receipt")]
|
||||
public AppleReceipt Receipt { get; set; }
|
||||
[JsonPropertyName("latest_receipt_info")]
|
||||
public List<AppleTransaction> LatestReceiptInfo { get; set; }
|
||||
[JsonPropertyName("pending_renewal_info")]
|
||||
public List<AppleRenewalInfo> PendingRenewalInfo { get; set; }
|
||||
|
||||
public string GetOriginalTransactionId()
|
||||
{
|
||||
return LatestReceiptInfo?.LastOrDefault()?.OriginalTransactionId;
|
||||
}
|
||||
|
||||
public string GetLastTransactionId()
|
||||
{
|
||||
return LatestReceiptInfo?.LastOrDefault()?.TransactionId;
|
||||
}
|
||||
|
||||
public AppleTransaction GetLastTransaction()
|
||||
{
|
||||
return LatestReceiptInfo?.LastOrDefault();
|
||||
}
|
||||
|
||||
public DateTime? GetLastExpiresDate()
|
||||
{
|
||||
return LatestReceiptInfo?.LastOrDefault()?.ExpiresDate;
|
||||
}
|
||||
|
||||
public string GetReceiptData()
|
||||
{
|
||||
return LatestReceipt;
|
||||
}
|
||||
|
||||
public DateTime? GetLastCancellationDate()
|
||||
{
|
||||
return LatestReceiptInfo?.LastOrDefault()?.CancellationDate;
|
||||
}
|
||||
|
||||
public bool IsRefunded()
|
||||
{
|
||||
var cancellationDate = GetLastCancellationDate();
|
||||
var expiresDate = GetLastCancellationDate();
|
||||
if (cancellationDate.HasValue && expiresDate.HasValue)
|
||||
{
|
||||
return cancellationDate.Value <= expiresDate.Value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Transaction BuildTransactionFromLastTransaction(decimal amount, Guid userId)
|
||||
{
|
||||
return new Transaction
|
||||
{
|
||||
Amount = amount,
|
||||
CreationDate = GetLastTransaction().PurchaseDate,
|
||||
Gateway = GatewayType.AppStore,
|
||||
GatewayId = GetLastTransactionId(),
|
||||
UserId = userId,
|
||||
PaymentMethodType = PaymentMethodType.AppleInApp,
|
||||
Details = GetLastTransactionId()
|
||||
};
|
||||
}
|
||||
|
||||
public class AppleReceipt
|
||||
{
|
||||
[JsonPropertyName("receipt_type")]
|
||||
public string ReceiptType { get; set; }
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public string BundleId { get; set; }
|
||||
[JsonPropertyName("receipt_creation_date_ms")]
|
||||
[JsonConverter(typeof(MsEpochConverter))]
|
||||
public DateTime ReceiptCreationDate { get; set; }
|
||||
[JsonPropertyName("in_app")]
|
||||
public List<AppleTransaction> InApp { get; set; }
|
||||
}
|
||||
|
||||
public class AppleRenewalInfo
|
||||
{
|
||||
[JsonPropertyName("expiration_intent")]
|
||||
public string ExpirationIntent { get; set; }
|
||||
[JsonPropertyName("auto_renew_product_id")]
|
||||
public string AutoRenewProductId { get; set; }
|
||||
[JsonPropertyName("original_transaction_id")]
|
||||
public string OriginalTransactionId { get; set; }
|
||||
[JsonPropertyName("is_in_billing_retry_period")]
|
||||
public string IsInBillingRetryPeriod { get; set; }
|
||||
[JsonPropertyName("product_id")]
|
||||
public string ProductId { get; set; }
|
||||
[JsonPropertyName("auto_renew_status")]
|
||||
public string AutoRenewStatus { get; set; }
|
||||
}
|
||||
|
||||
public class AppleTransaction
|
||||
{
|
||||
[JsonPropertyName("quantity")]
|
||||
public string Quantity { get; set; }
|
||||
[JsonPropertyName("product_id")]
|
||||
public string ProductId { get; set; }
|
||||
[JsonPropertyName("transaction_id")]
|
||||
public string TransactionId { get; set; }
|
||||
[JsonPropertyName("original_transaction_id")]
|
||||
public string OriginalTransactionId { get; set; }
|
||||
[JsonPropertyName("purchase_date_ms")]
|
||||
[JsonConverter(typeof(MsEpochConverter))]
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
[JsonPropertyName("original_purchase_date_ms")]
|
||||
[JsonConverter(typeof(MsEpochConverter))]
|
||||
public DateTime OriginalPurchaseDate { get; set; }
|
||||
[JsonPropertyName("expires_date_ms")]
|
||||
[JsonConverter(typeof(MsEpochConverter))]
|
||||
public DateTime ExpiresDate { get; set; }
|
||||
[JsonPropertyName("cancellation_date_ms")]
|
||||
[JsonConverter(typeof(MsEpochConverter))]
|
||||
public DateTime? CancellationDate { get; set; }
|
||||
[JsonPropertyName("web_order_line_item_id")]
|
||||
public string WebOrderLineItemId { get; set; }
|
||||
[JsonPropertyName("cancellation_reason")]
|
||||
public string CancellationReason { get; set; }
|
||||
}
|
||||
}
|
329
src/Core/Models/Business/CompleteSubscriptionUpdate.cs
Normal file
329
src/Core/Models/Business/CompleteSubscriptionUpdate.cs
Normal file
@ -0,0 +1,329 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Models.Business;
|
||||
|
||||
/// <summary>
|
||||
/// A model representing the data required to upgrade from one subscription to another using a <see cref="CompleteSubscriptionUpdate"/>.
|
||||
/// </summary>
|
||||
public class SubscriptionData
|
||||
{
|
||||
public StaticStore.Plan Plan { get; init; }
|
||||
public int PurchasedPasswordManagerSeats { get; init; }
|
||||
public bool SubscribedToSecretsManager { get; set; }
|
||||
public int? PurchasedSecretsManagerSeats { get; init; }
|
||||
public int? PurchasedAdditionalSecretsManagerServiceAccounts { get; init; }
|
||||
public int PurchasedAdditionalStorage { get; init; }
|
||||
}
|
||||
|
||||
public class CompleteSubscriptionUpdate : SubscriptionUpdate
|
||||
{
|
||||
private readonly SubscriptionData _currentSubscription;
|
||||
private readonly SubscriptionData _updatedSubscription;
|
||||
|
||||
private readonly Dictionary<string, SubscriptionUpdateType> _subscriptionUpdateMap = new();
|
||||
|
||||
private enum SubscriptionUpdateType
|
||||
{
|
||||
PasswordManagerSeats,
|
||||
SecretsManagerSeats,
|
||||
SecretsManagerServiceAccounts,
|
||||
Storage
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model used to generate the Stripe <see cref="SubscriptionItemOptions"/>
|
||||
/// necessary to both upgrade an organization's subscription and revert that upgrade
|
||||
/// in the case of an error.
|
||||
/// </summary>
|
||||
/// <param name="organization">The <see cref="Organization"/> to upgrade.</param>
|
||||
/// <param name="updatedSubscription">The updates you want to apply to the organization's subscription.</param>
|
||||
public CompleteSubscriptionUpdate(
|
||||
Organization organization,
|
||||
SubscriptionData updatedSubscription)
|
||||
{
|
||||
_currentSubscription = GetSubscriptionDataFor(organization);
|
||||
_updatedSubscription = updatedSubscription;
|
||||
}
|
||||
|
||||
protected override List<string> PlanIds => new()
|
||||
{
|
||||
GetPasswordManagerPlanId(_updatedSubscription.Plan),
|
||||
_updatedSubscription.Plan.SecretsManager.StripeSeatPlanId,
|
||||
_updatedSubscription.Plan.SecretsManager.StripeServiceAccountPlanId,
|
||||
_updatedSubscription.Plan.PasswordManager.StripeStoragePlanId
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Generates the <see cref="SubscriptionItemOptions"/> necessary to revert an <see cref="Organization"/>'s
|
||||
/// <see cref="Subscription"/> upgrade in the case of an error.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
|
||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
||||
{
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>
|
||||
{
|
||||
GetPasswordManagerOptions(subscription, _updatedSubscription, _currentSubscription)
|
||||
};
|
||||
|
||||
if (_updatedSubscription.SubscribedToSecretsManager || _currentSubscription.SubscribedToSecretsManager)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _updatedSubscription, _currentSubscription));
|
||||
|
||||
if (_updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 ||
|
||||
_currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _updatedSubscription, _currentSubscription));
|
||||
}
|
||||
}
|
||||
|
||||
if (_updatedSubscription.PurchasedAdditionalStorage != 0 || _currentSubscription.PurchasedAdditionalStorage != 0)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetStorageOptions(subscription, _updatedSubscription, _currentSubscription));
|
||||
}
|
||||
|
||||
return subscriptionItemOptions;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is almost certainly overkill. If we trust the data in the Vault DB, we should just be able to
|
||||
* compare the _currentSubscription against the _updatedSubscription to see if there are any differences.
|
||||
* However, for the sake of ensuring we're checking against the Stripe subscription itself, I'll leave this
|
||||
* included for now.
|
||||
*/
|
||||
/// <summary>
|
||||
/// Checks whether the updates provided in the <see cref="CompleteSubscriptionUpdate"/>'s constructor
|
||||
/// are actually different than the organization's current <see cref="Subscription"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
|
||||
public override bool UpdateNeeded(Subscription subscription)
|
||||
{
|
||||
var upgradeItemsOptions = UpgradeItemsOptions(subscription);
|
||||
|
||||
foreach (var subscriptionItemOptions in upgradeItemsOptions)
|
||||
{
|
||||
var success = _subscriptionUpdateMap.TryGetValue(subscriptionItemOptions.Price, out var updateType);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var updateNeeded = updateType switch
|
||||
{
|
||||
SubscriptionUpdateType.PasswordManagerSeats => ContainsUpdatesBetween(
|
||||
GetPasswordManagerPlanId(_currentSubscription.Plan),
|
||||
subscriptionItemOptions),
|
||||
SubscriptionUpdateType.SecretsManagerSeats => ContainsUpdatesBetween(
|
||||
_currentSubscription.Plan.SecretsManager.StripeSeatPlanId,
|
||||
subscriptionItemOptions),
|
||||
SubscriptionUpdateType.SecretsManagerServiceAccounts => ContainsUpdatesBetween(
|
||||
_currentSubscription.Plan.SecretsManager.StripeServiceAccountPlanId,
|
||||
subscriptionItemOptions),
|
||||
SubscriptionUpdateType.Storage => ContainsUpdatesBetween(
|
||||
_currentSubscription.Plan.PasswordManager.StripeStoragePlanId,
|
||||
subscriptionItemOptions),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (updateNeeded)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
bool ContainsUpdatesBetween(string currentPlanId, SubscriptionItemOptions options)
|
||||
{
|
||||
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
|
||||
|
||||
return (subscriptionItem.Plan.Id != options.Plan && subscriptionItem.Price.Id != options.Plan) ||
|
||||
subscriptionItem.Quantity != options.Quantity ||
|
||||
subscriptionItem.Deleted != options.Deleted;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the <see cref="SubscriptionItemOptions"/> necessary to upgrade an <see cref="Organization"/>'s
|
||||
/// <see cref="Subscription"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>
|
||||
{
|
||||
GetPasswordManagerOptions(subscription, _currentSubscription, _updatedSubscription)
|
||||
};
|
||||
|
||||
if (_currentSubscription.SubscribedToSecretsManager || _updatedSubscription.SubscribedToSecretsManager)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _currentSubscription, _updatedSubscription));
|
||||
|
||||
if (_currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 ||
|
||||
_updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _currentSubscription, _updatedSubscription));
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentSubscription.PurchasedAdditionalStorage != 0 || _updatedSubscription.PurchasedAdditionalStorage != 0)
|
||||
{
|
||||
subscriptionItemOptions.Add(GetStorageOptions(subscription, _currentSubscription, _updatedSubscription));
|
||||
}
|
||||
|
||||
return subscriptionItemOptions;
|
||||
}
|
||||
|
||||
private SubscriptionItemOptions GetPasswordManagerOptions(
|
||||
Subscription subscription,
|
||||
SubscriptionData from,
|
||||
SubscriptionData to)
|
||||
{
|
||||
var currentPlanId = GetPasswordManagerPlanId(from.Plan);
|
||||
|
||||
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
|
||||
|
||||
if (subscriptionItem == null)
|
||||
{
|
||||
throw new GatewayException("Could not find Password Manager subscription");
|
||||
}
|
||||
|
||||
var updatedPlanId = GetPasswordManagerPlanId(to.Plan);
|
||||
|
||||
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.PasswordManagerSeats;
|
||||
|
||||
return new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = updatedPlanId,
|
||||
Quantity = IsNonSeatBasedPlan(to.Plan) ? 1 : to.PurchasedPasswordManagerSeats
|
||||
};
|
||||
}
|
||||
|
||||
private SubscriptionItemOptions GetSecretsManagerOptions(
|
||||
Subscription subscription,
|
||||
SubscriptionData from,
|
||||
SubscriptionData to)
|
||||
{
|
||||
var currentPlanId = from.Plan?.SecretsManager?.StripeSeatPlanId;
|
||||
|
||||
var subscriptionItem = !string.IsNullOrEmpty(currentPlanId)
|
||||
? FindSubscriptionItem(subscription, currentPlanId)
|
||||
: null;
|
||||
|
||||
var updatedPlanId = to.Plan.SecretsManager.StripeSeatPlanId;
|
||||
|
||||
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerSeats;
|
||||
|
||||
return new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem?.Id,
|
||||
Price = updatedPlanId,
|
||||
Quantity = to.PurchasedSecretsManagerSeats,
|
||||
Deleted = subscriptionItem?.Id != null && to.PurchasedSecretsManagerSeats == 0
|
||||
? true
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private SubscriptionItemOptions GetServiceAccountsOptions(
|
||||
Subscription subscription,
|
||||
SubscriptionData from,
|
||||
SubscriptionData to)
|
||||
{
|
||||
var currentPlanId = from.Plan?.SecretsManager?.StripeServiceAccountPlanId;
|
||||
|
||||
var subscriptionItem = !string.IsNullOrEmpty(currentPlanId)
|
||||
? FindSubscriptionItem(subscription, currentPlanId)
|
||||
: null;
|
||||
|
||||
var updatedPlanId = to.Plan.SecretsManager.StripeServiceAccountPlanId;
|
||||
|
||||
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerServiceAccounts;
|
||||
|
||||
return new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem?.Id,
|
||||
Price = updatedPlanId,
|
||||
Quantity = to.PurchasedAdditionalSecretsManagerServiceAccounts,
|
||||
Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalSecretsManagerServiceAccounts == 0
|
||||
? true
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private SubscriptionItemOptions GetStorageOptions(
|
||||
Subscription subscription,
|
||||
SubscriptionData from,
|
||||
SubscriptionData to)
|
||||
{
|
||||
var currentPlanId = from.Plan.PasswordManager.StripeStoragePlanId;
|
||||
|
||||
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
|
||||
|
||||
var updatedPlanId = to.Plan.PasswordManager.StripeStoragePlanId;
|
||||
|
||||
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.Storage;
|
||||
|
||||
return new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem?.Id,
|
||||
Price = updatedPlanId,
|
||||
Quantity = to.PurchasedAdditionalStorage,
|
||||
Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalStorage == 0
|
||||
? true
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(planId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = subscription.Items.Data;
|
||||
|
||||
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
|
||||
|
||||
return subscriptionItem;
|
||||
}
|
||||
|
||||
private static string GetPasswordManagerPlanId(StaticStore.Plan plan)
|
||||
=> IsNonSeatBasedPlan(plan)
|
||||
? plan.PasswordManager.StripePlanId
|
||||
: plan.PasswordManager.StripeSeatPlanId;
|
||||
|
||||
private static SubscriptionData GetSubscriptionDataFor(Organization organization)
|
||||
{
|
||||
var plan = Utilities.StaticStore.GetPlan(organization.PlanType);
|
||||
|
||||
return new SubscriptionData
|
||||
{
|
||||
Plan = plan,
|
||||
PurchasedPasswordManagerSeats = organization.Seats.HasValue
|
||||
? organization.Seats.Value - plan.PasswordManager.BaseSeats
|
||||
: 0,
|
||||
SubscribedToSecretsManager = organization.UseSecretsManager,
|
||||
PurchasedSecretsManagerSeats = plan.SecretsManager is not null
|
||||
? organization.SmSeats - plan.SecretsManager.BaseSeats
|
||||
: 0,
|
||||
PurchasedAdditionalSecretsManagerServiceAccounts = plan.SecretsManager is not null
|
||||
? organization.SmServiceAccounts - plan.SecretsManager.BaseServiceAccount
|
||||
: 0,
|
||||
PurchasedAdditionalStorage = organization.MaxStorageGb.HasValue
|
||||
? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) :
|
||||
0
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
||||
=> plan.Type is
|
||||
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
||||
or PlanType.FamiliesAnnually
|
||||
or PlanType.TeamsStarter;
|
||||
}
|
@ -53,6 +53,7 @@ public class OrganizationLicense : ILicense
|
||||
SmSeats = org.SmSeats;
|
||||
SmServiceAccounts = org.SmServiceAccounts;
|
||||
LimitCollectionCreationDeletion = org.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems;
|
||||
|
||||
if (subscriptionInfo?.Subscription == null)
|
||||
{
|
||||
@ -137,6 +138,7 @@ public class OrganizationLicense : ILicense
|
||||
public int? SmSeats { get; set; }
|
||||
public int? SmServiceAccounts { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; } = true;
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; } = true;
|
||||
public bool Trial { get; set; }
|
||||
public LicenseType? LicenseType { get; set; }
|
||||
public string Hash { get; set; }
|
||||
@ -148,10 +150,10 @@ public class OrganizationLicense : ILicense
|
||||
/// </summary>
|
||||
/// <remarks>Intentionally set one version behind to allow self hosted users some time to update before
|
||||
/// getting out of date license errors</remarks>
|
||||
public const int CurrentLicenseFileVersion = 13;
|
||||
public const int CurrentLicenseFileVersion = 14;
|
||||
private bool ValidLicenseVersion
|
||||
{
|
||||
get => Version is >= 1 and <= 14;
|
||||
get => Version is >= 1 and <= 15;
|
||||
}
|
||||
|
||||
public byte[] GetDataBytes(bool forHash = false)
|
||||
@ -194,6 +196,8 @@ public class OrganizationLicense : ILicense
|
||||
(Version >= 13 || !p.Name.Equals(nameof(SmServiceAccounts))) &&
|
||||
// LimitCollectionCreationDeletion was added in Version 14
|
||||
(Version >= 14 || !p.Name.Equals(nameof(LimitCollectionCreationDeletion))) &&
|
||||
// AllowAdminAccessToAllCollectionItems was added in Version 15
|
||||
(Version >= 15 || !p.Name.Equals(nameof(AllowAdminAccessToAllCollectionItems))) &&
|
||||
(
|
||||
!forHash ||
|
||||
(
|
||||
@ -347,6 +351,10 @@ public class OrganizationLicense : ILicense
|
||||
// {
|
||||
// valid = organization.LimitCollectionCreationDeletion == LimitCollectionCreationDeletion;
|
||||
// }
|
||||
// if (valid && Version >= 15)
|
||||
// {
|
||||
// valid = organization.AllowAdminAccessToAllCollectionItems == AllowAdminAccessToAllCollectionItems;
|
||||
// }
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ public class SubscriptionInfo
|
||||
public BillingCustomerDiscount CustomerDiscount { get; set; }
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public BillingUpcomingInvoice UpcomingInvoice { get; set; }
|
||||
public bool UsingInAppPurchase { get; set; }
|
||||
|
||||
public class BillingCustomerDiscount
|
||||
{
|
||||
|
@ -9,7 +9,7 @@ public abstract class SubscriptionUpdate
|
||||
public abstract List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription);
|
||||
public abstract List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription);
|
||||
|
||||
public bool UpdateNeeded(Subscription subscription)
|
||||
public virtual bool UpdateNeeded(Subscription subscription)
|
||||
{
|
||||
var upgradeItemsOptions = UpgradeItemsOptions(subscription);
|
||||
foreach (var upgradeItemOptions in upgradeItemsOptions)
|
||||
|
@ -31,8 +31,8 @@ public record Enterprise2019Plan : Models.StaticStore.Plan
|
||||
UsersGetPremium = true;
|
||||
HasCustomPermissions = true;
|
||||
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
UpgradeSortOrder = 4;
|
||||
DisplaySortOrder = 4;
|
||||
LegacyYear = 2020;
|
||||
|
||||
SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual);
|
||||
|
@ -31,8 +31,8 @@ public record Enterprise2020Plan : Models.StaticStore.Plan
|
||||
UsersGetPremium = true;
|
||||
HasCustomPermissions = true;
|
||||
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
UpgradeSortOrder = 4;
|
||||
DisplaySortOrder = 4;
|
||||
LegacyYear = 2023;
|
||||
|
||||
PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual);
|
||||
|
@ -31,8 +31,8 @@ public record EnterprisePlan : Models.StaticStore.Plan
|
||||
UsersGetPremium = true;
|
||||
HasCustomPermissions = true;
|
||||
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
UpgradeSortOrder = 4;
|
||||
DisplaySortOrder = 4;
|
||||
|
||||
PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual);
|
||||
SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual);
|
||||
|
@ -24,8 +24,8 @@ public record Teams2019Plan : Models.StaticStore.Plan
|
||||
HasApi = true;
|
||||
UsersGetPremium = true;
|
||||
|
||||
UpgradeSortOrder = 2;
|
||||
DisplaySortOrder = 2;
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
LegacyYear = 2020;
|
||||
|
||||
SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual);
|
||||
|
@ -24,8 +24,8 @@ public record Teams2020Plan : Models.StaticStore.Plan
|
||||
HasApi = true;
|
||||
UsersGetPremium = true;
|
||||
|
||||
UpgradeSortOrder = 2;
|
||||
DisplaySortOrder = 2;
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
LegacyYear = 2023;
|
||||
|
||||
PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual);
|
||||
|
@ -24,8 +24,8 @@ public record TeamsPlan : Models.StaticStore.Plan
|
||||
HasApi = true;
|
||||
UsersGetPremium = true;
|
||||
|
||||
UpgradeSortOrder = 2;
|
||||
DisplaySortOrder = 2;
|
||||
UpgradeSortOrder = 3;
|
||||
DisplaySortOrder = 3;
|
||||
|
||||
PasswordManager = new TeamsPasswordManagerFeatures(isAnnual);
|
||||
SecretsManager = new TeamsSecretsManagerFeatures(isAnnual);
|
||||
|
@ -64,6 +64,7 @@ public record TeamsStarterPlan : Plan
|
||||
HasAdditionalStorageOption = true;
|
||||
|
||||
StripePlanId = "teams-org-starter";
|
||||
StripeStoragePlanId = "storage-gb-monthly";
|
||||
AdditionalStoragePricePerGb = 0.5M;
|
||||
}
|
||||
}
|
||||
|
@ -65,9 +65,10 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
|
||||
private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHostedOrganizationDetails, OrganizationLicense license)
|
||||
{
|
||||
var flexibleCollectionsIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
var flexibleCollectionsMvpIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext);
|
||||
var organization = selfHostedOrganizationDetails.ToOrganization();
|
||||
organization.UpdateFromLicense(license, flexibleCollectionsIsEnabled);
|
||||
organization.UpdateFromLicense(license, flexibleCollectionsMvpIsEnabled, flexibleCollectionsV1IsEnabled);
|
||||
|
||||
await _organizationService.ReplaceAndUpdateCacheAsync(organization);
|
||||
}
|
||||
|
@ -97,11 +97,6 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
throw new BadRequestException("You cannot upgrade to this plan.");
|
||||
}
|
||||
|
||||
if (existingPlan.Type != PlanType.Free)
|
||||
{
|
||||
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
|
||||
}
|
||||
|
||||
_organizationService.ValidatePasswordManagerPlan(newPlan, upgrade);
|
||||
|
||||
if (upgrade.UseSecretsManager)
|
||||
@ -226,8 +221,16 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: Update existing sub
|
||||
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
|
||||
paymentIntentClientSecret = await _paymentService.AdjustSubscription(
|
||||
organization,
|
||||
newPlan,
|
||||
upgrade.AdditionalSeats,
|
||||
upgrade.UseSecretsManager,
|
||||
upgrade.AdditionalSmSeats,
|
||||
upgrade.AdditionalServiceAccounts,
|
||||
upgrade.AdditionalStorageGb);
|
||||
|
||||
success = string.IsNullOrEmpty(paymentIntentClientSecret);
|
||||
}
|
||||
|
||||
organization.BusinessName = upgrade.BusinessName;
|
||||
|
@ -1,10 +0,0 @@
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IMetaDataRepository
|
||||
{
|
||||
Task DeleteAsync(string objectName, string id);
|
||||
Task<IDictionary<string, string>> GetAsync(string objectName, string id);
|
||||
Task<string> GetAsync(string objectName, string id, string prop);
|
||||
Task UpsertAsync(string objectName, string id, IDictionary<string, string> dict);
|
||||
Task UpsertAsync(string objectName, string id, KeyValuePair<string, string> keyValuePair);
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
namespace Bit.Core.Repositories.Noop;
|
||||
|
||||
public class MetaDataRepository : IMetaDataRepository
|
||||
{
|
||||
public Task DeleteAsync(string objectName, string id)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IDictionary<string, string>> GetAsync(string objectName, string id)
|
||||
{
|
||||
return Task.FromResult(null as IDictionary<string, string>);
|
||||
}
|
||||
|
||||
public Task<string> GetAsync(string objectName, string id, string prop)
|
||||
{
|
||||
return Task.FromResult(null as string);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(string objectName, string id, IDictionary<string, string> dict)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(string objectName, string id, KeyValuePair<string, string> keyValuePair)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
using System.Net;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
|
||||
namespace Bit.Core.Repositories.TableStorage;
|
||||
|
||||
public class MetaDataRepository : IMetaDataRepository
|
||||
{
|
||||
private readonly CloudTable _table;
|
||||
|
||||
public MetaDataRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.Events.ConnectionString)
|
||||
{ }
|
||||
|
||||
public MetaDataRepository(string storageConnectionString)
|
||||
{
|
||||
var storageAccount = CloudStorageAccount.Parse(storageConnectionString);
|
||||
var tableClient = storageAccount.CreateCloudTableClient();
|
||||
_table = tableClient.GetTableReference("metadata");
|
||||
}
|
||||
|
||||
public async Task<IDictionary<string, string>> GetAsync(string objectName, string id)
|
||||
{
|
||||
var query = new TableQuery<DictionaryEntity>().Where(
|
||||
TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, $"{objectName}_{id}"));
|
||||
var queryResults = await _table.ExecuteQuerySegmentedAsync(query, null);
|
||||
return queryResults.Results.FirstOrDefault()?.ToDictionary(d => d.Key, d => d.Value.StringValue);
|
||||
}
|
||||
|
||||
public async Task<string> GetAsync(string objectName, string id, string prop)
|
||||
{
|
||||
var dict = await GetAsync(objectName, id);
|
||||
if (dict != null && dict.ContainsKey(prop))
|
||||
{
|
||||
return dict[prop];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(string objectName, string id, KeyValuePair<string, string> keyValuePair)
|
||||
{
|
||||
var query = new TableQuery<DictionaryEntity>().Where(
|
||||
TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, $"{objectName}_{id}"));
|
||||
var queryResults = await _table.ExecuteQuerySegmentedAsync(query, null);
|
||||
var entity = queryResults.Results.FirstOrDefault();
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new DictionaryEntity
|
||||
{
|
||||
PartitionKey = $"{objectName}_{id}",
|
||||
RowKey = string.Empty
|
||||
};
|
||||
}
|
||||
if (entity.ContainsKey(keyValuePair.Key))
|
||||
{
|
||||
entity.Remove(keyValuePair.Key);
|
||||
}
|
||||
entity.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
await _table.ExecuteAsync(TableOperation.InsertOrReplace(entity));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(string objectName, string id, IDictionary<string, string> dict)
|
||||
{
|
||||
var entity = new DictionaryEntity
|
||||
{
|
||||
PartitionKey = $"{objectName}_{id}",
|
||||
RowKey = string.Empty
|
||||
};
|
||||
foreach (var item in dict)
|
||||
{
|
||||
entity.Add(item.Key, item.Value);
|
||||
}
|
||||
await _table.ExecuteAsync(TableOperation.InsertOrReplace(entity));
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string objectName, string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _table.ExecuteAsync(TableOperation.Delete(new DictionaryEntity
|
||||
{
|
||||
PartitionKey = $"{objectName}_{id}",
|
||||
RowKey = string.Empty,
|
||||
ETag = "*"
|
||||
}));
|
||||
}
|
||||
catch (StorageException e) when (e.RequestInformation.HttpStatusCode != (int)HttpStatusCode.NotFound)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
using Bit.Billing.Models;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IAppleIapService
|
||||
{
|
||||
Task<AppleReceiptStatus> GetVerifiedReceiptStatusAsync(string receiptData);
|
||||
Task SaveReceiptAsync(AppleReceiptStatus receiptStatus, Guid userId);
|
||||
Task<Tuple<string, Guid?>> GetReceiptAsync(string originalTransactionId);
|
||||
}
|
@ -18,17 +18,25 @@ public interface IPaymentService
|
||||
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade);
|
||||
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
|
||||
short additionalStorageGb, TaxInfo taxInfo);
|
||||
Task<string> AdjustSubscription(
|
||||
Organization organization,
|
||||
Plan updatedPlan,
|
||||
int newlyPurchasedPasswordManagerSeats,
|
||||
bool subscribedToSecretsManager,
|
||||
int? newlyPurchasedSecretsManagerSeats,
|
||||
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
|
||||
int newlyPurchasedAdditionalStorage,
|
||||
DateTime? prorationDate = null);
|
||||
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
|
||||
|
||||
Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts,
|
||||
DateTime? prorationDate = null);
|
||||
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
|
||||
bool skipInAppPurchaseCheck = false);
|
||||
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false);
|
||||
Task ReinstateSubscriptionAsync(ISubscriber subscriber);
|
||||
Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
|
||||
string paymentToken, bool allowInAppPurchases = false, TaxInfo taxInfo = null);
|
||||
string paymentToken, TaxInfo taxInfo = null);
|
||||
Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount);
|
||||
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
|
||||
Task<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber);
|
||||
|
@ -54,11 +54,10 @@ public interface IUserService
|
||||
Task<Tuple<bool, string>> SignUpPremiumAsync(User user, string paymentToken,
|
||||
PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license,
|
||||
TaxInfo taxInfo);
|
||||
Task IapCheckAsync(User user, PaymentMethodType paymentMethodType);
|
||||
Task UpdateLicenseAsync(User user, UserLicense license);
|
||||
Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb);
|
||||
Task ReplacePaymentMethodAsync(User user, string paymentToken, PaymentMethodType paymentMethodType, TaxInfo taxInfo);
|
||||
Task CancelPremiumAsync(User user, bool? endOfPeriod = null, bool accountDelete = false);
|
||||
Task CancelPremiumAsync(User user, bool? endOfPeriod = null);
|
||||
Task ReinstatePremiumAsync(User user);
|
||||
Task EnablePremiumAsync(Guid userId, DateTime? expirationDate);
|
||||
Task EnablePremiumAsync(User user, DateTime? expirationDate);
|
||||
|
@ -1,132 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AppleIapService : IAppleIapService
|
||||
{
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IWebHostEnvironment _hostingEnvironment;
|
||||
private readonly IMetaDataRepository _metaDataRepository;
|
||||
private readonly ILogger<AppleIapService> _logger;
|
||||
|
||||
public AppleIapService(
|
||||
GlobalSettings globalSettings,
|
||||
IWebHostEnvironment hostingEnvironment,
|
||||
IMetaDataRepository metaDataRepository,
|
||||
ILogger<AppleIapService> logger)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_metaDataRepository = metaDataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AppleReceiptStatus> GetVerifiedReceiptStatusAsync(string receiptData)
|
||||
{
|
||||
var receiptStatus = await GetReceiptStatusAsync(receiptData);
|
||||
if (receiptStatus?.Status != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var validEnvironment = _globalSettings.AppleIap.AppInReview ||
|
||||
(!(_hostingEnvironment.IsProduction() || _hostingEnvironment.IsEnvironment("QA")) && receiptStatus.Environment == "Sandbox") ||
|
||||
((_hostingEnvironment.IsProduction() || _hostingEnvironment.IsEnvironment("QA")) && receiptStatus.Environment != "Sandbox");
|
||||
var validProductBundle = receiptStatus.Receipt.BundleId == "com.bitwarden.desktop" ||
|
||||
receiptStatus.Receipt.BundleId == "com.8bit.bitwarden";
|
||||
var validProduct = receiptStatus.LatestReceiptInfo.LastOrDefault()?.ProductId == "premium_annually";
|
||||
var validIds = receiptStatus.GetOriginalTransactionId() != null &&
|
||||
receiptStatus.GetLastTransactionId() != null;
|
||||
var validTransaction = receiptStatus.GetLastExpiresDate()
|
||||
.GetValueOrDefault(DateTime.MinValue) > DateTime.UtcNow;
|
||||
if (validEnvironment && validProductBundle && validProduct && validIds && validTransaction)
|
||||
{
|
||||
return receiptStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SaveReceiptAsync(AppleReceiptStatus receiptStatus, Guid userId)
|
||||
{
|
||||
var originalTransactionId = receiptStatus.GetOriginalTransactionId();
|
||||
if (string.IsNullOrWhiteSpace(originalTransactionId))
|
||||
{
|
||||
throw new Exception("OriginalTransactionId is null");
|
||||
}
|
||||
await _metaDataRepository.UpsertAsync("AppleReceipt", originalTransactionId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["Data"] = receiptStatus.GetReceiptData(),
|
||||
["UserId"] = userId.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Tuple<string, Guid?>> GetReceiptAsync(string originalTransactionId)
|
||||
{
|
||||
var receipt = await _metaDataRepository.GetAsync("AppleReceipt", originalTransactionId);
|
||||
if (receipt == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Tuple<string, Guid?>(receipt.ContainsKey("Data") ? receipt["Data"] : null,
|
||||
receipt.ContainsKey("UserId") ? new Guid(receipt["UserId"]) : (Guid?)null);
|
||||
}
|
||||
|
||||
// Internal for testing
|
||||
internal async Task<AppleReceiptStatus> GetReceiptStatusAsync(string receiptData, bool prod = true,
|
||||
int attempt = 0, AppleReceiptStatus lastReceiptStatus = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attempt > 4)
|
||||
{
|
||||
throw new Exception(
|
||||
$"Failed verifying Apple IAP after too many attempts. Last attempt status: {lastReceiptStatus?.Status.ToString() ?? "null"}");
|
||||
}
|
||||
|
||||
var url = string.Format("https://{0}.itunes.apple.com/verifyReceipt", prod ? "buy" : "sandbox");
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, new AppleVerifyReceiptRequestModel
|
||||
{
|
||||
ReceiptData = receiptData,
|
||||
Password = _globalSettings.AppleIap.Password
|
||||
});
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var receiptStatus = await response.Content.ReadFromJsonAsync<AppleReceiptStatus>();
|
||||
if (receiptStatus.Status == 21007)
|
||||
{
|
||||
return await GetReceiptStatusAsync(receiptData, false, attempt + 1, receiptStatus);
|
||||
}
|
||||
else if (receiptStatus.Status == 21005)
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
return await GetReceiptStatusAsync(receiptData, prod, attempt + 1, receiptStatus);
|
||||
}
|
||||
return receiptStatus;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Error verifying Apple IAP receipt.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class AppleVerifyReceiptRequestModel
|
||||
{
|
||||
[JsonPropertyName("receipt-data")]
|
||||
public string ReceiptData { get; set; }
|
||||
[JsonPropertyName("password")]
|
||||
public string Password { get; set; }
|
||||
}
|
@ -43,9 +43,6 @@ public class CollectionService : ICollectionService
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
|
||||
public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null,
|
||||
IEnumerable<CollectionAccessSelection> users = null)
|
||||
{
|
||||
@ -59,11 +56,11 @@ public class CollectionService : ICollectionService
|
||||
var usersList = users?.ToList();
|
||||
|
||||
// If using Flexible Collections - a collection should always have someone with Can Manage permissions
|
||||
if (UseFlexibleCollections)
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext))
|
||||
{
|
||||
var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false;
|
||||
var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false;
|
||||
if (!groupHasManageAccess && !userHasManageAccess)
|
||||
if (!groupHasManageAccess && !userHasManageAccess && !org.AllowAdminAccessToAllCollectionItems)
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"At least one member or group must have can manage permission.");
|
||||
@ -125,7 +122,10 @@ public class CollectionService : ICollectionService
|
||||
}
|
||||
else
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, UseFlexibleCollections);
|
||||
var collections = await _collectionRepository.GetManyByUserIdAsync(
|
||||
_currentContext.UserId.Value,
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext)
|
||||
);
|
||||
orgCollections = collections.Where(c => c.OrganizationId == organizationId);
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -16,14 +15,11 @@ namespace Bit.Core.Services;
|
||||
public class StripePaymentService : IPaymentService
|
||||
{
|
||||
private const string PremiumPlanId = "premium-annually";
|
||||
private const string PremiumPlanAppleIapId = "premium-annually-appleiap";
|
||||
private const decimal PremiumPlanAppleIapPrice = 14.99M;
|
||||
private const string StoragePlanId = "storage-gb-annually";
|
||||
private const string ProviderDiscountId = "msp-discount-35";
|
||||
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IAppleIapService _appleIapService;
|
||||
private readonly ILogger<StripePaymentService> _logger;
|
||||
private readonly Braintree.IBraintreeGateway _btGateway;
|
||||
private readonly ITaxRateRepository _taxRateRepository;
|
||||
@ -33,7 +29,6 @@ public class StripePaymentService : IPaymentService
|
||||
public StripePaymentService(
|
||||
ITransactionRepository transactionRepository,
|
||||
IUserRepository userRepository,
|
||||
IAppleIapService appleIapService,
|
||||
ILogger<StripePaymentService> logger,
|
||||
ITaxRateRepository taxRateRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@ -42,7 +37,6 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
_transactionRepository = transactionRepository;
|
||||
_userRepository = userRepository;
|
||||
_appleIapService = appleIapService;
|
||||
_logger = logger;
|
||||
_taxRateRepository = taxRateRepository;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
@ -345,21 +339,16 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
throw new BadRequestException("Your account does not have any credit available.");
|
||||
}
|
||||
if (paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.GoogleInApp)
|
||||
if (paymentMethodType is PaymentMethodType.BankAccount)
|
||||
{
|
||||
throw new GatewayException("Payment method is not supported at this time.");
|
||||
}
|
||||
if ((paymentMethodType == PaymentMethodType.GoogleInApp ||
|
||||
paymentMethodType == PaymentMethodType.AppleInApp) && additionalStorageGb > 0)
|
||||
{
|
||||
throw new BadRequestException("You cannot add storage with this payment method.");
|
||||
}
|
||||
|
||||
var createdStripeCustomer = false;
|
||||
Stripe.Customer customer = null;
|
||||
Braintree.Customer braintreeCustomer = null;
|
||||
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
|
||||
paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.Credit;
|
||||
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount
|
||||
or PaymentMethodType.Credit;
|
||||
|
||||
string stipeCustomerPaymentMethodId = null;
|
||||
string stipeCustomerSourceToken = null;
|
||||
@ -379,19 +368,9 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(paymentToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, true, taxInfo);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var message = e.Message.ToLowerInvariant();
|
||||
if (message.Contains("apple") || message.Contains("in-app"))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId);
|
||||
@ -425,18 +404,6 @@ public class StripePaymentService : IPaymentService
|
||||
braintreeCustomer = customerResult.Target;
|
||||
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
|
||||
}
|
||||
else if (paymentMethodType == PaymentMethodType.AppleInApp)
|
||||
{
|
||||
var verifiedReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
|
||||
if (verifiedReceiptStatus == null)
|
||||
{
|
||||
throw new GatewayException("Cannot verify apple in-app purchase.");
|
||||
}
|
||||
var receiptOriginalTransactionId = verifiedReceiptStatus.GetOriginalTransactionId();
|
||||
await VerifyAppleReceiptNotInUseAsync(receiptOriginalTransactionId, user);
|
||||
await _appleIapService.SaveReceiptAsync(verifiedReceiptStatus, user.Id);
|
||||
stripeCustomerMetadata.Add("appleReceipt", receiptOriginalTransactionId);
|
||||
}
|
||||
else if (!stripePaymentMethod)
|
||||
{
|
||||
throw new GatewayException("Payment method is not supported at this time.");
|
||||
@ -488,8 +455,8 @@ public class StripePaymentService : IPaymentService
|
||||
|
||||
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
|
||||
{
|
||||
Plan = paymentMethodType == PaymentMethodType.AppleInApp ? PremiumPlanAppleIapId : PremiumPlanId,
|
||||
Quantity = 1,
|
||||
Plan = PremiumPlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
|
||||
@ -547,7 +514,6 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
var addedCreditToStripeCustomer = false;
|
||||
Braintree.Transaction braintreeTransaction = null;
|
||||
Transaction appleTransaction = null;
|
||||
|
||||
var subInvoiceMetadata = new Dictionary<string, string>();
|
||||
Stripe.Subscription subscription = null;
|
||||
@ -564,39 +530,9 @@ public class StripePaymentService : IPaymentService
|
||||
|
||||
if (previewInvoice.AmountDue > 0)
|
||||
{
|
||||
var appleReceiptOrigTransactionId = customer.Metadata != null &&
|
||||
customer.Metadata.ContainsKey("appleReceipt") ? customer.Metadata["appleReceipt"] : null;
|
||||
var braintreeCustomerId = customer.Metadata != null &&
|
||||
customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null;
|
||||
if (!string.IsNullOrWhiteSpace(appleReceiptOrigTransactionId))
|
||||
{
|
||||
if (!subscriber.IsUser())
|
||||
{
|
||||
throw new GatewayException("In-app purchase is only allowed for users.");
|
||||
}
|
||||
|
||||
var appleReceipt = await _appleIapService.GetReceiptAsync(
|
||||
appleReceiptOrigTransactionId);
|
||||
var verifiedAppleReceipt = await _appleIapService.GetVerifiedReceiptStatusAsync(
|
||||
appleReceipt.Item1);
|
||||
if (verifiedAppleReceipt == null)
|
||||
{
|
||||
throw new GatewayException("Failed to get Apple in-app purchase receipt data.");
|
||||
}
|
||||
subInvoiceMetadata.Add("appleReceipt", verifiedAppleReceipt.GetOriginalTransactionId());
|
||||
var lastTransactionId = verifiedAppleReceipt.GetLastTransactionId();
|
||||
subInvoiceMetadata.Add("appleReceiptTransactionId", lastTransactionId);
|
||||
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.AppStore, lastTransactionId);
|
||||
if (existingTransaction == null)
|
||||
{
|
||||
appleTransaction = verifiedAppleReceipt.BuildTransactionFromLastTransaction(
|
||||
PremiumPlanAppleIapPrice, subscriber.Id);
|
||||
appleTransaction.Type = TransactionType.Charge;
|
||||
await _transactionRepository.CreateAsync(appleTransaction);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
|
||||
if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
|
||||
{
|
||||
var btInvoiceAmount = (previewInvoice.AmountDue / 100M);
|
||||
var transactionResult = await _btGateway.Transaction.SaleAsync(
|
||||
@ -712,10 +648,6 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
|
||||
}
|
||||
if (appleTransaction != null)
|
||||
{
|
||||
await _transactionRepository.DeleteAsync(appleTransaction);
|
||||
}
|
||||
|
||||
if (e is Stripe.StripeException strEx &&
|
||||
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
|
||||
@ -741,7 +673,6 @@ public class StripePaymentService : IPaymentService
|
||||
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate)
|
||||
{
|
||||
// remember, when in doubt, throw
|
||||
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId);
|
||||
if (sub == null)
|
||||
{
|
||||
@ -860,6 +791,30 @@ public class StripePaymentService : IPaymentService
|
||||
return paymentIntentClientSecret;
|
||||
}
|
||||
|
||||
public Task<string> AdjustSubscription(
|
||||
Organization organization,
|
||||
StaticStore.Plan updatedPlan,
|
||||
int newlyPurchasedPasswordManagerSeats,
|
||||
bool subscribedToSecretsManager,
|
||||
int? newlyPurchasedSecretsManagerSeats,
|
||||
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
|
||||
int newlyPurchasedAdditionalStorage,
|
||||
DateTime? prorationDate = null)
|
||||
=> FinalizeSubscriptionChangeAsync(
|
||||
organization,
|
||||
new CompleteSubscriptionUpdate(
|
||||
organization,
|
||||
new SubscriptionData
|
||||
{
|
||||
Plan = updatedPlan,
|
||||
PurchasedPasswordManagerSeats = newlyPurchasedPasswordManagerSeats,
|
||||
SubscribedToSecretsManager = subscribedToSecretsManager,
|
||||
PurchasedSecretsManagerSeats = newlyPurchasedSecretsManagerSeats,
|
||||
PurchasedAdditionalSecretsManagerServiceAccounts = newlyPurchasedAdditionalSecretsManagerServiceAccounts,
|
||||
PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage
|
||||
}),
|
||||
prorationDate);
|
||||
|
||||
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
|
||||
{
|
||||
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
|
||||
@ -942,12 +897,6 @@ public class StripePaymentService : IPaymentService
|
||||
customerOptions.AddExpand("default_source");
|
||||
customerOptions.AddExpand("invoice_settings.default_payment_method");
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
|
||||
var usingInAppPaymentMethod = customer.Metadata.ContainsKey("appleReceipt");
|
||||
if (usingInAppPaymentMethod)
|
||||
{
|
||||
throw new BadRequestException("Cannot perform this action with in-app purchase payment method. " +
|
||||
"Contact support.");
|
||||
}
|
||||
|
||||
string paymentIntentClientSecret = null;
|
||||
|
||||
@ -1105,8 +1054,7 @@ public class StripePaymentService : IPaymentService
|
||||
return paymentIntentClientSecret;
|
||||
}
|
||||
|
||||
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
|
||||
bool skipInAppPurchaseCheck = false)
|
||||
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
|
||||
{
|
||||
if (subscriber == null)
|
||||
{
|
||||
@ -1118,15 +1066,6 @@ public class StripePaymentService : IPaymentService
|
||||
throw new GatewayException("No subscription.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId) && !skipInAppPurchaseCheck)
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
||||
if (customer.Metadata.ContainsKey("appleReceipt"))
|
||||
{
|
||||
throw new BadRequestException("You are required to manage your subscription from the app store.");
|
||||
}
|
||||
}
|
||||
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
||||
if (sub == null)
|
||||
{
|
||||
@ -1193,7 +1132,7 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
|
||||
string paymentToken, bool allowInAppPurchases = false, TaxInfo taxInfo = null)
|
||||
string paymentToken, TaxInfo taxInfo = null)
|
||||
{
|
||||
if (subscriber == null)
|
||||
{
|
||||
@ -1207,7 +1146,6 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
|
||||
var createdCustomer = false;
|
||||
AppleReceiptStatus appleReceiptStatus = null;
|
||||
Braintree.Customer braintreeCustomer = null;
|
||||
string stipeCustomerSourceToken = null;
|
||||
string stipeCustomerPaymentMethodId = null;
|
||||
@ -1215,23 +1153,10 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
|
||||
};
|
||||
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
|
||||
paymentMethodType == PaymentMethodType.BankAccount;
|
||||
var inAppPurchase = paymentMethodType == PaymentMethodType.AppleInApp ||
|
||||
paymentMethodType == PaymentMethodType.GoogleInApp;
|
||||
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount;
|
||||
|
||||
Stripe.Customer customer = null;
|
||||
|
||||
if (!allowInAppPurchases && inAppPurchase)
|
||||
{
|
||||
throw new GatewayException("In-app purchase payment method is not allowed.");
|
||||
}
|
||||
|
||||
if (!subscriber.IsUser() && inAppPurchase)
|
||||
{
|
||||
throw new GatewayException("In-app purchase payment method is only allowed for users.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
{
|
||||
var options = new Stripe.CustomerGetOptions();
|
||||
@ -1243,16 +1168,6 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
if (inAppPurchase && customer != null && customer.Balance != 0)
|
||||
{
|
||||
throw new GatewayException("Customer balance cannot exist when using in-app purchases.");
|
||||
}
|
||||
|
||||
if (!inAppPurchase && customer != null && stripeCustomerMetadata.ContainsKey("appleReceipt"))
|
||||
{
|
||||
throw new GatewayException("Cannot change from in-app payment method. Contact support.");
|
||||
}
|
||||
|
||||
var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId");
|
||||
if (stripePaymentMethod)
|
||||
{
|
||||
@ -1322,15 +1237,6 @@ public class StripePaymentService : IPaymentService
|
||||
braintreeCustomer = customerResult.Target;
|
||||
}
|
||||
}
|
||||
else if (paymentMethodType == PaymentMethodType.AppleInApp)
|
||||
{
|
||||
appleReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
|
||||
if (appleReceiptStatus == null)
|
||||
{
|
||||
throw new GatewayException("Cannot verify Apple in-app purchase.");
|
||||
}
|
||||
await VerifyAppleReceiptNotInUseAsync(appleReceiptStatus.GetOriginalTransactionId(), subscriber);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new GatewayException("Payment method is not supported at this time.");
|
||||
@ -1350,25 +1256,6 @@ public class StripePaymentService : IPaymentService
|
||||
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
|
||||
}
|
||||
|
||||
if (appleReceiptStatus != null)
|
||||
{
|
||||
var originalTransactionId = appleReceiptStatus.GetOriginalTransactionId();
|
||||
if (stripeCustomerMetadata.ContainsKey("appleReceipt"))
|
||||
{
|
||||
if (originalTransactionId != stripeCustomerMetadata["appleReceipt"])
|
||||
{
|
||||
var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow);
|
||||
stripeCustomerMetadata.Add($"appleReceipt_{nowSec}", stripeCustomerMetadata["appleReceipt"]);
|
||||
}
|
||||
stripeCustomerMetadata["appleReceipt"] = originalTransactionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
stripeCustomerMetadata.Add("appleReceipt", originalTransactionId);
|
||||
}
|
||||
await _appleIapService.SaveReceiptAsync(appleReceiptStatus, subscriber.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (customer == null)
|
||||
@ -1572,11 +1459,6 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount);
|
||||
}
|
||||
|
||||
if (subscriber.IsUser())
|
||||
{
|
||||
subscriptionInfo.UsingInAppPurchase = customer.Metadata.ContainsKey("appleReceipt");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
||||
@ -1739,19 +1621,6 @@ public class StripePaymentService : IPaymentService
|
||||
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault();
|
||||
}
|
||||
|
||||
private async Task VerifyAppleReceiptNotInUseAsync(string receiptOriginalTransactionId, ISubscriber subscriber)
|
||||
{
|
||||
var existingReceipt = await _appleIapService.GetReceiptAsync(receiptOriginalTransactionId);
|
||||
if (existingReceipt != null && existingReceipt.Item2.HasValue && existingReceipt.Item2 != subscriber.Id)
|
||||
{
|
||||
var existingUser = await _userRepository.GetByIdAsync(existingReceipt.Item2.Value);
|
||||
if (existingUser != null)
|
||||
{
|
||||
throw new GatewayException("Apple receipt already in use by another user.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private decimal GetBillingBalance(Stripe.Customer customer)
|
||||
{
|
||||
return customer != null ? customer.Balance / 100M : default;
|
||||
@ -1764,14 +1633,6 @@ public class StripePaymentService : IPaymentService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customer.Metadata?.ContainsKey("appleReceipt") ?? false)
|
||||
{
|
||||
return new BillingInfo.BillingSource
|
||||
{
|
||||
Type = PaymentMethodType.AppleInApp
|
||||
};
|
||||
}
|
||||
|
||||
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
|
||||
{
|
||||
try
|
||||
|
@ -255,7 +255,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
await CancelPremiumAsync(user, null, true);
|
||||
await CancelPremiumAsync(user);
|
||||
}
|
||||
catch (GatewayException) { }
|
||||
}
|
||||
@ -973,12 +973,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
throw new BadRequestException("You can't subtract storage!");
|
||||
}
|
||||
|
||||
if ((paymentMethodType == PaymentMethodType.GoogleInApp ||
|
||||
paymentMethodType == PaymentMethodType.AppleInApp) && additionalStorageGb > 0)
|
||||
{
|
||||
throw new BadRequestException("You cannot add storage with this payment method.");
|
||||
}
|
||||
|
||||
string paymentIntentClientSecret = null;
|
||||
IPaymentService paymentService = null;
|
||||
if (_globalSettings.SelfHosted)
|
||||
@ -1039,29 +1033,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
paymentIntentClientSecret);
|
||||
}
|
||||
|
||||
public async Task IapCheckAsync(User user, PaymentMethodType paymentMethodType)
|
||||
{
|
||||
if (paymentMethodType != PaymentMethodType.AppleInApp)
|
||||
{
|
||||
throw new BadRequestException("Payment method not supported for in-app purchases.");
|
||||
}
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
throw new BadRequestException("Already a premium user.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.GatewayCustomerId))
|
||||
{
|
||||
var customerService = new Stripe.CustomerService();
|
||||
var customer = await customerService.GetAsync(user.GatewayCustomerId);
|
||||
if (customer != null && customer.Balance != 0)
|
||||
{
|
||||
throw new BadRequestException("Customer balance cannot exist when using in-app purchases.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateLicenseAsync(User user, UserLicense license)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted)
|
||||
@ -1136,7 +1107,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null, bool accountDelete = false)
|
||||
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null)
|
||||
{
|
||||
var eop = endOfPeriod.GetValueOrDefault(true);
|
||||
if (!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue &&
|
||||
@ -1144,11 +1115,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
{
|
||||
eop = false;
|
||||
}
|
||||
await _paymentService.CancelSubscriptionAsync(user, eop, accountDelete);
|
||||
await _paymentService.CancelSubscriptionAsync(user, eop);
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.CancelSubscription, user, _currentContext)
|
||||
{
|
||||
EndOfPeriod = eop,
|
||||
EndOfPeriod = eop
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -327,6 +327,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public string CertificateThumbprint { get; set; }
|
||||
public string CertificatePassword { get; set; }
|
||||
public string RedisConnectionString { get; set; }
|
||||
public string CosmosConnectionString { get; set; }
|
||||
public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzAxODIwODAwLCJleHAiOjE3MzM0NDMyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNDMxOSIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.iLA771PffgIh0ClRS8OWHbg2cAgjhgOkUjRRkLNr9dpQXhYZkVKdpUn-Gw9T7grsGcAx0f4p-TQmtcCpbN9EJCF5jlF0-NfsRTp_gmCgQ5eXyiE4DzJp2OCrz_3STf07N1dILwhD3nk9rzcA6SRQ4_kja8wAMHKnD5LisW98r5DfRDBecRs16KS5HUhg99DRMR5fd9ntfydVMTC_E23eEOHVLsR4YhiSXaEINPjFDG1czyOBClJItDW8g9X8qlClZegr630UjnKKg06A4usoL25VFHHn8Ew3v-_-XdlWoWsIpMMVvacwZT8rwkxjIesFNsXG6yzuROIhaxAvB1297A";
|
||||
}
|
||||
|
||||
|
@ -338,16 +338,50 @@ public static class CoreHelpers
|
||||
return Encoding.UTF8.GetString(Base64UrlDecode(input));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a Base64 URL formatted string.
|
||||
/// </summary>
|
||||
/// <param name="input">Byte data</param>
|
||||
/// <returns>Base64 URL formatted string</returns>
|
||||
public static string Base64UrlEncode(byte[] input)
|
||||
{
|
||||
var output = Convert.ToBase64String(input)
|
||||
// Standard base64 encoder
|
||||
var standardB64 = Convert.ToBase64String(input);
|
||||
return TransformToBase64Url(standardB64);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a Base64 standard formatted string to a Base64 URL formatted string.
|
||||
/// </summary>
|
||||
/// <param name="input">Base64 standard formatted string</param>
|
||||
/// <returns>Base64 URL formatted string</returns>
|
||||
public static string TransformToBase64Url(string input)
|
||||
{
|
||||
var output = input
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.Replace("=", string.Empty);
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a Base64 URL formatted string.
|
||||
/// </summary>
|
||||
/// <param name="input">Base64 URL formatted string</param>
|
||||
/// <returns>Data as bytes</returns>
|
||||
public static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var standardB64 = TransformFromBase64Url(input);
|
||||
// Standard base64 decoder
|
||||
return Convert.FromBase64String(standardB64);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a Base64 URL formatted string to a Base64 standard formatted string.
|
||||
/// </summary>
|
||||
/// <param name="input">Base64 URL formatted string</param>
|
||||
/// <returns>Base64 standard formatted string</returns>
|
||||
public static string TransformFromBase64Url(string input)
|
||||
{
|
||||
var output = input;
|
||||
// 62nd char of encoding
|
||||
@ -370,8 +404,8 @@ public static class CoreHelpers
|
||||
throw new InvalidOperationException("Illegal base64url string!");
|
||||
}
|
||||
|
||||
// Standard base64 decoder
|
||||
return Convert.FromBase64String(output);
|
||||
// Standard base64 string output
|
||||
return output;
|
||||
}
|
||||
|
||||
public static string PunyEncode(string text)
|
||||
|
40
src/Core/Utilities/SystemTextJsonCosmosSerializer.cs
Normal file
40
src/Core/Utilities/SystemTextJsonCosmosSerializer.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
|
||||
// ref: https://github.com/Azure/azure-cosmos-dotnet-v3/blob/master/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs
|
||||
public class SystemTextJsonCosmosSerializer : CosmosSerializer
|
||||
{
|
||||
private readonly JsonObjectSerializer _systemTextJsonSerializer;
|
||||
|
||||
public SystemTextJsonCosmosSerializer(JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
_systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override T FromStream<T>(Stream stream)
|
||||
{
|
||||
using (stream)
|
||||
{
|
||||
if (stream.CanSeek && stream.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (typeof(Stream).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
return (T)(object)stream;
|
||||
}
|
||||
return (T)_systemTextJsonSerializer.Deserialize(stream, typeof(T), default);
|
||||
}
|
||||
}
|
||||
|
||||
public override Stream ToStream<T>(T input)
|
||||
{
|
||||
var streamPayload = new MemoryStream();
|
||||
_systemTextJsonSerializer.Serialize(streamPayload, input, input.GetType(), default);
|
||||
streamPayload.Position = 0;
|
||||
return streamPayload;
|
||||
}
|
||||
}
|
@ -1,18 +1,24 @@
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Grant = Bit.Core.Auth.Entities.Grant;
|
||||
|
||||
namespace Bit.Identity.IdentityServer;
|
||||
|
||||
public class PersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private readonly IGrantRepository _grantRepository;
|
||||
private readonly Func<PersistedGrant, IGrant> _toGrant;
|
||||
private readonly IPersistedGrantStore _fallbackGrantStore;
|
||||
|
||||
public PersistedGrantStore(
|
||||
IGrantRepository grantRepository)
|
||||
IGrantRepository grantRepository,
|
||||
Func<PersistedGrant, IGrant> toGrant,
|
||||
IPersistedGrantStore fallbackGrantStore = null)
|
||||
{
|
||||
_grantRepository = grantRepository;
|
||||
_toGrant = toGrant;
|
||||
_fallbackGrantStore = fallbackGrantStore;
|
||||
}
|
||||
|
||||
public async Task<PersistedGrant> GetAsync(string key)
|
||||
@ -20,6 +26,11 @@ public class PersistedGrantStore : IPersistedGrantStore
|
||||
var grant = await _grantRepository.GetByKeyAsync(key);
|
||||
if (grant == null)
|
||||
{
|
||||
if (_fallbackGrantStore != null)
|
||||
{
|
||||
// It wasn't found, there is a chance is was instead stored in the fallback store
|
||||
return await _fallbackGrantStore.GetAsync(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -47,28 +58,11 @@ public class PersistedGrantStore : IPersistedGrantStore
|
||||
|
||||
public async Task StoreAsync(PersistedGrant pGrant)
|
||||
{
|
||||
var grant = ToGrant(pGrant);
|
||||
var grant = _toGrant(pGrant);
|
||||
await _grantRepository.SaveAsync(grant);
|
||||
}
|
||||
|
||||
private Grant ToGrant(PersistedGrant pGrant)
|
||||
{
|
||||
return new Grant
|
||||
{
|
||||
Key = pGrant.Key,
|
||||
Type = pGrant.Type,
|
||||
SubjectId = pGrant.SubjectId,
|
||||
SessionId = pGrant.SessionId,
|
||||
ClientId = pGrant.ClientId,
|
||||
Description = pGrant.Description,
|
||||
CreationDate = pGrant.CreationTime,
|
||||
ExpirationDate = pGrant.Expiration,
|
||||
ConsumedDate = pGrant.ConsumedTime,
|
||||
Data = pGrant.Data
|
||||
};
|
||||
}
|
||||
|
||||
private PersistedGrant ToPersistedGrant(Grant grant)
|
||||
private PersistedGrant ToPersistedGrant(IGrant grant)
|
||||
{
|
||||
return new PersistedGrant
|
||||
{
|
||||
|
@ -1,12 +1,10 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Identity.Utilities;
|
||||
|
||||
namespace Bit.Identity.IdentityServer;
|
||||
@ -20,7 +18,6 @@ namespace Bit.Identity.IdentityServer;
|
||||
public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
@ -31,13 +28,11 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
|
||||
public UserDecryptionOptionsBuilder(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IDeviceRepository deviceRepository,
|
||||
IOrganizationUserRepository organizationUserRepository
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_deviceRepository = deviceRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
}
|
||||
@ -95,7 +90,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
private async Task BuildTrustedDeviceOptions()
|
||||
{
|
||||
// TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change
|
||||
if (_ssoConfig == null || !_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext))
|
||||
if (_ssoConfig == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
@ -213,7 +213,11 @@ public class Startup
|
||||
app.UseRouting();
|
||||
|
||||
// Add Cors
|
||||
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
|
||||
app.UseCors(policy => policy.SetIsOriginAllowed(o =>
|
||||
CoreHelpers.IsCorsOriginAllowed(o, globalSettings) ||
|
||||
|
||||
// If development - allow requests from the Swagger UI so it can authorize
|
||||
(Environment.IsDevelopment() && o == globalSettings.BaseServiceUri.Api))
|
||||
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
|
||||
|
||||
// Add current context
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.IdentityServer;
|
||||
@ -51,31 +52,58 @@ public static class ServiceCollectionExtensions
|
||||
.AddIdentityServerCertificate(env, globalSettings)
|
||||
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.RedisConnectionString))
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
|
||||
{
|
||||
// If we have redis, prefer it
|
||||
|
||||
// Add the original persisted grant store via it's implementation type
|
||||
// so we can inject it right after.
|
||||
services.AddSingleton<PersistedGrantStore>();
|
||||
|
||||
services.AddSingleton<IPersistedGrantStore>(sp =>
|
||||
services.AddSingleton<IPersistedGrantStore>(sp => BuildCosmosGrantStore(sp, globalSettings));
|
||||
}
|
||||
else if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.RedisConnectionString))
|
||||
{
|
||||
return new RedisPersistedGrantStore(
|
||||
// TODO: .NET 8 create a keyed service for this connection multiplexer and even PersistedGrantStore
|
||||
ConnectionMultiplexer.Connect(globalSettings.IdentityServer.RedisConnectionString),
|
||||
sp.GetRequiredService<ILogger<RedisPersistedGrantStore>>(),
|
||||
sp.GetRequiredService<PersistedGrantStore>() // Fallback grant store
|
||||
);
|
||||
});
|
||||
services.AddSingleton<IPersistedGrantStore>(sp => BuildRedisGrantStore(sp, globalSettings));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the original grant store
|
||||
identityServerBuilder.AddPersistedGrantStore<PersistedGrantStore>();
|
||||
services.AddTransient<IPersistedGrantStore>(sp => BuildSqlGrantStore(sp));
|
||||
}
|
||||
|
||||
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
|
||||
return identityServerBuilder;
|
||||
}
|
||||
|
||||
private static PersistedGrantStore BuildCosmosGrantStore(IServiceProvider sp, GlobalSettings globalSettings)
|
||||
{
|
||||
if (!CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
|
||||
{
|
||||
throw new ArgumentException("No cosmos config string available.");
|
||||
}
|
||||
return new PersistedGrantStore(
|
||||
// TODO: Perhaps we want to evaluate moving this repo to DI as a keyed service singleton in .NET 8
|
||||
new Core.Auth.Repositories.Cosmos.GrantRepository(globalSettings),
|
||||
g => new Core.Auth.Models.Data.GrantItem(g),
|
||||
fallbackGrantStore: BuildRedisGrantStore(sp, globalSettings, true));
|
||||
}
|
||||
|
||||
private static RedisPersistedGrantStore BuildRedisGrantStore(IServiceProvider sp,
|
||||
GlobalSettings globalSettings, bool allowNull = false)
|
||||
{
|
||||
if (!CoreHelpers.SettingHasValue(globalSettings.IdentityServer.RedisConnectionString))
|
||||
{
|
||||
if (allowNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
throw new ArgumentException("No redis config string available.");
|
||||
}
|
||||
|
||||
return new RedisPersistedGrantStore(
|
||||
// TODO: .NET 8 create a keyed service for this connection multiplexer and even PersistedGrantStore
|
||||
ConnectionMultiplexer.Connect(globalSettings.IdentityServer.RedisConnectionString),
|
||||
sp.GetRequiredService<ILogger<RedisPersistedGrantStore>>(),
|
||||
fallbackGrantStore: BuildSqlGrantStore(sp));
|
||||
}
|
||||
|
||||
private static PersistedGrantStore BuildSqlGrantStore(IServiceProvider sp)
|
||||
{
|
||||
return new PersistedGrantStore(sp.GetRequiredService<IGrantRepository>(),
|
||||
g => new Core.Auth.Entities.Grant(g));
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Data;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
@ -18,7 +19,7 @@ public class GrantRepository : BaseRepository, IGrantRepository
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<Grant> GetByKeyAsync(string key)
|
||||
public async Task<IGrant> GetByKeyAsync(string key)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
@ -31,7 +32,7 @@ public class GrantRepository : BaseRepository, IGrantRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<Grant>> GetManyAsync(string subjectId, string sessionId,
|
||||
public async Task<ICollection<IGrant>> GetManyAsync(string subjectId, string sessionId,
|
||||
string clientId, string type)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
@ -41,17 +42,34 @@ public class GrantRepository : BaseRepository, IGrantRepository
|
||||
new { SubjectId = subjectId, SessionId = sessionId, ClientId = clientId, Type = type },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
return results.ToList<IGrant>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Grant obj)
|
||||
public async Task SaveAsync(IGrant obj)
|
||||
{
|
||||
if (obj is not Grant gObj)
|
||||
{
|
||||
throw new ArgumentException(null, nameof(obj));
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
"[dbo].[Grant_Save]",
|
||||
obj,
|
||||
new
|
||||
{
|
||||
obj.Key,
|
||||
obj.Type,
|
||||
obj.SubjectId,
|
||||
obj.SessionId,
|
||||
obj.ClientId,
|
||||
obj.Description,
|
||||
obj.CreationDate,
|
||||
obj.ExpirationDate,
|
||||
obj.ConsumedDate,
|
||||
obj.Data
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
|
||||
|
||||
public class OrganizationEntityTypeConfiguration : IEntityTypeConfiguration<Organization>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Organization> builder)
|
||||
{
|
||||
builder
|
||||
.Property(o => o.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.LimitCollectionCreationDeletion)
|
||||
.ValueGeneratedNever()
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Property(c => c.AllowAdminAccessToAllCollectionItems)
|
||||
.ValueGeneratedNever()
|
||||
.HasDefaultValue(true);
|
||||
|
||||
NpgsqlIndexBuilderExtensions.IncludeProperties(
|
||||
builder.HasIndex(o => new { o.Id, o.Enabled }),
|
||||
o => o.UseTotp);
|
||||
|
||||
builder.ToTable(nameof(Organization));
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
|
||||
|
||||
public class PolicyEntityTypeConfiguration : IEntityTypeConfiguration<Policy>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Policy> builder)
|
||||
{
|
||||
builder
|
||||
.Property(p => p.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(p => p.OrganizationId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(p => new { p.OrganizationId, p.Type })
|
||||
.IsUnique()
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(Policy));
|
||||
}
|
||||
}
|
@ -88,7 +88,10 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
UseResetPassword = e.UseResetPassword,
|
||||
UseScim = e.UseScim,
|
||||
UseCustomPermissions = e.UseCustomPermissions,
|
||||
UsePolicies = e.UsePolicies
|
||||
UsePolicies = e.UsePolicies,
|
||||
LimitCollectionCreationDeletion = e.LimitCollectionCreationDeletion,
|
||||
AllowAdminAccessToAllCollectionItems = e.AllowAdminAccessToAllCollectionItems,
|
||||
FlexibleCollections = e.FlexibleCollections
|
||||
}).ToListAsync();
|
||||
}
|
||||
}
|
||||
@ -157,6 +160,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
await deleteCiphersTransaction.CommitAsync();
|
||||
|
||||
var organizationDeleteTransaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await dbContext.AuthRequests.Where(ar => ar.OrganizationId == organization.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
await dbContext.SsoUsers.Where(su => su.OrganizationId == organization.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
await dbContext.SsoConfigs.Where(sc => sc.OrganizationId == organization.Id)
|
||||
|
@ -0,0 +1,25 @@
|
||||
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Auth.Configurations;
|
||||
|
||||
public class GrantEntityTypeConfiguration : IEntityTypeConfiguration<Grant>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Grant> builder)
|
||||
{
|
||||
builder
|
||||
.HasKey(s => s.Id)
|
||||
.IsClustered();
|
||||
|
||||
builder
|
||||
.Property(s => s.Id)
|
||||
.UseIdentityColumn();
|
||||
|
||||
builder
|
||||
.HasIndex(s => s.Key)
|
||||
.IsUnique(true);
|
||||
|
||||
builder.ToTable(nameof(Grant));
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
@ -42,7 +43,7 @@ public class GrantRepository : BaseEntityFrameworkRepository, IGrantRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Core.Auth.Entities.Grant> GetByKeyAsync(string key)
|
||||
public async Task<IGrant> GetByKeyAsync(string key)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
@ -55,7 +56,7 @@ public class GrantRepository : BaseEntityFrameworkRepository, IGrantRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<Core.Auth.Entities.Grant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type)
|
||||
public async Task<ICollection<IGrant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
@ -67,25 +68,31 @@ public class GrantRepository : BaseEntityFrameworkRepository, IGrantRepository
|
||||
g.Type == type
|
||||
select g;
|
||||
var grants = await query.ToListAsync();
|
||||
return (ICollection<Core.Auth.Entities.Grant>)grants;
|
||||
return (ICollection<IGrant>)grants;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Core.Auth.Entities.Grant obj)
|
||||
public async Task SaveAsync(IGrant obj)
|
||||
{
|
||||
if (obj is not Core.Auth.Entities.Grant gObj)
|
||||
{
|
||||
throw new ArgumentException(null, nameof(obj));
|
||||
}
|
||||
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var existingGrant = await (from g in dbContext.Grants
|
||||
where g.Key == obj.Key
|
||||
where g.Key == gObj.Key
|
||||
select g).FirstOrDefaultAsync();
|
||||
if (existingGrant != null)
|
||||
{
|
||||
dbContext.Entry(existingGrant).CurrentValues.SetValues(obj);
|
||||
gObj.Id = existingGrant.Id;
|
||||
dbContext.Entry(existingGrant).CurrentValues.SetValues(gObj);
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = Mapper.Map<Grant>(obj);
|
||||
var entity = Mapper.Map<Grant>(gObj);
|
||||
await dbContext.AddAsync(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class DeviceEntityTypeConfiguration : IEntityTypeConfiguration<Device>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Device> builder)
|
||||
{
|
||||
builder
|
||||
.HasIndex(d => d.UserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(d => new { d.UserId, d.Identifier })
|
||||
.IsUnique()
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(d => d.Identifier)
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(Device));
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class EventEntityTypeConfiguration : IEntityTypeConfiguration<Event>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Event> builder)
|
||||
{
|
||||
builder
|
||||
.Property(e => e.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId })
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(Event));
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class OrganizationSponsorshipEntityTypeConfiguration : IEntityTypeConfiguration<OrganizationSponsorship>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OrganizationSponsorship> builder)
|
||||
{
|
||||
builder
|
||||
.Property(o => o.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(o => o.SponsoringOrganizationUserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(OrganizationSponsorship));
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class OrganizationUserEntityTypeConfiguration : IEntityTypeConfiguration<OrganizationUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OrganizationUser> builder)
|
||||
{
|
||||
builder
|
||||
.Property(ou => ou.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
NpgsqlIndexBuilderExtensions.IncludeProperties(
|
||||
builder.HasIndex(ou => new { ou.UserId, ou.OrganizationId, ou.Status }).IsClustered(false),
|
||||
ou => ou.AccessAll);
|
||||
|
||||
builder
|
||||
.HasIndex(ou => ou.OrganizationId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(ou => ou.UserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(OrganizationUser));
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class TransactionEntityTypeConfiguration : IEntityTypeConfiguration<Transaction>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Transaction> builder)
|
||||
{
|
||||
builder
|
||||
.Property(t => t.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(t => t.UserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(t => new { t.UserId, t.OrganizationId, t.CreationDate })
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(Transaction));
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> builder)
|
||||
{
|
||||
builder
|
||||
.Property(u => u.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(u => u.Email)
|
||||
.IsUnique()
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(u => new { u.Premium, u.PremiumExpirationDate, u.RenewalReminderDate })
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(User));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user