diff --git a/bitwarden-core.sln b/bitwarden-core.sln index 8bc449fdba..5d80609608 100644 --- a/bitwarden-core.sln +++ b/bitwarden-core.sln @@ -47,6 +47,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Events", "src\Events\Events EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventsProcessor", "src\EventsProcessor\EventsProcessor.csproj", "{FCB77723-0FF5-4B1B-AB90-6930CF2E25F9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim", "src\Scim\Scim.csproj", "{B8C5FFEB-186A-46FF-B914-BB3D50AA8D61}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -107,6 +109,10 @@ Global {FCB77723-0FF5-4B1B-AB90-6930CF2E25F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {FCB77723-0FF5-4B1B-AB90-6930CF2E25F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {FCB77723-0FF5-4B1B-AB90-6930CF2E25F9}.Release|Any CPU.Build.0 = Release|Any CPU + {B8C5FFEB-186A-46FF-B914-BB3D50AA8D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8C5FFEB-186A-46FF-B914-BB3D50AA8D61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8C5FFEB-186A-46FF-B914-BB3D50AA8D61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8C5FFEB-186A-46FF-B914-BB3D50AA8D61}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -125,6 +131,7 @@ Global {9CF59342-3912-4B45-A2BA-0F173666586D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} {994DD611-F266-4BD3-8072-3B1B57267ED5} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} {FCB77723-0FF5-4B1B-AB90-6930CF2E25F9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} + {B8C5FFEB-186A-46FF-B914-BB3D50AA8D61} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/src/Core/Models/Data/OrganizationUserUserDetails.cs b/src/Core/Models/Data/OrganizationUserUserDetails.cs index 4811de4098..897b82ebd8 100644 --- a/src/Core/Models/Data/OrganizationUserUserDetails.cs +++ b/src/Core/Models/Data/OrganizationUserUserDetails.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Models.Data { - public class OrganizationUserUserDetails + public class OrganizationUserUserDetails : IExternal { public Guid Id { get; set; } public Guid OrganizationId { get; set; } diff --git a/src/Core/Models/IExternal.cs b/src/Core/Models/IExternal.cs new file mode 100644 index 0000000000..f6d51add24 --- /dev/null +++ b/src/Core/Models/IExternal.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models +{ + public interface IExternal + { + string ExternalId { get; } + } +} diff --git a/src/Core/Models/Table/Group.cs b/src/Core/Models/Table/Group.cs index 502e2faf60..bdb72b4d5d 100644 --- a/src/Core/Models/Table/Group.cs +++ b/src/Core/Models/Table/Group.cs @@ -3,7 +3,7 @@ using Bit.Core.Utilities; namespace Bit.Core.Models.Table { - public class Group : ITableObject + public class Group : ITableObject, IExternal { public Guid Id { get; set; } public Guid OrganizationId { get; set; } diff --git a/src/Core/Models/Table/OrganizationUser.cs b/src/Core/Models/Table/OrganizationUser.cs index 7b2b5d55d7..b85c95e8e8 100644 --- a/src/Core/Models/Table/OrganizationUser.cs +++ b/src/Core/Models/Table/OrganizationUser.cs @@ -4,7 +4,7 @@ using Bit.Core.Enums; namespace Bit.Core.Models.Table { - public class OrganizationUser : ITableObject + public class OrganizationUser : ITableObject, IExternal { public Guid Id { get; set; } public Guid OrganizationId { get; set; } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 9a9d795ee2..a5b985965c 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -26,15 +26,15 @@ namespace Bit.Core.Services Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task EnableAsync(Guid organizationId); Task UpdateAsync(Organization organization, bool updateBilling = false); - Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, + Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); - Task> InviteUserAsync(Guid organizationId, Guid invitingUserId, IEnumerable emails, + Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId, IEnumerable emails, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); 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 DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); Task DeleteUserAsync(Guid organizationId, Guid userId); Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable groupIds); Task GenerateLicenseAsync(Guid organizationId, Guid installationId); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 488a2489df..6eeed646aa 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -819,7 +819,7 @@ namespace Bit.Core.Services } } - public async Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, + 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, @@ -827,7 +827,7 @@ namespace Bit.Core.Services return result.FirstOrDefault(); } - public async Task> InviteUserAsync(Guid organizationId, Guid invitingUserId, + public async Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId, IEnumerable emails, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) { @@ -837,9 +837,9 @@ namespace Bit.Core.Services throw new NotFoundException(); } - if(type == OrganizationUserType.Owner) + if(type == OrganizationUserType.Owner && invitingUserId.HasValue) { - var invitingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(invitingUserId); + var invitingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(invitingUserId.Value); if(!invitingUserOrgs.Any(u => u.OrganizationId == organizationId && u.Type == OrganizationUserType.Owner)) { throw new BadRequestException("Only owners can invite new owners."); @@ -1065,7 +1065,7 @@ namespace Bit.Core.Services await _eventService.LogOrganizationUserEventAsync(user, EventType.OrganizationUser_Updated); } - public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) { var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); if(orgUser == null || orgUser.OrganizationId != organizationId) @@ -1073,14 +1073,14 @@ namespace Bit.Core.Services throw new BadRequestException("User not valid."); } - if(orgUser.UserId == deletingUserId) + if(deletingUserId.HasValue && orgUser.UserId == deletingUserId.Value) { throw new BadRequestException("You cannot remove yourself."); } - if(orgUser.Type == OrganizationUserType.Owner) + if(orgUser.Type == OrganizationUserType.Owner && deletingUserId.HasValue) { - var deletingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(deletingUserId); + var deletingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(deletingUserId.Value); if(!deletingUserOrgs.Any(u => u.OrganizationId == organizationId && u.Type == OrganizationUserType.Owner)) { throw new BadRequestException("Only owners can delete other owners."); diff --git a/src/Scim/Constants.cs b/src/Scim/Constants.cs new file mode 100644 index 0000000000..fa056b7790 --- /dev/null +++ b/src/Scim/Constants.cs @@ -0,0 +1,20 @@ +namespace Bit.Scim +{ + public class Constants + { + public static class Schemas + { + public const string User = @"urn:ietf:params:scim:schemas:core:2.0:User"; + public const string UserEnterprise = @"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; + public const string Group = @"urn:ietf:params:scim:schemas:core:2.0:Group"; + } + + public static class Messages + { + public const string Error = @"urn:ietf:params:scim:api:messages:2.0:Error"; + public const string PatchOp = @"urn:ietf:params:scim:api:messages:2.0:PatchOp"; + public const string ListResponse = @"urn:ietf:params:scim:api:messages:2.0:ListResponse"; + public const string SearchRequest = @"urn:ietf:params:scim:api:messages:2.0:SearchRequest"; + } + } +} diff --git a/src/Scim/Controllers/BaseController.cs b/src/Scim/Controllers/BaseController.cs new file mode 100644 index 0000000000..4469d41d3c --- /dev/null +++ b/src/Scim/Controllers/BaseController.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Bit.Core.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Scim.Controllers +{ + public class BaseController : Controller + { + protected ICollection FilterResources(ICollection resources, string filter) where T : IExternal + { + if(!string.IsNullOrWhiteSpace(filter)) + { + var filterMatch = Regex.Match(filter, "(\\w+) eq \"([^\"]*)\""); + if(filterMatch.Success && filterMatch.Groups.Count > 2) + { + var searchKey = filterMatch.Groups[1].Value; + var searchValue = filterMatch.Groups[2].Value; + + if(!string.IsNullOrWhiteSpace(searchKey) && !string.IsNullOrWhiteSpace(searchValue)) + { + var searchKeyLower = searchKey.ToLowerInvariant(); + if(searchKeyLower == "externalid") + { + resources = resources.Where(u => u.ExternalId == searchValue).ToList(); + } + } + } + } + + return resources; + } + } +} diff --git a/src/Scim/Controllers/GroupsController.cs b/src/Scim/Controllers/GroupsController.cs new file mode 100644 index 0000000000..1d27e7c1eb --- /dev/null +++ b/src/Scim/Controllers/GroupsController.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Mvc; +using System.Linq; +using Bit.Scim.Models; +using System.Diagnostics; +using System.IO; +using Bit.Core.Services; +using Bit.Core.Exceptions; + +namespace Bit.Scim.Controllers +{ + [Route("groups")] + [Route("scim/groups")] + public class GroupsController : BaseController + { + private readonly IGroupRepository _groupRepository; + private readonly IGroupService _groupService; + private Guid _orgId = new Guid("2933f760-9c0b-4efb-a437-a82a00ed3fc1"); // TODO: come from context + + public GroupsController( + IGroupRepository groupRepository, + IGroupService groupService) + { + _groupRepository = groupRepository; + _groupService = groupService; + } + + [HttpGet] + public async Task GetAll([FromQuery]string filter, [FromQuery]string excludedAttributes, + [FromQuery]string attributes) + { + var groups = await _groupRepository.GetManyByOrganizationIdAsync(_orgId); + groups = FilterResources(groups, filter); + var groupsResult = groups.Select(g => new ScimGroup(g)); + var result = new ScimListResponse(groupsResult); + return new OkObjectResult(result); + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var group = await _groupRepository.GetByIdAsync(new Guid(id)); + if(group == null || group.OrganizationId != _orgId) + { + throw new NotFoundException(); + } + + var result = new ScimGroup(group); + return new OkObjectResult(result); + } + + [HttpPost] + public async Task Post([FromBody]ScimGroup model) + { + var group = model.ToGroup(_orgId); + await _groupService.SaveAsync(group); + var result = new ScimGroup(group); + var getUrl = Url.Action("Get", "Groups", new { id = group.Id.ToString() }, Request.Protocol, Request.Host.Value); + return new CreatedResult(getUrl, result); + } + + [HttpPut("{id}")] + public async Task Put(string id, [FromBody]ScimGroup model) + { + var group = await _groupRepository.GetByIdAsync(new Guid(id)); + if(group == null || group.OrganizationId != _orgId) + { + throw new NotFoundException(); + } + + group = model.ToGroup(group); + await _groupService.SaveAsync(group); + + var result = new ScimGroup(group); + return new OkObjectResult(result); + } + + [HttpPatch("{id}")] + public async Task Patch(string id) + { + var group = await _groupRepository.GetByIdAsync(new Guid(id)); + if(group == null || group.OrganizationId != _orgId) + { + throw new NotFoundException(); + } + + var memstream = new MemoryStream(); + Request.Body.CopyTo(memstream); + memstream.Position = 0; + using(var reader = new StreamReader(memstream)) + { + var text = reader.ReadToEnd(); + Debug.WriteLine(text); + } + + // TODO: Do patch + + var result = new ScimGroup(group); + return new OkObjectResult(result); + } + + [HttpDelete("{id}")] + public async Task Delete(string id) + { + var group = await _groupRepository.GetByIdAsync(new Guid(id)); + if(group == null || group.OrganizationId != _orgId) + { + throw new NotFoundException(); + } + + await _groupService.DeleteAsync(group); + return new OkResult(); + } + } +} diff --git a/src/Scim/Controllers/UsersController.cs b/src/Scim/Controllers/UsersController.cs new file mode 100644 index 0000000000..79f51c848b --- /dev/null +++ b/src/Scim/Controllers/UsersController.cs @@ -0,0 +1,122 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Models; +using Microsoft.AspNetCore.Mvc; +using Bit.Core.Models.Data; +using Bit.Core.Exceptions; + +namespace Bit.Scim.Controllers +{ + [Route("users")] + [Route("scim/users")] + public class UsersController : BaseController + { + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private Guid _orgId = new Guid("2933f760-9c0b-4efb-a437-a82a00ed3fc1"); // TODO: come from context + + public UsersController( + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService) + { + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + } + + [HttpGet] + public async Task GetAll([FromQuery]string filter, [FromQuery]string excludedAttributes, + [FromQuery]string attributes) + { + var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_orgId); + users = FilterResources(users, filter); + var usersResult = users.Select(u => new ScimUser(u)); + var result = new ScimListResponse(usersResult); + return new OkObjectResult(result); + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_orgId); + var user = users.SingleOrDefault(u => u.Id == new Guid(id)); + if(user == null) + { + throw new NotFoundException(); + } + + var result = new ScimUser(user); + return new OkObjectResult(result); + } + + [HttpPost] + public async Task Post([FromBody]ScimUser model) + { + var email = model.Emails?.FirstOrDefault(); + if(email == null) + { + throw new BadRequestException("No email address available."); + } + + var orgUser = await _organizationService.InviteUserAsync(_orgId, null, email.Value, + OrganizationUserType.User, false, model.ExternalId, new List()); + var result = new ScimUser(orgUser); + var getUrl = Url.Action("Get", "Users", new { id = orgUser.Id.ToString() }, Request.Protocol, Request.Host.Value); + return new CreatedResult(getUrl, result); + } + + [HttpPut("{id}")] + public async Task Put(string id, [FromBody]ScimUser model) + { + var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_orgId); + var user = users.SingleOrDefault(u => u.Id == new Guid(id)); + if(user == null) + { + throw new NotFoundException(); + } + + // TODO: update + + var result = new ScimUser(user); + return new OkObjectResult(result); + } + + [HttpPatch("{id}")] + public async Task Patch(string id) + { + var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_orgId); + var user = users.SingleOrDefault(u => u.Id == new Guid(id)); + if(user == null) + { + throw new NotFoundException(); + } + + var memstream = new MemoryStream(); + Request.Body.CopyTo(memstream); + memstream.Position = 0; + using(var reader = new StreamReader(memstream)) + { + var text = reader.ReadToEnd(); + Debug.WriteLine(text); + } + + // TODO: patch + + var result = new ScimUser(user); + return new OkObjectResult(result); + } + + [HttpDelete("{id}")] + public async Task Delete(string id) + { + await _organizationService.DeleteUserAsync(_orgId, new Guid(id), null); + return new OkResult(); + } + } +} diff --git a/src/Scim/Models/ScimError.cs b/src/Scim/Models/ScimError.cs new file mode 100644 index 0000000000..30ba69f2d0 --- /dev/null +++ b/src/Scim/Models/ScimError.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimError + { + private IEnumerable _schemas; + + public ScimError() + { + _schemas = new[] { Constants.Messages.Error }; + } + + public ScimError(HttpStatusCode status, string detail = null) + : this() + { + Status = (int)status; + Detail = detail; + } + + [JsonProperty("schemas")] + public IEnumerable Schemas + { + get => _schemas; + set { _schemas = value; } + } + [JsonProperty("status")] + public int Status { get; set; } + [JsonProperty("detail")] + public string Detail { get; set; } + } +} diff --git a/src/Scim/Models/ScimGroup.cs b/src/Scim/Models/ScimGroup.cs new file mode 100644 index 0000000000..dfcc442663 --- /dev/null +++ b/src/Scim/Models/ScimGroup.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using Bit.Core.Models.Table; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimGroup : ScimResource + { + public ScimGroup() { } + + public ScimGroup(Group group) + { + Id = group.Id.ToString(); + ExternalId = group.ExternalId; + DisplayName = group.Name; + Meta = new ScimResourceMetadata("Group"); + } + + public override string SchemaIdentifier => Constants.Schemas.Group; + [JsonProperty("displayName")] + public string DisplayName { get; set; } + [JsonProperty("members")] + public IEnumerable Members { get; set; } + + public Group ToGroup(Guid orgId) + { + return new Group + { + ExternalId = ExternalId, + Name = DisplayName, + OrganizationId = orgId + }; + } + + public Group ToGroup(Group group) + { + group.Name = DisplayName; + return group; + } + } +} diff --git a/src/Scim/Models/ScimListResponse.cs b/src/Scim/Models/ScimListResponse.cs new file mode 100644 index 0000000000..f39b1ae9e6 --- /dev/null +++ b/src/Scim/Models/ScimListResponse.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimListResponse : ScimSchemaBase + { + public ScimListResponse(IEnumerable resources) + { + Resources = resources; + } + + public override string SchemaIdentifier => Constants.Messages.ListResponse; + [JsonProperty("totalResults", Order = 0)] + public int TotalResults => Resources == null ? 0 : Resources.Count(); + [JsonProperty("Resources", Order = 1)] + public IEnumerable Resources { get; private set; } + [JsonProperty("startIndex", Order = 2)] + public int StartIndex { get; set; } = 0; + [JsonProperty("itemsPerPage", Order = 3)] + public int ItemsPerPage => Resources == null ? 0 : Resources.Count(); + } +} diff --git a/src/Scim/Models/ScimMultiValuedAttribute.cs b/src/Scim/Models/ScimMultiValuedAttribute.cs new file mode 100644 index 0000000000..80ad98371c --- /dev/null +++ b/src/Scim/Models/ScimMultiValuedAttribute.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimMultiValuedAttribute + { + [JsonProperty("type")] + public string Type { get; set; } + [JsonProperty("primary")] + public bool Primary { get; set; } + [JsonProperty("display")] + public string Display { get; set; } + [JsonProperty("value")] + public string Value { get; set; } + [JsonProperty("$ref")] + public Uri Ref { get; set; } + } +} diff --git a/src/Scim/Models/ScimResource.cs b/src/Scim/Models/ScimResource.cs new file mode 100644 index 0000000000..2040478468 --- /dev/null +++ b/src/Scim/Models/ScimResource.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public abstract class ScimResource : ScimSchemaBase + { + [JsonProperty(Order = -5, PropertyName = "id")] + public string Id { get; set; } + [JsonProperty(PropertyName = "externalId")] + public string ExternalId { get; set; } + [JsonProperty(Order = 9999, PropertyName = "meta")] + public ScimResourceMetadata Meta { get; set; } + } +} diff --git a/src/Scim/Models/ScimResourceMetadata.cs b/src/Scim/Models/ScimResourceMetadata.cs new file mode 100644 index 0000000000..60c96b6a03 --- /dev/null +++ b/src/Scim/Models/ScimResourceMetadata.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimResourceMetadata + { + private ScimResourceMetadata() { } + + public ScimResourceMetadata(string resourceType) + { + ResourceType = resourceType; + } + + [JsonProperty("resourceType")] + public string ResourceType { get; set; } + } +} diff --git a/src/Scim/Models/ScimSchemaBase.cs b/src/Scim/Models/ScimSchemaBase.cs new file mode 100644 index 0000000000..f0531fc815 --- /dev/null +++ b/src/Scim/Models/ScimSchemaBase.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public abstract class ScimSchemaBase + { + [JsonProperty("schemas", Order = -10)] + public virtual ISet Schemas => new HashSet(new[] { SchemaIdentifier }); + [JsonIgnore] + public abstract string SchemaIdentifier { get; } + } +} diff --git a/src/Scim/Models/ScimUser.cs b/src/Scim/Models/ScimUser.cs new file mode 100644 index 0000000000..88dcf5f8ac --- /dev/null +++ b/src/Scim/Models/ScimUser.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; +using Newtonsoft.Json; + +namespace Bit.Scim.Models +{ + public class ScimUser : ScimResource + { + public ScimUser() { } + + public ScimUser(OrganizationUserUserDetails userDetails) + { + Id = userDetails.Id.ToString(); + ExternalId = userDetails.ExternalId; + UserName = userDetails.Email; + Name = new ScimName + { + Formatted = userDetails.Name + }; + DisplayName = userDetails.Name; + Active = true; + Emails = new List { + new ScimMultiValuedAttribute { Type = "work", Value = userDetails.Email } }; + Meta = new ScimResourceMetadata("User"); + } + + public ScimUser(OrganizationUser orgUser) + { + Id = orgUser.Id.ToString(); + ExternalId = orgUser.ExternalId; + UserName = orgUser.Email; + Active = true; + Emails = new List { + new ScimMultiValuedAttribute { Type = "work", Value = orgUser.Email } }; + Meta = new ScimResourceMetadata("User"); + } + + public override string SchemaIdentifier => Constants.Schemas.User; + [JsonProperty("userName")] + public string UserName { get; set; } + [JsonProperty("name")] + public ScimName Name { get; set; } + [JsonProperty("displayName")] + public string DisplayName { get; set; } + [JsonProperty("active")] + public bool Active { get; set; } + [JsonProperty("emails")] + public IEnumerable Emails { get; set; } + [JsonProperty("groups")] + public IEnumerable Groups { get; set; } + + public class ScimName + { + [JsonProperty("formatted")] + public string Formatted { get; set; } + [JsonProperty("familyName")] + public string FamilyName { get; set; } + [JsonProperty("givenName")] + public string GivenName { get; set; } + } + } +} diff --git a/src/Scim/Program.cs b/src/Scim/Program.cs new file mode 100644 index 0000000000..2ac608137a --- /dev/null +++ b/src/Scim/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace Bit.Scim +{ + public class Program + { + public static void Main(string[] args) + { + WebHost + .CreateDefaultBuilder(args) + .UseStartup() + .Build() + .Run(); + } + } +} diff --git a/src/Scim/Properties/launchSettings.json b/src/Scim/Properties/launchSettings.json new file mode 100644 index 0000000000..c2baeaf1ee --- /dev/null +++ b/src/Scim/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:9000/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Scim": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:9000/" + } + } +} diff --git a/src/Scim/Scim.csproj b/src/Scim/Scim.csproj new file mode 100644 index 0000000000..5ae2021c99 --- /dev/null +++ b/src/Scim/Scim.csproj @@ -0,0 +1,25 @@ + + + + 1.15.1 + netcoreapp2.0 + Bit.Scim + bitwarden-Scim + false + false + + + + + + + + + + + + + + + + diff --git a/src/Scim/Startup.cs b/src/Scim/Startup.cs new file mode 100644 index 0000000000..79e50a8f3e --- /dev/null +++ b/src/Scim/Startup.cs @@ -0,0 +1,79 @@ +using Bit.Core; +using Bit.Core.Utilities; +using Bit.Scim.Utilities; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Serilog.Events; + +namespace Bit.Scim +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + // Options + services.AddOptions(); + + // Settings + var globalSettings = services.AddGlobalSettingsServices(Configuration); + + // Repositories + services.AddSqlServerRepositories(globalSettings); + + // Context + services.AddScoped(); + + // Identity + services.AddCustomIdentityServices(globalSettings); + + // Services + services.AddBaseServices(); + services.AddDefaultServices(globalSettings); + + services.TryAddSingleton(); + + // Mvc + services.AddMvc(config => + { + config.Filters.Add(new ExceptionHandlerFilterAttribute()); + }); + } + + public void Configure( + IApplicationBuilder app, + IHostingEnvironment env, + IApplicationLifetime appLifetime, + GlobalSettings globalSettings, + ILoggerFactory loggerFactory) + { + // Disable app insights + var telConfig = app.ApplicationServices.GetService(); + telConfig.DisableTelemetry = true; + + loggerFactory.AddSerilog(env, appLifetime, globalSettings, (e) => e.Level >= LogEventLevel.Error); + + if(env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + // Default Middleware + app.UseDefaultMiddleware(env); + + app.UseMvc(); + } + } +} diff --git a/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs new file mode 100644 index 0000000000..7b9e9690b7 --- /dev/null +++ b/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs @@ -0,0 +1,43 @@ +using Bit.Core.Exceptions; +using Bit.Scim.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.Scim.Utilities +{ + public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + var exception = context.Exception; + if(exception == null) + { + // Should never happen. + return; + } + + var error = new ScimError(); + if(exception is BadRequestException) + { + context.HttpContext.Response.StatusCode = error.Status = 400; + error.Detail = exception.Message; + } + else if(exception is NotFoundException) + { + context.HttpContext.Response.StatusCode = error.Status = 404; + error.Detail = "Resource not found."; + } + else + { + context.HttpContext.Response.StatusCode = error.Status = 500; + error.Detail = "An unhandled server error has occurred."; + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError(0, exception, exception.Message); + } + + context.Result = new ObjectResult(error); + } + } +} diff --git a/src/Scim/appsettings.Production.json b/src/Scim/appsettings.Production.json new file mode 100644 index 0000000000..e0dbff6b09 --- /dev/null +++ b/src/Scim/appsettings.Production.json @@ -0,0 +1,13 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.bitwarden.com", + "api": "https://api.bitwarden.com", + "identity": "https://identity.bitwarden.com", + "internalIdentity": "https://identity.bitwarden.com" + }, + "braintree": { + "production": true + } + } +} diff --git a/src/Scim/appsettings.json b/src/Scim/appsettings.json new file mode 100644 index 0000000000..f4cf9fb956 --- /dev/null +++ b/src/Scim/appsettings.json @@ -0,0 +1,49 @@ +{ + "globalSettings": { + "selfHosted": false, + "siteName": "bitwarden", + "projectName": "Billing", + "stripeApiKey": "SECRET", + "baseServiceUri": { + "vault": "http://localhost:4001", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "internalIdentity": "http://localhost:33656" + }, + "sqlServer": { + "connectionString": "SECRET" + }, + "mail": { + "sendGridApiKey": "SECRET", + "replyToEmail": "hello@bitwarden.com" + }, + "identityServer": { + "certificateThumbprint": "SECRET" + }, + "dataProtection": { + "certificateThumbprint": "SECRET" + }, + "storage": { + "connectionString": "SECRET" + }, + "documentDb": { + "uri": "SECRET", + "key": "SECRET" + }, + "notificationHub": { + "connectionString": "SECRET", + "hubName": "SECRET" + } + }, + "billingSettings": { + "stripeWebhookKey": "SECRET", + "stripeWebhookSecret": "SECRET", + "braintreeWebhookKey": "SECRET" + }, + "braintree": { + "production": false, + "merchantId": "SECRET", + "publicKey": "SECRET", + "privateKey": "SECRET" + } +}