1
0
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:
Rui Tome
2024-01-12 12:02:14 +00:00
166 changed files with 33487 additions and 1600 deletions

View File

@ -4,27 +4,11 @@
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "6.5.0", "version": "6.5.0",
"commands": [ "commands": ["swagger"]
"swagger"
]
},
"coverlet.console": {
"version": "3.1.2",
"commands": [
"coverlet"
]
},
"dotnet-reportgenerator-globaltool": {
"version": "5.1.6",
"commands": [
"reportgenerator"
]
}, },
"dotnet-ef": { "dotnet-ef": {
"version": "7.0.14", "version": "7.0.14",
"commands": [ "commands": ["dotnet-ef"]
"dotnet-ef"
]
} }
} }
} }

5
.github/CODEOWNERS vendored
View File

@ -14,7 +14,12 @@
# Database Operations for database changes # Database Operations for database changes
src/Sql/** @bitwarden/dept-dbops src/Sql/** @bitwarden/dept-dbops
util/EfShared/** @bitwarden/dept-dbops
util/Migrator/** @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 team
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev

View File

@ -37,6 +37,10 @@
"matchManagers": ["github-actions"], "matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"] "matchUpdateTypes": ["minor", "patch"]
}, },
{
"matchManagers": ["github-actions", "dockerfile", "docker-compose"],
"commitMessagePrefix": "[deps] DevOps:"
},
{ {
"matchPackageNames": ["DnsClient", "Quartz"], "matchPackageNames": ["DnsClient", "Quartz"],
"description": "Admin Console owned dependencies", "description": "Admin Console owned dependencies",
@ -59,8 +63,7 @@
"Azure.Storage.Blobs", "Azure.Storage.Blobs",
"Azure.Storage.Queues", "Azure.Storage.Queues",
"Fido2.AspNet", "Fido2.AspNet",
"IdentityServer4", "Duende.IdentityServer",
"IdentityServer4.AccessTokenValidation",
"Microsoft.Azure.Cosmos", "Microsoft.Azure.Cosmos",
"Microsoft.Azure.Cosmos.Table", "Microsoft.Azure.Cosmos.Table",
"Microsoft.Extensions.Caching.StackExchangeRedis", "Microsoft.Extensions.Caching.StackExchangeRedis",

View File

@ -61,28 +61,14 @@ jobs:
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
- name: Restore
run: dotnet restore --locked-mode
shell: pwsh
- name: Remove SQL proj - name: Remove SQL proj
run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj 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 - name: Test OSS solution
run: dotnet test ./test --configuration Debug --no-build --logger "trx;LogFileName=oss-test-results.trx" run: dotnet test ./test --configuration Release --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
shell: pwsh
- name: Test Bitwarden solution - name: Test Bitwarden solution
run: dotnet test ./bitwarden_license/test --configuration Debug --no-build --logger "trx;LogFileName=bw-test-results.trx" run: dotnet test ./bitwarden_license/test --configuration Release --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
shell: pwsh
- name: Report test results - name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
@ -93,6 +79,11 @@ jobs:
reporter: dotnet-trx reporter: dotnet-trx
fail-on-error: true 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: build-artifacts:
name: Build artifacts name: Build artifacts
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -156,14 +147,6 @@ jobs:
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" 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 - name: Build node
if: ${{ matrix.node }} if: ${{ matrix.node }}
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
@ -357,9 +340,6 @@ jobs:
- name: Login to PROD ACR - name: Login to PROD ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Restore
run: dotnet tool restore
- name: Make Docker stubs - name: Make Docker stubs
if: github.ref == 'refs/heads/main' || if: github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/rc' ||
@ -443,10 +423,8 @@ jobs:
- name: Build Swagger - name: Build Swagger
run: | run: |
cd ./src/Api cd ./src/Api
echo "Restore" echo "Restore tools"
dotnet restore dotnet tool restore
echo "Clean"
dotnet clean -c "Release" -o obj/build-output/publish
echo "Publish" echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish dotnet publish -c "Release" -o obj/build-output/publish
@ -495,11 +473,6 @@ jobs:
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
- name: Restore project
run: |
echo "Restore"
dotnet restore
- name: Publish project - name: Publish project
run: | run: |
dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \ 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 path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
if-no-files-found: error if-no-files-found: error
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs: build-docker
- build-docker
steps: steps:
- name: Login to Azure - CI Subscription - name: Login to Azure - CI Subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 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: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
@ -568,6 +573,7 @@ jobs:
- upload - upload
- build-mssqlmigratorutility - build-mssqlmigratorutility
- self-host-build - self-host-build
- trigger-k8s-deploy
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
@ -583,6 +589,7 @@ jobs:
UPLOAD_STATUS: ${{ needs.upload.result }} UPLOAD_STATUS: ${{ needs.upload.result }}
BUILD_MSSQLMIGRATORUTILITY_STATUS: ${{ needs.build-mssqlmigratorutility.result }} BUILD_MSSQLMIGRATORUTILITY_STATUS: ${{ needs.build-mssqlmigratorutility.result }}
TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }} TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }}
TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }}
run: | run: |
if [ "$CLOC_STATUS" = "failure" ]; then if [ "$CLOC_STATUS" = "failure" ]; then
exit 1 exit 1
@ -600,6 +607,8 @@ jobs:
exit 1 exit 1
elif [ "$TRIGGER_SELF_HOST_BUILD_STATUS" = "failure" ]; then elif [ "$TRIGGER_SELF_HOST_BUILD_STATUS" = "failure" ]; then
exit 1 exit 1
elif [ "$TRIGGER_K8S_DEPLOY_STATUS" = "failure" ]; then
exit 1
fi fi
- name: Login to Azure - CI subscription - name: Login to Azure - CI subscription

View File

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2023.12.1</Version> <Version>2024.1.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>

View File

@ -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.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -33,13 +36,14 @@ public class ProviderService : IProviderService
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IStripeAdapter _stripeAdapter;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
IUserService userService, IOrganizationService organizationService, IMailService mailService, IUserService userService, IOrganizationService organizationService, IMailService mailService,
IDataProtectionProvider dataProtectionProvider, IEventService eventService, IDataProtectionProvider dataProtectionProvider, IEventService eventService,
IOrganizationRepository organizationRepository, GlobalSettings globalSettings, IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext) ICurrentContext currentContext, IStripeAdapter stripeAdapter)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
@ -53,6 +57,7 @@ public class ProviderService : IProviderService
_globalSettings = globalSettings; _globalSettings = globalSettings;
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); _dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_currentContext = currentContext; _currentContext = currentContext;
_stripeAdapter = stripeAdapter;
} }
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
@ -369,6 +374,7 @@ public class ProviderService : IProviderService
Key = key, Key = key,
}; };
await ApplyProviderPriceRateAsync(organizationId, providerId);
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added); 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."); 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) if (existingProviderOrganizationsCount > 0)
{ {
throw new BadRequestException("Organizations must not be assigned to any Provider."); 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); var insertedProviderOrganizations = await _providerOrganizationRepository.CreateManyAsync(providerOrganizationsToInsert);
await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null))); 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, public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
OrganizationSignup organizationSignup, string clientOwnerEmail, User user) OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
{ {

View File

@ -18,6 +18,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions; using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit; using Xunit;
using Provider = Bit.Core.AdminConsole.Entities.Provider.Provider; using Provider = Bit.Core.AdminConsole.Entities.Provider.Provider;
using ProviderUser = Bit.Core.AdminConsole.Entities.Provider.ProviderUser; using ProviderUser = Bit.Core.AdminConsole.Entities.Provider.ProviderUser;
@ -598,4 +599,98 @@ public class ProviderServiceTests
await sutProvider.GetDependency<IEventService>().Received() await sutProvider.GetDependency<IEventService>().Received()
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); .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
View File

@ -1,6 +1,6 @@
.data .data
secrets.json secrets.json
db *.db
# Docker container configurations # Docker container configurations
.env .env

View File

@ -13,8 +13,11 @@ public class RedisPersistedGrantStoreTests
{ {
const string SQL = nameof(SQL); const string SQL = nameof(SQL);
const string Redis = nameof(Redis); const string Redis = nameof(Redis);
const string Cosmos = nameof(Cosmos);
private readonly IPersistedGrantStore _redisGrantStore; private readonly IPersistedGrantStore _redisGrantStore;
private readonly IPersistedGrantStore _sqlGrantStore; private readonly IPersistedGrantStore _sqlGrantStore;
private readonly IPersistedGrantStore _cosmosGrantStore;
private readonly PersistedGrant _updateGrant; private readonly PersistedGrant _updateGrant;
private IPersistedGrantStore _grantStore = null!; private IPersistedGrantStore _grantStore = null!;
@ -45,12 +48,18 @@ public class RedisPersistedGrantStoreTests
); );
var sqlConnectionString = "YOUR CONNECTION STRING HERE"; var sqlConnectionString = "YOUR CONNECTION STRING HERE";
_sqlGrantStore = new PersistedGrantStore( _sqlGrantStore = new PersistedGrantStore(
new GrantRepository( new GrantRepository(
sqlConnectionString, sqlConnectionString,
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); 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!; public string StoreType { get; set; } = null!;
[GlobalSetup] [GlobalSetup]
@ -83,6 +92,10 @@ public class RedisPersistedGrantStoreTests
{ {
_grantStore = _sqlGrantStore; _grantStore = _sqlGrantStore;
} }
else if (StoreType == Cosmos)
{
_grantStore = _cosmosGrantStore;
}
else else
{ {
throw new InvalidProgramException(); throw new InvalidProgramException();

View File

@ -6,12 +6,12 @@
@section Scripts { @section Scripts {
<script> <script>
function onRowSelect(selectingPage = false) { function onRowSelect(selectingPage = false) {
var checkboxes = document.getElementsByClassName('row-check'); let checkboxes = document.getElementsByClassName('row-check');
var checkedCheckboxCount = 0; let checkedCheckboxCount = 0;
var bulkActions = document.getElementById('bulkActions'); let bulkActions = document.getElementById('bulkActions');
var selectPage = document.getElementById('selectPage'); let selectPage = document.getElementById('selectPage');
for(var i = 0; i < checkboxes.length; i++){ for(let i = 0; i < checkboxes.length; i++){
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) { if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
checkboxes[i].checked = true; checkboxes[i].checked = true;
checkedCheckboxCount += 1; checkedCheckboxCount += 1;
@ -26,40 +26,39 @@
bulkActions.classList.add("d-none"); bulkActions.classList.add("d-none");
} }
var selectPage = document.getElementById('selectPage'); let selectAll = document.getElementById('selectAll');
var selectAll = document.getElementById('selectAll'); if (checkedCheckboxCount === checkboxes.length) {
if (checkedCheckboxCount == checkboxes.length) {
selectPage.checked = true; selectPage.checked = true;
selectAll.classList.remove("d-none"); selectAll.classList.remove("d-none");
var selectAllElement = document.getElementById('selectAllElement'); let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.remove('d-none'); selectAllElement.classList.remove('d-none');
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation'); let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.add('d-none'); selectedAllConfirmation.classList.add('d-none');
} else { } else {
selectPage.checked = false; selectPage.checked = false;
selectAll.classList.add("d-none"); selectAll.classList.add("d-none");
var selectAllInput = document.getElementById('selectAllInput'); let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = false; selectAllInput.checked = false;
} }
} }
function onSelectAll() { function onSelectAll() {
var selectAllInput = document.getElementById('selectAllInput'); let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = true; selectAllInput.checked = true;
var selectAllElement = document.getElementById('selectAllElement'); let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.add('d-none'); selectAllElement.classList.add('d-none');
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation'); let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.remove('d-none'); selectedAllConfirmation.classList.remove('d-none');
} }
function exportSelectedSubscriptions() { function exportSelectedSubscriptions() {
var selectAll = document.getElementById('selectAll'); let selectAll = document.getElementById('selectAll');
var httpRequest = new XMLHttpRequest(); let httpRequest = new XMLHttpRequest();
httpRequest.open('POST'); httpRequest.open("POST");
httpRequest.send(); httpRequest.send();
} }
@ -74,200 +73,209 @@
{ {
<div class="alert alert-success"></div> <div class="alert alert-success"></div>
} }
<form method="post"> <form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
<label asp-for="Filter.Status">Status</label> <label asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2"> <select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2">
<option asp-selected="Model.Filter.Status == null" value="all">All</option> <option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option> <option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option> <option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-6">
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label> <label asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group"> <div class="input-group">
<div class="input-group-append"> <div class="input-group-append">
<div class="input-group-text"> <div class="input-group-text">
<span class="mr-1"> <span class="mr-1">
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before <input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before
</span> </span>
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After <input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
}
</div> </div>
</div> </div>
</div> @{
<div class="row mt-2"> var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
<div class="col-6"> <input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
<label asp-for="Filter.Price">Price ID</label> }
<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>}
</select>
</div>
<div class="col-6">
<label asp-for="Filter.TestClock">Test Clock</label>
<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>}
</select>
</div>
</div>
<div class="row col-12 d-flex justify-content-end my-3">
<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">
<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/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
</div> </div>
</div> </div>
<div class="table-responsive"> </div>
<table class="table table-striped table-hover"> <div class="row mt-2">
<thead> <div class="col-6">
<tr> <label asp-for="Filter.Price">Price ID</label>
<th> <select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
<div class="form-check form-check-inline"> <option asp-selected="Model.Filter.Price == null" value="@null">All</option>
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)"> @foreach (var price in Model.Prices)
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if(!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for(var i = 0; i < Model.Items.Count; i++)
{
<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">
@for(var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; 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"
>
}
<div class="form-check">
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex">
<ul class="pagination">
@if(!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{ {
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore"> <option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>
<li class="page-item"> }
<button </select>
</div>
<div class="col-6">
<label asp-for="Filter.TestClock">Test Clock</label>
<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>
}
</select>
</div>
</div>
<div class="row col-12 d-flex justify-content-end my-3">
<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" 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/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>
<div class="form-check form-check-inline">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td>
@{
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[i1].Subscription.Items.Data[j1].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
}
<div class="form-check">
@{
var i2 = i;
}
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex">
<ul class="pagination">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit" type="submit"
class="page-link" class="page-link"
name="action" name="action"
asp-for="Action" asp-for="Action"
value="@StripeSubscriptionsAction.PreviousPage" value="@StripeSubscriptionsAction.PreviousPage">
> Previous
Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if(!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage"
>
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ml-2">
<span class="d-inline-flex">
<button
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export"
>
Export
</button> </button>
<button </li>
type="submit" }
class="btn btn-danger" else
name="action" {
asp-for="Action" <li class="page-item disabled">
value="@StripeSubscriptionsAction.BulkCancel" <a class="page-link" href="#" tabindex="-1">Previous</a>
> </li>
Bulk Cancel }
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage">
Next
</button> </button>
</span> </li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ml-2">
<span class="d-inline-flex">
<button
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export">
Export
</button>
<button
type="submit"
class="btn btn-danger"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</span> </span>
</nav> </span>
</form> </nav>
</form>

View File

@ -1,14 +1,12 @@
using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -16,7 +14,6 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{orgId}/auth-requests")] [Route("organizations/{orgId}/auth-requests")]
[Authorize("Application")] [Authorize("Application")]
[RequireFeature(FeatureFlagKeys.TrustedDeviceEncryption)]
public class OrganizationAuthRequestsController : Controller public class OrganizationAuthRequestsController : Controller
{ {
private readonly IAuthRequestRepository _authRequestRepository; private readonly IAuthRequestRepository _authRequestRepository;

View File

@ -764,12 +764,6 @@ public class OrganizationsController : Controller
throw new NotFoundException(); 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); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(id);
ssoConfig = ssoConfig == null ? model.ToSsoConfig(id) : model.ToSsoConfig(ssoConfig); ssoConfig = ssoConfig == null ? model.ToSsoConfig(id) : model.ToSsoConfig(ssoConfig);
organization.Identifier = model.Identifier; organization.Identifier = model.Identifier;

View File

@ -56,6 +56,7 @@ public class OrganizationResponseModel : ResponseModel
MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts; MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
FlexibleCollections = organization.FlexibleCollections;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -97,6 +98,7 @@ public class OrganizationResponseModel : ResponseModel
public int? MaxAutoscaleSmServiceAccounts { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool LimitCollectionCreationDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool FlexibleCollections { get; set; }
} }
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@ -61,6 +61,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
AccessSecretsManager = organization.AccessSecretsManager; AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
FlexibleCollections = organization.FlexibleCollections;
if (organization.SsoConfig != null) if (organization.SsoConfig != null)
{ {
@ -116,4 +117,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool AccessSecretsManager { get; set; } public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreationDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool FlexibleCollections { get; set; }
} }

View File

@ -21,6 +21,7 @@ public class ProviderResponseModel : ResponseModel
BusinessCountry = provider.BusinessCountry; BusinessCountry = provider.BusinessCountry;
BusinessTaxNumber = provider.BusinessTaxNumber; BusinessTaxNumber = provider.BusinessTaxNumber;
BillingEmail = provider.BillingEmail; BillingEmail = provider.BillingEmail;
CreationDate = provider.CreationDate;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -32,4 +33,5 @@ public class ProviderResponseModel : ResponseModel
public string BusinessCountry { get; set; } public string BusinessCountry { get; set; }
public string BusinessTaxNumber { get; set; } public string BusinessTaxNumber { get; set; }
public string BillingEmail { get; set; } public string BillingEmail { get; set; }
public DateTime CreationDate { get; set; }
} }

View File

@ -110,7 +110,7 @@ public class GroupsController : Controller
public async Task<IActionResult> Post([FromBody] GroupCreateUpdateRequestModel model) public async Task<IActionResult> Post([FromBody] GroupCreateUpdateRequestModel model)
{ {
var group = model.ToGroup(_currentContext.OrganizationId.Value); 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); var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
await _createGroupCommand.CreateGroupAsync(group, organization, associations); await _createGroupCommand.CreateGroupAsync(group, organization, associations);
var response = new GroupResponseModel(group, associations); var response = new GroupResponseModel(group, associations);
@ -139,7 +139,7 @@ public class GroupsController : Controller
} }
var updatedGroup = model.ToGroup(existingGroup); 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); var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
await _updateGroupCommand.UpdateGroupAsync(updatedGroup, organization, associations); await _updateGroupCommand.UpdateGroupAsync(updatedGroup, organization, associations);
var response = new GroupResponseModel(updatedGroup, associations); var response = new GroupResponseModel(updatedGroup, associations);

View File

@ -119,7 +119,7 @@ public class MembersController : Controller
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model) 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 var invite = new OrganizationUserInvite
{ {
Emails = new List<string> { model.Email }, Emails = new List<string> { model.Email },
@ -154,7 +154,7 @@ public class MembersController : Controller
return new NotFoundResult(); return new NotFoundResult();
} }
var updatedUser = model.ToOrganizationUser(existingUser); 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); await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups);
MemberResponseModel response = null; MemberResponseModel response = null;
if (existingUser.UserId.HasValue) if (existingUser.UserId.HasValue)

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Auth.Models.Public; namespace Bit.Api.AdminConsole.Public.Models;
public abstract class AssociationWithPermissionsBaseModel public abstract class AssociationWithPermissionsBaseModel
{ {
@ -15,4 +15,9 @@ public abstract class AssociationWithPermissionsBaseModel
/// </summary> /// </summary>
[Required] [Required]
public bool? ReadOnly { get; set; } 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; }
} }

View File

@ -1,15 +1,16 @@
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Api.Auth.Models.Public.Request; namespace Bit.Api.AdminConsole.Public.Models.Request;
public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel
{ {
public CollectionAccessSelection ToSelectionReadOnly() public CollectionAccessSelection ToCollectionAccessSelection()
{ {
return new CollectionAccessSelection return new CollectionAccessSelection
{ {
Id = Id.Value, Id = Id.Value,
ReadOnly = ReadOnly.Value ReadOnly = ReadOnly.Value,
HidePasswords = HidePasswords.GetValueOrDefault()
}; };
} }
} }

View File

@ -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; namespace Bit.Api.AdminConsole.Public.Models.Request;

View File

@ -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; namespace Bit.Api.AdminConsole.Public.Models.Request;

View File

@ -1,6 +1,6 @@
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Api.Auth.Models.Public.Response; namespace Bit.Api.AdminConsole.Public.Models.Response;
public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
{ {
@ -12,5 +12,6 @@ public class AssociationWithPermissionsResponseModel : AssociationWithPermission
} }
Id = selection.Id; Id = selection.Id;
ReadOnly = selection.ReadOnly; ReadOnly = selection.ReadOnly;
HidePasswords = selection.HidePasswords;
} }
} }

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Public.Response;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Public.Response;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;

View File

@ -684,17 +684,6 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState); 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")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremium(PremiumRequestModel model) public async Task<PaymentResponseModel> PostPremium(PremiumRequestModel model)
{ {

View 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;
}
}

View File

@ -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);
}
}
}

View File

@ -584,11 +584,6 @@ public class CollectionsController : Controller
// Filter the assigned collections to only return those where the user has Manage permission // Filter the assigned collections to only return those where the user has Manage permission
var manageableOrgCollections = assignedOrgCollections.Where(c => c.Item1.Manage).ToList(); 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 => return new ListResponseModel<CollectionAccessDetailsResponseModel>(manageableOrgCollections.Select(c =>
new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users)
@ -609,16 +604,8 @@ public class CollectionsController : Controller
} }
else else
{ {
var collections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled); var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
var readAuthorized = (await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Read)).Succeeded; orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList();
if (readAuthorized)
{
orgCollections = collections.Where(c => c.OrganizationId == orgId);
}
else
{
throw new NotFoundException();
}
} }
var responses = orgCollections.Select(c => new CollectionResponseModel(c)); var responses = orgCollections.Select(c => new CollectionResponseModel(c));

View File

@ -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.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
@ -206,10 +207,8 @@ public class DevicesController : Controller
[AllowAnonymous] [AllowAnonymous]
[HttpGet("knowndevice")] [HttpGet("knowndevice")]
public async Task<bool> GetByIdentifierQuery( public async Task<bool> GetByIdentifierQuery([FromHeader] KnownDeviceRequestModel request)
[FromHeader(Name = "X-Request-Email")] string email, => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(request.Email), request.DeviceIdentifier);
[FromHeader(Name = "X-Device-Identifier")] string deviceIdentifier)
=> await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(email), deviceIdentifier);
[Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")]
[AllowAnonymous] [AllowAnonymous]

View File

@ -1,4 +1,4 @@
using Bit.Api.Auth.Models.Public.Request; using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Core.Entities; using Bit.Core.Entities;
namespace Bit.Api.Models.Public.Request; namespace Bit.Api.Models.Public.Request;

View File

@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations; 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.Entities;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;

View File

@ -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) });
}
}
}

View 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; }
}

View File

@ -36,6 +36,7 @@ public class ProfileResponseModel : ResponseModel
ForcePasswordReset = user.ForcePasswordReset; ForcePasswordReset = user.ForcePasswordReset;
UsesKeyConnector = user.UsesKeyConnector; UsesKeyConnector = user.UsesKeyConnector;
AvatarColor = user.AvatarColor; AvatarColor = user.AvatarColor;
CreationDate = user.CreationDate;
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o)); Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o));
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations = ProviderOrganizations =
@ -61,6 +62,7 @@ public class ProfileResponseModel : ResponseModel
public bool ForcePasswordReset { get; set; } public bool ForcePasswordReset { get; set; }
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public DateTime CreationDate { get; set; }
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; } public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; } public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; } public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }

View File

@ -18,7 +18,6 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb; MaxStorageGb = user.MaxStorageGb;
License = license; License = license;
Expiration = License.Expires; Expiration = License.Expires;
UsingInAppPurchase = subscription.UsingInAppPurchase;
} }
public SubscriptionResponseModel(User user, UserLicense license = null) public SubscriptionResponseModel(User user, UserLicense license = null)
@ -42,7 +41,6 @@ public class SubscriptionResponseModel : ResponseModel
public BillingSubscription Subscription { get; set; } public BillingSubscription Subscription { get; set; }
public UserLicense License { get; set; } public UserLicense License { get; set; }
public DateTime? Expiration { get; set; } public DateTime? Expiration { get; set; }
public bool UsingInAppPurchase { get; set; }
} }
public class BillingCustomerDiscount public class BillingCustomerDiscount

View File

@ -89,7 +89,7 @@ public class CollectionsController : Controller
return new NotFoundResult(); return new NotFoundResult();
} }
var updatedCollection = model.ToCollection(existingCollection); 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); await _collectionService.SaveAsync(updatedCollection, associations);
var response = new CollectionResponseModel(updatedCollection, associations); var response = new CollectionResponseModel(updatedCollection, associations);
return new JsonResult(response); return new JsonResult(response);

View File

@ -288,6 +288,12 @@ public class Startup
"Bitwarden Public API"); "Bitwarden Public API");
config.OAuthClientId("accountType.id"); config.OAuthClientId("accountType.id");
config.OAuthClientSecret("secretKey"); config.OAuthClientSecret("secretKey");
// Persist authorization on page refresh - for development use only
if (Environment.IsDevelopment())
{
config.EnablePersistAuthorization();
}
}); });
} }

View File

@ -4,6 +4,7 @@ using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -20,6 +21,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService;
private Guid _targetOrganizationId; private Guid _targetOrganizationId;
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
@ -27,11 +29,13 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
public BulkCollectionAuthorizationHandler( public BulkCollectionAuthorizationHandler(
ICurrentContext currentContext, ICurrentContext currentContext,
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
IFeatureService featureService) IFeatureService featureService,
IApplicationCacheService applicationCacheService)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService;
} }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 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.Update:
case not null when requirement == BulkCollectionOperations.ModifyAccess: case not null when requirement == BulkCollectionOperations.ModifyAccess:
await CanUpdateCollection(context, requirement, resources, org); await CanUpdateCollectionAsync(context, requirement, resources, org);
break; break;
case not null when requirement == BulkCollectionOperations.Delete: case not null when requirement == BulkCollectionOperations.Delete:
@ -96,10 +100,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
private async Task CanCreateAsync(AuthorizationHandlerContext context, IAuthorizationRequirement requirement, private async Task CanCreateAsync(AuthorizationHandlerContext context, IAuthorizationRequirement requirement,
CurrentContextOrganization? org) CurrentContextOrganization? org)
{ {
// If the limit collection management setting is disabled, allow any user to create collections // Owners, Admins, and users with CreateNewCollections permission can always create collections
// Otherwise, Owners, Admins, and users with CreateNewCollections permission can always create collections
if (org is if (org is
{ LimitCollectionCreationDeletion: false } or
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.CreateNewCollections: true }) { Permissions.CreateNewCollections: true })
{ {
@ -107,6 +109,13 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
return; 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 // Allow provider users to create collections if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(_targetOrganizationId)) if (await _currentContext.ProviderUserForOrgAsync(_targetOrganizationId))
{ {
@ -131,8 +140,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
// ensure they have access for the collection being read // ensure they have access for the collection being read
if (org is not null) if (org is not null)
{ {
var isAssignedToCollections = await IsAssignedToCollectionsAsync(resources, org, false); var canManageCollections = await CanManageCollectionsAsync(resources, org);
if (isAssignedToCollections) if (canManageCollections)
{ {
context.Succeed(requirement); context.Succeed(requirement);
return; return;
@ -164,8 +173,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
// ensure they have access with manage permission for the collection being read // ensure they have access with manage permission for the collection being read
if (org is not null) if (org is not null)
{ {
var isAssignedToCollections = await IsAssignedToCollectionsAsync(resources, org, true); var canManageCollections = await CanManageCollectionsAsync(resources, org);
if (isAssignedToCollections) if (canManageCollections)
{ {
context.Succeed(requirement); context.Succeed(requirement);
return; return;
@ -182,7 +191,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
/// <summary> /// <summary>
/// Ensures the acting user is allowed to update the target collections or manage access permissions for them. /// Ensures the acting user is allowed to update the target collections or manage access permissions for them.
/// </summary> /// </summary>
private async Task CanUpdateCollection(AuthorizationHandlerContext context, private async Task CanUpdateCollectionAsync(AuthorizationHandlerContext context,
IAuthorizationRequirement requirement, ICollection<Collection> resources, IAuthorizationRequirement requirement, ICollection<Collection> resources,
CurrentContextOrganization? org) CurrentContextOrganization? org)
{ {
@ -199,7 +208,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
// ensure they have manage permission for the collection being managed // ensure they have manage permission for the collection being managed
if (org is not null) if (org is not null)
{ {
var canManageCollections = await IsAssignedToCollectionsAsync(resources, org, true); var canManageCollections = await CanManageCollectionsAsync(resources, org);
if (canManageCollections) if (canManageCollections)
{ {
context.Succeed(requirement); context.Succeed(requirement);
@ -226,11 +235,12 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
return; 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, // The limit collection management setting is disabled,
// ensure acting user has manage permissions for all collections being deleted // 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) if (canManageCollections)
{ {
context.Succeed(requirement); 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, ICollection<Collection> targetCollections,
CurrentContextOrganization org, CurrentContextOrganization org)
bool requireManagePermission)
{ {
// List of collection Ids the acting user has access to // List of collection Ids the acting user has access to
var assignedCollectionIds = var assignedCollectionIds =
(await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value, useFlexibleCollections: true)) (await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value, useFlexibleCollections: true))
.Where(c => .Where(c =>
// Check Collections with Manage permission // Check Collections with Manage permission
(!requireManagePermission || c.Manage) && c.OrganizationId == org.Id) c.Manage && c.OrganizationId == org.Id)
.Select(c => c.Id) .Select(c => c.Id)
.ToHashSet(); .ToHashSet();
// Check if the acting user has access to all target collections // Check if the acting user has access to all target collections
return targetCollections.All(tc => assignedCollectionIds.Contains(tc.Id)); 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;
}
} }

View File

@ -3,6 +3,7 @@ using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -16,15 +17,18 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService;
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
public GroupAuthorizationHandler( public GroupAuthorizationHandler(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService) IFeatureService featureService,
IApplicationCacheService applicationCacheService)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService;
} }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
@ -62,10 +66,8 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement, private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
CurrentContextOrganization? org) CurrentContextOrganization? org)
{ {
// If the limit collection management setting is disabled, allow any user to read all groups // Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all groups
// Otherwise, Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all groups
if (org is if (org is
{ LimitCollectionCreationDeletion: false } or
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.ManageGroups: true } or { Permissions.ManageGroups: true } or
{ Permissions.ManageUsers: true } or { Permissions.ManageUsers: true } or
@ -77,10 +79,33 @@ public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequ
return; 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 // Allow provider users to read all groups if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId)) if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{ {
context.Succeed(requirement); 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;
}
} }

View File

@ -3,6 +3,7 @@ using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -16,15 +17,18 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler<Organiz
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService;
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
public OrganizationUserAuthorizationHandler( public OrganizationUserAuthorizationHandler(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService) IFeatureService featureService,
IApplicationCacheService applicationCacheService)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService;
} }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 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 // 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 // Otherwise, Owners, Admins, and users with any of ManageGroups, ManageUsers, EditAnyCollection, DeleteAnyCollection, CreateNewCollections permissions can always read all organization users
if (org is if (org is
{ LimitCollectionCreationDeletion: false } or
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.ManageGroups: true } or { Permissions.ManageGroups: true } or
{ Permissions.ManageUsers: true } or { Permissions.ManageUsers: true } or
@ -76,10 +79,33 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler<Organiz
return; 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 // Allow provider users to read all organization users if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId)) if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{ {
context.Succeed(requirement); 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;
}
} }

View File

@ -96,6 +96,7 @@ public class SyncController : Controller
{ {
collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections); collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
var collectionCiphers = await _collectionCipherRepository.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); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);

View File

@ -30,7 +30,6 @@ namespace Bit.Billing.Controllers;
[Route("stripe")] [Route("stripe")]
public class StripeController : Controller public class StripeController : Controller
{ {
private const decimal PremiumPlanAppleIapPrice = 14.99M;
private const string PremiumPlanId = "premium-annually"; private const string PremiumPlanId = "premium-annually";
private const string PremiumPlanIdAppStore = "premium-annually-app"; private const string PremiumPlanIdAppStore = "premium-annually-app";
@ -42,7 +41,6 @@ public class StripeController : Controller
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ITransactionRepository _transactionRepository; private readonly ITransactionRepository _transactionRepository;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IAppleIapService _appleIapService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly ILogger<StripeController> _logger; private readonly ILogger<StripeController> _logger;
private readonly BraintreeGateway _btGateway; private readonly BraintreeGateway _btGateway;
@ -64,7 +62,6 @@ public class StripeController : Controller
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
IUserService userService, IUserService userService,
IAppleIapService appleIapService,
IMailService mailService, IMailService mailService,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
ILogger<StripeController> logger, ILogger<StripeController> logger,
@ -82,7 +79,6 @@ public class StripeController : Controller
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_transactionRepository = transactionRepository; _transactionRepository = transactionRepository;
_userService = userService; _userService = userService;
_appleIapService = appleIapService;
_mailService = mailService; _mailService = mailService;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_taxRateRepository = taxRateRepository; _taxRateRepository = taxRateRepository;
@ -681,10 +677,6 @@ public class StripeController : Controller
{ {
var customerService = new CustomerService(); var customerService = new CustomerService();
var customer = await customerService.GetAsync(invoice.CustomerId); var customer = await customerService.GetAsync(invoice.CustomerId);
if (customer?.Metadata?.ContainsKey("appleReceipt") ?? false)
{
return await AttemptToPayInvoiceWithAppleReceiptAsync(invoice, customer);
}
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
{ {
@ -699,99 +691,6 @@ public class StripeController : Controller
return false; 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) private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
{ {
_logger.LogDebug("Attempting to pay invoice with Braintree"); _logger.LogDebug("Attempting to pay invoice with Braintree");

View File

@ -15,14 +15,10 @@ public class CurrentContextOrganization
Type = orgUser.Type; Type = orgUser.Type;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(orgUser.Permissions); Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(orgUser.Permissions);
AccessSecretsManager = orgUser.AccessSecretsManager && orgUser.UseSecretsManager && orgUser.Enabled; AccessSecretsManager = orgUser.AccessSecretsManager && orgUser.UseSecretsManager && orgUser.Enabled;
LimitCollectionCreationDeletion = orgUser.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = orgUser.AllowAdminAccessToAllCollectionItems;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
public OrganizationUserType Type { get; set; } public OrganizationUserType Type { get; set; }
public Permissions Permissions { get; set; } = new(); public Permissions Permissions { get; set; } = new();
public bool AccessSecretsManager { get; set; } public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
} }

View File

@ -91,6 +91,11 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
/// </remarks> /// </remarks>
/// </summary> /// </summary>
public bool AllowAdminAccessToAllCollectionItems { get; set; } 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() public void SetNewId()
{ {
@ -236,7 +241,10 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
return providers[provider]; return providers[provider];
} }
public void UpdateFromLicense(OrganizationLicense license, bool flexibleCollectionsIsEnabled) public void UpdateFromLicense(
OrganizationLicense license,
bool flexibleCollectionsMvpIsEnabled,
bool flexibleCollectionsV1IsEnabled)
{ {
Name = license.Name; Name = license.Name;
BusinessName = license.BusinessName; BusinessName = license.BusinessName;
@ -267,6 +275,7 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
UseSecretsManager = license.UseSecretsManager; UseSecretsManager = license.UseSecretsManager;
SmSeats = license.SmSeats; SmSeats = license.SmSeats;
SmServiceAccounts = license.SmServiceAccounts; SmServiceAccounts = license.SmServiceAccounts;
LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled || license.LimitCollectionCreationDeletion; LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems;
} }
} }

View File

@ -21,6 +21,9 @@ public class OrganizationAbility
UseResetPassword = organization.UseResetPassword; UseResetPassword = organization.UseResetPassword;
UseCustomPermissions = organization.UseCustomPermissions; UseCustomPermissions = organization.UseCustomPermissions;
UsePolicies = organization.UsePolicies; UsePolicies = organization.UsePolicies;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
FlexibleCollections = organization.FlexibleCollections;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -35,4 +38,7 @@ public class OrganizationAbility
public bool UseResetPassword { get; set; } public bool UseResetPassword { get; set; }
public bool UseCustomPermissions { get; set; } public bool UseCustomPermissions { get; set; }
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool FlexibleCollections { get; set; }
} }

View File

@ -50,4 +50,5 @@ public class OrganizationUserOrganizationDetails
public int? SmServiceAccounts { get; set; } public int? SmServiceAccounts { get; set; }
public bool LimitCollectionCreationDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool FlexibleCollections { get; set; }
} }

View File

@ -558,8 +558,10 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(owner.Id); await ValidateSignUpPoliciesAsync(owner.Id);
var flexibleCollectionsIsEnabled = var flexibleCollectionsMvpIsEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
var flexibleCollectionsV1IsEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext);
var organization = new Organization var organization = new Organization
{ {
@ -601,7 +603,8 @@ public class OrganizationService : IOrganizationService
UseSecretsManager = license.UseSecretsManager, UseSecretsManager = license.UseSecretsManager,
SmSeats = license.SmSeats, SmSeats = license.SmSeats,
SmServiceAccounts = license.SmServiceAccounts, 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); 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 // 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. // 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 // 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. // need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies && 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 // Generate the list of org users and expiring tokens
// create helper function to create 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) 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"); ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager");
if (plan.PasswordManager.BaseSeats + upgrade.AdditionalSeats <= 0) 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) public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
{ {
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); 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."); throw new BadRequestException("Plan not found.");
} }

View File

@ -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; 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)] [MaxLength(200)]
public string Key { get; set; } public string Key { get; set; } = null!;
[MaxLength(50)] [MaxLength(50)]
public string Type { get; set; } public string Type { get; set; } = null!;
[MaxLength(200)] [MaxLength(200)]
public string SubjectId { get; set; } public string? SubjectId { get; set; }
[MaxLength(100)] [MaxLength(100)]
public string SessionId { get; set; } public string? SessionId { get; set; }
[MaxLength(200)] [MaxLength(200)]
public string ClientId { get; set; } public string ClientId { get; set; } = null!;
[MaxLength(200)] [MaxLength(200)]
public string Description { get; set; } public string? Description { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
public DateTime? ConsumedDate { get; set; } public DateTime? ConsumedDate { get; set; }
public string Data { get; set; } public string Data { get; set; } = null!;
} }

View 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;
}
}
}
}

View 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; }
}

View 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);
}
}

View 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();
}

View File

@ -1,12 +1,12 @@
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data;
namespace Bit.Core.Auth.Repositories; namespace Bit.Core.Auth.Repositories;
public interface IGrantRepository public interface IGrantRepository
{ {
Task<Grant> GetByKeyAsync(string key); Task<IGrant> GetByKeyAsync(string key);
Task<ICollection<Grant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type); Task<ICollection<IGrant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type);
Task SaveAsync(Grant obj); Task SaveAsync(IGrant obj);
Task DeleteByKeyAsync(string key); Task DeleteByKeyAsync(string key);
Task DeleteManyAsync(string subjectId, string sessionId, string clientId, string type); Task DeleteManyAsync(string subjectId, string sessionId, string clientId, string type);
} }

View File

@ -29,6 +29,12 @@ public static class Constants
/// Used by IdentityServer to identify our own provider. /// Used by IdentityServer to identify our own provider.
/// </summary> /// </summary>
public const string IdentityProvider = "bitwarden"; 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 public static class AuthConstants

View File

@ -54,7 +54,7 @@ public class CurrentContext : ICurrentContext
{ {
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_featureService = featureService; _featureService = featureService; ;
} }
public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) 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."); throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF.");
} }
var canCreateNewCollections = false;
var org = GetOrganization(orgId); var org = GetOrganization(orgId);
if (org != null)
{
canCreateNewCollections = !org.LimitCollectionCreationDeletion || org.Permissions.CreateNewCollections;
}
return await EditAssignedCollections(orgId) return await EditAssignedCollections(orgId)
|| await DeleteAssignedCollections(orgId) || await DeleteAssignedCollections(orgId)
|| canCreateNewCollections; || (org != null && org.Permissions.CreateNewCollections);
} }
public async Task<bool> ManageGroups(Guid orgId) public async Task<bool> ManageGroups(Guid orgId)

View File

@ -21,8 +21,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="1.0.1" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="1.0.1" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.0.150" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.31" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.2.47" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.300.31" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" /> <PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" />
@ -37,7 +37,7 @@
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.1.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.1.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.0.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.0.1" />
<!-- Azure.Identity is a explicit dependency to Microsoft.Data.SqlClient --> <!-- Azure.Identity is a explicit dependency to Microsoft.Data.SqlClient -->
<PackageReference Include="Azure.Identity" Version="1.10.2"/> <PackageReference Include="Azure.Identity" Version="1.10.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.25" /> <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.25" />
@ -47,7 +47,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="3.41.3" /> <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="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.AzureCosmosDB" Version="2.0.0" /> <PackageReference Include="Serilog.Sinks.AzureCosmosDB" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="2.0.9" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="2.0.9" />

View File

@ -16,10 +16,6 @@ public enum PaymentMethodType : byte
Credit = 4, Credit = 4,
[Display(Name = "Wire Transfer")] [Display(Name = "Wire Transfer")]
WireTransfer = 5, WireTransfer = 5,
[Display(Name = "Apple In-App Purchase")]
AppleInApp = 6,
[Display(Name = "Google In-App Purchase")]
GoogleInApp = 7,
[Display(Name = "Check")] [Display(Name = "Check")]
Check = 8, Check = 8,
[Display(Name = "None")] [Display(Name = "None")]

View File

@ -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; }
}
}

View 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;
}

View File

@ -53,6 +53,7 @@ public class OrganizationLicense : ILicense
SmSeats = org.SmSeats; SmSeats = org.SmSeats;
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
LimitCollectionCreationDeletion = org.LimitCollectionCreationDeletion; LimitCollectionCreationDeletion = org.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems;
if (subscriptionInfo?.Subscription == null) if (subscriptionInfo?.Subscription == null)
{ {
@ -137,6 +138,7 @@ public class OrganizationLicense : ILicense
public int? SmSeats { get; set; } public int? SmSeats { get; set; }
public int? SmServiceAccounts { get; set; } public int? SmServiceAccounts { get; set; }
public bool LimitCollectionCreationDeletion { get; set; } = true; public bool LimitCollectionCreationDeletion { get; set; } = true;
public bool AllowAdminAccessToAllCollectionItems { get; set; } = true;
public bool Trial { get; set; } public bool Trial { get; set; }
public LicenseType? LicenseType { get; set; } public LicenseType? LicenseType { get; set; }
public string Hash { get; set; } public string Hash { get; set; }
@ -148,10 +150,10 @@ public class OrganizationLicense : ILicense
/// </summary> /// </summary>
/// <remarks>Intentionally set one version behind to allow self hosted users some time to update before /// <remarks>Intentionally set one version behind to allow self hosted users some time to update before
/// getting out of date license errors</remarks> /// getting out of date license errors</remarks>
public const int CurrentLicenseFileVersion = 13; public const int CurrentLicenseFileVersion = 14;
private bool ValidLicenseVersion private bool ValidLicenseVersion
{ {
get => Version is >= 1 and <= 14; get => Version is >= 1 and <= 15;
} }
public byte[] GetDataBytes(bool forHash = false) public byte[] GetDataBytes(bool forHash = false)
@ -194,6 +196,8 @@ public class OrganizationLicense : ILicense
(Version >= 13 || !p.Name.Equals(nameof(SmServiceAccounts))) && (Version >= 13 || !p.Name.Equals(nameof(SmServiceAccounts))) &&
// LimitCollectionCreationDeletion was added in Version 14 // LimitCollectionCreationDeletion was added in Version 14
(Version >= 14 || !p.Name.Equals(nameof(LimitCollectionCreationDeletion))) && (Version >= 14 || !p.Name.Equals(nameof(LimitCollectionCreationDeletion))) &&
// AllowAdminAccessToAllCollectionItems was added in Version 15
(Version >= 15 || !p.Name.Equals(nameof(AllowAdminAccessToAllCollectionItems))) &&
( (
!forHash || !forHash ||
( (
@ -347,6 +351,10 @@ public class OrganizationLicense : ILicense
// { // {
// valid = organization.LimitCollectionCreationDeletion == LimitCollectionCreationDeletion; // valid = organization.LimitCollectionCreationDeletion == LimitCollectionCreationDeletion;
// } // }
// if (valid && Version >= 15)
// {
// valid = organization.AllowAdminAccessToAllCollectionItems == AllowAdminAccessToAllCollectionItems;
// }
return valid; return valid;
} }

View File

@ -7,7 +7,6 @@ public class SubscriptionInfo
public BillingCustomerDiscount CustomerDiscount { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; }
public BillingSubscription Subscription { get; set; } public BillingSubscription Subscription { get; set; }
public BillingUpcomingInvoice UpcomingInvoice { get; set; } public BillingUpcomingInvoice UpcomingInvoice { get; set; }
public bool UsingInAppPurchase { get; set; }
public class BillingCustomerDiscount public class BillingCustomerDiscount
{ {

View File

@ -9,7 +9,7 @@ public abstract class SubscriptionUpdate
public abstract List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription); public abstract List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription);
public abstract List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription); public abstract List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription);
public bool UpdateNeeded(Subscription subscription) public virtual bool UpdateNeeded(Subscription subscription)
{ {
var upgradeItemsOptions = UpgradeItemsOptions(subscription); var upgradeItemsOptions = UpgradeItemsOptions(subscription);
foreach (var upgradeItemOptions in upgradeItemsOptions) foreach (var upgradeItemOptions in upgradeItemsOptions)

View File

@ -31,8 +31,8 @@ public record Enterprise2019Plan : Models.StaticStore.Plan
UsersGetPremium = true; UsersGetPremium = true;
HasCustomPermissions = true; HasCustomPermissions = true;
UpgradeSortOrder = 3; UpgradeSortOrder = 4;
DisplaySortOrder = 3; DisplaySortOrder = 4;
LegacyYear = 2020; LegacyYear = 2020;
SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual); SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual);

View File

@ -31,8 +31,8 @@ public record Enterprise2020Plan : Models.StaticStore.Plan
UsersGetPremium = true; UsersGetPremium = true;
HasCustomPermissions = true; HasCustomPermissions = true;
UpgradeSortOrder = 3; UpgradeSortOrder = 4;
DisplaySortOrder = 3; DisplaySortOrder = 4;
LegacyYear = 2023; LegacyYear = 2023;
PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual); PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual);

View File

@ -31,8 +31,8 @@ public record EnterprisePlan : Models.StaticStore.Plan
UsersGetPremium = true; UsersGetPremium = true;
HasCustomPermissions = true; HasCustomPermissions = true;
UpgradeSortOrder = 3; UpgradeSortOrder = 4;
DisplaySortOrder = 3; DisplaySortOrder = 4;
PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual); PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual);
SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual); SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record Teams2019Plan : Models.StaticStore.Plan
HasApi = true; HasApi = true;
UsersGetPremium = true; UsersGetPremium = true;
UpgradeSortOrder = 2; UpgradeSortOrder = 3;
DisplaySortOrder = 2; DisplaySortOrder = 3;
LegacyYear = 2020; LegacyYear = 2020;
SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual); SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record Teams2020Plan : Models.StaticStore.Plan
HasApi = true; HasApi = true;
UsersGetPremium = true; UsersGetPremium = true;
UpgradeSortOrder = 2; UpgradeSortOrder = 3;
DisplaySortOrder = 2; DisplaySortOrder = 3;
LegacyYear = 2023; LegacyYear = 2023;
PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual); PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record TeamsPlan : Models.StaticStore.Plan
HasApi = true; HasApi = true;
UsersGetPremium = true; UsersGetPremium = true;
UpgradeSortOrder = 2; UpgradeSortOrder = 3;
DisplaySortOrder = 2; DisplaySortOrder = 3;
PasswordManager = new TeamsPasswordManagerFeatures(isAnnual); PasswordManager = new TeamsPasswordManagerFeatures(isAnnual);
SecretsManager = new TeamsSecretsManagerFeatures(isAnnual); SecretsManager = new TeamsSecretsManagerFeatures(isAnnual);

View File

@ -64,6 +64,7 @@ public record TeamsStarterPlan : Plan
HasAdditionalStorageOption = true; HasAdditionalStorageOption = true;
StripePlanId = "teams-org-starter"; StripePlanId = "teams-org-starter";
StripeStoragePlanId = "storage-gb-monthly";
AdditionalStoragePricePerGb = 0.5M; AdditionalStoragePricePerGb = 0.5M;
} }
} }

View File

@ -65,9 +65,10 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHostedOrganizationDetails, OrganizationLicense license) 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(); var organization = selfHostedOrganizationDetails.ToOrganization();
organization.UpdateFromLicense(license, flexibleCollectionsIsEnabled); organization.UpdateFromLicense(license, flexibleCollectionsMvpIsEnabled, flexibleCollectionsV1IsEnabled);
await _organizationService.ReplaceAndUpdateCacheAsync(organization); await _organizationService.ReplaceAndUpdateCacheAsync(organization);
} }

View File

@ -97,11 +97,6 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
throw new BadRequestException("You cannot upgrade to this plan."); 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); _organizationService.ValidatePasswordManagerPlan(newPlan, upgrade);
if (upgrade.UseSecretsManager) if (upgrade.UseSecretsManager)
@ -226,8 +221,16 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
} }
else else
{ {
// TODO: Update existing sub paymentIntentClientSecret = await _paymentService.AdjustSubscription(
throw new BadRequestException("You can only upgrade from the free plan. Contact support."); organization,
newPlan,
upgrade.AdditionalSeats,
upgrade.UseSecretsManager,
upgrade.AdditionalSmSeats,
upgrade.AdditionalServiceAccounts,
upgrade.AdditionalStorageGb);
success = string.IsNullOrEmpty(paymentIntentClientSecret);
} }
organization.BusinessName = upgrade.BusinessName; organization.BusinessName = upgrade.BusinessName;

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -18,17 +18,25 @@ public interface IPaymentService
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade); Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo); 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> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustSmSeatsAsync(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> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts, Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts,
DateTime? prorationDate = null); DateTime? prorationDate = null);
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false);
bool skipInAppPurchaseCheck = false);
Task ReinstateSubscriptionAsync(ISubscriber subscriber); Task ReinstateSubscriptionAsync(ISubscriber subscriber);
Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, 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<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount);
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber); Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
Task<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber); Task<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber);

View File

@ -54,11 +54,10 @@ public interface IUserService
Task<Tuple<bool, string>> SignUpPremiumAsync(User user, string paymentToken, Task<Tuple<bool, string>> SignUpPremiumAsync(User user, string paymentToken,
PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license, PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license,
TaxInfo taxInfo); TaxInfo taxInfo);
Task IapCheckAsync(User user, PaymentMethodType paymentMethodType);
Task UpdateLicenseAsync(User user, UserLicense license); Task UpdateLicenseAsync(User user, UserLicense license);
Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb); Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb);
Task ReplacePaymentMethodAsync(User user, string paymentToken, PaymentMethodType paymentMethodType, TaxInfo taxInfo); 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 ReinstatePremiumAsync(User user);
Task EnablePremiumAsync(Guid userId, DateTime? expirationDate); Task EnablePremiumAsync(Guid userId, DateTime? expirationDate);
Task EnablePremiumAsync(User user, DateTime? expirationDate); Task EnablePremiumAsync(User user, DateTime? expirationDate);

View File

@ -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; }
}

View File

@ -43,9 +43,6 @@ public class CollectionService : ICollectionService
_featureService = featureService; _featureService = featureService;
} }
private bool UseFlexibleCollections =>
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null, public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null,
IEnumerable<CollectionAccessSelection> users = null) IEnumerable<CollectionAccessSelection> users = null)
{ {
@ -59,11 +56,11 @@ public class CollectionService : ICollectionService
var usersList = users?.ToList(); var usersList = users?.ToList();
// If using Flexible Collections - a collection should always have someone with Can Manage permissions // 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 groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false;
var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false; var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false;
if (!groupHasManageAccess && !userHasManageAccess) if (!groupHasManageAccess && !userHasManageAccess && !org.AllowAdminAccessToAllCollectionItems)
{ {
throw new BadRequestException( throw new BadRequestException(
"At least one member or group must have can manage permission."); "At least one member or group must have can manage permission.");
@ -125,7 +122,10 @@ public class CollectionService : ICollectionService
} }
else 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); orgCollections = collections.Where(c => c.OrganizationId == organizationId);
} }

View File

@ -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.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -16,14 +15,11 @@ namespace Bit.Core.Services;
public class StripePaymentService : IPaymentService public class StripePaymentService : IPaymentService
{ {
private const string PremiumPlanId = "premium-annually"; 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 StoragePlanId = "storage-gb-annually";
private const string ProviderDiscountId = "msp-discount-35"; private const string ProviderDiscountId = "msp-discount-35";
private readonly ITransactionRepository _transactionRepository; private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IAppleIapService _appleIapService;
private readonly ILogger<StripePaymentService> _logger; private readonly ILogger<StripePaymentService> _logger;
private readonly Braintree.IBraintreeGateway _btGateway; private readonly Braintree.IBraintreeGateway _btGateway;
private readonly ITaxRateRepository _taxRateRepository; private readonly ITaxRateRepository _taxRateRepository;
@ -33,7 +29,6 @@ public class StripePaymentService : IPaymentService
public StripePaymentService( public StripePaymentService(
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
IUserRepository userRepository, IUserRepository userRepository,
IAppleIapService appleIapService,
ILogger<StripePaymentService> logger, ILogger<StripePaymentService> logger,
ITaxRateRepository taxRateRepository, ITaxRateRepository taxRateRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
@ -42,7 +37,6 @@ public class StripePaymentService : IPaymentService
{ {
_transactionRepository = transactionRepository; _transactionRepository = transactionRepository;
_userRepository = userRepository; _userRepository = userRepository;
_appleIapService = appleIapService;
_logger = logger; _logger = logger;
_taxRateRepository = taxRateRepository; _taxRateRepository = taxRateRepository;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
@ -345,21 +339,16 @@ public class StripePaymentService : IPaymentService
{ {
throw new BadRequestException("Your account does not have any credit available."); 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."); 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; var createdStripeCustomer = false;
Stripe.Customer customer = null; Stripe.Customer customer = null;
Braintree.Customer braintreeCustomer = null; Braintree.Customer braintreeCustomer = null;
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount
paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.Credit; or PaymentMethodType.Credit;
string stipeCustomerPaymentMethodId = null; string stipeCustomerPaymentMethodId = null;
string stipeCustomerSourceToken = null; string stipeCustomerSourceToken = null;
@ -379,19 +368,9 @@ public class StripePaymentService : IPaymentService
{ {
if (!string.IsNullOrWhiteSpace(paymentToken)) if (!string.IsNullOrWhiteSpace(paymentToken))
{ {
try await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo);
{
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, true, taxInfo);
}
catch (Exception e)
{
var message = e.Message.ToLowerInvariant();
if (message.Contains("apple") || message.Contains("in-app"))
{
throw;
}
}
} }
try try
{ {
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId); customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId);
@ -425,18 +404,6 @@ public class StripePaymentService : IPaymentService
braintreeCustomer = customerResult.Target; braintreeCustomer = customerResult.Target;
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); 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) else if (!stripePaymentMethod)
{ {
throw new GatewayException("Payment method is not supported at this time."); 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 subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
{ {
Plan = paymentMethodType == PaymentMethodType.AppleInApp ? PremiumPlanAppleIapId : PremiumPlanId, Plan = PremiumPlanId,
Quantity = 1, Quantity = 1
}); });
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry) if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
@ -547,7 +514,6 @@ public class StripePaymentService : IPaymentService
{ {
var addedCreditToStripeCustomer = false; var addedCreditToStripeCustomer = false;
Braintree.Transaction braintreeTransaction = null; Braintree.Transaction braintreeTransaction = null;
Transaction appleTransaction = null;
var subInvoiceMetadata = new Dictionary<string, string>(); var subInvoiceMetadata = new Dictionary<string, string>();
Stripe.Subscription subscription = null; Stripe.Subscription subscription = null;
@ -564,39 +530,9 @@ public class StripePaymentService : IPaymentService
if (previewInvoice.AmountDue > 0) if (previewInvoice.AmountDue > 0)
{ {
var appleReceiptOrigTransactionId = customer.Metadata != null &&
customer.Metadata.ContainsKey("appleReceipt") ? customer.Metadata["appleReceipt"] : null;
var braintreeCustomerId = customer.Metadata != null && var braintreeCustomerId = customer.Metadata != null &&
customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null; customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null;
if (!string.IsNullOrWhiteSpace(appleReceiptOrigTransactionId)) if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
{
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))
{ {
var btInvoiceAmount = (previewInvoice.AmountDue / 100M); var btInvoiceAmount = (previewInvoice.AmountDue / 100M);
var transactionResult = await _btGateway.Transaction.SaleAsync( var transactionResult = await _btGateway.Transaction.SaleAsync(
@ -712,10 +648,6 @@ public class StripePaymentService : IPaymentService
{ {
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
} }
if (appleTransaction != null)
{
await _transactionRepository.DeleteAsync(appleTransaction);
}
if (e is Stripe.StripeException strEx && if (e is Stripe.StripeException strEx &&
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false)) (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) SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate)
{ {
// remember, when in doubt, throw // remember, when in doubt, throw
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId); var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId);
if (sub == null) if (sub == null)
{ {
@ -860,6 +791,30 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret; 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) public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
{ {
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
@ -942,12 +897,6 @@ public class StripePaymentService : IPaymentService
customerOptions.AddExpand("default_source"); customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method"); customerOptions.AddExpand("invoice_settings.default_payment_method");
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); 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; string paymentIntentClientSecret = null;
@ -1105,8 +1054,7 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret; return paymentIntentClientSecret;
} }
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
bool skipInAppPurchaseCheck = false)
{ {
if (subscriber == null) if (subscriber == null)
{ {
@ -1118,15 +1066,6 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("No subscription."); 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); var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
if (sub == null) if (sub == null)
{ {
@ -1193,7 +1132,7 @@ public class StripePaymentService : IPaymentService
} }
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, 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) if (subscriber == null)
{ {
@ -1207,7 +1146,6 @@ public class StripePaymentService : IPaymentService
} }
var createdCustomer = false; var createdCustomer = false;
AppleReceiptStatus appleReceiptStatus = null;
Braintree.Customer braintreeCustomer = null; Braintree.Customer braintreeCustomer = null;
string stipeCustomerSourceToken = null; string stipeCustomerSourceToken = null;
string stipeCustomerPaymentMethodId = null; string stipeCustomerPaymentMethodId = null;
@ -1215,23 +1153,10 @@ public class StripePaymentService : IPaymentService
{ {
{ "region", _globalSettings.BaseServiceUri.CloudRegion } { "region", _globalSettings.BaseServiceUri.CloudRegion }
}; };
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount;
paymentMethodType == PaymentMethodType.BankAccount;
var inAppPurchase = paymentMethodType == PaymentMethodType.AppleInApp ||
paymentMethodType == PaymentMethodType.GoogleInApp;
Stripe.Customer customer = null; 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)) if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{ {
var options = new Stripe.CustomerGetOptions(); 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"); var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId");
if (stripePaymentMethod) if (stripePaymentMethod)
{ {
@ -1322,15 +1237,6 @@ public class StripePaymentService : IPaymentService
braintreeCustomer = customerResult.Target; 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 else
{ {
throw new GatewayException("Payment method is not supported at this time."); throw new GatewayException("Payment method is not supported at this time.");
@ -1350,25 +1256,6 @@ public class StripePaymentService : IPaymentService
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); 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 try
{ {
if (customer == null) if (customer == null)
@ -1572,11 +1459,6 @@ public class StripePaymentService : IPaymentService
{ {
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount); subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount);
} }
if (subscriber.IsUser())
{
subscriptionInfo.UsingInAppPurchase = customer.Metadata.ContainsKey("appleReceipt");
}
} }
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
@ -1739,19 +1621,6 @@ public class StripePaymentService : IPaymentService
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault(); 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) private decimal GetBillingBalance(Stripe.Customer customer)
{ {
return customer != null ? customer.Balance / 100M : default; return customer != null ? customer.Balance / 100M : default;
@ -1764,14 +1633,6 @@ public class StripePaymentService : IPaymentService
return null; return null;
} }
if (customer.Metadata?.ContainsKey("appleReceipt") ?? false)
{
return new BillingInfo.BillingSource
{
Type = PaymentMethodType.AppleInApp
};
}
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false) if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
{ {
try try

View File

@ -255,7 +255,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
{ {
try try
{ {
await CancelPremiumAsync(user, null, true); await CancelPremiumAsync(user);
} }
catch (GatewayException) { } catch (GatewayException) { }
} }
@ -973,12 +973,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
throw new BadRequestException("You can't subtract storage!"); 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; string paymentIntentClientSecret = null;
IPaymentService paymentService = null; IPaymentService paymentService = null;
if (_globalSettings.SelfHosted) if (_globalSettings.SelfHosted)
@ -1039,29 +1033,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
paymentIntentClientSecret); 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) public async Task UpdateLicenseAsync(User user, UserLicense license)
{ {
if (!_globalSettings.SelfHosted) 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); var eop = endOfPeriod.GetValueOrDefault(true);
if (!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue && if (!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue &&
@ -1144,11 +1115,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
{ {
eop = false; eop = false;
} }
await _paymentService.CancelSubscriptionAsync(user, eop, accountDelete); await _paymentService.CancelSubscriptionAsync(user, eop);
await _referenceEventService.RaiseEventAsync( await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.CancelSubscription, user, _currentContext) new ReferenceEvent(ReferenceEventType.CancelSubscription, user, _currentContext)
{ {
EndOfPeriod = eop, EndOfPeriod = eop
}); });
} }

View File

@ -327,6 +327,7 @@ public class GlobalSettings : IGlobalSettings
public string CertificateThumbprint { get; set; } public string CertificateThumbprint { get; set; }
public string CertificatePassword { get; set; } public string CertificatePassword { get; set; }
public string RedisConnectionString { 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"; public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzAxODIwODAwLCJleHAiOjE3MzM0NDMyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNDMxOSIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.iLA771PffgIh0ClRS8OWHbg2cAgjhgOkUjRRkLNr9dpQXhYZkVKdpUn-Gw9T7grsGcAx0f4p-TQmtcCpbN9EJCF5jlF0-NfsRTp_gmCgQ5eXyiE4DzJp2OCrz_3STf07N1dILwhD3nk9rzcA6SRQ4_kja8wAMHKnD5LisW98r5DfRDBecRs16KS5HUhg99DRMR5fd9ntfydVMTC_E23eEOHVLsR4YhiSXaEINPjFDG1czyOBClJItDW8g9X8qlClZegr630UjnKKg06A4usoL25VFHHn8Ew3v-_-XdlWoWsIpMMVvacwZT8rwkxjIesFNsXG6yzuROIhaxAvB1297A";
} }

View File

@ -338,16 +338,50 @@ public static class CoreHelpers
return Encoding.UTF8.GetString(Base64UrlDecode(input)); 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) 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('/', '_') .Replace('/', '_')
.Replace("=", string.Empty); .Replace("=", string.Empty);
return output; 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) 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; var output = input;
// 62nd char of encoding // 62nd char of encoding
@ -370,8 +404,8 @@ public static class CoreHelpers
throw new InvalidOperationException("Illegal base64url string!"); throw new InvalidOperationException("Illegal base64url string!");
} }
// Standard base64 decoder // Standard base64 string output
return Convert.FromBase64String(output); return output;
} }
public static string PunyEncode(string text) public static string PunyEncode(string text)

View 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;
}
}

View File

@ -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.Models;
using Duende.IdentityServer.Stores; using Duende.IdentityServer.Stores;
using Grant = Bit.Core.Auth.Entities.Grant;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;
public class PersistedGrantStore : IPersistedGrantStore public class PersistedGrantStore : IPersistedGrantStore
{ {
private readonly IGrantRepository _grantRepository; private readonly IGrantRepository _grantRepository;
private readonly Func<PersistedGrant, IGrant> _toGrant;
private readonly IPersistedGrantStore _fallbackGrantStore;
public PersistedGrantStore( public PersistedGrantStore(
IGrantRepository grantRepository) IGrantRepository grantRepository,
Func<PersistedGrant, IGrant> toGrant,
IPersistedGrantStore fallbackGrantStore = null)
{ {
_grantRepository = grantRepository; _grantRepository = grantRepository;
_toGrant = toGrant;
_fallbackGrantStore = fallbackGrantStore;
} }
public async Task<PersistedGrant> GetAsync(string key) public async Task<PersistedGrant> GetAsync(string key)
@ -20,6 +26,11 @@ public class PersistedGrantStore : IPersistedGrantStore
var grant = await _grantRepository.GetByKeyAsync(key); var grant = await _grantRepository.GetByKeyAsync(key);
if (grant == null) 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; return null;
} }
@ -47,28 +58,11 @@ public class PersistedGrantStore : IPersistedGrantStore
public async Task StoreAsync(PersistedGrant pGrant) public async Task StoreAsync(PersistedGrant pGrant)
{ {
var grant = ToGrant(pGrant); var grant = _toGrant(pGrant);
await _grantRepository.SaveAsync(grant); await _grantRepository.SaveAsync(grant);
} }
private Grant ToGrant(PersistedGrant pGrant) private PersistedGrant ToPersistedGrant(IGrant grant)
{
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)
{ {
return new PersistedGrant return new PersistedGrant
{ {

View File

@ -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.Enums;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Utilities; using Bit.Core.Auth.Utilities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Identity.Utilities; using Bit.Identity.Utilities;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;
@ -20,7 +18,6 @@ namespace Bit.Identity.IdentityServer;
public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
@ -31,13 +28,11 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
public UserDecryptionOptionsBuilder( public UserDecryptionOptionsBuilder(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
IOrganizationUserRepository organizationUserRepository IOrganizationUserRepository organizationUserRepository
) )
{ {
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
} }
@ -95,7 +90,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
private async Task BuildTrustedDeviceOptions() private async Task BuildTrustedDeviceOptions()
{ {
// TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change // 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; return;
} }

View File

@ -213,7 +213,11 @@ public class Startup
app.UseRouting(); app.UseRouting();
// Add Cors // 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()); .AllowAnyMethod().AllowAnyHeader().AllowCredentials());
// Add current context // Add current context

View File

@ -1,4 +1,5 @@
using Bit.Core.IdentityServer; using Bit.Core.Auth.Repositories;
using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer;
@ -51,31 +52,58 @@ public static class ServiceCollectionExtensions
.AddIdentityServerCertificate(env, globalSettings) .AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator<WebAuthnGrantValidator>(); .AddExtensionGrantValidator<WebAuthnGrantValidator>();
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.RedisConnectionString)) if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
{ {
// If we have redis, prefer it services.AddSingleton<IPersistedGrantStore>(sp => BuildCosmosGrantStore(sp, globalSettings));
}
// Add the original persisted grant store via it's implementation type else if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.RedisConnectionString))
// so we can inject it right after. {
services.AddSingleton<PersistedGrantStore>(); services.AddSingleton<IPersistedGrantStore>(sp => BuildRedisGrantStore(sp, globalSettings));
services.AddSingleton<IPersistedGrantStore>(sp =>
{
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
);
});
} }
else else
{ {
// Use the original grant store services.AddTransient<IPersistedGrantStore>(sp => BuildSqlGrantStore(sp));
identityServerBuilder.AddPersistedGrantStore<PersistedGrantStore>();
} }
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>(); services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
return identityServerBuilder; 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));
}
} }

View File

@ -1,5 +1,6 @@
using System.Data; using System.Data;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories; using Bit.Infrastructure.Dapper.Repositories;
@ -18,7 +19,7 @@ public class GrantRepository : BaseRepository, IGrantRepository
: base(connectionString, readOnlyConnectionString) : base(connectionString, readOnlyConnectionString)
{ } { }
public async Task<Grant> GetByKeyAsync(string key) public async Task<IGrant> GetByKeyAsync(string key)
{ {
using (var connection = new SqlConnection(ConnectionString)) 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) string clientId, string type)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))
@ -41,17 +42,34 @@ public class GrantRepository : BaseRepository, IGrantRepository
new { SubjectId = subjectId, SessionId = sessionId, ClientId = clientId, Type = type }, new { SubjectId = subjectId, SessionId = sessionId, ClientId = clientId, Type = type },
commandType: CommandType.StoredProcedure); 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)) using (var connection = new SqlConnection(ConnectionString))
{ {
var results = await connection.ExecuteAsync( var results = await connection.ExecuteAsync(
"[dbo].[Grant_Save]", "[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); commandType: CommandType.StoredProcedure);
} }
} }

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -88,7 +88,10 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
UseResetPassword = e.UseResetPassword, UseResetPassword = e.UseResetPassword,
UseScim = e.UseScim, UseScim = e.UseScim,
UseCustomPermissions = e.UseCustomPermissions, UseCustomPermissions = e.UseCustomPermissions,
UsePolicies = e.UsePolicies UsePolicies = e.UsePolicies,
LimitCollectionCreationDeletion = e.LimitCollectionCreationDeletion,
AllowAdminAccessToAllCollectionItems = e.AllowAdminAccessToAllCollectionItems,
FlexibleCollections = e.FlexibleCollections
}).ToListAsync(); }).ToListAsync();
} }
} }
@ -157,6 +160,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
await deleteCiphersTransaction.CommitAsync(); await deleteCiphersTransaction.CommitAsync();
var organizationDeleteTransaction = await dbContext.Database.BeginTransactionAsync(); 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) await dbContext.SsoUsers.Where(su => su.OrganizationId == organization.Id)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await dbContext.SsoConfigs.Where(sc => sc.OrganizationId == organization.Id) await dbContext.SsoConfigs.Where(sc => sc.OrganizationId == organization.Id)

View File

@ -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));
}
}

View File

@ -1,4 +1,5 @@
using AutoMapper; using AutoMapper;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Auth.Models;
using Bit.Infrastructure.EntityFramework.Repositories; 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()) 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()) using (var scope = ServiceScopeFactory.CreateScope())
{ {
@ -67,25 +68,31 @@ public class GrantRepository : BaseEntityFrameworkRepository, IGrantRepository
g.Type == type g.Type == type
select g; select g;
var grants = await query.ToListAsync(); 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()) using (var scope = ServiceScopeFactory.CreateScope())
{ {
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var existingGrant = await (from g in dbContext.Grants var existingGrant = await (from g in dbContext.Grants
where g.Key == obj.Key where g.Key == gObj.Key
select g).FirstOrDefaultAsync(); select g).FirstOrDefaultAsync();
if (existingGrant != null) if (existingGrant != null)
{ {
dbContext.Entry(existingGrant).CurrentValues.SetValues(obj); gObj.Id = existingGrant.Id;
dbContext.Entry(existingGrant).CurrentValues.SetValues(gObj);
} }
else else
{ {
var entity = Mapper.Map<Grant>(obj); var entity = Mapper.Map<Grant>(gObj);
await dbContext.AddAsync(entity); await dbContext.AddAsync(entity);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
} }

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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