From de1b00533f3b9e896b56f598c25619662f8f029b Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 5 Mar 2019 23:24:14 -0500 Subject: [PATCH] org members public api --- .../Public/Controllers/MembersController.cs | 158 ++++++++++++++++++ ...=> AssociationWithPermissionsBaseModel.cs} | 2 +- src/Core/Models/Api/Public/MemberBaseModel.cs | 55 ++++++ .../AssociationWithPermissionsRequestModel.cs | 2 +- .../Request/MemberCreateRequestModel.cs | 22 +++ .../Request/MemberUpdateRequestModel.cs | 21 +++ ...AssociationWithPermissionsResponseModel.cs | 2 +- .../Public/Response/MemberResponseModel.cs | 88 ++++++++++ src/Core/Services/IOrganizationService.cs | 2 +- .../Implementations/OrganizationService.cs | 16 +- 10 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 src/Api/Public/Controllers/MembersController.cs rename src/Core/Models/Api/Public/{BaseAssociationWithPermissionsModel.cs => AssociationWithPermissionsBaseModel.cs} (90%) create mode 100644 src/Core/Models/Api/Public/MemberBaseModel.cs create mode 100644 src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs create mode 100644 src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs create mode 100644 src/Core/Models/Api/Public/Response/MemberResponseModel.cs diff --git a/src/Api/Public/Controllers/MembersController.cs b/src/Api/Public/Controllers/MembersController.cs new file mode 100644 index 000000000..54f71dffe --- /dev/null +++ b/src/Api/Public/Controllers/MembersController.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bit.Core; +using Bit.Core.Models.Api.Public; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Public.Controllers +{ + [Route("public/members")] + [Authorize("Organization")] + public class MembersController : Controller + { + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly IUserService _userService; + private readonly CurrentContext _currentContext; + + public MembersController( + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IUserService userService, + CurrentContext currentContext) + { + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _userService = userService; + _currentContext = currentContext; + } + + /// + /// Retrieve a member. + /// + /// + /// Retrieves the details of an existing member of the organization. You need only supply the + /// unique member identifier that was returned upon member creation. + /// + /// The identifier of the member to be retrieved. + [HttpGet("{id}")] + [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Get(Guid id) + { + var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); + var orgUser = userDetails?.Item1; + if(orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), + userDetails.Item2); + return new JsonResult(response); + } + + /// + /// List all members. + /// + /// + /// Returns a list of your organization's members. + /// Member objects listed in this call do not include information about their associated collections. + /// + [HttpGet] + [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] + public async Task List() + { + var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync( + _currentContext.OrganizationId.Value); + // TODO: Get all CollectionUser associations for the organization and marry them up here for the response. + var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u, + await _userService.TwoFactorIsEnabledAsync(u), null)); + var memberResponses = await Task.WhenAll(memberResponsesTasks); + var response = new ListResponseModel(memberResponses); + return new JsonResult(response); + } + + /// + /// Create a member. + /// + /// + /// Creates a new member object by inviting a user to the organization. + /// + /// The request model. + [HttpPost] + [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + public async Task Post([FromBody]MemberCreateRequestModel model) + { + var associations = model.Collections?.Select(c => c.ToSelectionReadOnly()); + var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, + model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations); + var response = new MemberResponseModel(user, associations); + return new JsonResult(response); + } + + /// + /// Update a member. + /// + /// + /// Updates the specified member object. If a property is not provided, + /// the value of the existing property will be reset. + /// + /// The identifier of the member to be updated. + /// The request model. + [HttpPut("{id}")] + [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Put(Guid id, [FromBody]MemberUpdateRequestModel model) + { + var existingUser = await _organizationUserRepository.GetByIdAsync(id); + if(existingUser == null || existingUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + var updatedUser = model.ToOrganizationUser(existingUser); + var associations = model.Collections?.Select(c => c.ToSelectionReadOnly()); + await _organizationService.SaveUserAsync(updatedUser, null, associations); + MemberResponseModel response = null; + if(existingUser.UserId.HasValue) + { + var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); + response = new MemberResponseModel(existingUserDetails, + await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations); + } + else + { + response = new MemberResponseModel(updatedUser, associations); + } + return new JsonResult(response); + } + + /// + /// Delete a member. + /// + /// + /// Permanently deletes a member from the organization. This cannot be undone. + /// The user account will still remain. The user is only removed from the organization. + /// + /// The identifier of the member to be deleted. + [HttpDelete("{id}")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Delete(Guid id) + { + var user = await _organizationUserRepository.GetByIdAsync(id); + if(user == null || user.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + await _organizationService.DeleteUserAsync(_currentContext.OrganizationId.Value, id, null); + return new OkResult(); + } + } +} diff --git a/src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs b/src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs similarity index 90% rename from src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs rename to src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs index 3cde538da..03a21ab31 100644 --- a/src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs +++ b/src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs @@ -4,7 +4,7 @@ using Newtonsoft.Json; namespace Bit.Core.Models.Api.Public { - public abstract class BaseAssociationWithPermissionsModel + public abstract class AssociationWithPermissionsBaseModel { /// /// The associated object's unique identifier. diff --git a/src/Core/Models/Api/Public/MemberBaseModel.cs b/src/Core/Models/Api/Public/MemberBaseModel.cs new file mode 100644 index 000000000..a8745de7e --- /dev/null +++ b/src/Core/Models/Api/Public/MemberBaseModel.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Api.Public +{ + public abstract class MemberBaseModel + { + public MemberBaseModel() { } + + public MemberBaseModel(OrganizationUser user) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + Type = user.Type; + AccessAll = user.AccessAll; + ExternalId = user.ExternalId; + } + + public MemberBaseModel(OrganizationUserUserDetails user) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + Type = user.Type; + AccessAll = user.AccessAll; + ExternalId = user.ExternalId; + } + + /// + /// The member's type (or role) within the organization. + /// + [Required] + public OrganizationUserType? Type { get; set; } + /// + /// Determines if this member can access all collections within the organization, or only the associated + /// collections. If set to true, this option overrides any collection assignments. + /// + [Required] + public bool? AccessAll { get; set; } + /// + /// External identifier linking this member to another system, such as a user directory. + /// + /// external_id_123456 + [StringLength(300)] + public string ExternalId { get; set; } + } +} diff --git a/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs b/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs index 2f0e566ee..3cf1bfba2 100644 --- a/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Models.Api.Public { - public class AssociationWithPermissionsRequestModel : BaseAssociationWithPermissionsModel + public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel { public SelectionReadOnly ToSelectionReadOnly() { diff --git a/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs b/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs new file mode 100644 index 000000000..3565d2d9c --- /dev/null +++ b/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Api.Public +{ + public class MemberCreateRequestModel : MemberUpdateRequestModel + { + /// + /// The member's email address. + /// + /// jsmith@company.com + [Required] + [EmailAddress] + public string Email { get; set; } + + public override OrganizationUser ToOrganizationUser(OrganizationUser existingUser) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs b/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs new file mode 100644 index 000000000..df69e0e2f --- /dev/null +++ b/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Api.Public +{ + public class MemberUpdateRequestModel : MemberBaseModel + { + /// + /// The associated collections that this member can access. + /// + public IEnumerable Collections { get; set; } + + public virtual OrganizationUser ToOrganizationUser(OrganizationUser existingUser) + { + existingUser.Type = Type.Value; + existingUser.AccessAll = AccessAll.Value; + existingUser.ExternalId = ExternalId; + return existingUser; + } + } +} diff --git a/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs b/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs index 2e8101f8e..027cd257d 100644 --- a/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs +++ b/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Models.Data; namespace Bit.Core.Models.Api.Public { - public class AssociationWithPermissionsResponseModel : BaseAssociationWithPermissionsModel + public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel { public AssociationWithPermissionsResponseModel(SelectionReadOnly selection) { diff --git a/src/Core/Models/Api/Public/Response/MemberResponseModel.cs b/src/Core/Models/Api/Public/Response/MemberResponseModel.cs new file mode 100644 index 000000000..c7a2c093e --- /dev/null +++ b/src/Core/Models/Api/Public/Response/MemberResponseModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Api.Public +{ + /// + /// An organization member. + /// + public class MemberResponseModel : MemberBaseModel, IResponseModel + { + public MemberResponseModel(OrganizationUser user, IEnumerable collections) + : base(user) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + Id = user.Id; + Email = user.Email; + Status = user.Status; + Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); + } + + public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, + IEnumerable collections) + : base(user) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + Id = user.Id; + Name = user.Name; + Email = user.Email; + TwoFactorEnabled = twoFactorEnabled; + Status = user.Status; + Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); + } + + /// + /// String representing the object's type. Objects of the same type share the same properties. + /// + /// member + [Required] + public string Object => "member"; + /// + /// The member's unique identifier. + /// + /// 539a36c5-e0d2-4cf9-979e-51ecf5cf6593 + [Required] + public Guid Id { get; set; } + /// + /// The member's name, set from their user account profile. + /// + /// John Smith + public string Name { get; set; } + /// + /// The member's email address. + /// + /// jsmith@company.com + [Required] + public string Email { get; set; } + /// + /// Returns true if the member has a two-step login method enabled on their user account. + /// + [Required] + public bool TwoFactorEnabled { get; set; } + /// + /// The member's status within the organization. All created members start with a status of "Invited". + /// Once a member accept's their invitation to join the organization, their status changes to "Accepted". + /// Accepted members are then "Confirmed" by an organization administrator. Once a member is "Confirmed", + /// their status can no longer change. + /// + [Required] + public OrganizationUserStatusType Status { get; set; } + /// + /// The associated collections that this member can access. + /// + public IEnumerable Collections { get; set; } + } +} diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 3ae5d5d2f..61e25dc31 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -35,7 +35,7 @@ namespace Bit.Core.Services Task ResendInviteAsync(Guid organizationId, Guid invitingUserId, Guid organizationUserId); Task AcceptUserAsync(Guid organizationUserId, User user, string token); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); - Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable collections); + Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections); Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); Task DeleteUserAsync(Guid organizationId, Guid userId); Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable groupIds); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 701f0aece..b90b9f67a 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -871,9 +871,14 @@ namespace Bit.Core.Services public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) { - var result = await InviteUserAsync(organizationId, invitingUserId, new List { email }, type, accessAll, + var results = await InviteUserAsync(organizationId, invitingUserId, new List { email }, type, accessAll, externalId, collections); - return result.FirstOrDefault(); + var result = results.FirstOrDefault(); + if(result == null) + { + throw new BadRequestException("This user has already been invited."); + } + return result; } public async Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId, @@ -1062,16 +1067,17 @@ namespace Bit.Core.Services return orgUser; } - public async Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable collections) + public async Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, + IEnumerable collections) { if(user.Id.Equals(default(Guid))) { throw new BadRequestException("Invite the user first."); } - if(user.Type == OrganizationUserType.Owner) + if(savingUserId.HasValue && user.Type == OrganizationUserType.Owner) { - var savingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(savingUserId); + var savingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(savingUserId.Value); if(!savingUserOrgs.Any(u => u.OrganizationId == user.OrganizationId && u.Type == OrganizationUserType.Owner)) { throw new BadRequestException("Only owners can update other owners.");