1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[AC-2084] Include Collection permissions for admin endpoints (#3793)

* [AC-2084] Add documentation to existing collection repository getters

* [AC-2084] Add new CollectionAdminDetails model

* [AC-2084] Add SQL and migration scripts

* [AC-2084] Introduce new repository methods to include permission details for collections

* [AC-2084] Add EF repository methods and integration tests

* [AC-2084] Update CollectionsController and response models

* [AC-2084] Fix failing SqlServer test

* [AC-2084] Clean up admin endpoint response models
- vNext endpoints should now always return CollectionDetailsResponse models
- Update constructors in CollectionDetailsResponseModel to be more explicit and add named static constructors for additional clarity

* [AC-2084] Fix failing tests

* [AC-2084] Fix potential provider/member bug

* [AC-2084] Fix broken collections controller

* [AC-2084] Cleanup collection response model types and constructors

* [AC-2084] Remove redundant authorization check

* [AC-2084] Cleanup ambiguous model name

* [AC-2084] Add GroupBy clause to sprocs

* [AC-2084] Add GroupBy logic to EF repository

* [AC-2084] Update collection repository tests

* [AC-2084] Update migration script date

* Update migration script date

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: kejaeger <138028972+kejaeger@users.noreply.github.com>
This commit is contained in:
Shane Melton 2024-05-03 06:33:06 -07:00 committed by GitHub
parent 25c87214ff
commit d965166a37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1232 additions and 45 deletions

View File

@ -553,47 +553,38 @@ public class CollectionsController : Controller
private async Task<CollectionAccessDetailsResponseModel> GetDetails_vNext(Guid id) private async Task<CollectionAccessDetailsResponseModel> GetDetails_vNext(Guid id)
{ {
// New flexible collections logic // New flexible collections logic
var (collection, access) = await _collectionRepository.GetByIdWithAccessAsync(id); var collectionAdminDetails =
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ReadWithAccess)).Succeeded; await _collectionRepository.GetByIdWithPermissionsAsync(id, _currentContext.UserId, true);
var authorized = (await _authorizationService.AuthorizeAsync(User, collectionAdminDetails, BulkCollectionOperations.ReadWithAccess)).Succeeded;
if (!authorized) if (!authorized)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users); return new CollectionAccessDetailsResponseModel(collectionAdminDetails);
} }
private async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails_vNext(Guid orgId) private async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails_vNext(Guid orgId)
{ {
// We always need to know which collections the current user is assigned to var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(
var assignedOrgCollections = await _collectionRepository orgId, _currentContext.UserId.Value, true);
.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, true);
var readAllAuthorized = var readAllAuthorized =
(await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded; (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded;
if (readAllAuthorized) if (readAllAuthorized)
{ {
// The user can view all collections, but they may not always be assigned to all of them return new ListResponseModel<CollectionAccessDetailsResponseModel>(
var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); allOrgCollections.Select(c => new CollectionAccessDetailsResponseModel(c))
return new ListResponseModel<CollectionAccessDetailsResponseModel>(allOrgCollections.Select(c =>
new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users)
{
// Manually determine which collections they're assigned to
Assigned = assignedOrgCollections.Any(ac => ac.Item1.Id == c.Item1.Id)
})
); );
} }
// Filter the assigned collections to only return those where the user has Manage permission // Filter collections to only return those where the user has Manage permission
var manageableOrgCollections = assignedOrgCollections.Where(c => c.Item1.Manage).ToList(); var manageableOrgCollections = allOrgCollections.Where(c => c.Manage).ToList();
return new ListResponseModel<CollectionAccessDetailsResponseModel>(manageableOrgCollections.Select(c => return new ListResponseModel<CollectionAccessDetailsResponseModel>(manageableOrgCollections.Select(c =>
new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) new CollectionAccessDetailsResponseModel(c)
{ ));
Assigned = true // Mapping from manageableOrgCollections implies they're all assigned
})
);
} }
private async Task<ListResponseModel<CollectionResponseModel>> GetByOrgId_vNext(Guid orgId) private async Task<ListResponseModel<CollectionResponseModel>> GetByOrgId_vNext(Guid orgId)
@ -629,7 +620,7 @@ public class CollectionsController : Controller
return responses; return responses;
} }
private async Task<CollectionResponseModel> Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model) private async Task<CollectionAccessDetailsResponseModel> Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model)
{ {
var collection = model.ToCollection(orgId); var collection = model.ToCollection(orgId);
@ -644,21 +635,18 @@ public class CollectionsController : Controller
await _collectionService.SaveAsync(collection, groups, users); await _collectionService.SaveAsync(collection, groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId)) if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(orgId) == null && await _currentContext.ProviderUserForOrgAsync(orgId)))
{ {
return new CollectionResponseModel(collection); return new CollectionAccessDetailsResponseModel(collection);
} }
// If we have a user, fetch the collection to get the latest permission details // If we have a user, fetch the latest collection permission details
var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, var collectionWithPermissions = await _collectionRepository.GetByIdWithPermissionsAsync(collection.Id, _currentContext.UserId.Value, false);
_currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId));
return userCollectionDetails == null return new CollectionAccessDetailsResponseModel(collectionWithPermissions);
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(userCollectionDetails);
} }
private async Task<CollectionResponseModel> Put_vNext(Guid id, CollectionRequestModel model) private async Task<CollectionAccessDetailsResponseModel> Put_vNext(Guid id, CollectionRequestModel model)
{ {
var collection = await _collectionRepository.GetByIdAsync(id); var collection = await _collectionRepository.GetByIdAsync(id);
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded; var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded;
@ -671,17 +659,15 @@ public class CollectionsController : Controller
var users = model.Users?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly());
await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); await _collectionService.SaveAsync(model.ToCollection(collection), groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)) if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(collection.OrganizationId) == null && await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)))
{ {
return new CollectionResponseModel(collection); return new CollectionAccessDetailsResponseModel(collection);
} }
// If we have a user, fetch the collection details to get the latest permission details for the user // If we have a user, fetch the latest collection permission details
var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); var collectionWithPermissions = await _collectionRepository.GetByIdWithPermissionsAsync(collection.Id, _currentContext.UserId.Value, false);
return updatedCollectionDetails == null return new CollectionAccessDetailsResponseModel(collectionWithPermissions);
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(updatedCollectionDetails);
} }
private async Task PutUsers_vNext(Guid id, IEnumerable<SelectionReadOnlyRequestModel> model) private async Task PutUsers_vNext(Guid id, IEnumerable<SelectionReadOnlyRequestModel> model)

View File

@ -26,8 +26,15 @@ public class CollectionResponseModel : ResponseModel
public string ExternalId { get; set; } public string ExternalId { get; set; }
} }
/// <summary>
/// Response model for a collection that is always assigned to the requesting user, including permissions.
/// </summary>
public class CollectionDetailsResponseModel : CollectionResponseModel public class CollectionDetailsResponseModel : CollectionResponseModel
{ {
/// <summary>
/// Create a response model for when the user is assumed to be assigned to the collection with permissions.
/// e.g. The collection details comes from a repository method that only returns collections the user is assigned to.
/// </summary>
public CollectionDetailsResponseModel(CollectionDetails collectionDetails) public CollectionDetailsResponseModel(CollectionDetails collectionDetails)
: base(collectionDetails, "collectionDetails") : base(collectionDetails, "collectionDetails")
{ {
@ -43,6 +50,27 @@ public class CollectionDetailsResponseModel : CollectionResponseModel
public class CollectionAccessDetailsResponseModel : CollectionResponseModel public class CollectionAccessDetailsResponseModel : CollectionResponseModel
{ {
/// <summary>
/// Create a response model for when the requesting user is assumed not assigned to the collection.
/// No user permissions are included.
///
/// Ideally, the CollectionAdminDetails constructor should be used instead wherever possible. This is only
/// used in the case of MSPs where the Provider user will likely never be assigned to the collection.
/// </summary>
/// <param name="collection"></param>
public CollectionAccessDetailsResponseModel(Collection collection)
: base(collection, "collectionAccessDetails")
{ }
/// <summary>
/// Create a response model for when the requesting user is assumed not assigned to the collection. Includes
/// the other groups and user relationships for the collection.
/// No user permissions are included.
/// </summary>
/// <param name="collection"></param>
/// <param name="groups"></param>
/// <param name="users"></param>
[Obsolete("Use the CollectionAdminDetails constructor instead.")]
public CollectionAccessDetailsResponseModel(Collection collection, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users) public CollectionAccessDetailsResponseModel(Collection collection, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users)
: base(collection, "collectionAccessDetails") : base(collection, "collectionAccessDetails")
{ {
@ -50,6 +78,21 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel
Users = users.Select(g => new SelectionReadOnlyResponseModel(g)); Users = users.Select(g => new SelectionReadOnlyResponseModel(g));
} }
/// <summary>
/// Create a response model for when the requesting user's assignment is available via CollectionAdminDetails.
/// </summary>
/// <param name="collection"></param>
public CollectionAccessDetailsResponseModel(CollectionAdminDetails collection)
: base(collection, "collectionAccessDetails")
{
Assigned = collection.Assigned;
ReadOnly = collection.ReadOnly;
HidePasswords = collection.HidePasswords;
Manage = collection.Manage;
Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>();
Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>();
}
public IEnumerable<SelectionReadOnlyResponseModel> Groups { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Groups { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Users { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Users { get; set; }
@ -57,4 +100,8 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel
/// True if the acting user is explicitly assigned to the collection /// True if the acting user is explicitly assigned to the collection
/// </summary> /// </summary>
public bool Assigned { get; set; } public bool Assigned { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
} }

View File

@ -0,0 +1,17 @@
#nullable enable
namespace Bit.Core.Models.Data;
/// <summary>
/// Collection information that includes permission details for a particular user along with optional
/// access relationships for Groups/Users. Used for collection management.
/// </summary>
public class CollectionAdminDetails : CollectionDetails
{
public IEnumerable<CollectionAccessSelection>? Groups { get; set; } = new List<CollectionAccessSelection>();
public IEnumerable<CollectionAccessSelection>? Users { get; set; } = new List<CollectionAccessSelection>();
/// <summary>
/// Flag for whether the user has been explicitly assigned to the collection either directly or through a group.
/// </summary>
public bool Assigned { get; set; }
}

View File

@ -2,6 +2,9 @@
namespace Bit.Core.Models.Data; namespace Bit.Core.Models.Data;
/// <summary>
/// Collection information that includes permission details for a particular user
/// </summary>
public class CollectionDetails : Collection public class CollectionDetails : Collection
{ {
public bool ReadOnly { get; set; } public bool ReadOnly { get; set; }

View File

@ -6,14 +6,64 @@ namespace Bit.Core.Repositories;
public interface ICollectionRepository : IRepository<Collection, Guid> public interface ICollectionRepository : IRepository<Collection, Guid>
{ {
Task<int> GetCountByOrganizationIdAsync(Guid organizationId); Task<int> GetCountByOrganizationIdAsync(Guid organizationId);
/// <summary>
/// Returns a collection and fetches group/user associations for the collection.
/// </summary>
Task<Tuple<Collection, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id); Task<Tuple<Collection, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id);
/// <summary>
/// Returns a collection with permission details for the provided userId and fetches group/user associations for
/// the collection.
/// If the user does not have a relationship with the collection, nothing is returned.
/// </summary>
Task<Tuple<CollectionDetails, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id, Guid userId, bool useFlexibleCollections); Task<Tuple<CollectionDetails, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id, Guid userId, bool useFlexibleCollections);
/// <summary>
/// Return all collections that belong to the organization. Does not include any permission details or group/user
/// access relationships.
/// </summary>
Task<ICollection<Collection>> GetManyByOrganizationIdAsync(Guid organizationId); Task<ICollection<Collection>> GetManyByOrganizationIdAsync(Guid organizationId);
/// <summary>
/// Return all collections that belong to the organization. Includes group/user access relationships for each collection.
/// </summary>
Task<ICollection<Tuple<Collection, CollectionAccessDetails>>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId); Task<ICollection<Tuple<Collection, CollectionAccessDetails>>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId);
/// <summary>
/// Returns collections that both, belong to the organization AND have an access relationship with the provided user.
/// Includes permission details for the provided user and group/user access relationships for each collection.
/// </summary>
Task<ICollection<Tuple<CollectionDetails, CollectionAccessDetails>>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId, bool useFlexibleCollections); Task<ICollection<Tuple<CollectionDetails, CollectionAccessDetails>>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId, bool useFlexibleCollections);
/// <summary>
/// Returns a collection with permission details for the provided userId. Does not include group/user access
/// relationships.
/// If the user does not have a relationship with the collection, nothing is returned.
/// </summary>
Task<CollectionDetails> GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections); Task<CollectionDetails> GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections);
Task<ICollection<Collection>> GetManyByManyIdsAsync(IEnumerable<Guid> collectionIds); Task<ICollection<Collection>> GetManyByManyIdsAsync(IEnumerable<Guid> collectionIds);
/// <summary>
/// Return all collections a user has access to across all of the organization they're a member of. Includes permission
/// details for each collection.
/// </summary>
Task<ICollection<CollectionDetails>> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections); Task<ICollection<CollectionDetails>> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections);
/// <summary>
/// Returns all collections for an organization, including permission info for the specified user.
/// This does not perform any authorization checks internally!
/// Optionally, you can include access relationships for other Groups/Users and the collections.
/// </summary>
Task<ICollection<CollectionAdminDetails>> GetManyByOrganizationIdWithPermissionsAsync(Guid organizationId, Guid userId, bool includeAccessRelationships);
/// <summary>
/// Returns the collection by Id, including permission info for the specified user.
/// This does not perform any authorization checks internally!
/// Optionally, you can include access relationships for other Groups/Users and the collection.
/// </summary>
Task<CollectionAdminDetails> GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships);
Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users); Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users);
Task ReplaceAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users); Task ReplaceAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users);
Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); Task DeleteUserAsync(Guid collectionId, Guid organizationUserId);

View File

@ -225,6 +225,75 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
} }
} }
public async Task<ICollection<CollectionAdminDetails>> GetManyByOrganizationIdWithPermissionsAsync(Guid organizationId, Guid userId, bool includeAccessRelationships)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryMultipleAsync(
$"[{Schema}].[Collection_ReadByOrganizationIdWithPermissions]",
new { OrganizationId = organizationId, UserId = userId, IncludeAccessRelationships = includeAccessRelationships },
commandType: CommandType.StoredProcedure);
var collections = (await results.ReadAsync<CollectionAdminDetails>()).ToList();
if (!includeAccessRelationships)
{
return collections;
}
var groups = (await results.ReadAsync<CollectionGroup>())
.GroupBy(g => g.CollectionId)
.ToList();
var users = (await results.ReadAsync<CollectionUser>())
.GroupBy(u => u.CollectionId)
.ToList();
foreach (var collection in collections)
{
collection.Groups = groups
.FirstOrDefault(g => g.Key == collection.Id)?
.Select(g => new CollectionAccessSelection
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly,
Manage = g.Manage
}).ToList() ?? new List<CollectionAccessSelection>();
collection.Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
.Select(c => new CollectionAccessSelection
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>();
}
return collections;
}
}
public async Task<CollectionAdminDetails> GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryMultipleAsync(
$"[{Schema}].[Collection_ReadByIdWithPermissions]",
new { CollectionId = collectionId, UserId = userId, IncludeAccessRelationships = includeAccessRelationships },
commandType: CommandType.StoredProcedure);
var collectionDetails = await results.ReadFirstOrDefaultAsync<CollectionAdminDetails>();
if (!includeAccessRelationships) return collectionDetails;
collectionDetails.Groups = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
collectionDetails.Users = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
return collectionDetails;
}
}
public async Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users) public async Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users)
{ {
obj.SetNewId(); obj.SetNewId();

View File

@ -370,6 +370,210 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
} }
} }
public async Task<ICollection<CollectionAdminDetails>> GetManyByOrganizationIdWithPermissionsAsync(
Guid organizationId, Guid userId, bool includeAccessRelationships)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = CollectionAdminDetailsQuery.ByOrganizationId(organizationId, userId).Run(dbContext);
ICollection<CollectionAdminDetails> collections;
// SQLite does not support the GROUP BY clause
if (dbContext.Database.IsSqlite())
{
collections = (await query.ToListAsync())
.GroupBy(c => new
{
c.Id,
c.OrganizationId,
c.Name,
c.CreationDate,
c.RevisionDate,
c.ExternalId
}).Select(collectionGroup => new CollectionAdminDetails
{
Id = collectionGroup.Key.Id,
OrganizationId = collectionGroup.Key.OrganizationId,
Name = collectionGroup.Key.Name,
CreationDate = collectionGroup.Key.CreationDate,
RevisionDate = collectionGroup.Key.RevisionDate,
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
}).ToList();
}
else
{
collections = await (from c in query
group c by new
{
c.Id,
c.OrganizationId,
c.Name,
c.CreationDate,
c.RevisionDate,
c.ExternalId
}
into collectionGroup
select new CollectionAdminDetails
{
Id = collectionGroup.Key.Id,
OrganizationId = collectionGroup.Key.OrganizationId,
Name = collectionGroup.Key.Name,
CreationDate = collectionGroup.Key.CreationDate,
RevisionDate = collectionGroup.Key.RevisionDate,
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
}).ToListAsync();
}
if (!includeAccessRelationships)
{
return collections;
}
var groups = (from c in collections
join cg in dbContext.CollectionGroups on c.Id equals cg.CollectionId
group cg by cg.CollectionId into g
select g).ToList();
var users = (from c in collections
join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId
group cu by cu.CollectionId into u
select u).ToList();
foreach (var collection in collections)
{
collection.Groups = groups
.FirstOrDefault(g => g.Key == collection.Id)?
.Select(g => new CollectionAccessSelection
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly,
Manage = g.Manage,
}).ToList() ?? new List<CollectionAccessSelection>();
collection.Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
.Select(c => new CollectionAccessSelection
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>();
}
return collections;
}
}
public async Task<CollectionAdminDetails> GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId,
bool includeAccessRelationships)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = CollectionAdminDetailsQuery.ByCollectionId(collectionId, userId).Run(dbContext);
CollectionAdminDetails collectionDetails;
// SQLite does not support the GROUP BY clause
if (dbContext.Database.IsSqlite())
{
collectionDetails = (await query.ToListAsync())
.GroupBy(c => new
{
c.Id,
c.OrganizationId,
c.Name,
c.CreationDate,
c.RevisionDate,
c.ExternalId
}).Select(collectionGroup => new CollectionAdminDetails
{
Id = collectionGroup.Key.Id,
OrganizationId = collectionGroup.Key.OrganizationId,
Name = collectionGroup.Key.Name,
CreationDate = collectionGroup.Key.CreationDate,
RevisionDate = collectionGroup.Key.RevisionDate,
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
}).FirstOrDefault();
}
else
{
collectionDetails = await (from c in query
group c by new
{
c.Id,
c.OrganizationId,
c.Name,
c.CreationDate,
c.RevisionDate,
c.ExternalId
}
into collectionGroup
select new CollectionAdminDetails
{
Id = collectionGroup.Key.Id,
OrganizationId = collectionGroup.Key.OrganizationId,
Name = collectionGroup.Key.Name,
CreationDate = collectionGroup.Key.CreationDate,
RevisionDate = collectionGroup.Key.RevisionDate,
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
}).FirstOrDefaultAsync();
}
if (!includeAccessRelationships)
{
return collectionDetails;
}
var groupsQuery = from cg in dbContext.CollectionGroups
where cg.CollectionId.Equals(collectionId)
select new CollectionAccessSelection
{
Id = cg.GroupId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage
};
collectionDetails.Groups = await groupsQuery.ToListAsync();
var usersQuery = from cg in dbContext.CollectionUsers
where cg.CollectionId.Equals(collectionId)
select new CollectionAccessSelection
{
Id = cg.OrganizationUserId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage
};
collectionDetails.Users = await usersQuery.ToListAsync();
return collectionDetails;
}
}
public async Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id) public async Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id)
{ {
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -0,0 +1,87 @@
using Bit.Core.Models.Data;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
/// <summary>
/// Query to get collection details, including permissions for the specified user if provided.
/// </summary>
public class CollectionAdminDetailsQuery : IQuery<CollectionAdminDetails>
{
private readonly Guid? _userId;
private readonly Guid? _organizationId;
private readonly Guid? _collectionId;
private CollectionAdminDetailsQuery(Guid? userId, Guid? organizationId, Guid? collectionId)
{
_userId = userId;
_organizationId = organizationId;
_collectionId = collectionId;
}
public virtual IQueryable<CollectionAdminDetails> Run(DatabaseContext dbContext)
{
var baseCollectionQuery = from c in dbContext.Collections
join ou in dbContext.OrganizationUsers
on new { c.OrganizationId, UserId = _userId } equals
new { ou.OrganizationId, ou.UserId } into ou_g
from ou in ou_g.DefaultIfEmpty()
join cu in dbContext.CollectionUsers
on new { CollectionId = c.Id, 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 g in dbContext.Groups
on gu.GroupId equals g.Id into g_g
from g in g_g.DefaultIfEmpty()
join cg in dbContext.CollectionGroups
on new { CollectionId = c.Id, gu.GroupId } equals
new { cg.CollectionId, cg.GroupId } into cg_g
from cg in cg_g.DefaultIfEmpty()
select new { c, cu, cg };
if (_organizationId.HasValue)
{
baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId);
}
else if (_collectionId.HasValue)
{
baseCollectionQuery = baseCollectionQuery.Where(x => x.c.Id == _collectionId);
}
else
{
throw new InvalidOperationException("OrganizationId or CollectionId must be specified.");
}
return baseCollectionQuery.Select(x => new CollectionAdminDetails
{
Id = x.c.Id,
OrganizationId = x.c.OrganizationId,
Name = x.c.Name,
ExternalId = x.c.ExternalId,
CreationDate = x.c.CreationDate,
RevisionDate = x.c.RevisionDate,
ReadOnly = (bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false,
HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false,
Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false,
Assigned = x.cu != null || x.cg != null,
});
}
public static CollectionAdminDetailsQuery ByCollectionId(Guid collectionId, Guid? userId)
{
return new CollectionAdminDetailsQuery(userId, null, collectionId);
}
public static CollectionAdminDetailsQuery ByOrganizationId(Guid organizationId, Guid? userId)
{
return new CollectionAdminDetailsQuery(userId, organizationId, null);
}
}

View File

@ -0,0 +1,62 @@
CREATE PROCEDURE [dbo].[Collection_ReadByIdWithPermissions]
@CollectionId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@IncludeAccessRelationships BIT
AS
BEGIN
SET NOCOUNT ON
SELECT
C.*,
MIN(CASE
WHEN
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
THEN 0
ELSE 1
END) AS [ReadOnly],
MIN (CASE
WHEN
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END) AS [HidePasswords],
MAX(CASE
WHEN
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END) AS [Manage],
MAX(CASE
WHEN
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0
ELSE 1
END) AS [Assigned]
FROM
[dbo].[CollectionView] C
LEFT JOIN
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
LEFT JOIN
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
C.[Id] = @CollectionId
GROUP BY
C.[Id],
C.[OrganizationId],
C.[Name],
C.[CreationDate],
C.[RevisionDate],
C.[ExternalId]
IF (@IncludeAccessRelationships = 1)
BEGIN
EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId
EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId
END
END

View File

@ -0,0 +1,62 @@
CREATE PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions]
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@IncludeAccessRelationships BIT
AS
BEGIN
SET NOCOUNT ON
SELECT
C.*,
MIN(CASE
WHEN
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
THEN 0
ELSE 1
END) AS [ReadOnly],
MIN(CASE
WHEN
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END) AS [HidePasswords],
MAX(CASE
WHEN
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END) AS [Manage],
MAX(CASE
WHEN
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0
ELSE 1
END) AS [Assigned]
FROM
[dbo].[CollectionView] C
LEFT JOIN
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
LEFT JOIN
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
C.[OrganizationId] = @OrganizationId
GROUP BY
C.[Id],
C.[OrganizationId],
C.[Name],
C.[CreationDate],
C.[RevisionDate],
C.[ExternalId]
IF (@IncludeAccessRelationships = 1)
BEGIN
EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId
EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId
END
END

View File

@ -115,15 +115,20 @@ public class CollectionsControllerTests
await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any<bool>()); await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true);
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task GetOrganizationCollectionsWithGroups_MissingReadAllPermissions_GetsAssignedCollections( public async Task GetOrganizationCollectionsWithGroups_MissingReadAllPermissions_GetsAssignedCollections(
OrganizationAbility organizationAbility, Guid userId, SutProvider<CollectionsController> sutProvider) OrganizationAbility organizationAbility, Guid userId, SutProvider<CollectionsController> sutProvider, List<CollectionAdminDetails> collections)
{ {
ArrangeOrganizationAbility(sutProvider, organizationAbility); ArrangeOrganizationAbility(sutProvider, organizationAbility);
collections.ForEach(c => c.OrganizationId = organizationAbility.Id);
collections.ForEach(c => c.Manage = false);
var managedCollection = collections.First();
managedCollection.Manage = true;
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
sutProvider.GetDependency<IAuthorizationService>() sutProvider.GetDependency<IAuthorizationService>()
@ -145,10 +150,16 @@ public class CollectionsControllerTests
operation.Name == nameof(BulkCollectionOperations.ReadWithAccess)))) operation.Name == nameof(BulkCollectionOperations.ReadWithAccess))))
.Returns(AuthorizationResult.Success()); .Returns(AuthorizationResult.Success());
await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true)
.Returns(collections);
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any<bool>()); var response = await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceive().GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true);
Assert.Single(response.Data);
Assert.All(response.Data, c => Assert.Equal(organizationAbility.Id, c.OrganizationId));
Assert.All(response.Data, c => Assert.Equal(managedCollection.Id, c.Id));
} }
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@ -36,6 +36,10 @@ public class CollectionCustomization : ICustomization
.With(o => o.OrganizationId, orgId) .With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(cd => cd.Id, _collectionIdSeed)); .WithGuidFromSeed(cd => cd.Id, _collectionIdSeed));
fixture.Customize<CollectionAdminDetails>(composer => composer
.With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(cd => cd.Id, _collectionIdSeed));
fixture.Customize<CollectionUser>(c => c fixture.Customize<CollectionUser>(c => c
.WithGuidFromSeed(cu => cu.OrganizationUserId, _userIdSeed) .WithGuidFromSeed(cu => cu.OrganizationUserId, _userIdSeed)
.WithGuidFromSeed(cu => cu.CollectionId, _collectionIdSeed)); .WithGuidFromSeed(cu => cu.CollectionId, _collectionIdSeed));

View File

@ -0,0 +1,459 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.Repositories;
public class CollectionRepositoryTests
{
/// <summary>
/// Test to ensure that access relationships are retrieved when requested
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetByIdWithPermissionsAsync_WithRelationships_Success(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository)
{
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 group = await groupRepository.CreateAsync(new Group
{
Name = "Test Group",
OrganizationId = organization.Id,
});
var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection, groups: new[]
{
new CollectionAccessSelection
{
Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false
}
}, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true
}
});
var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true);
Assert.NotNull(collectionWithPermissions);
Assert.Equal(1, collectionWithPermissions.Users?.Count());
Assert.Equal(1, collectionWithPermissions.Groups?.Count());
Assert.True(collectionWithPermissions.Assigned);
Assert.True(collectionWithPermissions.Manage);
Assert.False(collectionWithPermissions.ReadOnly);
Assert.False(collectionWithPermissions.HidePasswords);
}
/// <summary>
/// Test to ensure that a user's explicitly assigned permissions replaces any group permissions
/// that user may belong to
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetByIdWithPermissionsAsync_UserOverrideGroup_Success(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository)
{
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 group = await groupRepository.CreateAsync(new Group
{
Name = "Test Group",
OrganizationId = organization.Id,
});
// Assign the test user to the test group
await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id });
var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection, groups: new[]
{
new CollectionAccessSelection
{
Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true // Group is Manage
}
}, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false // User is given ReadOnly (should override group)
}
});
var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true);
Assert.NotNull(collectionWithPermissions);
Assert.Equal(1, collectionWithPermissions.Users?.Count());
Assert.Equal(1, collectionWithPermissions.Groups?.Count());
Assert.True(collectionWithPermissions.Assigned);
Assert.False(collectionWithPermissions.Manage);
Assert.True(collectionWithPermissions.ReadOnly);
Assert.False(collectionWithPermissions.HidePasswords);
}
/// <summary>
/// Test to ensure that the returned permissions are the most permissive combination of group permissions when
/// multiple groups are assigned to the same collection with different permissions
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetByIdWithPermissionsAsync_CombineGroupPermissions_Success(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository)
{
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 group = await groupRepository.CreateAsync(new Group
{
Name = "Test Group",
OrganizationId = organization.Id,
});
var group2 = await groupRepository.CreateAsync(new Group
{
Name = "Test Group 2",
OrganizationId = organization.Id,
});
// Assign the test user to the test groups
await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id });
await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id });
var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection, groups: new[]
{
new CollectionAccessSelection
{
Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false // Group 1 is ReadOnly
},
new CollectionAccessSelection
{
Id = group2.Id, HidePasswords = false, ReadOnly = false, Manage = true // Group 2 is Manage
}
}, users: new List<CollectionAccessSelection>()); // No explicit user permissions for this test
var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true);
Assert.NotNull(collectionWithPermissions);
Assert.Equal(2, collectionWithPermissions.Groups?.Count());
Assert.True(collectionWithPermissions.Assigned);
// Since Group2 is Manage the user should have Manage
Assert.True(collectionWithPermissions.Manage);
// Similarly, ReadOnly and HidePassword should be false
Assert.False(collectionWithPermissions.ReadOnly);
Assert.False(collectionWithPermissions.HidePasswords);
}
/// <summary>
/// Test to ensure the basic usage works as expected
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyByOrganizationIdWithPermissionsAsync_Success(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository)
{
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 group = await groupRepository.CreateAsync(new Group
{
Name = "Test Group",
OrganizationId = organization.Id,
});
var collection1 = new Collection { Name = "Collection 1", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection1, groups: new[]
{
new CollectionAccessSelection
{
Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false
}
}, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true
}
});
var collection2 = new Collection { Name = "Collection 2", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection2, null, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false
}
});
var collection3 = new Collection { Name = "Collection 3", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection3, groups: new[]
{
new CollectionAccessSelection()
{
Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true
}
}, null);
var collections = await collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(organization.Id, user.Id, true);
Assert.NotNull(collections);
collections = collections.OrderBy(c => c.Name).ToList();
Assert.Collection(collections, c1 =>
{
Assert.NotNull(c1);
Assert.Equal(1, c1.Users?.Count());
Assert.Equal(1, c1.Groups?.Count());
Assert.True(c1.Assigned);
Assert.True(c1.Manage);
Assert.False(c1.ReadOnly);
Assert.False(c1.HidePasswords);
}, c2 =>
{
Assert.NotNull(c2);
Assert.Equal(1, c2.Users?.Count());
Assert.Equal(0, c2.Groups?.Count());
Assert.True(c2.Assigned);
Assert.False(c2.Manage);
Assert.True(c2.ReadOnly);
Assert.False(c2.HidePasswords);
}, c3 =>
{
Assert.NotNull(c3);
Assert.Equal(0, c3.Users?.Count());
Assert.Equal(1, c3.Groups?.Count());
Assert.False(c3.Assigned);
Assert.False(c3.Manage);
Assert.False(c3.ReadOnly);
Assert.False(c3.HidePasswords);
});
}
/// <summary>
/// Test to ensure collections assigned to multiple groups do not duplicate in the results
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyByOrganizationIdWithPermissionsAsync_GroupBy_Success(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository)
{
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 group = await groupRepository.CreateAsync(new Group
{
Name = "Test Group",
OrganizationId = organization.Id,
});
var group2 = await groupRepository.CreateAsync(new Group
{
Name = "Test Group 2",
OrganizationId = organization.Id,
});
// Assign the test user to the test groups
await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id });
await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id });
var collection1 = new Collection { Name = "Collection 1", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection1, groups: new[]
{
new CollectionAccessSelection
{
Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false
},
}, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true
}
});
var collection2 = new Collection { Name = "Collection 2", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection2, null, users: new[]
{
new CollectionAccessSelection()
{
Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false
}
});
var collection3 = new Collection { Name = "Collection 3", OrganizationId = organization.Id, };
await collectionRepository.CreateAsync(collection3, groups: new[]
{
new CollectionAccessSelection()
{
Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true
},
new CollectionAccessSelection()
{
Id = group2.Id, HidePasswords = false, ReadOnly = true, Manage = false
}
}, null);
var collections = await collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(organization.Id, user.Id, true);
Assert.NotNull(collections);
Assert.Equal(3, collections.Count);
collections = collections.OrderBy(c => c.Name).ToList();
Assert.Collection(collections, c1 =>
{
Assert.NotNull(c1);
Assert.Equal(1, c1.Users?.Count());
Assert.Equal(1, c1.Groups?.Count());
Assert.True(c1.Assigned);
Assert.True(c1.Manage);
Assert.False(c1.ReadOnly);
Assert.False(c1.HidePasswords);
}, c2 =>
{
Assert.NotNull(c2);
Assert.Equal(1, c2.Users?.Count());
Assert.Equal(0, c2.Groups?.Count());
Assert.True(c2.Assigned);
Assert.False(c2.Manage);
Assert.True(c2.ReadOnly);
Assert.False(c2.HidePasswords);
}, c3 =>
{
Assert.NotNull(c3);
Assert.Equal(0, c3.Users?.Count());
Assert.Equal(2, c3.Groups?.Count());
Assert.True(c3.Assigned); // User is a member of both Groups
Assert.True(c3.Manage); // Group 2 is Manage
Assert.False(c3.ReadOnly);
Assert.False(c3.HidePasswords);
});
}
}

View File

@ -0,0 +1,126 @@
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions]
@CollectionId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@IncludeAccessRelationships BIT
AS
BEGIN
SET NOCOUNT ON
SELECT
C.*,
MIN(CASE
WHEN
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
THEN 0
ELSE 1
END) AS [ReadOnly],
MIN (CASE
WHEN
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END) AS [HidePasswords],
MAX(CASE
WHEN
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END) AS [Manage],
MAX(CASE
WHEN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0
ELSE 1
END) AS [Assigned]
FROM
[dbo].[CollectionView] C
LEFT JOIN
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
LEFT JOIN
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
C.[Id] = @CollectionId
GROUP BY
C.[Id],
C.[OrganizationId],
C.[Name],
C.[CreationDate],
C.[RevisionDate],
C.[ExternalId]
IF (@IncludeAccessRelationships = 1)
BEGIN
EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId
EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions]
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@IncludeAccessRelationships BIT
AS
BEGIN
SET NOCOUNT ON
SELECT
C.*,
MIN(CASE
WHEN
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
THEN 0
ELSE 1
END) AS [ReadOnly],
MIN(CASE
WHEN
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END) AS [HidePasswords],
MAX(CASE
WHEN
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END) AS [Manage],
MAX(CASE
WHEN
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0
ELSE 1
END) AS [Assigned]
FROM
[dbo].[CollectionView] C
LEFT JOIN
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
LEFT JOIN
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
C.[OrganizationId] = @OrganizationId
GROUP BY
C.[Id],
C.[OrganizationId],
C.[Name],
C.[CreationDate],
C.[RevisionDate],
C.[ExternalId]
IF (@IncludeAccessRelationships = 1)
BEGIN
EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId
EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId
END
END
GO