diff --git a/.github/workflows/container-registry-purge.yml b/.github/workflows/container-registry-purge.yml
deleted file mode 100644
index 1fc4c511bd..0000000000
--- a/.github/workflows/container-registry-purge.yml
+++ /dev/null
@@ -1,102 +0,0 @@
----
-name: Container registry purge
-
-on:
- schedule:
- - cron: "0 0 * * SUN"
- workflow_dispatch:
- inputs: {}
-
-jobs:
- purge:
- name: Purge old images
- runs-on: ubuntu-22.04
- steps:
- - name: Log in to Azure
- uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- with:
- creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
-
- - name: Purge images
- env:
- REGISTRY: bitwardenprod
- AGO_DUR_VER: "180d"
- AGO_DUR: "30d"
- run: |
- REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
- for REPO in $REPO_LIST
- do
-
- PURGE_LATEST=""
- PURGE_VERSION=""
- PURGE_ELSE=""
-
- TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
- for TAG in $TAG_LIST
- do
- if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
- PURGE_LATEST+="--filter '$REPO:$TAG' "
- elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
- PURGE_VERSION+="--filter '$REPO:$TAG' "
- else
- PURGE_ELSE+="--filter '$REPO:$TAG' "
- fi
- done
-
- if [ ! -z "$PURGE_LATEST" ]
- then
- PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
- az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
- fi
-
- if [ ! -z "$PURGE_VERSION" ]
- then
- PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
- az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
- fi
-
- if [ ! -z "$PURGE_ELSE" ]
- then
- PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
- az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
- fi
-
- wait
-
- done
-
- check-failures:
- name: Check for failures
- if: always()
- runs-on: ubuntu-22.04
- needs: [purge]
- steps:
- - name: Check if any job failed
- if: |
- (github.ref == 'refs/heads/main'
- || github.ref == 'refs/heads/rc'
- || github.ref == 'refs/heads/hotfix-rc')
- && contains(needs.*.result, 'failure')
- run: exit 1
-
- - name: Log in to Azure - CI subscription
- uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- if: failure()
- with:
- creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
-
- - name: Retrieve secrets
- id: retrieve-secrets
- uses: bitwarden/gh-actions/get-keyvault-secrets@main
- if: failure()
- with:
- keyvault: "bitwarden-ci"
- secrets: "devops-alerts-slack-webhook-url"
-
- - name: Notify Slack on failure
- uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
- if: failure()
- env:
- SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
- with:
- status: ${{ job.status }}
diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml
index f4cf36f925..37cda8417a 100644
--- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml
+++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml
@@ -174,18 +174,15 @@
- @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
- {
-
-
-
+
+
+
-
-
+
+
-
+
-
- }
+
}
diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs
index 1f60a554c4..547db90765 100644
--- a/src/Core/AdminConsole/Entities/Organization.cs
+++ b/src/Core/AdminConsole/Entities/Organization.cs
@@ -94,6 +94,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable,
/// they have Can Manage permissions for.
///
public bool LimitCollectionCreationDeletion { get; set; }
+
///
/// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
/// If set to false, users generally need collection-level permissions to read/write a collection or its items.
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 3f54f7d42a..1003a65b51 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -110,7 +110,6 @@ public static class FeatureFlagKeys
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
- public const string EnableDeleteProvider = "AC-1218-delete-provider";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
@@ -144,6 +143,7 @@ public static class FeatureFlagKeys
public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment";
public const string Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api";
+ public const string RemoveServerVersionHeader = "remove-server-version-header";
public static List GetAllKeys()
{
diff --git a/src/Core/NotificationCenter/Authorization/NotificationAuthorizationHandler.cs b/src/Core/NotificationCenter/Authorization/NotificationAuthorizationHandler.cs
new file mode 100644
index 0000000000..6e06c92db1
--- /dev/null
+++ b/src/Core/NotificationCenter/Authorization/NotificationAuthorizationHandler.cs
@@ -0,0 +1,68 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.NotificationCenter.Entities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Authorization;
+
+public class NotificationAuthorizationHandler : AuthorizationHandler
+{
+ private readonly ICurrentContext _currentContext;
+
+ public NotificationAuthorizationHandler(ICurrentContext currentContext)
+ {
+ _currentContext = currentContext;
+ }
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
+ NotificationOperationsRequirement requirement,
+ Notification notification)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ return;
+ }
+
+ var authorized = requirement switch
+ {
+ not null when requirement == NotificationOperations.Read => CanRead(notification),
+ not null when requirement == NotificationOperations.Create => await CanCreate(notification),
+ not null when requirement == NotificationOperations.Update => await CanUpdate(notification),
+ _ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
+ };
+
+ if (authorized)
+ {
+ context.Succeed(requirement);
+ }
+ }
+
+ private bool CanRead(Notification notification)
+ {
+ var userMatching = !notification.UserId.HasValue || notification.UserId.Value == _currentContext.UserId!.Value;
+ var organizationMatching = !notification.OrganizationId.HasValue ||
+ _currentContext.GetOrganization(notification.OrganizationId.Value) != null;
+
+ return notification.Global || (userMatching && organizationMatching);
+ }
+
+ private async Task CanCreate(Notification notification)
+ {
+ var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
+ await _currentContext.AccessReports(notification.OrganizationId.Value);
+ var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
+ notification.UserId.Value == _currentContext.UserId!.Value;
+
+ return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
+ }
+
+ private async Task CanUpdate(Notification notification)
+ {
+ var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
+ await _currentContext.AccessReports(notification.OrganizationId.Value);
+ var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
+ notification.UserId.Value == _currentContext.UserId!.Value;
+
+ return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
+ }
+}
diff --git a/src/Core/NotificationCenter/Authorization/NotificationOperations.cs b/src/Core/NotificationCenter/Authorization/NotificationOperations.cs
new file mode 100644
index 0000000000..5a67805b4a
--- /dev/null
+++ b/src/Core/NotificationCenter/Authorization/NotificationOperations.cs
@@ -0,0 +1,19 @@
+#nullable enable
+using Microsoft.AspNetCore.Authorization.Infrastructure;
+
+namespace Bit.Core.NotificationCenter.Authorization;
+
+public class NotificationOperationsRequirement : OperationAuthorizationRequirement
+{
+ public NotificationOperationsRequirement(string name)
+ {
+ Name = name;
+ }
+}
+
+public static class NotificationOperations
+{
+ public static readonly NotificationOperationsRequirement Read = new(nameof(Read));
+ public static readonly NotificationOperationsRequirement Create = new(nameof(Create));
+ public static readonly NotificationOperationsRequirement Update = new(nameof(Update));
+}
diff --git a/src/Core/NotificationCenter/Authorization/NotificationStatusAuthorizationHandler.cs b/src/Core/NotificationCenter/Authorization/NotificationStatusAuthorizationHandler.cs
new file mode 100644
index 0000000000..df018c0811
--- /dev/null
+++ b/src/Core/NotificationCenter/Authorization/NotificationStatusAuthorizationHandler.cs
@@ -0,0 +1,57 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.NotificationCenter.Entities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Authorization;
+
+public class NotificationStatusAuthorizationHandler : AuthorizationHandler
+{
+ private readonly ICurrentContext _currentContext;
+
+ public NotificationStatusAuthorizationHandler(ICurrentContext currentContext)
+ {
+ _currentContext = currentContext;
+ }
+
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
+ NotificationStatusOperationsRequirement requirement,
+ NotificationStatus notificationStatus)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ return Task.CompletedTask;
+ }
+
+ var authorized = requirement switch
+ {
+ not null when requirement == NotificationStatusOperations.Read => CanRead(notificationStatus),
+ not null when requirement == NotificationStatusOperations.Create => CanCreate(notificationStatus),
+ not null when requirement == NotificationStatusOperations.Update => CanUpdate(notificationStatus),
+ _ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
+ };
+
+ if (authorized)
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private bool CanRead(NotificationStatus notificationStatus)
+ {
+ return notificationStatus.UserId == _currentContext.UserId!.Value;
+ }
+
+ private bool CanCreate(NotificationStatus notificationStatus)
+ {
+ return notificationStatus.UserId == _currentContext.UserId!.Value;
+ }
+
+ private bool CanUpdate(NotificationStatus notificationStatus)
+ {
+ return notificationStatus.UserId == _currentContext.UserId!.Value;
+ }
+}
diff --git a/src/Core/NotificationCenter/Authorization/NotificationStatusOperations.cs b/src/Core/NotificationCenter/Authorization/NotificationStatusOperations.cs
new file mode 100644
index 0000000000..e097d67cb0
--- /dev/null
+++ b/src/Core/NotificationCenter/Authorization/NotificationStatusOperations.cs
@@ -0,0 +1,19 @@
+#nullable enable
+using Microsoft.AspNetCore.Authorization.Infrastructure;
+
+namespace Bit.Core.NotificationCenter.Authorization;
+
+public class NotificationStatusOperationsRequirement : OperationAuthorizationRequirement
+{
+ public NotificationStatusOperationsRequirement(string name)
+ {
+ Name = name;
+ }
+}
+
+public static class NotificationStatusOperations
+{
+ public static readonly NotificationStatusOperationsRequirement Read = new(nameof(Read));
+ public static readonly NotificationStatusOperationsRequirement Create = new(nameof(Create));
+ public static readonly NotificationStatusOperationsRequirement Update = new(nameof(Update));
+}
diff --git a/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs
new file mode 100644
index 0000000000..4f76950a34
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs
@@ -0,0 +1,36 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands.Interfaces;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Commands;
+
+public class CreateNotificationCommand : ICreateNotificationCommand
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationRepository _notificationRepository;
+
+ public CreateNotificationCommand(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationRepository notificationRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationRepository = notificationRepository;
+ }
+
+ public async Task CreateAsync(Notification notification)
+ {
+ notification.CreationDate = notification.RevisionDate = DateTime.UtcNow;
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
+ NotificationOperations.Create);
+
+ return await _notificationRepository.CreateAsync(notification);
+ }
+}
diff --git a/src/Core/NotificationCenter/Commands/CreateNotificationStatusCommand.cs b/src/Core/NotificationCenter/Commands/CreateNotificationStatusCommand.cs
new file mode 100644
index 0000000000..fcd61ceebc
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/CreateNotificationStatusCommand.cs
@@ -0,0 +1,47 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands.Interfaces;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Commands;
+
+public class CreateNotificationStatusCommand : ICreateNotificationStatusCommand
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationRepository _notificationRepository;
+ private readonly INotificationStatusRepository _notificationStatusRepository;
+
+ public CreateNotificationStatusCommand(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationRepository notificationRepository,
+ INotificationStatusRepository notificationStatusRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationRepository = notificationRepository;
+ _notificationStatusRepository = notificationStatusRepository;
+ }
+
+ public async Task CreateAsync(NotificationStatus notificationStatus)
+ {
+ var notification = await _notificationRepository.GetByIdAsync(notificationStatus.NotificationId);
+ if (notification == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
+ NotificationOperations.Read);
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
+ NotificationStatusOperations.Create);
+
+ return await _notificationStatusRepository.CreateAsync(notificationStatus);
+ }
+}
diff --git a/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs
new file mode 100644
index 0000000000..a3b4d894e6
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs
@@ -0,0 +1,9 @@
+#nullable enable
+using Bit.Core.NotificationCenter.Entities;
+
+namespace Bit.Core.NotificationCenter.Commands.Interfaces;
+
+public interface ICreateNotificationCommand
+{
+ Task CreateAsync(Notification notification);
+}
diff --git a/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationStatusCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationStatusCommand.cs
new file mode 100644
index 0000000000..ea9695e2ed
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationStatusCommand.cs
@@ -0,0 +1,9 @@
+#nullable enable
+using Bit.Core.NotificationCenter.Entities;
+
+namespace Bit.Core.NotificationCenter.Commands.Interfaces;
+
+public interface ICreateNotificationStatusCommand
+{
+ Task CreateAsync(NotificationStatus notificationStatus);
+}
diff --git a/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationDeletedCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationDeletedCommand.cs
new file mode 100644
index 0000000000..39bc0735c3
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationDeletedCommand.cs
@@ -0,0 +1,7 @@
+#nullable enable
+namespace Bit.Core.NotificationCenter.Commands.Interfaces;
+
+public interface IMarkNotificationDeletedCommand
+{
+ Task MarkDeletedAsync(Guid notificationId);
+}
diff --git a/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationReadCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationReadCommand.cs
new file mode 100644
index 0000000000..91ce63defb
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/Interfaces/IMarkNotificationReadCommand.cs
@@ -0,0 +1,7 @@
+#nullable enable
+namespace Bit.Core.NotificationCenter.Commands.Interfaces;
+
+public interface IMarkNotificationReadCommand
+{
+ Task MarkReadAsync(Guid notificationId);
+}
diff --git a/src/Core/NotificationCenter/Commands/Interfaces/IUpdateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/IUpdateNotificationCommand.cs
new file mode 100644
index 0000000000..8db10e6f1b
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/Interfaces/IUpdateNotificationCommand.cs
@@ -0,0 +1,9 @@
+#nullable enable
+using Bit.Core.NotificationCenter.Entities;
+
+namespace Bit.Core.NotificationCenter.Commands.Interfaces;
+
+public interface IUpdateNotificationCommand
+{
+ Task UpdateAsync(Notification notification);
+}
diff --git a/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs b/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs
new file mode 100644
index 0000000000..fed9fd0469
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs
@@ -0,0 +1,74 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands.Interfaces;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Commands;
+
+public class MarkNotificationDeletedCommand : IMarkNotificationDeletedCommand
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationRepository _notificationRepository;
+ private readonly INotificationStatusRepository _notificationStatusRepository;
+
+ public MarkNotificationDeletedCommand(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationRepository notificationRepository,
+ INotificationStatusRepository notificationStatusRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationRepository = notificationRepository;
+ _notificationStatusRepository = notificationStatusRepository;
+ }
+
+ public async Task MarkDeletedAsync(Guid notificationId)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ throw new NotFoundException();
+ }
+
+ var notification = await _notificationRepository.GetByIdAsync(notificationId);
+ if (notification == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
+ NotificationOperations.Read);
+
+ var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
+ _currentContext.UserId.Value);
+
+ if (notificationStatus == null)
+ {
+ notificationStatus = new NotificationStatus()
+ {
+ NotificationId = notificationId,
+ UserId = _currentContext.UserId.Value,
+ DeletedDate = DateTime.Now
+ };
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
+ NotificationStatusOperations.Create);
+
+ await _notificationStatusRepository.CreateAsync(notificationStatus);
+ }
+ else
+ {
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
+ NotificationStatusOperations.Update);
+
+ notificationStatus.DeletedDate = DateTime.UtcNow;
+
+ await _notificationStatusRepository.UpdateAsync(notificationStatus);
+ }
+ }
+}
diff --git a/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs b/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs
new file mode 100644
index 0000000000..9368660501
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs
@@ -0,0 +1,74 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands.Interfaces;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Commands;
+
+public class MarkNotificationReadCommand : IMarkNotificationReadCommand
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationRepository _notificationRepository;
+ private readonly INotificationStatusRepository _notificationStatusRepository;
+
+ public MarkNotificationReadCommand(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationRepository notificationRepository,
+ INotificationStatusRepository notificationStatusRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationRepository = notificationRepository;
+ _notificationStatusRepository = notificationStatusRepository;
+ }
+
+ public async Task MarkReadAsync(Guid notificationId)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ throw new NotFoundException();
+ }
+
+ var notification = await _notificationRepository.GetByIdAsync(notificationId);
+ if (notification == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notification,
+ NotificationOperations.Read);
+
+ var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
+ _currentContext.UserId.Value);
+
+ if (notificationStatus == null)
+ {
+ notificationStatus = new NotificationStatus()
+ {
+ NotificationId = notificationId,
+ UserId = _currentContext.UserId.Value,
+ ReadDate = DateTime.Now
+ };
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus,
+ NotificationStatusOperations.Create);
+
+ await _notificationStatusRepository.CreateAsync(notificationStatus);
+ }
+ else
+ {
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
+ notificationStatus, NotificationStatusOperations.Update);
+
+ notificationStatus.ReadDate = DateTime.UtcNow;
+
+ await _notificationStatusRepository.UpdateAsync(notificationStatus);
+ }
+ }
+}
diff --git a/src/Core/NotificationCenter/Commands/UpdateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/UpdateNotificationCommand.cs
new file mode 100644
index 0000000000..f049478178
--- /dev/null
+++ b/src/Core/NotificationCenter/Commands/UpdateNotificationCommand.cs
@@ -0,0 +1,47 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands.Interfaces;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Commands;
+
+public class UpdateNotificationCommand : IUpdateNotificationCommand
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationRepository _notificationRepository;
+
+ public UpdateNotificationCommand(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationRepository notificationRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationRepository = notificationRepository;
+ }
+
+ public async Task UpdateAsync(Notification notificationToUpdate)
+ {
+ var notification = await _notificationRepository.GetByIdAsync(notificationToUpdate.Id);
+ if (notification == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
+ notification, NotificationOperations.Update);
+
+ notification.Priority = notificationToUpdate.Priority;
+ notification.ClientType = notificationToUpdate.ClientType;
+ notification.Title = notificationToUpdate.Title;
+ notification.Body = notificationToUpdate.Body;
+ notification.RevisionDate = DateTime.UtcNow;
+
+ await _notificationRepository.ReplaceAsync(notification);
+ }
+}
diff --git a/src/Core/NotificationCenter/Models/Data/NotificationStatusDetails.cs b/src/Core/NotificationCenter/Models/Data/NotificationStatusDetails.cs
new file mode 100644
index 0000000000..d48985e725
--- /dev/null
+++ b/src/Core/NotificationCenter/Models/Data/NotificationStatusDetails.cs
@@ -0,0 +1,25 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Enums;
+using Bit.Core.NotificationCenter.Enums;
+
+namespace Bit.Core.NotificationCenter.Models.Data;
+
+public class NotificationStatusDetails
+{
+ // Notification fields
+ public Guid Id { get; set; }
+ public Priority Priority { get; set; }
+ public bool Global { get; set; }
+ public ClientType ClientType { get; set; }
+ public Guid? UserId { get; set; }
+ public Guid? OrganizationId { get; set; }
+ [MaxLength(256)]
+ public string? Title { get; set; }
+ public string? Body { get; set; }
+ public DateTime CreationDate { get; set; }
+ public DateTime RevisionDate { get; set; }
+ // Notification Status fields
+ public DateTime? ReadDate { get; set; }
+ public DateTime? DeletedDate { get; set; }
+}
diff --git a/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs b/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs
new file mode 100644
index 0000000000..0a783a59ba
--- /dev/null
+++ b/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs
@@ -0,0 +1,38 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Models.Data;
+using Bit.Core.NotificationCenter.Models.Filter;
+using Bit.Core.NotificationCenter.Queries.Interfaces;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.NotificationCenter.Queries;
+
+public class GetNotificationStatusDetailsForUserQuery : IGetNotificationStatusDetailsForUserQuery
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly INotificationRepository _notificationRepository;
+
+ public GetNotificationStatusDetailsForUserQuery(ICurrentContext currentContext,
+ INotificationRepository notificationRepository)
+ {
+ _currentContext = currentContext;
+ _notificationRepository = notificationRepository;
+ }
+
+ public async Task> GetByUserIdStatusFilterAsync(
+ NotificationStatusFilter statusFilter)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ throw new NotFoundException();
+ }
+
+ var clientType = DeviceTypes.ToClientType(_currentContext.DeviceType);
+
+ // Note: only returns the user's notifications - no authorization check needed
+ return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType,
+ statusFilter);
+ }
+}
diff --git a/src/Core/NotificationCenter/Queries/GetNotificationStatusForUserQuery.cs b/src/Core/NotificationCenter/Queries/GetNotificationStatusForUserQuery.cs
new file mode 100644
index 0000000000..b28a0444a8
--- /dev/null
+++ b/src/Core/NotificationCenter/Queries/GetNotificationStatusForUserQuery.cs
@@ -0,0 +1,47 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Queries.Interfaces;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Core.NotificationCenter.Queries;
+
+public class GetNotificationStatusForUserQuery : IGetNotificationStatusForUserQuery
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly INotificationStatusRepository _notificationStatusRepository;
+
+ public GetNotificationStatusForUserQuery(ICurrentContext currentContext,
+ IAuthorizationService authorizationService,
+ INotificationStatusRepository notificationStatusRepository)
+ {
+ _currentContext = currentContext;
+ _authorizationService = authorizationService;
+ _notificationStatusRepository = notificationStatusRepository;
+ }
+
+ public async Task GetByNotificationIdAndUserIdAsync(Guid notificationId)
+ {
+ if (!_currentContext.UserId.HasValue)
+ {
+ throw new NotFoundException();
+ }
+
+ var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(notificationId,
+ _currentContext.UserId.Value);
+ if (notificationStatus == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User,
+ notificationStatus, NotificationStatusOperations.Read);
+
+ return notificationStatus;
+ }
+}
diff --git a/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs
new file mode 100644
index 0000000000..456a0e9400
--- /dev/null
+++ b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs
@@ -0,0 +1,10 @@
+#nullable enable
+using Bit.Core.NotificationCenter.Models.Data;
+using Bit.Core.NotificationCenter.Models.Filter;
+
+namespace Bit.Core.NotificationCenter.Queries.Interfaces;
+
+public interface IGetNotificationStatusDetailsForUserQuery
+{
+ Task> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter);
+}
diff --git a/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusForUserQuery.cs b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusForUserQuery.cs
new file mode 100644
index 0000000000..a319565357
--- /dev/null
+++ b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusForUserQuery.cs
@@ -0,0 +1,9 @@
+#nullable enable
+using Bit.Core.NotificationCenter.Entities;
+
+namespace Bit.Core.NotificationCenter.Queries.Interfaces;
+
+public interface IGetNotificationStatusForUserQuery
+{
+ Task GetByNotificationIdAndUserIdAsync(Guid notificationId);
+}
diff --git a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs
index 623e759dfb..2c3faed914 100644
--- a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs
+++ b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs
@@ -1,6 +1,7 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.Repositories;
@@ -23,7 +24,8 @@ public interface INotificationRepository : IRepository
///
///
/// Ordered by priority (highest to lowest) and creation date (descending).
+ /// Includes all fields from and
///
- Task> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType,
+ Task> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType,
NotificationStatusFilter? statusFilter);
}
diff --git a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs
index 40bfd4b0ea..f70c50f49f 100644
--- a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs
+++ b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs
@@ -2,6 +2,7 @@
using System.Data;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Settings;
@@ -23,12 +24,12 @@ public class NotificationRepository : Repository, INotificat
{
}
- public async Task> GetByUserIdAndStatusAsync(Guid userId,
+ public async Task> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter)
{
await using var connection = new SqlConnection(ConnectionString);
- var results = await connection.QueryAsync(
+ var results = await connection.QueryAsync(
"[dbo].[Notification_ReadByUserIdAndStatus]",
new { UserId = userId, ClientType = clientType, statusFilter?.Read, statusFilter?.Deleted },
commandType: CommandType.StoredProcedure);
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs
index 4d6b1b915d..47369f5e3d 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs
@@ -12,10 +12,6 @@ public class OrganizationEntityTypeConfiguration : IEntityTypeConfiguration o.Id)
.ValueGeneratedNever();
- builder.Property(c => c.LimitCollectionCreationDeletion)
- .ValueGeneratedNever()
- .HasDefaultValue(true);
-
builder.Property(c => c.AllowAdminAccessToAllCollectionItems)
.ValueGeneratedNever()
.HasDefaultValue(true);
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs
index d7f83d829d..288b5c6a9d 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs
@@ -9,6 +9,10 @@ namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class Organization : Core.AdminConsole.Entities.Organization
{
+ // Shadow properties - to be introduced by https://bitwarden.atlassian.net/browse/PM-10863
+ public bool LimitCollectionCreation { get => LimitCollectionCreationDeletion; set => LimitCollectionCreationDeletion = value; }
+ public bool LimitCollectionDeletion { get => LimitCollectionCreationDeletion; set => LimitCollectionCreationDeletion = value; }
+
public virtual ICollection Ciphers { get; set; }
public virtual ICollection OrganizationUsers { get; set; }
public virtual ICollection Groups { get; set; }
@@ -38,6 +42,9 @@ public class OrganizationMapperProfile : Profile
.ForMember(org => org.ApiKeys, opt => opt.Ignore())
.ForMember(org => org.Connections, opt => opt.Ignore())
.ForMember(org => org.Domains, opt => opt.Ignore())
+ // Shadow properties - to be introduced by https://bitwarden.atlassian.net/browse/PM-10863
+ .ForMember(org => org.LimitCollectionCreation, opt => opt.Ignore())
+ .ForMember(org => org.LimitCollectionDeletion, opt => opt.Ignore())
.ReverseMap();
CreateProjection()
diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs
index 03ae63c598..a413e78748 100644
--- a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs
+++ b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs
@@ -1,9 +1,11 @@
#nullable enable
using AutoMapper;
using Bit.Core.Enums;
+using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Infrastructure.EntityFramework.NotificationCenter.Models;
+using Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories.Queries;
using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -20,34 +22,13 @@ public class NotificationRepository : Repository> GetByUserIdAsync(Guid userId,
ClientType clientType)
- {
- return await GetByUserIdAndStatusAsync(userId, clientType, new NotificationStatusFilter());
- }
-
- public async Task> GetByUserIdAndStatusAsync(Guid userId,
- ClientType clientType, NotificationStatusFilter? statusFilter)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
- var notificationQuery = BuildNotificationQuery(dbContext, userId, clientType);
+ var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType);
- if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null))
- {
- notificationQuery = from n in notificationQuery
- join ns in dbContext.NotificationStatuses on n.Id equals ns.NotificationId
- where
- ns.UserId == userId &&
- (
- statusFilter.Read == null ||
- (statusFilter.Read == true ? ns.ReadDate != null : ns.ReadDate == null) ||
- statusFilter.Deleted == null ||
- (statusFilter.Deleted == true ? ns.DeletedDate != null : ns.DeletedDate == null)
- )
- select n;
- }
-
- var notifications = await notificationQuery
+ var notifications = await notificationStatusDetailsViewQuery.Run(dbContext)
.OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate)
.ToListAsync();
@@ -55,38 +36,28 @@ public class NotificationRepository : Repository>(notifications);
}
- private static IQueryable BuildNotificationQuery(DatabaseContext dbContext, Guid userId,
- ClientType clientType)
+ public async Task> GetByUserIdAndStatusAsync(Guid userId,
+ ClientType clientType, NotificationStatusFilter? statusFilter)
{
- var clientTypes = new[] { ClientType.All };
- if (clientType != ClientType.All)
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType);
+
+ var query = notificationStatusDetailsViewQuery.Run(dbContext);
+ if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null))
{
- clientTypes = [ClientType.All, clientType];
+ query = from n in query
+ where statusFilter.Read == null ||
+ (statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null) ||
+ statusFilter.Deleted == null ||
+ (statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null)
+ select n;
}
- return from n in dbContext.Notifications
- join ou in dbContext.OrganizationUsers.Where(ou => ou.UserId == userId)
- on n.OrganizationId equals ou.OrganizationId into grouping
- from ou in grouping.DefaultIfEmpty()
- where
- clientTypes.Contains(n.ClientType) &&
- (
- (
- n.Global &&
- n.UserId == null &&
- n.OrganizationId == null
- ) ||
- (
- !n.Global &&
- n.UserId == userId &&
- (n.OrganizationId == null || ou != null)
- ) ||
- (
- !n.Global &&
- n.UserId == null &&
- ou != null
- )
- )
- select n;
+ return await query
+ .OrderByDescending(n => n.Priority)
+ .ThenByDescending(n => n.CreationDate)
+ .ToListAsync();
}
}
diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/Queries/NotificationStatusDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/Queries/NotificationStatusDetailsViewQuery.cs
new file mode 100644
index 0000000000..2f8bade1d3
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/Queries/NotificationStatusDetailsViewQuery.cs
@@ -0,0 +1,63 @@
+#nullable enable
+using Bit.Core.Enums;
+using Bit.Core.NotificationCenter.Models.Data;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories.Queries;
+
+namespace Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories.Queries;
+
+public class NotificationStatusDetailsViewQuery(Guid userId, ClientType clientType) : IQuery
+{
+ public IQueryable Run(DatabaseContext dbContext)
+ {
+ var clientTypes = new[] { ClientType.All };
+ if (clientType != ClientType.All)
+ {
+ clientTypes = [ClientType.All, clientType];
+ }
+
+ var query = from n in dbContext.Notifications
+ join ou in dbContext.OrganizationUsers.Where(ou => ou.UserId == userId)
+ on n.OrganizationId equals ou.OrganizationId into groupingOrganizationUsers
+ from ou in groupingOrganizationUsers.DefaultIfEmpty()
+ join ns in dbContext.NotificationStatuses.Where(ns => ns.UserId == userId) on n.Id equals ns.NotificationId
+ into groupingNotificationStatus
+ from ns in groupingNotificationStatus.DefaultIfEmpty()
+ where
+ clientTypes.Contains(n.ClientType) &&
+ (
+ (
+ n.Global &&
+ n.UserId == null &&
+ n.OrganizationId == null
+ ) ||
+ (
+ !n.Global &&
+ n.UserId == userId &&
+ (n.OrganizationId == null || ou != null)
+ ) ||
+ (
+ !n.Global &&
+ n.UserId == null &&
+ ou != null
+ )
+ )
+ select new { n, ns };
+
+ return query.Select(x => new NotificationStatusDetails
+ {
+ Id = x.n.Id,
+ Priority = x.n.Priority,
+ Global = x.n.Global,
+ ClientType = x.n.ClientType,
+ UserId = x.n.UserId,
+ OrganizationId = x.n.OrganizationId,
+ Title = x.n.Title,
+ Body = x.n.Body,
+ CreationDate = x.n.CreationDate,
+ RevisionDate = x.n.RevisionDate,
+ ReadDate = x.ns != null ? x.ns.ReadDate : null,
+ DeletedDate = x.ns != null ? x.ns.DeletedDate : null,
+ });
+ }
+}
diff --git a/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs b/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs
new file mode 100644
index 0000000000..4fb0e8f92e
--- /dev/null
+++ b/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs
@@ -0,0 +1,117 @@
+using System.Collections;
+using Bit.Core;
+using Bit.Core.Services;
+using Bit.Core.Settings;
+using Bit.Core.Utilities;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+#nullable enable
+
+namespace Bit.SharedWeb.Utilities;
+
+public sealed class RequestLoggingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly GlobalSettings _globalSettings;
+
+ public RequestLoggingMiddleware(RequestDelegate next, ILogger logger, GlobalSettings globalSettings)
+ {
+ _next = next;
+ _logger = logger;
+ _globalSettings = globalSettings;
+ }
+
+ public Task Invoke(HttpContext context, IFeatureService featureService)
+ {
+ if (!featureService.IsEnabled(FeatureFlagKeys.RemoveServerVersionHeader))
+ {
+ context.Response.OnStarting(() =>
+ {
+ context.Response.Headers.Append("Server-Version", AssemblyHelpers.GetVersion());
+ return Task.CompletedTask;
+ });
+ }
+
+ using (_logger.BeginScope(
+ new RequestLogScope(context.GetIpAddress(_globalSettings),
+ GetHeaderValue(context, "user-agent"),
+ GetHeaderValue(context, "device-type"),
+ GetHeaderValue(context, "device-type"))))
+ {
+ return _next(context);
+ }
+
+ static string? GetHeaderValue(HttpContext httpContext, string header)
+ {
+ if (httpContext.Request.Headers.TryGetValue(header, out var value))
+ {
+ return value;
+ }
+
+ return null;
+ }
+ }
+
+
+ private sealed class RequestLogScope : IReadOnlyList>
+ {
+ private string? _cachedToString;
+
+ public RequestLogScope(string? ipAddress, string? userAgent, string? deviceType, string? origin)
+ {
+ IpAddress = ipAddress;
+ UserAgent = userAgent;
+ DeviceType = deviceType;
+ Origin = origin;
+ }
+
+ public KeyValuePair this[int index]
+ {
+ get
+ {
+ if (index == 0)
+ {
+ return new KeyValuePair(nameof(IpAddress), IpAddress);
+ }
+ else if (index == 1)
+ {
+ return new KeyValuePair(nameof(UserAgent), UserAgent);
+ }
+ else if (index == 2)
+ {
+ return new KeyValuePair(nameof(DeviceType), DeviceType);
+ }
+ else if (index == 3)
+ {
+ return new KeyValuePair(nameof(Origin), Origin);
+ }
+
+ throw new ArgumentOutOfRangeException(nameof(index));
+ }
+ }
+
+ public int Count => 4;
+
+ public string? IpAddress { get; }
+ public string? UserAgent { get; }
+ public string? DeviceType { get; }
+ public string? Origin { get; }
+
+ public IEnumerator> GetEnumerator()
+ {
+ for (var i = 0; i < Count; i++)
+ {
+ yield return this[i];
+ }
+ }
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public override string ToString()
+ {
+ _cachedToString ??= $"IpAddress:{IpAddress} UserAgent:{UserAgent} DeviceType:{DeviceType} Origin:{Origin}";
+ return _cachedToString;
+ }
+ }
+}
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index be451ea318..bd3aecf2f5 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -48,7 +48,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Localization;
@@ -60,7 +59,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using Serilog.Context;
using StackExchange.Redis;
using NoopRepos = Bit.Core.Repositories.Noop;
using Role = Bit.Core.Entities.Role;
@@ -540,31 +538,7 @@ public static class ServiceCollectionExtensions
public static void UseDefaultMiddleware(this IApplicationBuilder app,
IWebHostEnvironment env, GlobalSettings globalSettings)
{
- string GetHeaderValue(HttpContext httpContext, string header)
- {
- if (httpContext.Request.Headers.ContainsKey(header))
- {
- return httpContext.Request.Headers[header];
- }
- return null;
- }
-
- // Add version information to response headers
- app.Use(async (httpContext, next) =>
- {
- using (LogContext.PushProperty("IPAddress", httpContext.GetIpAddress(globalSettings)))
- using (LogContext.PushProperty("UserAgent", GetHeaderValue(httpContext, "user-agent")))
- using (LogContext.PushProperty("DeviceType", GetHeaderValue(httpContext, "device-type")))
- using (LogContext.PushProperty("Origin", GetHeaderValue(httpContext, "origin")))
- {
- httpContext.Response.OnStarting((state) =>
- {
- httpContext.Response.Headers.Append("Server-Version", AssemblyHelpers.GetVersion());
- return Task.FromResult(0);
- }, null);
- await next.Invoke();
- }
- });
+ app.UseMiddleware();
}
public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings)
diff --git a/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql b/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql
index baf144501e..b98f85f73c 100644
--- a/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql
+++ b/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql
@@ -8,12 +8,11 @@ BEGIN
SET NOCOUNT ON
SELECT n.*
- FROM [dbo].[NotificationView] n
+ FROM [dbo].[NotificationStatusDetailsView] n
LEFT JOIN [dbo].[OrganizationUserView] ou ON n.[OrganizationId] = ou.[OrganizationId]
AND ou.[UserId] = @UserId
- LEFT JOIN [dbo].[NotificationStatusView] ns ON n.[Id] = ns.[NotificationId]
- AND ns.[UserId] = @UserId
- WHERE [ClientType] IN (0, CASE WHEN @ClientType != 0 THEN @ClientType END)
+ WHERE (n.[NotificationStatusUserId] IS NULL OR n.[NotificationStatusUserId] = @UserId)
+ AND [ClientType] IN (0, CASE WHEN @ClientType != 0 THEN @ClientType END)
AND ([Global] = 1
OR (n.[UserId] = @UserId
AND (n.[OrganizationId] IS NULL
@@ -21,14 +20,14 @@ BEGIN
OR (n.[UserId] IS NULL
AND ou.[OrganizationId] IS NOT NULL))
AND ((@Read IS NULL AND @Deleted IS NULL)
- OR (ns.[NotificationId] IS NOT NULL
+ OR (n.[NotificationStatusUserId] IS NOT NULL
AND ((@Read IS NULL
- OR IIF((@Read = 1 AND ns.[ReadDate] IS NOT NULL) OR
- (@Read = 0 AND ns.[ReadDate] IS NULL),
+ OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR
+ (@Read = 0 AND n.[ReadDate] IS NULL),
1, 0) = 1)
OR (@Deleted IS NULL
- OR IIF((@Deleted = 1 AND ns.[DeletedDate] IS NOT NULL) OR
- (@Deleted = 0 AND ns.[DeletedDate] IS NULL),
+ OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
+ (@Deleted = 0 AND n.[DeletedDate] IS NULL),
1, 0) = 1))))
ORDER BY [Priority] DESC, n.[CreationDate] DESC
END
diff --git a/src/Sql/NotificationCenter/dbo/Views/NotificationStatusDetailsView.sql b/src/Sql/NotificationCenter/dbo/Views/NotificationStatusDetailsView.sql
new file mode 100644
index 0000000000..5264be2009
--- /dev/null
+++ b/src/Sql/NotificationCenter/dbo/Views/NotificationStatusDetailsView.sql
@@ -0,0 +1,13 @@
+CREATE VIEW [dbo].[NotificationStatusDetailsView]
+AS
+SELECT
+ N.*,
+ NS.UserId AS NotificationStatusUserId,
+ NS.ReadDate,
+ NS.DeletedDate
+FROM
+ [dbo].[Notification] AS N
+LEFT JOIN
+ [dbo].[NotificationStatus] as NS
+ON
+ N.[Id] = NS.[NotificationId]
diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql
index 5ddfa16500..9084f0dffc 100644
--- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql
+++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql
@@ -1,4 +1,4 @@
-CREATE PROCEDURE [dbo].[Organization_Create]
+CREATE PROCEDURE [dbo].[Organization_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@@ -51,12 +51,17 @@
@MaxAutoscaleSmSeats INT= null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
- @LimitCollectionCreationDeletion BIT = 0,
+ @LimitCollectionCreationDeletion BIT = NULL, -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ @LimitCollectionCreation BIT = NULL,
+ @LimitCollectionDeletion BIT = NULL,
@AllowAdminAccessToAllCollectionItems BIT = 0
AS
BEGIN
SET NOCOUNT ON
+ SET @LimitCollectionCreation = COALESCE(@LimitCollectionCreation, @LimitCollectionCreationDeletion, 0);
+ SET @LimitCollectionDeletion = COALESCE(@LimitCollectionDeletion, @LimitCollectionCreationDeletion, 0);
+
INSERT INTO [dbo].[Organization]
(
[Id],
@@ -111,7 +116,9 @@ BEGIN
[MaxAutoscaleSmSeats],
[MaxAutoscaleSmServiceAccounts],
[SecretsManagerBeta],
- [LimitCollectionCreationDeletion],
+ [LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ [LimitCollectionCreation],
+ [LimitCollectionDeletion],
[AllowAdminAccessToAllCollectionItems]
)
VALUES
@@ -168,7 +175,9 @@ BEGIN
@MaxAutoscaleSmSeats,
@MaxAutoscaleSmServiceAccounts,
@SecretsManagerBeta,
- @LimitCollectionCreationDeletion,
+ COALESCE(@LimitCollectionCreation, @LimitCollectionDeletion, 0), -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863)
+ @LimitCollectionCreation,
+ @LimitCollectionDeletion,
@AllowAdminAccessToAllCollectionItems
)
END
diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql
index 7a10f309d0..fc85dad248 100644
--- a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql
+++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql
@@ -1,4 +1,4 @@
-CREATE PROCEDURE [dbo].[Organization_ReadAbilities]
+CREATE PROCEDURE [dbo].[Organization_ReadAbilities]
AS
BEGIN
SET NOCOUNT ON
@@ -21,7 +21,9 @@ BEGIN
[UseResetPassword],
[UsePolicies],
[Enabled],
- [LimitCollectionCreationDeletion],
+ [LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ [LimitCollectionCreation],
+ [LimitCollectionDeletion],
[AllowAdminAccessToAllCollectionItems]
FROM
[dbo].[Organization]
diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql
index c76045a49d..630f48d2ae 100644
--- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql
+++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql
@@ -1,4 +1,4 @@
-CREATE PROCEDURE [dbo].[Organization_Update]
+CREATE PROCEDURE [dbo].[Organization_Update]
@Id UNIQUEIDENTIFIER,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@@ -51,12 +51,17 @@
@MaxAutoscaleSmSeats INT = null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
- @LimitCollectionCreationDeletion BIT = 0,
+ @LimitCollectionCreationDeletion BIT = null, -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ @LimitCollectionCreation BIT = null,
+ @LimitCollectionDeletion BIT = null,
@AllowAdminAccessToAllCollectionItems BIT = 0
AS
BEGIN
SET NOCOUNT ON
+ SET @LimitCollectionCreation = COALESCE(@LimitCollectionCreation, @LimitCollectionCreationDeletion, 0);
+ SET @LimitCollectionDeletion = COALESCE(@LimitCollectionDeletion, @LimitCollectionCreationDeletion, 0);
+
UPDATE
[dbo].[Organization]
SET
@@ -111,7 +116,9 @@ BEGIN
[MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
[MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
[SecretsManagerBeta] = @SecretsManagerBeta,
- [LimitCollectionCreationDeletion] = @LimitCollectionCreationDeletion,
+ [LimitCollectionCreationDeletion] = COALESCE(@LimitCollectionCreation, @LimitCollectionDeletion, 0),
+ [LimitCollectionCreation] = @LimitCollectionCreation,
+ [LimitCollectionDeletion] = @LimitCollectionDeletion,
[AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems
WHERE
[Id] = @Id
diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql
index de6fa62830..1f181e5ee6 100644
--- a/src/Sql/dbo/Tables/Organization.sql
+++ b/src/Sql/dbo/Tables/Organization.sql
@@ -1,4 +1,4 @@
-CREATE TABLE [dbo].[Organization] (
+CREATE TABLE [dbo].[Organization] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[Identifier] NVARCHAR (50) NULL,
[Name] NVARCHAR (50) NOT NULL,
@@ -52,6 +52,8 @@
[MaxAutoscaleSmServiceAccounts] INT NULL,
[SecretsManagerBeta] BIT NOT NULL CONSTRAINT [DF_Organization_SecretsManagerBeta] DEFAULT (0),
[LimitCollectionCreationDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionCreationDeletion] DEFAULT (0),
+ [LimitCollectionCreation] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionCreation] DEFAULT (0),
+ [LimitCollectionDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionDeletion] DEFAULT (0),
[AllowAdminAccessToAllCollectionItems] BIT NOT NULL CONSTRAINT [DF_Organization_AllowAdminAccessToAllCollectionItems] DEFAULT (0),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);
diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql
index 14343ce5c3..cbc54aeeb4 100644
--- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql
+++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql
@@ -1,4 +1,4 @@
-CREATE VIEW [dbo].[OrganizationUserOrganizationDetailsView]
+CREATE VIEW [dbo].[OrganizationUserOrganizationDetailsView]
AS
SELECT
OU.[UserId],
@@ -46,7 +46,9 @@ SELECT
O.[UsePasswordManager],
O.[SmSeats],
O.[SmServiceAccounts],
- O.[LimitCollectionCreationDeletion],
+ O.[LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ O.[LimitCollectionCreation],
+ O.[LimitCollectionDeletion],
O.[AllowAdminAccessToAllCollectionItems]
FROM
[dbo].[OrganizationUser] OU
diff --git a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql
index f2be08ebf6..e90d4ad6f2 100644
--- a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql
+++ b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql
@@ -1,4 +1,4 @@
-CREATE VIEW [dbo].[ProviderUserProviderOrganizationDetailsView]
+CREATE VIEW [dbo].[ProviderUserProviderOrganizationDetailsView]
AS
SELECT
PU.[UserId],
@@ -32,7 +32,9 @@ SELECT
PU.[Id] ProviderUserId,
P.[Name] ProviderName,
O.[PlanType],
- O.[LimitCollectionCreationDeletion],
+ O.[LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ O.[LimitCollectionCreation],
+ O.[LimitCollectionDeletion],
O.[AllowAdminAccessToAllCollectionItems]
FROM
[dbo].[ProviderUser] PU
diff --git a/test/Core.Test/NotificationCenter/Authorization/NotificationAuthorizationHandlerTest.cs b/test/Core.Test/NotificationCenter/Authorization/NotificationAuthorizationHandlerTest.cs
new file mode 100644
index 0000000000..9985d279bd
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Authorization/NotificationAuthorizationHandlerTest.cs
@@ -0,0 +1,419 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.Authorization;
+
+using System.Security.Claims;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Entities;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+public class NotificationAuthorizationHandlerTests
+{
+ private static void SetupUserPermission(SutProvider sutProvider,
+ Guid? userId = null, Guid? organizationId = null, bool canAccessReports = false)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ sutProvider.GetDependency().GetOrganization(organizationId.GetValueOrDefault(Guid.NewGuid()))
+ .Returns(new CurrentContextOrganization());
+ sutProvider.GetDependency().AccessReports(organizationId.GetValueOrDefault(Guid.NewGuid()))
+ .Returns(canAccessReports);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_UnsupportedNotificationOperationRequirement_Throws(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = new NotificationOperationsRequirement("UnsupportedOperation");
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context));
+ }
+
+ [Theory]
+ [BitAutoData(nameof(NotificationOperations.Read))]
+ [BitAutoData(nameof(NotificationOperations.Create))]
+ [BitAutoData(nameof(NotificationOperations.Update))]
+ public async Task HandleAsync_NotLoggedIn_Unauthorized(
+ string requirementName,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, userId: null);
+ var requirement = new NotificationOperationsRequirement(requirementName);
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(nameof(NotificationOperations.Read))]
+ [BitAutoData(nameof(NotificationOperations.Create))]
+ [BitAutoData(nameof(NotificationOperations.Update))]
+ public async Task HandleAsync_ResourceEmpty_Unauthorized(
+ string requirementName,
+ SutProvider sutProvider,
+ ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = new NotificationOperationsRequirement(requirementName);
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, null);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: true)]
+ public async Task HandleAsync_ReadRequirementGlobalNotification_Authorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+
+ var requirement = NotificationOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(false)]
+ [BitAutoData(true)]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_ReadRequirementUserNotMatching_Unauthorized(
+ bool hasOrganizationId,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid(), notification.OrganizationId);
+
+ if (!hasOrganizationId)
+ {
+ notification.OrganizationId = null;
+ }
+
+ var requirement = NotificationOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [BitAutoData(false)]
+ [BitAutoData(true)]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_ReadRequirementOrganizationNotMatching_Unauthorized(
+ bool hasUserId,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, Guid.NewGuid());
+
+ if (!hasUserId)
+ {
+ notification.UserId = null;
+ }
+
+ var requirement = NotificationOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(false, true)]
+ [BitAutoData(true, false)]
+ [BitAutoData(true, true)]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_ReadRequirement_Authorized(
+ bool hasUserId,
+ bool hasOrganizationId,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, notification.OrganizationId);
+
+ if (!hasUserId)
+ {
+ notification.UserId = null;
+ }
+
+ if (!hasOrganizationId)
+ {
+ notification.OrganizationId = null;
+ }
+
+ var requirement = NotificationOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: true)]
+ public async Task HandleAsync_CreateRequirementGlobalNotification_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_CreateRequirementUserNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid(), notification.OrganizationId);
+
+ notification.OrganizationId = null;
+
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_CreateRequirementOrganizationNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, Guid.NewGuid());
+
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_CreateRequirementOrganizationUserNoAccessReportsPermission_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, notification.OrganizationId, canAccessReports: false);
+
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_CreateRequirementUserNotPartOfOrganization_Authorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId);
+
+ notification.OrganizationId = null;
+
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(false)]
+ [BitAutoData(true)]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_CreateRequirementOrganizationUserCanAccessReports_Authorized(
+ bool hasUserId,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, notification.OrganizationId, true);
+
+ if (!hasUserId)
+ {
+ notification.UserId = null;
+ }
+
+ var requirement = NotificationOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ // TODO
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: true)]
+ public async Task HandleAsync_UpdateRequirementGlobalNotification_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_UpdateRequirementUserNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid(), notification.OrganizationId);
+
+ notification.OrganizationId = null;
+
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_UpdateRequirementOrganizationNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, Guid.NewGuid());
+
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_UpdateRequirementOrganizationUserNoAccessReportsPermission_Unauthorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, notification.OrganizationId, canAccessReports: false);
+
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_UpdateRequirementUserNotPartOfOrganization_Authorized(
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId);
+
+ notification.OrganizationId = null;
+
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(false)]
+ [BitAutoData(true)]
+ [NotificationCustomize(global: false)]
+ public async Task HandleAsync_UpdateRequirementOrganizationUserCanAccessReports_Authorized(
+ bool hasUserId,
+ SutProvider sutProvider,
+ Notification notification, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notification.UserId, notification.OrganizationId, true);
+
+ if (!hasUserId)
+ {
+ notification.UserId = null;
+ }
+
+ var requirement = NotificationOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notification);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Authorization/NotificationStatusAuthorizationHandlerTest.cs b/test/Core.Test/NotificationCenter/Authorization/NotificationStatusAuthorizationHandlerTest.cs
new file mode 100644
index 0000000000..a43c40ea75
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Authorization/NotificationStatusAuthorizationHandlerTest.cs
@@ -0,0 +1,179 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.Authorization;
+
+using System.Security.Claims;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Entities;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+[SutProviderCustomize]
+[NotificationStatusCustomize]
+public class NotificationStatusAuthorizationHandlerTests
+{
+ private static void SetupUserPermission(SutProvider sutProvider,
+ Guid? userId = null)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_UnsupportedNotificationOperationRequirement_Throws(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = new NotificationStatusOperationsRequirement("UnsupportedOperation");
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context));
+ }
+
+ [Theory]
+ [BitAutoData(nameof(NotificationStatusOperations.Read))]
+ [BitAutoData(nameof(NotificationStatusOperations.Create))]
+ [BitAutoData(nameof(NotificationStatusOperations.Update))]
+ public async Task HandleAsync_NotLoggedIn_Unauthorized(
+ string requirementName,
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, userId: null);
+ var requirement = new NotificationStatusOperationsRequirement(requirementName);
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData(nameof(NotificationStatusOperations.Read))]
+ [BitAutoData(nameof(NotificationStatusOperations.Create))]
+ [BitAutoData(nameof(NotificationStatusOperations.Update))]
+ public async Task HandleAsync_ResourceEmpty_Unauthorized(
+ string requirementName,
+ SutProvider sutProvider,
+ ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+ var requirement = new NotificationStatusOperationsRequirement(requirementName);
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, null);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_ReadRequirementUserNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+
+ var requirement = NotificationStatusOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_ReadRequirement_Authorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notificationStatus.UserId);
+
+ var requirement = NotificationStatusOperations.Read;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_CreateRequirementUserNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+
+ var requirement = NotificationStatusOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_CreateRequirement_Authorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notificationStatus.UserId);
+
+ var requirement = NotificationStatusOperations.Create;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_UpdateRequirementUserNotMatching_Unauthorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, Guid.NewGuid());
+
+ var requirement = NotificationStatusOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task HandleAsync_UpdateRequirement_Authorized(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus, ClaimsPrincipal claimsPrincipal)
+ {
+ SetupUserPermission(sutProvider, notificationStatus.UserId);
+
+ var requirement = NotificationStatusOperations.Update;
+ var context = new AuthorizationHandlerContext(new List { requirement },
+ claimsPrincipal, notificationStatus);
+
+ await sutProvider.Sut.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs b/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs
new file mode 100644
index 0000000000..f14a0746aa
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs
@@ -0,0 +1,31 @@
+#nullable enable
+using AutoFixture;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.AutoFixture;
+
+public class NotificationCustomization(bool global) : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer =>
+ {
+ var postprocessComposer = composer.With(n => n.Id, Guid.NewGuid())
+ .With(n => n.Global, global);
+
+ postprocessComposer = global
+ ? postprocessComposer.Without(n => n.UserId)
+ : postprocessComposer.With(n => n.UserId, Guid.NewGuid());
+
+ return global
+ ? postprocessComposer.Without(n => n.OrganizationId)
+ : postprocessComposer.With(n => n.OrganizationId, Guid.NewGuid());
+ });
+ }
+}
+
+public class NotificationCustomizeAttribute(bool global = true) : BitCustomizeAttribute
+{
+ public override ICustomization GetCustomization() => new NotificationCustomization(global);
+}
diff --git a/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs
new file mode 100644
index 0000000000..1e1d066d16
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs
@@ -0,0 +1,21 @@
+#nullable enable
+using AutoFixture;
+using Bit.Core.NotificationCenter.Models.Data;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.AutoFixture;
+
+public class NotificationStatusDetailsCustomization : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer.With(n => n.Id, Guid.NewGuid())
+ .With(n => n.UserId, Guid.NewGuid())
+ .With(n => n.OrganizationId, Guid.NewGuid()));
+ }
+}
+
+public class NotificationStatusDetailsCustomizeAttribute : BitCustomizeAttribute
+{
+ public override ICustomization GetCustomization() => new NotificationStatusDetailsCustomization();
+}
diff --git a/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusFixtures.cs b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusFixtures.cs
new file mode 100644
index 0000000000..40eccb3420
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusFixtures.cs
@@ -0,0 +1,20 @@
+#nullable enable
+using AutoFixture;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.AutoFixture;
+
+public class NotificationStatusCustomization : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer.With(ns => ns.NotificationId, Guid.NewGuid())
+ .With(ns => ns.UserId, Guid.NewGuid()));
+ }
+}
+
+public class NotificationStatusCustomizeAttribute : BitCustomizeAttribute
+{
+ public override ICustomization GetCustomization() => new NotificationStatusCustomization();
+}
diff --git a/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs
new file mode 100644
index 0000000000..4f5842d1c7
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs
@@ -0,0 +1,59 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Commands;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+public class CreateNotificationCommandTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Notification notification, bool authorized = false)
+ {
+ sutProvider.GetDependency()
+ .CreateAsync(notification)
+ .Returns(notification);
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notification,
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationOperations.Create)))
+ .Returns(authorized ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_AuthorizationFailed_NotFoundException(
+ SutProvider sutProvider,
+ Notification notification)
+ {
+ Setup(sutProvider, notification, authorized: false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(notification));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_Authorized_NotificationCreated(
+ SutProvider sutProvider,
+ Notification notification)
+ {
+ Setup(sutProvider, notification, true);
+
+ var newNotification = await sutProvider.Sut.CreateAsync(notification);
+
+ Assert.Equal(notification, newNotification);
+ Assert.Equal(DateTime.UtcNow, notification.CreationDate, TimeSpan.FromMinutes(1));
+ Assert.Equal(notification.CreationDate, notification.RevisionDate);
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Commands/CreateNotificationStatusCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/CreateNotificationStatusCommandTest.cs
new file mode 100644
index 0000000000..8dc8524926
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Commands/CreateNotificationStatusCommandTest.cs
@@ -0,0 +1,89 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Commands;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+[NotificationStatusCustomize]
+public class CreateNotificationStatusCommandTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Notification? notification, NotificationStatus notificationStatus,
+ bool authorizedNotification = false, bool authorizedCreate = false)
+ {
+ sutProvider.GetDependency()
+ .GetByIdAsync(notificationStatus.NotificationId)
+ .Returns(notification);
+ sutProvider.GetDependency()
+ .CreateAsync(notificationStatus)
+ .Returns(notificationStatus);
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notification ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationOperations.Read)))
+ .Returns(authorizedNotification ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus,
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Create)))
+ .Returns(authorizedCreate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_NotificationNotFound_NotFoundException(
+ SutProvider sutProvider,
+ NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notification: null, notificationStatus, true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(notificationStatus));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_NotificationReadNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notification, notificationStatus, authorizedNotification: false, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(notificationStatus));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_CreateNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notification, notificationStatus, true, authorizedCreate: false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(notificationStatus));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateAsync_NotificationFoundAuthorized_NotificationStatusCreated(
+ SutProvider sutProvider,
+ Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notification, notificationStatus, true, true);
+
+ var newNotificationStatus = await sutProvider.Sut.CreateAsync(notificationStatus);
+
+ Assert.Equal(notificationStatus, newNotificationStatus);
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Commands/MarkNotificationDeletedCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/MarkNotificationDeletedCommandTest.cs
new file mode 100644
index 0000000000..a5bb20423c
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Commands/MarkNotificationDeletedCommandTest.cs
@@ -0,0 +1,151 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Commands;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+[NotificationStatusCustomize]
+public class MarkNotificationDeletedCommandTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Guid notificationId, Guid? userId, Notification? notification, NotificationStatus? notificationStatus,
+ bool authorizedNotification = false, bool authorizedCreate = false, bool authorizedUpdate = false)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ sutProvider.GetDependency()
+ .GetByIdAsync(notificationId)
+ .Returns(notification);
+ sutProvider.GetDependency()
+ .GetByNotificationIdAndUserIdAsync(notificationId, userId ?? Arg.Any())
+ .Returns(notificationStatus);
+ sutProvider.GetDependency()
+ .CreateAsync(Arg.Any());
+ sutProvider.GetDependency()
+ .UpdateAsync(notificationStatus ?? Arg.Any());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notification ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationOperations.Read)))
+ .Returns(authorizedNotification ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Create)))
+ .Returns(authorizedCreate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Update)))
+ .Returns(authorizedUpdate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+
+ sutProvider.GetDependency().ClearReceivedCalls();
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_NotLoggedIn_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId: null, notification, notificationStatus, true, true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkDeletedAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_NotificationNotFound_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification: null, notificationStatus, true, true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkDeletedAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_ReadRequirementNotificationNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, authorizedNotification: false,
+ true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkDeletedAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_CreateRequirementNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus: null, true,
+ authorizedCreate: false, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkDeletedAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_UpdateRequirementNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, true, true,
+ authorizedUpdate: false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkDeletedAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_NotificationStatusNotFoundCreateAuthorized_NotificationStatusCreated(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus: null, true, true, true);
+
+ await sutProvider.Sut.MarkDeletedAsync(notificationId);
+
+ await sutProvider.GetDependency().Received(1)
+ .CreateAsync(Arg.Is(ns =>
+ ns.NotificationId == notificationId && ns.UserId == userId && !ns.ReadDate.HasValue &&
+ ns.DeletedDate.HasValue && DateTime.UtcNow - ns.DeletedDate.Value < TimeSpan.FromMinutes(1)));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkDeletedAsync_NotificationStatusFoundCreateAuthorized_NotificationStatusUpdated(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ var deletedDate = notificationStatus.DeletedDate;
+
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, true, true, true);
+
+ await sutProvider.Sut.MarkDeletedAsync(notificationId);
+
+ await sutProvider.GetDependency().Received(1)
+ .UpdateAsync(Arg.Is(ns =>
+ ns.Equals(notificationStatus) &&
+ ns.NotificationId == notificationStatus.NotificationId && ns.UserId == notificationStatus.UserId &&
+ ns.ReadDate == notificationStatus.ReadDate && ns.DeletedDate != deletedDate &&
+ ns.DeletedDate.HasValue &&
+ DateTime.UtcNow - ns.DeletedDate.Value < TimeSpan.FromMinutes(1)));
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Commands/MarkNotificationReadCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/MarkNotificationReadCommandTest.cs
new file mode 100644
index 0000000000..f80234c075
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Commands/MarkNotificationReadCommandTest.cs
@@ -0,0 +1,151 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Commands;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+[NotificationStatusCustomize]
+public class MarkNotificationReadCommandTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Guid notificationId, Guid? userId, Notification? notification, NotificationStatus? notificationStatus,
+ bool authorizedNotification = false, bool authorizedCreate = false, bool authorizedUpdate = false)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ sutProvider.GetDependency()
+ .GetByIdAsync(notificationId)
+ .Returns(notification);
+ sutProvider.GetDependency()
+ .GetByNotificationIdAndUserIdAsync(notificationId, userId ?? Arg.Any())
+ .Returns(notificationStatus);
+ sutProvider.GetDependency()
+ .CreateAsync(Arg.Any());
+ sutProvider.GetDependency()
+ .UpdateAsync(notificationStatus ?? Arg.Any());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notification ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationOperations.Read)))
+ .Returns(authorizedNotification ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Create)))
+ .Returns(authorizedCreate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Update)))
+ .Returns(authorizedUpdate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+
+ sutProvider.GetDependency().ClearReceivedCalls();
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_NotLoggedIn_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId: null, notification, notificationStatus, true, true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkReadAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_NotificationNotFound_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification: null, notificationStatus, true, true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkReadAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_ReadRequirementNotificationNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, authorizedNotification: false,
+ true, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkReadAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_CreateRequirementNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus: null, true,
+ authorizedCreate: false, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkReadAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_UpdateRequirementNotAuthorized_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, true, true,
+ authorizedUpdate: false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.MarkReadAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_NotificationStatusNotFoundCreateAuthorized_NotificationStatusCreated(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification)
+ {
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus: null, true, true, true);
+
+ await sutProvider.Sut.MarkReadAsync(notificationId);
+
+ await sutProvider.GetDependency().Received(1)
+ .CreateAsync(Arg.Is(ns =>
+ ns.NotificationId == notificationId && ns.UserId == userId && !ns.DeletedDate.HasValue &&
+ ns.ReadDate.HasValue && DateTime.UtcNow - ns.ReadDate.Value < TimeSpan.FromMinutes(1)));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task MarkReadAsync_NotificationStatusFoundCreateAuthorized_NotificationStatusUpdated(
+ SutProvider sutProvider,
+ Guid notificationId, Guid userId, Notification notification, NotificationStatus notificationStatus)
+ {
+ var readDate = notificationStatus.ReadDate;
+
+ Setup(sutProvider, notificationId, userId, notification, notificationStatus, true, true, true);
+
+ await sutProvider.Sut.MarkReadAsync(notificationId);
+
+ await sutProvider.GetDependency().Received(1)
+ .UpdateAsync(Arg.Is(ns =>
+ ns.Equals(notificationStatus) &&
+ ns.NotificationId == notificationStatus.NotificationId && ns.UserId == notificationStatus.UserId &&
+ ns.DeletedDate == notificationStatus.DeletedDate && ns.ReadDate != readDate &&
+ ns.ReadDate.HasValue &&
+ DateTime.UtcNow - ns.ReadDate.Value < TimeSpan.FromMinutes(1)));
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Commands/UpdateNotificationCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/UpdateNotificationCommandTest.cs
new file mode 100644
index 0000000000..976d1d77a3
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Commands/UpdateNotificationCommandTest.cs
@@ -0,0 +1,95 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Commands;
+using Bit.Core.NotificationCenter.Entities;
+using Bit.Core.NotificationCenter.Enums;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Core.Utilities;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Commands;
+
+[SutProviderCustomize]
+[NotificationCustomize]
+public class UpdateNotificationCommandTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Guid notificationId, Notification? notification, bool authorized = false)
+ {
+ sutProvider.GetDependency()
+ .GetByIdAsync(notificationId)
+ .Returns(notification);
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notification ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationOperations.Update)))
+ .Returns(authorized ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+
+ sutProvider.GetDependency().ClearReceivedCalls();
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task UpdateAsync_NotificationNotFound_NotFoundException(
+ SutProvider sutProvider,
+ Notification notification)
+ {
+ Setup(sutProvider, notification.Id, notification: null, true);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(notification));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task UpdateAsync_AuthorizationFailed_NotFoundException(
+ SutProvider sutProvider,
+ Notification notification)
+ {
+ Setup(sutProvider, notification.Id, notification, authorized: false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(notification));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task UpdateAsync_Authorized_NotificationCreated(
+ SutProvider sutProvider,
+ Notification notification)
+ {
+ notification.Priority = Priority.Medium;
+ notification.ClientType = ClientType.Web;
+ notification.Title = "Title";
+ notification.Body = "Body";
+ notification.RevisionDate = DateTime.UtcNow.AddMinutes(-60);
+
+ Setup(sutProvider, notification.Id, notification, true);
+
+ var notificationToUpdate = CoreHelpers.CloneObject(notification);
+ notificationToUpdate.Priority = Priority.High;
+ notificationToUpdate.ClientType = ClientType.Mobile;
+ notificationToUpdate.Title = "Updated Title";
+ notificationToUpdate.Body = "Updated Body";
+ notificationToUpdate.RevisionDate = DateTime.UtcNow.AddMinutes(-30);
+
+ await sutProvider.Sut.UpdateAsync(notificationToUpdate);
+
+ await sutProvider.GetDependency().Received(1)
+ .ReplaceAsync(Arg.Is(n =>
+ // Not updated fields
+ n.Id == notificationToUpdate.Id && n.Global == notificationToUpdate.Global &&
+ n.UserId == notificationToUpdate.UserId && n.OrganizationId == notificationToUpdate.OrganizationId &&
+ n.CreationDate == notificationToUpdate.CreationDate &&
+ // Updated fields
+ n.Priority == notificationToUpdate.Priority && n.ClientType == notificationToUpdate.ClientType &&
+ n.Title == notificationToUpdate.Title && n.Body == notificationToUpdate.Body &&
+ DateTime.UtcNow - n.RevisionDate < TimeSpan.FromMinutes(1)));
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs
new file mode 100644
index 0000000000..7d9c265606
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs
@@ -0,0 +1,55 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Models.Data;
+using Bit.Core.NotificationCenter.Models.Filter;
+using Bit.Core.NotificationCenter.Queries;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.NotificationCenter.Queries;
+
+[SutProviderCustomize]
+[NotificationStatusDetailsCustomize]
+public class GetNotificationStatusDetailsForUserQueryTest
+{
+ private static void Setup(SutProvider sutProvider,
+ List notificationsStatusDetails, NotificationStatusFilter statusFilter, Guid? userId)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ sutProvider.GetDependency().GetByUserIdAndStatusAsync(
+ userId.GetValueOrDefault(Guid.NewGuid()), Arg.Any(), statusFilter)
+ .Returns(notificationsStatusDetails);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_NotLoggedIn_NotFoundException(
+ SutProvider sutProvider,
+ List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter)
+ {
+ Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, userId: null);
+
+ await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_NotificationsFound_Returned(
+ SutProvider sutProvider,
+ List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter)
+ {
+ Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, Guid.NewGuid());
+
+ var actualNotificationsStatusDetails =
+ await sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter);
+
+ Assert.Equal(notificationsStatusDetails, actualNotificationsStatusDetails);
+ }
+}
diff --git a/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusForUserTest.cs b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusForUserTest.cs
new file mode 100644
index 0000000000..5ae22508bc
--- /dev/null
+++ b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusForUserTest.cs
@@ -0,0 +1,85 @@
+#nullable enable
+using Bit.Core.Context;
+using Bit.Core.Exceptions;
+using Bit.Core.NotificationCenter.Queries;
+using Bit.Core.NotificationCenter.Repositories;
+using Bit.Core.Test.NotificationCenter.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+
+namespace Bit.Core.Test.NotificationCenter.Queries;
+
+using System.Security.Claims;
+using Bit.Core.NotificationCenter.Authorization;
+using Bit.Core.NotificationCenter.Entities;
+using Microsoft.AspNetCore.Authorization;
+using NSubstitute;
+using Xunit;
+
+[SutProviderCustomize]
+[NotificationStatusCustomize]
+public class GetNotificationStatusForUserQueryTest
+{
+ private static void Setup(SutProvider sutProvider,
+ Guid notificationId, NotificationStatus? notificationStatus, Guid? userId, bool authorized = false)
+ {
+ sutProvider.GetDependency().UserId.Returns(userId);
+ sutProvider.GetDependency()
+ .GetByNotificationIdAndUserIdAsync(notificationId, userId.GetValueOrDefault(Guid.NewGuid()))
+ .Returns(notificationStatus);
+ sutProvider.GetDependency()
+ .AuthorizeAsync(Arg.Any(), notificationStatus ?? Arg.Any(),
+ Arg.Is>(reqs =>
+ reqs.Contains(NotificationStatusOperations.Read)))
+ .Returns(authorized ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_UserNotLoggedIn_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, notificationStatus, userId: null, true);
+
+ await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.GetByNotificationIdAndUserIdAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_NotificationStatusNotFound_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId)
+ {
+ Setup(sutProvider, notificationId, notificationStatus: null, Guid.NewGuid(), true);
+
+ await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.GetByNotificationIdAndUserIdAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_AuthorizationFailed_NotFoundException(
+ SutProvider sutProvider,
+ Guid notificationId, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, notificationStatus, Guid.NewGuid(), authorized: false);
+
+ await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.GetByNotificationIdAndUserIdAsync(notificationId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task GetByUserIdStatusFilterAsync_NotificationFoundAuthorized_Returned(
+ SutProvider sutProvider,
+ Guid notificationId, NotificationStatus notificationStatus)
+ {
+ Setup(sutProvider, notificationId, notificationStatus, Guid.NewGuid(), true);
+
+ var actualNotificationStatus = await sutProvider.Sut.GetByNotificationIdAndUserIdAsync(notificationId);
+
+ Assert.Equal(notificationStatus, actualNotificationStatus);
+ }
+}
diff --git a/util/Migrator/DbScripts/2024-09-25_00_AddLimitCollectionCreationColumn.sql b/util/Migrator/DbScripts/2024-09-25_00_AddLimitCollectionCreationColumn.sql
new file mode 100644
index 0000000000..9da6bbdc9a
--- /dev/null
+++ b/util/Migrator/DbScripts/2024-09-25_00_AddLimitCollectionCreationColumn.sql
@@ -0,0 +1,486 @@
+-- Add Columns
+IF COL_LENGTH('[dbo].[Organization]', 'LimitCollectionCreation') IS NULL
+BEGIN
+ ALTER TABLE
+ [dbo].[Organization]
+ ADD
+ [LimitCollectionCreation] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionCreation] DEFAULT (0)
+END
+GO
+
+IF COL_LENGTH('[dbo].[Organization]', 'LimitCollectionDeletion') IS NULL
+BEGIN
+ ALTER TABLE
+ [dbo].[Organization]
+ ADD
+ [LimitCollectionDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionDeletion] DEFAULT (0)
+END
+GO
+
+-- Refresh Views
+CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView]
+AS
+SELECT
+ PU.[UserId],
+ PO.[OrganizationId],
+ O.[Name],
+ O.[Enabled],
+ O.[UsePolicies],
+ O.[UseSso],
+ O.[UseKeyConnector],
+ O.[UseScim],
+ O.[UseGroups],
+ O.[UseDirectory],
+ O.[UseEvents],
+ O.[UseTotp],
+ O.[Use2fa],
+ O.[UseApi],
+ O.[UseResetPassword],
+ O.[SelfHost],
+ O.[UsersGetPremium],
+ O.[UseCustomPermissions],
+ O.[Seats],
+ O.[MaxCollections],
+ O.[MaxStorageGb],
+ O.[Identifier],
+ PO.[Key],
+ O.[PublicKey],
+ O.[PrivateKey],
+ PU.[Status],
+ PU.[Type],
+ PO.[ProviderId],
+ PU.[Id] ProviderUserId,
+ P.[Name] ProviderName,
+ O.[PlanType],
+ O.[LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ O.[LimitCollectionCreation],
+ O.[LimitCollectionDeletion],
+ O.[AllowAdminAccessToAllCollectionItems]
+FROM
+ [dbo].[ProviderUser] PU
+INNER JOIN
+ [dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId]
+INNER JOIN
+ [dbo].[Organization] O ON O.[Id] = PO.[OrganizationId]
+INNER JOIN
+ [dbo].[Provider] P ON P.[Id] = PU.[ProviderId]
+GO
+
+CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView]
+AS
+SELECT
+ OU.[UserId],
+ OU.[OrganizationId],
+ OU.[Id] OrganizationUserId,
+ O.[Name],
+ O.[Enabled],
+ O.[PlanType],
+ O.[UsePolicies],
+ O.[UseSso],
+ O.[UseKeyConnector],
+ O.[UseScim],
+ O.[UseGroups],
+ O.[UseDirectory],
+ O.[UseEvents],
+ O.[UseTotp],
+ O.[Use2fa],
+ O.[UseApi],
+ O.[UseResetPassword],
+ O.[SelfHost],
+ O.[UsersGetPremium],
+ O.[UseCustomPermissions],
+ O.[UseSecretsManager],
+ O.[Seats],
+ O.[MaxCollections],
+ O.[MaxStorageGb],
+ O.[Identifier],
+ OU.[Key],
+ OU.[ResetPasswordKey],
+ O.[PublicKey],
+ O.[PrivateKey],
+ OU.[Status],
+ OU.[Type],
+ SU.[ExternalId] SsoExternalId,
+ OU.[Permissions],
+ PO.[ProviderId],
+ P.[Name] ProviderName,
+ P.[Type] ProviderType,
+ SS.[Data] SsoConfig,
+ OS.[FriendlyName] FamilySponsorshipFriendlyName,
+ OS.[LastSyncDate] FamilySponsorshipLastSyncDate,
+ OS.[ToDelete] FamilySponsorshipToDelete,
+ OS.[ValidUntil] FamilySponsorshipValidUntil,
+ OU.[AccessSecretsManager],
+ O.[UsePasswordManager],
+ O.[SmSeats],
+ O.[SmServiceAccounts],
+ O.[LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ O.[LimitCollectionCreation],
+ O.[LimitCollectionDeletion],
+ O.[AllowAdminAccessToAllCollectionItems]
+FROM
+ [dbo].[OrganizationUser] OU
+LEFT JOIN
+ [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
+LEFT JOIN
+ [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
+LEFT JOIN
+ [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id]
+LEFT JOIN
+ [dbo].[Provider] P ON P.[Id] = PO.[ProviderId]
+LEFT JOIN
+ [dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId]
+LEFT JOIN
+ [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id]
+GO
+
+IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL
+ BEGIN
+ EXECUTE sp_refreshview N'[dbo].[OrganizationView]';
+ END
+GO
+
+-- Refresh Stored Procedures
+CREATE OR ALTER PROCEDURE [dbo].[Organization_Create]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @Identifier NVARCHAR(50),
+ @Name NVARCHAR(50),
+ @BusinessName NVARCHAR(50),
+ @BusinessAddress1 NVARCHAR(50),
+ @BusinessAddress2 NVARCHAR(50),
+ @BusinessAddress3 NVARCHAR(50),
+ @BusinessCountry VARCHAR(2),
+ @BusinessTaxNumber NVARCHAR(30),
+ @BillingEmail NVARCHAR(256),
+ @Plan NVARCHAR(50),
+ @PlanType TINYINT,
+ @Seats INT,
+ @MaxCollections SMALLINT,
+ @UsePolicies BIT,
+ @UseSso BIT,
+ @UseGroups BIT,
+ @UseDirectory BIT,
+ @UseEvents BIT,
+ @UseTotp BIT,
+ @Use2fa BIT,
+ @UseApi BIT,
+ @UseResetPassword BIT,
+ @SelfHost BIT,
+ @UsersGetPremium BIT,
+ @Storage BIGINT,
+ @MaxStorageGb SMALLINT,
+ @Gateway TINYINT,
+ @GatewayCustomerId VARCHAR(50),
+ @GatewaySubscriptionId VARCHAR(50),
+ @ReferenceData VARCHAR(MAX),
+ @Enabled BIT,
+ @LicenseKey VARCHAR(100),
+ @PublicKey VARCHAR(MAX),
+ @PrivateKey VARCHAR(MAX),
+ @TwoFactorProviders NVARCHAR(MAX),
+ @ExpirationDate DATETIME2(7),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @OwnersNotifiedOfAutoscaling DATETIME2(7),
+ @MaxAutoscaleSeats INT,
+ @UseKeyConnector BIT = 0,
+ @UseScim BIT = 0,
+ @UseCustomPermissions BIT = 0,
+ @UseSecretsManager BIT = 0,
+ @Status TINYINT = 0,
+ @UsePasswordManager BIT = 1,
+ @SmSeats INT = null,
+ @SmServiceAccounts INT = null,
+ @MaxAutoscaleSmSeats INT= null,
+ @MaxAutoscaleSmServiceAccounts INT = null,
+ @SecretsManagerBeta BIT = 0,
+ @LimitCollectionCreationDeletion BIT = NULL, -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ @LimitCollectionCreation BIT = NULL,
+ @LimitCollectionDeletion BIT = NULL,
+ @AllowAdminAccessToAllCollectionItems BIT = 0
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SET @LimitCollectionCreation = COALESCE(@LimitCollectionCreation, @LimitCollectionCreationDeletion, 0);
+ SET @LimitCollectionDeletion = COALESCE(@LimitCollectionDeletion, @LimitCollectionCreationDeletion, 0);
+
+ INSERT INTO [dbo].[Organization]
+ (
+ [Id],
+ [Identifier],
+ [Name],
+ [BusinessName],
+ [BusinessAddress1],
+ [BusinessAddress2],
+ [BusinessAddress3],
+ [BusinessCountry],
+ [BusinessTaxNumber],
+ [BillingEmail],
+ [Plan],
+ [PlanType],
+ [Seats],
+ [MaxCollections],
+ [UsePolicies],
+ [UseSso],
+ [UseGroups],
+ [UseDirectory],
+ [UseEvents],
+ [UseTotp],
+ [Use2fa],
+ [UseApi],
+ [UseResetPassword],
+ [SelfHost],
+ [UsersGetPremium],
+ [Storage],
+ [MaxStorageGb],
+ [Gateway],
+ [GatewayCustomerId],
+ [GatewaySubscriptionId],
+ [ReferenceData],
+ [Enabled],
+ [LicenseKey],
+ [PublicKey],
+ [PrivateKey],
+ [TwoFactorProviders],
+ [ExpirationDate],
+ [CreationDate],
+ [RevisionDate],
+ [OwnersNotifiedOfAutoscaling],
+ [MaxAutoscaleSeats],
+ [UseKeyConnector],
+ [UseScim],
+ [UseCustomPermissions],
+ [UseSecretsManager],
+ [Status],
+ [UsePasswordManager],
+ [SmSeats],
+ [SmServiceAccounts],
+ [MaxAutoscaleSmSeats],
+ [MaxAutoscaleSmServiceAccounts],
+ [SecretsManagerBeta],
+ [LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ [LimitCollectionCreation],
+ [LimitCollectionDeletion],
+ [AllowAdminAccessToAllCollectionItems]
+ )
+ VALUES
+ (
+ @Id,
+ @Identifier,
+ @Name,
+ @BusinessName,
+ @BusinessAddress1,
+ @BusinessAddress2,
+ @BusinessAddress3,
+ @BusinessCountry,
+ @BusinessTaxNumber,
+ @BillingEmail,
+ @Plan,
+ @PlanType,
+ @Seats,
+ @MaxCollections,
+ @UsePolicies,
+ @UseSso,
+ @UseGroups,
+ @UseDirectory,
+ @UseEvents,
+ @UseTotp,
+ @Use2fa,
+ @UseApi,
+ @UseResetPassword,
+ @SelfHost,
+ @UsersGetPremium,
+ @Storage,
+ @MaxStorageGb,
+ @Gateway,
+ @GatewayCustomerId,
+ @GatewaySubscriptionId,
+ @ReferenceData,
+ @Enabled,
+ @LicenseKey,
+ @PublicKey,
+ @PrivateKey,
+ @TwoFactorProviders,
+ @ExpirationDate,
+ @CreationDate,
+ @RevisionDate,
+ @OwnersNotifiedOfAutoscaling,
+ @MaxAutoscaleSeats,
+ @UseKeyConnector,
+ @UseScim,
+ @UseCustomPermissions,
+ @UseSecretsManager,
+ @Status,
+ @UsePasswordManager,
+ @SmSeats,
+ @SmServiceAccounts,
+ @MaxAutoscaleSmSeats,
+ @MaxAutoscaleSmServiceAccounts,
+ @SecretsManagerBeta,
+ COALESCE(@LimitCollectionCreation, @LimitCollectionDeletion, 0), -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ @LimitCollectionCreation,
+ @LimitCollectionDeletion,
+ @AllowAdminAccessToAllCollectionItems
+ )
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities]
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ [Id],
+ [UseEvents],
+ [Use2fa],
+ CASE
+ WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN
+ 1
+ ELSE
+ 0
+ END AS [Using2fa],
+ [UsersGetPremium],
+ [UseCustomPermissions],
+ [UseSso],
+ [UseKeyConnector],
+ [UseScim],
+ [UseResetPassword],
+ [UsePolicies],
+ [Enabled],
+ [LimitCollectionCreationDeletion], -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ [LimitCollectionCreation],
+ [LimitCollectionDeletion],
+ [AllowAdminAccessToAllCollectionItems]
+ FROM
+ [dbo].[Organization]
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Organization_Update]
+ @Id UNIQUEIDENTIFIER,
+ @Identifier NVARCHAR(50),
+ @Name NVARCHAR(50),
+ @BusinessName NVARCHAR(50),
+ @BusinessAddress1 NVARCHAR(50),
+ @BusinessAddress2 NVARCHAR(50),
+ @BusinessAddress3 NVARCHAR(50),
+ @BusinessCountry VARCHAR(2),
+ @BusinessTaxNumber NVARCHAR(30),
+ @BillingEmail NVARCHAR(256),
+ @Plan NVARCHAR(50),
+ @PlanType TINYINT,
+ @Seats INT,
+ @MaxCollections SMALLINT,
+ @UsePolicies BIT,
+ @UseSso BIT,
+ @UseGroups BIT,
+ @UseDirectory BIT,
+ @UseEvents BIT,
+ @UseTotp BIT,
+ @Use2fa BIT,
+ @UseApi BIT,
+ @UseResetPassword BIT,
+ @SelfHost BIT,
+ @UsersGetPremium BIT,
+ @Storage BIGINT,
+ @MaxStorageGb SMALLINT,
+ @Gateway TINYINT,
+ @GatewayCustomerId VARCHAR(50),
+ @GatewaySubscriptionId VARCHAR(50),
+ @ReferenceData VARCHAR(MAX),
+ @Enabled BIT,
+ @LicenseKey VARCHAR(100),
+ @PublicKey VARCHAR(MAX),
+ @PrivateKey VARCHAR(MAX),
+ @TwoFactorProviders NVARCHAR(MAX),
+ @ExpirationDate DATETIME2(7),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @OwnersNotifiedOfAutoscaling DATETIME2(7),
+ @MaxAutoscaleSeats INT,
+ @UseKeyConnector BIT = 0,
+ @UseScim BIT = 0,
+ @UseCustomPermissions BIT = 0,
+ @UseSecretsManager BIT = 0,
+ @Status TINYINT = 0,
+ @UsePasswordManager BIT = 1,
+ @SmSeats INT = null,
+ @SmServiceAccounts INT = null,
+ @MaxAutoscaleSmSeats INT = null,
+ @MaxAutoscaleSmServiceAccounts INT = null,
+ @SecretsManagerBeta BIT = 0,
+ @LimitCollectionCreationDeletion BIT = null, -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ @LimitCollectionCreation BIT = null,
+ @LimitCollectionDeletion BIT = null,
+ @AllowAdminAccessToAllCollectionItems BIT = 0
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SET @LimitCollectionCreation = COALESCE(@LimitCollectionCreation, @LimitCollectionCreationDeletion, 0);
+ SET @LimitCollectionDeletion = COALESCE(@LimitCollectionDeletion, @LimitCollectionCreationDeletion, 0);
+
+ UPDATE
+ [dbo].[Organization]
+ SET
+ [Identifier] = @Identifier,
+ [Name] = @Name,
+ [BusinessName] = @BusinessName,
+ [BusinessAddress1] = @BusinessAddress1,
+ [BusinessAddress2] = @BusinessAddress2,
+ [BusinessAddress3] = @BusinessAddress3,
+ [BusinessCountry] = @BusinessCountry,
+ [BusinessTaxNumber] = @BusinessTaxNumber,
+ [BillingEmail] = @BillingEmail,
+ [Plan] = @Plan,
+ [PlanType] = @PlanType,
+ [Seats] = @Seats,
+ [MaxCollections] = @MaxCollections,
+ [UsePolicies] = @UsePolicies,
+ [UseSso] = @UseSso,
+ [UseGroups] = @UseGroups,
+ [UseDirectory] = @UseDirectory,
+ [UseEvents] = @UseEvents,
+ [UseTotp] = @UseTotp,
+ [Use2fa] = @Use2fa,
+ [UseApi] = @UseApi,
+ [UseResetPassword] = @UseResetPassword,
+ [SelfHost] = @SelfHost,
+ [UsersGetPremium] = @UsersGetPremium,
+ [Storage] = @Storage,
+ [MaxStorageGb] = @MaxStorageGb,
+ [Gateway] = @Gateway,
+ [GatewayCustomerId] = @GatewayCustomerId,
+ [GatewaySubscriptionId] = @GatewaySubscriptionId,
+ [ReferenceData] = @ReferenceData,
+ [Enabled] = @Enabled,
+ [LicenseKey] = @LicenseKey,
+ [PublicKey] = @PublicKey,
+ [PrivateKey] = @PrivateKey,
+ [TwoFactorProviders] = @TwoFactorProviders,
+ [ExpirationDate] = @ExpirationDate,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate,
+ [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
+ [MaxAutoscaleSeats] = @MaxAutoscaleSeats,
+ [UseKeyConnector] = @UseKeyConnector,
+ [UseScim] = @UseScim,
+ [UseCustomPermissions] = @UseCustomPermissions,
+ [UseSecretsManager] = @UseSecretsManager,
+ [Status] = @Status,
+ [UsePasswordManager] = @UsePasswordManager,
+ [SmSeats] = @SmSeats,
+ [SmServiceAccounts] = @SmServiceAccounts,
+ [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
+ [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
+ [SecretsManagerBeta] = @SecretsManagerBeta,
+ [LimitCollectionCreationDeletion] = COALESCE(@LimitCollectionCreation, @LimitCollectionDeletion, 0), -- Deprecated https://bitwarden.atlassian.net/browse/PM-10863
+ [LimitCollectionCreation] = @LimitCollectionCreation,
+ [LimitCollectionDeletion] = @LimitCollectionDeletion,
+ [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems
+ WHERE
+ [Id] = @Id
+END
+GO
diff --git a/util/Migrator/DbScripts/2024-09-25_01_SyncLimitCollectionCreationColumn.sql b/util/Migrator/DbScripts/2024-09-25_01_SyncLimitCollectionCreationColumn.sql
new file mode 100644
index 0000000000..d46ac3e99e
--- /dev/null
+++ b/util/Migrator/DbScripts/2024-09-25_01_SyncLimitCollectionCreationColumn.sql
@@ -0,0 +1,8 @@
+-- Sync existing data
+UPDATE [dbo].[Organization]
+SET
+ [LimitCollectionCreation] = 1,
+ [LimitCollectionDeletion] = 1
+WHERE [LimitCollectionCreationDeletion] = 1
+GO
+
diff --git a/util/Migrator/DbScripts/2024-10-03_00_NotificationStatusDetailsView.sql b/util/Migrator/DbScripts/2024-10-03_00_NotificationStatusDetailsView.sql
new file mode 100644
index 0000000000..5d9e4fec23
--- /dev/null
+++ b/util/Migrator/DbScripts/2024-10-03_00_NotificationStatusDetailsView.sql
@@ -0,0 +1,61 @@
+-- View NotificationStatusDetailsView
+
+IF EXISTS(SELECT *
+ FROM sys.views
+ WHERE [Name] = 'NotificationStatusDetailsView')
+BEGIN
+DROP VIEW [dbo].[NotificationStatusDetailsView]
+END
+GO
+
+CREATE VIEW [dbo].[NotificationStatusDetailsView]
+AS
+SELECT
+ N.*,
+ NS.UserId AS NotificationStatusUserId,
+ NS.ReadDate,
+ NS.DeletedDate
+FROM
+ [dbo].[Notification] AS N
+LEFT JOIN
+ [dbo].[NotificationStatus] as NS
+ON
+ N.[Id] = NS.[NotificationId]
+GO
+
+-- Stored Procedure Notification_ReadByUserIdAndStatus
+
+CREATE OR ALTER PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus]
+ @UserId UNIQUEIDENTIFIER,
+ @ClientType TINYINT,
+ @Read BIT,
+ @Deleted BIT
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT n.*
+ FROM [dbo].[NotificationStatusDetailsView] n
+ LEFT JOIN [dbo].[OrganizationUserView] ou ON n.[OrganizationId] = ou.[OrganizationId]
+ AND ou.[UserId] = @UserId
+ WHERE (n.[NotificationStatusUserId] IS NULL OR n.[NotificationStatusUserId] = @UserId)
+ AND [ClientType] IN (0, CASE WHEN @ClientType != 0 THEN @ClientType END)
+ AND ([Global] = 1
+ OR (n.[UserId] = @UserId
+ AND (n.[OrganizationId] IS NULL
+ OR ou.[OrganizationId] IS NOT NULL))
+ OR (n.[UserId] IS NULL
+ AND ou.[OrganizationId] IS NOT NULL))
+ AND ((@Read IS NULL AND @Deleted IS NULL)
+ OR (n.[NotificationStatusUserId] IS NOT NULL
+ AND ((@Read IS NULL
+ OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR
+ (@Read = 0 AND n.[ReadDate] IS NULL),
+ 1, 0) = 1)
+ OR (@Deleted IS NULL
+ OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
+ (@Deleted = 0 AND n.[DeletedDate] IS NULL),
+ 1, 0) = 1))))
+ ORDER BY [Priority] DESC, n.[CreationDate] DESC
+END
+GO
diff --git a/util/MySqlMigrations/Migrations/20240925201836_SplitOrganizationLimitCollectionCreationDeletionColumn.Designer.cs b/util/MySqlMigrations/Migrations/20240925201836_SplitOrganizationLimitCollectionCreationDeletionColumn.Designer.cs
new file mode 100644
index 0000000000..093cdb0759
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20240925201836_SplitOrganizationLimitCollectionCreationDeletionColumn.Designer.cs
@@ -0,0 +1,2798 @@
+//
+using System;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bit.MySqlMigrations.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20240925201836_SplitOrganizationLimitCollectionCreationDeletionColumn")]
+ partial class SplitOrganizationLimitCollectionCreationDeletionColumn
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowAdminAccessToAllCollectionItems")
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property