diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs
new file mode 100644
index 0000000000..7b0bfa0bfb
--- /dev/null
+++ b/src/Api/Vault/Controllers/SecurityTaskController.cs
@@ -0,0 +1,40 @@
+using Bit.Api.Models.Response;
+using Bit.Api.Vault.Models.Response;
+using Bit.Core;
+using Bit.Core.Services;
+using Bit.Core.Utilities;
+using Bit.Core.Vault.Enums;
+using Bit.Core.Vault.Queries;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bit.Api.Vault.Controllers;
+
+[Route("tasks")]
+[Authorize("Application")]
+[RequireFeature(FeatureFlagKeys.SecurityTasks)]
+public class SecurityTaskController : Controller
+{
+ private readonly IUserService _userService;
+ private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery;
+
+ public SecurityTaskController(IUserService userService, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery)
+ {
+ _userService = userService;
+ _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery;
+ }
+
+ ///
+ /// Retrieves security tasks for the current user.
+ ///
+ /// Optional filter for task status. If not provided returns tasks of all statuses.
+ /// A list response model containing the security tasks for the user.
+ [HttpGet("")]
+ public async Task> Get([FromQuery] SecurityTaskStatus? status)
+ {
+ var userId = _userService.GetProperUserId(User).Value;
+ var securityTasks = await _getTaskDetailsForUserQuery.GetTaskDetailsForUserAsync(userId, status);
+ var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
+ return new ListResponseModel(response);
+ }
+}
diff --git a/src/Api/Vault/Models/Response/SecurityTasksResponseModel.cs b/src/Api/Vault/Models/Response/SecurityTasksResponseModel.cs
new file mode 100644
index 0000000000..c41c54b983
--- /dev/null
+++ b/src/Api/Vault/Models/Response/SecurityTasksResponseModel.cs
@@ -0,0 +1,30 @@
+using Bit.Core.Models.Api;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+
+namespace Bit.Api.Vault.Models.Response;
+
+public class SecurityTasksResponseModel : ResponseModel
+{
+ public SecurityTasksResponseModel(SecurityTask securityTask, string obj = "securityTask")
+ : base(obj)
+ {
+ ArgumentNullException.ThrowIfNull(securityTask);
+
+ Id = securityTask.Id;
+ OrganizationId = securityTask.OrganizationId;
+ CipherId = securityTask.CipherId;
+ Type = securityTask.Type;
+ Status = securityTask.Status;
+ CreationDate = securityTask.CreationDate;
+ RevisionDate = securityTask.RevisionDate;
+ }
+
+ public Guid Id { get; set; }
+ public Guid OrganizationId { get; set; }
+ public Guid? CipherId { get; set; }
+ public SecurityTaskType Type { get; set; }
+ public SecurityTaskStatus Status { get; set; }
+ public DateTime CreationDate { get; set; }
+ public DateTime RevisionDate { get; set; }
+}
diff --git a/src/Core/Vault/Queries/GetTaskDetailsForUserQuery.cs b/src/Core/Vault/Queries/GetTaskDetailsForUserQuery.cs
new file mode 100644
index 0000000000..976f8fb0ca
--- /dev/null
+++ b/src/Core/Vault/Queries/GetTaskDetailsForUserQuery.cs
@@ -0,0 +1,13 @@
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+using Bit.Core.Vault.Repositories;
+
+namespace Bit.Core.Vault.Queries;
+
+public class GetTaskDetailsForUserQuery(ISecurityTaskRepository securityTaskRepository) : IGetTaskDetailsForUserQuery
+{
+ ///
+ public async Task> GetTaskDetailsForUserAsync(Guid userId,
+ SecurityTaskStatus? status = null)
+ => await securityTaskRepository.GetManyByUserIdStatusAsync(userId, status);
+}
diff --git a/src/Core/Vault/Queries/IGetTaskDetailsForUserQuery.cs b/src/Core/Vault/Queries/IGetTaskDetailsForUserQuery.cs
new file mode 100644
index 0000000000..14733c3188
--- /dev/null
+++ b/src/Core/Vault/Queries/IGetTaskDetailsForUserQuery.cs
@@ -0,0 +1,15 @@
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+
+namespace Bit.Core.Vault.Queries;
+
+public interface IGetTaskDetailsForUserQuery
+{
+ ///
+ /// Retrieves security tasks for a user based on their organization and cipher access permissions.
+ ///
+ /// The Id of the user retrieving tasks
+ /// Optional filter for task status. If not provided, returns tasks of all statuses
+ /// A collection of security tasks
+ Task> GetTaskDetailsForUserAsync(Guid userId, SecurityTaskStatus? status = null);
+}
diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs
index f2262f207a..34f1f2ee64 100644
--- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs
+++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs
@@ -1,9 +1,16 @@
using Bit.Core.Repositories;
using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
namespace Bit.Core.Vault.Repositories;
public interface ISecurityTaskRepository : IRepository
{
-
+ ///
+ /// Retrieves security tasks for a user based on their organization and cipher access permissions.
+ ///
+ /// The Id of the user retrieving tasks
+ /// Optional filter for task status. If not provided, returns tasks of all statuses
+ ///
+ Task> GetManyByUserIdStatusAsync(Guid userId, SecurityTaskStatus? status = null);
}
diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs
index 5296f47e3e..d3c9dd9648 100644
--- a/src/Core/Vault/VaultServiceCollectionExtensions.cs
+++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs
@@ -15,5 +15,6 @@ public static class VaultServiceCollectionExtensions
private static void AddVaultQueries(this IServiceCollection services)
{
services.AddScoped();
+ services.AddScoped();
}
}
diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs
index 1674b965f0..dfe8a04814 100644
--- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs
+++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs
@@ -1,7 +1,11 @@
-using Bit.Core.Settings;
+using System.Data;
+using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Vault.Repositories;
@@ -15,4 +19,17 @@ public class SecurityTaskRepository : Repository, ISecurityT
: base(connectionString, readOnlyConnectionString)
{ }
+ ///
+ public async Task> GetManyByUserIdStatusAsync(Guid userId,
+ SecurityTaskStatus? status = null)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ var results = await connection.QueryAsync(
+ $"[{Schema}].[SecurityTask_ReadByUserIdStatus]",
+ new { UserId = userId, Status = status },
+ commandType: CommandType.StoredProcedure);
+
+ return results.ToList();
+ }
}
diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/SecurityTaskReadByUserIdStatusQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/SecurityTaskReadByUserIdStatusQuery.cs
new file mode 100644
index 0000000000..73f4249542
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/SecurityTaskReadByUserIdStatusQuery.cs
@@ -0,0 +1,90 @@
+using Bit.Core.Enums;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories.Queries;
+
+namespace Bit.Infrastructure.EntityFramework.Vault.Repositories.Queries;
+
+public class SecurityTaskReadByUserIdStatusQuery : IQuery
+{
+ private readonly Guid _userId;
+ private readonly SecurityTaskStatus? _status;
+
+ public SecurityTaskReadByUserIdStatusQuery(Guid userId, SecurityTaskStatus? status)
+ {
+ _userId = userId;
+ _status = status;
+ }
+
+ public IQueryable Run(DatabaseContext dbContext)
+ {
+ var query = from st in dbContext.SecurityTasks
+
+ join ou in dbContext.OrganizationUsers
+ on st.OrganizationId equals ou.OrganizationId
+
+ join o in dbContext.Organizations
+ on st.OrganizationId equals o.Id
+
+ join c in dbContext.Ciphers
+ on st.CipherId equals c.Id into c_g
+ from c in c_g.DefaultIfEmpty()
+
+ join cc in dbContext.CollectionCiphers
+ on c.Id equals cc.CipherId into cc_g
+ from cc in cc_g.DefaultIfEmpty()
+
+ join cu in dbContext.CollectionUsers
+ on new { cc.CollectionId, OrganizationUserId = ou.Id } equals
+ new { cu.CollectionId, cu.OrganizationUserId } into cu_g
+ from cu in cu_g.DefaultIfEmpty()
+
+ join gu in dbContext.GroupUsers
+ on new { CollectionId = (Guid?)cu.CollectionId, OrganizationUserId = ou.Id } equals
+ new { CollectionId = (Guid?)null, gu.OrganizationUserId } into gu_g
+ from gu in gu_g.DefaultIfEmpty()
+
+ join cg in dbContext.CollectionGroups
+ on new { cc.CollectionId, gu.GroupId } equals
+ new { cg.CollectionId, cg.GroupId } into cg_g
+ from cg in cg_g.DefaultIfEmpty()
+
+ where
+ ou.UserId == _userId &&
+ ou.Status == OrganizationUserStatusType.Confirmed &&
+ o.Enabled &&
+ (
+ st.CipherId == null ||
+ (
+ c != null &&
+ (
+ (cu != null && !cu.ReadOnly) || (cg != null && !cg.ReadOnly && cu == null)
+ )
+ )
+ ) &&
+ (_status == null || st.Status == _status)
+ group st by new
+ {
+ st.Id,
+ st.OrganizationId,
+ st.CipherId,
+ st.Type,
+ st.Status,
+ st.CreationDate,
+ st.RevisionDate
+ } into g
+ select new SecurityTask
+ {
+ Id = g.Key.Id,
+ OrganizationId = g.Key.OrganizationId,
+ CipherId = g.Key.CipherId,
+ Type = g.Key.Type,
+ Status = g.Key.Status,
+ CreationDate = g.Key.CreationDate,
+ RevisionDate = g.Key.RevisionDate
+ };
+
+ return query.OrderByDescending(st => st.CreationDate);
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs
index 82c06bcc6b..bd56df1bcf 100644
--- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs
+++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs
@@ -1,7 +1,10 @@
using AutoMapper;
+using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Vault.Models;
+using Bit.Infrastructure.EntityFramework.Vault.Repositories.Queries;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Infrastructure.EntityFramework.Vault.Repositories;
@@ -11,4 +14,15 @@ public class SecurityTaskRepository : Repository context.SecurityTasks)
{ }
+
+ ///
+ public async Task> GetManyByUserIdStatusAsync(Guid userId,
+ SecurityTaskStatus? status = null)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+ var query = new SecurityTaskReadByUserIdStatusQuery(userId, status);
+ var data = await query.Run(dbContext).ToListAsync();
+ return data;
+ }
}
diff --git a/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql b/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql
new file mode 100644
index 0000000000..2a4ecdb4c1
--- /dev/null
+++ b/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql
@@ -0,0 +1,56 @@
+CREATE PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus]
+ @UserId UNIQUEIDENTIFIER,
+ @Status TINYINT = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ ST.Id,
+ ST.OrganizationId,
+ ST.CipherId,
+ ST.Type,
+ ST.Status,
+ ST.CreationDate,
+ ST.RevisionDate
+ FROM
+ [dbo].[SecurityTaskView] ST
+ INNER JOIN
+ [dbo].[OrganizationUserView] OU ON OU.[OrganizationId] = ST.[OrganizationId]
+ INNER JOIN
+ [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId]
+ LEFT JOIN
+ [dbo].[CipherView] C ON C.[Id] = ST.[CipherId]
+ LEFT JOIN
+ [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id] AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] IS NULL AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[CollectionGroup] CG ON CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = CC.[CollectionId]
+ WHERE
+ OU.[UserId] = @UserId
+ AND OU.[Status] = 2 -- Ensure user is confirmed
+ AND O.[Enabled] = 1
+ AND (
+ ST.[CipherId] IS NULL
+ OR (
+ C.[Id] IS NOT NULL
+ AND (
+ CU.[ReadOnly] = 0
+ OR CG.[ReadOnly] = 0
+ )
+ )
+ )
+ AND ST.[Status] = COALESCE(@Status, ST.[Status])
+ GROUP BY
+ ST.Id,
+ ST.OrganizationId,
+ ST.CipherId,
+ ST.Type,
+ ST.Status,
+ ST.CreationDate,
+ ST.RevisionDate
+ ORDER BY ST.[CreationDate] DESC
+END
diff --git a/test/Infrastructure.IntegrationTest/Comparers/SecurityTaskComparer.cs b/test/Infrastructure.IntegrationTest/Comparers/SecurityTaskComparer.cs
new file mode 100644
index 0000000000..847896d3a0
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Comparers/SecurityTaskComparer.cs
@@ -0,0 +1,22 @@
+using System.Diagnostics.CodeAnalysis;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Infrastructure.IntegrationTest.Comparers;
+
+///
+/// Determines the equality of two SecurityTask objects.
+///
+public class SecurityTaskComparer : IEqualityComparer
+{
+ public bool Equals(SecurityTask x, SecurityTask y)
+ {
+ return x.Id.Equals(y.Id) &&
+ x.Type.Equals(y.Type) &&
+ x.Status.Equals(y.Status);
+ }
+
+ public int GetHashCode([DisallowNull] SecurityTask obj)
+ {
+ return base.GetHashCode();
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs
index 79cc1d2bc9..2010c90a5e 100644
--- a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs
@@ -1,9 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Repositories;
+using Bit.Infrastructure.IntegrationTest.Comparers;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.Vault.Repositories;
@@ -120,4 +124,103 @@ public class SecurityTaskRepositoryTests
Assert.Equal(task.Id, updatedTask.Id);
Assert.Equal(SecurityTaskStatus.Completed, updatedTask.Status);
}
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByUserIdAsync_ReturnsExpectedTasks(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ ICipherRepository cipherRepository,
+ ISecurityTaskRepository securityTaskRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{Guid.NewGuid()}@email.com",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ });
+
+ var organization = await organizationRepository.CreateAsync(new Organization
+ {
+ Name = "Test Org",
+ PlanType = PlanType.EnterpriseAnnually,
+ Plan = "Test Plan",
+ BillingEmail = "billing@email.com"
+ });
+
+ var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = user.Id,
+ Status = OrganizationUserStatusType.Confirmed
+ });
+
+ var collection = await collectionRepository.CreateAsync(new Collection
+ {
+ OrganizationId = organization.Id,
+ Name = "Test Collection 1",
+ });
+
+ var collection2 = await collectionRepository.CreateAsync(new Collection
+ {
+ OrganizationId = organization.Id,
+ Name = "Test Collection 2",
+ });
+
+ var cipher1 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
+ await cipherRepository.CreateAsync(cipher1, [collection.Id, collection2.Id]);
+
+ var cipher2 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
+ await cipherRepository.CreateAsync(cipher2, [collection.Id]);
+
+ var task1 = await securityTaskRepository.CreateAsync(new SecurityTask
+ {
+ OrganizationId = organization.Id,
+ CipherId = cipher1.Id,
+ Status = SecurityTaskStatus.Pending,
+ Type = SecurityTaskType.UpdateAtRiskCredential,
+ });
+
+ var task2 = await securityTaskRepository.CreateAsync(new SecurityTask
+ {
+ OrganizationId = organization.Id,
+ CipherId = cipher2.Id,
+ Status = SecurityTaskStatus.Completed,
+ Type = SecurityTaskType.UpdateAtRiskCredential,
+ });
+
+ var task3 = await securityTaskRepository.CreateAsync(new SecurityTask
+ {
+ OrganizationId = organization.Id,
+ CipherId = cipher2.Id,
+ Status = SecurityTaskStatus.Pending,
+ Type = SecurityTaskType.UpdateAtRiskCredential,
+ });
+
+ await collectionRepository.UpdateUsersAsync(collection.Id,
+ new List
+ {
+ new() {Id = orgUser.Id, ReadOnly = false, HidePasswords = false, Manage = true}
+ });
+
+ var allTasks = await securityTaskRepository.GetManyByUserIdStatusAsync(user.Id);
+ Assert.Equal(3, allTasks.Count);
+ Assert.Contains(task1, allTasks, new SecurityTaskComparer());
+ Assert.Contains(task2, allTasks, new SecurityTaskComparer());
+ Assert.Contains(task3, allTasks, new SecurityTaskComparer());
+
+ var pendingTasks = await securityTaskRepository.GetManyByUserIdStatusAsync(user.Id, SecurityTaskStatus.Pending);
+ Assert.Equal(2, pendingTasks.Count);
+ Assert.Contains(task1, pendingTasks, new SecurityTaskComparer());
+ Assert.Contains(task3, pendingTasks, new SecurityTaskComparer());
+ Assert.DoesNotContain(task2, pendingTasks, new SecurityTaskComparer());
+
+ var completedTasks = await securityTaskRepository.GetManyByUserIdStatusAsync(user.Id, SecurityTaskStatus.Completed);
+ Assert.Single(completedTasks);
+ Assert.Contains(task2, completedTasks, new SecurityTaskComparer());
+ Assert.DoesNotContain(task1, completedTasks, new SecurityTaskComparer());
+ Assert.DoesNotContain(task3, completedTasks, new SecurityTaskComparer());
+ }
}
diff --git a/util/Migrator/DbScripts/2024-11-21_00_SecurityTaskReadByUserIdStatus.sql b/util/Migrator/DbScripts/2024-11-21_00_SecurityTaskReadByUserIdStatus.sql
new file mode 100644
index 0000000000..a5760227cb
--- /dev/null
+++ b/util/Migrator/DbScripts/2024-11-21_00_SecurityTaskReadByUserIdStatus.sql
@@ -0,0 +1,59 @@
+-- Security Task Read By UserId Status
+-- Stored Procedure: ReadByUserIdStatus
+CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus]
+ @UserId UNIQUEIDENTIFIER,
+ @Status TINYINT = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ ST.Id,
+ ST.OrganizationId,
+ ST.CipherId,
+ ST.Type,
+ ST.Status,
+ ST.CreationDate,
+ ST.RevisionDate
+ FROM
+ [dbo].[SecurityTaskView] ST
+ INNER JOIN
+ [dbo].[OrganizationUserView] OU ON OU.[OrganizationId] = ST.[OrganizationId]
+ INNER JOIN
+ [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId]
+ LEFT JOIN
+ [dbo].[CipherView] C ON C.[Id] = ST.[CipherId]
+ LEFT JOIN
+ [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id] AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] IS NULL AND C.[Id] IS NOT NULL
+ LEFT JOIN
+ [dbo].[CollectionGroup] CG ON CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = CC.[CollectionId]
+ WHERE
+ OU.[UserId] = @UserId
+ AND OU.[Status] = 2 -- Ensure user is confirmed
+ AND O.[Enabled] = 1
+ AND (
+ ST.[CipherId] IS NULL
+ OR (
+ C.[Id] IS NOT NULL
+ AND (
+ CU.[ReadOnly] = 0
+ OR CG.[ReadOnly] = 0
+ )
+ )
+ )
+ AND ST.[Status] = COALESCE(@Status, ST.[Status])
+ GROUP BY
+ ST.Id,
+ ST.OrganizationId,
+ ST.CipherId,
+ ST.Type,
+ ST.Status,
+ ST.CreationDate,
+ ST.RevisionDate
+ ORDER BY ST.[CreationDate] DESC
+END
+GO