1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-21 16:47:35 +01:00

[PM-14376] Add GET tasks endpoint (#5089)

* Added CQRS pattern

* Added the GetManyByUserIdAsync signature to the repositiory

* Added sql sproc

Created user defined type to hold status

Created migration file

* Added ef core query

* Added absract and concrete implementation for GetManyByUserIdStatusAsync

* Added integration tests

* Updated params to status

* Implemented new query to utilize repository method

* Added controller for the security task endpoint

* Fixed lint issues

* Added documentation

* simplified to require single status

modified script to check for users with edit rights

* Updated ef core query

* Added new assertions

* simplified to require single status

* fixed formatting

* Fixed sql script

* Removed default null

* Added security tasks feature flag
This commit is contained in:
SmithThe4th 2024-12-12 14:27:31 -05:00 committed by GitHub
parent 03dde0d008
commit a332a69112
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 469 additions and 2 deletions

View File

@ -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;
}
/// <summary>
/// Retrieves security tasks for the current user.
/// </summary>
/// <param name="status">Optional filter for task status. If not provided returns tasks of all statuses.</param>
/// <returns>A list response model containing the security tasks for the user.</returns>
[HttpGet("")]
public async Task<ListResponseModel<SecurityTasksResponseModel>> 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<SecurityTasksResponseModel>(response);
}
}

View File

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

View File

@ -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
{
/// <inheritdoc />
public async Task<IEnumerable<SecurityTask>> GetTaskDetailsForUserAsync(Guid userId,
SecurityTaskStatus? status = null)
=> await securityTaskRepository.GetManyByUserIdStatusAsync(userId, status);
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
namespace Bit.Core.Vault.Queries;
public interface IGetTaskDetailsForUserQuery
{
/// <summary>
/// Retrieves security tasks for a user based on their organization and cipher access permissions.
/// </summary>
/// <param name="userId">The Id of the user retrieving tasks</param>
/// <param name="status">Optional filter for task status. If not provided, returns tasks of all statuses</param>
/// <returns>A collection of security tasks</returns>
Task<IEnumerable<SecurityTask>> GetTaskDetailsForUserAsync(Guid userId, SecurityTaskStatus? status = null);
}

View File

@ -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<SecurityTask, Guid>
{
/// <summary>
/// Retrieves security tasks for a user based on their organization and cipher access permissions.
/// </summary>
/// <param name="userId">The Id of the user retrieving tasks</param>
/// <param name="status">Optional filter for task status. If not provided, returns tasks of all statuses</param>
/// <returns></returns>
Task<ICollection<SecurityTask>> GetManyByUserIdStatusAsync(Guid userId, SecurityTaskStatus? status = null);
}

View File

@ -15,5 +15,6 @@ public static class VaultServiceCollectionExtensions
private static void AddVaultQueries(this IServiceCollection services)
{
services.AddScoped<IOrganizationCiphersQuery, OrganizationCiphersQuery>();
services.AddScoped<IGetTaskDetailsForUserQuery, GetTaskDetailsForUserQuery>();
}
}

View File

@ -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<SecurityTask, Guid>, ISecurityT
: base(connectionString, readOnlyConnectionString)
{ }
/// <inheritdoc />
public async Task<ICollection<SecurityTask>> GetManyByUserIdStatusAsync(Guid userId,
SecurityTaskStatus? status = null)
{
await using var connection = new SqlConnection(ConnectionString);
var results = await connection.QueryAsync<SecurityTask>(
$"[{Schema}].[SecurityTask_ReadByUserIdStatus]",
new { UserId = userId, Status = status },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}

View File

@ -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<SecurityTask>
{
private readonly Guid _userId;
private readonly SecurityTaskStatus? _status;
public SecurityTaskReadByUserIdStatusQuery(Guid userId, SecurityTaskStatus? status)
{
_userId = userId;
_status = status;
}
public IQueryable<SecurityTask> 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);
}
}

View File

@ -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<Core.Vault.Entities.SecurityTas
public SecurityTaskRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, (context) => context.SecurityTasks)
{ }
/// <inheritdoc />
public async Task<ICollection<Core.Vault.Entities.SecurityTask>> 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;
}
}

View File

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

View File

@ -0,0 +1,22 @@
using System.Diagnostics.CodeAnalysis;
using Bit.Core.Vault.Entities;
namespace Bit.Infrastructure.IntegrationTest.Comparers;
/// <summary>
/// Determines the equality of two SecurityTask objects.
/// </summary>
public class SecurityTaskComparer : IEqualityComparer<SecurityTask>
{
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();
}
}

View File

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

View File

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