Browse Source
* scim project stub * some scim models and v2 controllers * implement some v2 scim endpoints * fix spacing * api key auth * EC-261 - SCIM Org API Key and connection type config * EC-261 - Fix lint errors/formatting * updates for okta implementation testing * fix var ref * updates from testing with Okta * implement scim context via provider parsing * support single and list of ids for add/remove groups * log ops not handled * touch up scim context * group list filtering * EC-261 - Additional SCIM provider types * EC-265 - UseScim flag and license update * EC-265 - SCIM provider type of default (0) * EC-265 - Add Scim URL and update connection validation * EC-265 - Model validation and cleanup for SCIM keys * implement scim org connection * EC-265 - Ensure ServiceUrl is not persisted to DB * EC-265 - Exclude provider type from DB if not configured * EC-261 - EF Migrations for SCIM * add docker builds for scim * EC-261 - Fix failing permissions tests * EC-261 - Fix unit tests and pgsql migrations * Formatting fixes from linter * EC-265 - Remove service URL from scim config * EC-265 - Fix unit tests, removed wayward validation * EC-265 - Require self-hosted for billing sync org conn * EC-265 - Fix formatting issues - whitespace * EC-261 - PR feedback and cleanup * scim constants rename * no scim settings right now * update project name * delete package lock * update appsettings configs for scim * use default scim provider for context Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com>pull/2121/head
117 changed files with 8553 additions and 169 deletions
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
* |
||||
!obj/build-output/publish/* |
||||
!obj/Docker/empty/ |
||||
!entrypoint.sh |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.OrganizationConnectionConfigs; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Settings; |
||||
|
||||
namespace Bit.Scim.Context |
||||
{ |
||||
public interface IScimContext |
||||
{ |
||||
ScimProviderType RequestScimProvider { get; set; } |
||||
ScimConfig ScimConfiguration { get; set; } |
||||
Guid? OrganizationId { get; set; } |
||||
Organization Organization { get; set; } |
||||
Task BuildAsync( |
||||
HttpContext httpContext, |
||||
GlobalSettings globalSettings, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationConnectionRepository organizationConnectionRepository); |
||||
} |
||||
} |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.OrganizationConnectionConfigs; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Settings; |
||||
|
||||
namespace Bit.Scim.Context |
||||
{ |
||||
public class ScimContext : IScimContext |
||||
{ |
||||
private bool _builtHttpContext; |
||||
|
||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default; |
||||
public ScimConfig ScimConfiguration { get; set; } |
||||
public Guid? OrganizationId { get; set; } |
||||
public Organization Organization { get; set; } |
||||
|
||||
public async virtual Task BuildAsync( |
||||
HttpContext httpContext, |
||||
GlobalSettings globalSettings, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationConnectionRepository organizationConnectionRepository) |
||||
{ |
||||
if (_builtHttpContext) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
_builtHttpContext = true; |
||||
|
||||
string orgIdString = null; |
||||
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject)) |
||||
{ |
||||
orgIdString = orgIdObject?.ToString(); |
||||
} |
||||
|
||||
if (Guid.TryParse(orgIdString, out var orgId)) |
||||
{ |
||||
OrganizationId = orgId; |
||||
Organization = await organizationRepository.GetByIdAsync(orgId); |
||||
if (Organization != null) |
||||
{ |
||||
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id, |
||||
OrganizationConnectionType.Scim); |
||||
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>(); |
||||
} |
||||
} |
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default && |
||||
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent)) |
||||
{ |
||||
if (userAgent.ToString().StartsWith("Okta")) |
||||
{ |
||||
RequestScimProvider = ScimProviderType.Okta; |
||||
} |
||||
} |
||||
if (RequestScimProvider == ScimProviderType.Default && |
||||
httpContext.Request.Headers.ContainsKey("Adscimversion")) |
||||
{ |
||||
RequestScimProvider = ScimProviderType.AzureAd; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
using Bit.Core.Utilities; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
|
||||
namespace Bit.Scim.Controllers |
||||
{ |
||||
public class InfoController : Controller |
||||
{ |
||||
[HttpGet("~/alive")] |
||||
[HttpGet("~/now")] |
||||
public DateTime GetAlive() |
||||
{ |
||||
return DateTime.UtcNow; |
||||
} |
||||
|
||||
[HttpGet("~/version")] |
||||
public JsonResult GetVersion() |
||||
{ |
||||
return Json(CoreHelpers.GetVersion()); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,290 @@
@@ -0,0 +1,290 @@
|
||||
using System.Text.Json; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Scim.Context; |
||||
using Bit.Scim.Models; |
||||
using Microsoft.AspNetCore.Authorization; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
using Microsoft.Extensions.Options; |
||||
|
||||
namespace Bit.Scim.Controllers.v2 |
||||
{ |
||||
[Authorize("Scim")] |
||||
[Route("v2/{organizationId}/groups")] |
||||
public class GroupsController : Controller |
||||
{ |
||||
private readonly ScimSettings _scimSettings; |
||||
private readonly IGroupRepository _groupRepository; |
||||
private readonly IGroupService _groupService; |
||||
private readonly IScimContext _scimContext; |
||||
private readonly ILogger<GroupsController> _logger; |
||||
|
||||
public GroupsController( |
||||
IGroupRepository groupRepository, |
||||
IGroupService groupService, |
||||
IOptions<ScimSettings> scimSettings, |
||||
IScimContext scimContext, |
||||
ILogger<GroupsController> logger) |
||||
{ |
||||
_scimSettings = scimSettings?.Value; |
||||
_groupRepository = groupRepository; |
||||
_groupService = groupService; |
||||
_scimContext = scimContext; |
||||
_logger = logger; |
||||
} |
||||
|
||||
[HttpGet("{id}")] |
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id) |
||||
{ |
||||
var group = await _groupRepository.GetByIdAsync(id); |
||||
if (group == null || group.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "Group not found." |
||||
}); |
||||
} |
||||
return new ObjectResult(new ScimGroupResponseModel(group)); |
||||
} |
||||
|
||||
[HttpGet("")] |
||||
public async Task<IActionResult> Get( |
||||
Guid organizationId, |
||||
[FromQuery] string filter, |
||||
[FromQuery] int? count, |
||||
[FromQuery] int? startIndex) |
||||
{ |
||||
string nameFilter = null; |
||||
string externalIdFilter = null; |
||||
if (!string.IsNullOrWhiteSpace(filter)) |
||||
{ |
||||
if (filter.StartsWith("displayName eq ")) |
||||
{ |
||||
nameFilter = filter.Substring(15).Trim('"'); |
||||
} |
||||
else if (filter.StartsWith("externalId eq ")) |
||||
{ |
||||
externalIdFilter = filter.Substring(14).Trim('"'); |
||||
} |
||||
} |
||||
|
||||
var groupList = new List<ScimGroupResponseModel>(); |
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); |
||||
var totalResults = 0; |
||||
if (!string.IsNullOrWhiteSpace(nameFilter)) |
||||
{ |
||||
var group = groups.FirstOrDefault(g => g.Name == nameFilter); |
||||
if (group != null) |
||||
{ |
||||
groupList.Add(new ScimGroupResponseModel(group)); |
||||
} |
||||
totalResults = groupList.Count; |
||||
} |
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter)) |
||||
{ |
||||
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter); |
||||
if (group != null) |
||||
{ |
||||
groupList.Add(new ScimGroupResponseModel(group)); |
||||
} |
||||
totalResults = groupList.Count; |
||||
} |
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) |
||||
{ |
||||
groupList = groups.OrderBy(g => g.Name) |
||||
.Skip(startIndex.Value - 1) |
||||
.Take(count.Value) |
||||
.Select(g => new ScimGroupResponseModel(g)) |
||||
.ToList(); |
||||
totalResults = groups.Count; |
||||
} |
||||
|
||||
var result = new ScimListResponseModel<ScimGroupResponseModel> |
||||
{ |
||||
Resources = groupList, |
||||
ItemsPerPage = count.GetValueOrDefault(groupList.Count), |
||||
TotalResults = totalResults, |
||||
StartIndex = startIndex.GetValueOrDefault(1), |
||||
}; |
||||
return new ObjectResult(result); |
||||
} |
||||
|
||||
[HttpPost("")] |
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model) |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(model.DisplayName)) |
||||
{ |
||||
return new BadRequestResult(); |
||||
} |
||||
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); |
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId)) |
||||
{ |
||||
return new ConflictResult(); |
||||
} |
||||
|
||||
var group = model.ToGroup(organizationId); |
||||
await _groupService.SaveAsync(group, null); |
||||
var response = new ScimGroupResponseModel(group); |
||||
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), response); |
||||
} |
||||
|
||||
[HttpPut("{id}")] |
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model) |
||||
{ |
||||
var group = await _groupRepository.GetByIdAsync(id); |
||||
if (group == null || group.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "Group not found." |
||||
}); |
||||
} |
||||
|
||||
group.Name = model.DisplayName; |
||||
await _groupService.SaveAsync(group); |
||||
return new ObjectResult(new ScimGroupResponseModel(group)); |
||||
} |
||||
|
||||
[HttpPatch("{id}")] |
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model) |
||||
{ |
||||
var group = await _groupRepository.GetByIdAsync(id); |
||||
if (group == null || group.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "Group not found." |
||||
}); |
||||
} |
||||
|
||||
var operationHandled = false; |
||||
|
||||
var replaceOp = model.Operations?.FirstOrDefault(o => o.Op == "replace"); |
||||
if (replaceOp != null) |
||||
{ |
||||
// Replace a list of members |
||||
if (replaceOp.Path == "members") |
||||
{ |
||||
var ids = GetOperationValueIds(replaceOp.Value); |
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids); |
||||
operationHandled = true; |
||||
} |
||||
// Replace group name |
||||
else if (replaceOp.Value.TryGetProperty("displayName", out var displayNameProperty)) |
||||
{ |
||||
group.Name = displayNameProperty.GetString(); |
||||
await _groupService.SaveAsync(group); |
||||
operationHandled = true; |
||||
} |
||||
} |
||||
|
||||
// Add a single member |
||||
var addMemberOp = model.Operations?.FirstOrDefault( |
||||
o => o.Op == "add" && !string.IsNullOrWhiteSpace(o.Path) && o.Path.StartsWith("members[value eq ")); |
||||
if (addMemberOp != null) |
||||
{ |
||||
var addId = GetOperationPathId(addMemberOp.Path); |
||||
if (addId.HasValue) |
||||
{ |
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); |
||||
orgUserIds.Add(addId.Value); |
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); |
||||
operationHandled = true; |
||||
} |
||||
} |
||||
|
||||
// Add a list of members |
||||
var addMembersOp = model.Operations?.FirstOrDefault(o => o.Op == "add" && o.Path == "members"); |
||||
if (addMembersOp != null) |
||||
{ |
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); |
||||
foreach (var v in GetOperationValueIds(addMembersOp.Value)) |
||||
{ |
||||
orgUserIds.Add(v); |
||||
} |
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); |
||||
operationHandled = true; |
||||
} |
||||
|
||||
// Remove a single member |
||||
var removeMemberOp = model.Operations?.FirstOrDefault( |
||||
o => o.Op == "remove" && !string.IsNullOrWhiteSpace(o.Path) && o.Path.StartsWith("members[value eq ")); |
||||
if (removeMemberOp != null) |
||||
{ |
||||
var removeId = GetOperationPathId(removeMemberOp.Path); |
||||
if (removeId.HasValue) |
||||
{ |
||||
await _groupService.DeleteUserAsync(group, removeId.Value); |
||||
operationHandled = true; |
||||
} |
||||
} |
||||
|
||||
// Remove a list of members |
||||
var removeMembersOp = model.Operations?.FirstOrDefault(o => o.Op == "remove" && o.Path == "members"); |
||||
if (removeMembersOp != null) |
||||
{ |
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); |
||||
foreach (var v in GetOperationValueIds(removeMembersOp.Value)) |
||||
{ |
||||
orgUserIds.Remove(v); |
||||
} |
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); |
||||
operationHandled = true; |
||||
} |
||||
|
||||
if (!operationHandled) |
||||
{ |
||||
_logger.LogWarning("Group patch operation not handled: {0} : ", |
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}"))); |
||||
} |
||||
|
||||
return new NoContentResult(); |
||||
} |
||||
|
||||
[HttpDelete("{id}")] |
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id) |
||||
{ |
||||
var group = await _groupRepository.GetByIdAsync(id); |
||||
if (group == null || group.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "Group not found." |
||||
}); |
||||
} |
||||
await _groupService.DeleteAsync(group); |
||||
return new NoContentResult(); |
||||
} |
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray) |
||||
{ |
||||
var ids = new List<Guid>(); |
||||
foreach (var obj in objArray.EnumerateArray()) |
||||
{ |
||||
if (obj.TryGetProperty("value", out var valueProperty)) |
||||
{ |
||||
if (valueProperty.TryGetGuid(out var guid)) |
||||
{ |
||||
ids.Add(guid); |
||||
} |
||||
} |
||||
} |
||||
return ids; |
||||
} |
||||
|
||||
private Guid? GetOperationPathId(string path) |
||||
{ |
||||
// Parse Guid from string like: members[value eq "{GUID}"}] |
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id)) |
||||
{ |
||||
return id; |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
|
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Core.Utilities; |
||||
using Bit.Scim.Context; |
||||
using Bit.Scim.Models; |
||||
using Microsoft.AspNetCore.Authorization; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
using Microsoft.Extensions.Options; |
||||
|
||||
namespace Bit.Scim.Controllers.v2 |
||||
{ |
||||
[Authorize("Scim")] |
||||
[Route("v2/{organizationId}/users")] |
||||
public class UsersController : Controller |
||||
{ |
||||
private readonly IUserService _userService; |
||||
private readonly IUserRepository _userRepository; |
||||
private readonly IOrganizationUserRepository _organizationUserRepository; |
||||
private readonly IOrganizationService _organizationService; |
||||
private readonly IScimContext _scimContext; |
||||
private readonly ScimSettings _scimSettings; |
||||
private readonly ILogger<UsersController> _logger; |
||||
|
||||
public UsersController( |
||||
IUserService userService, |
||||
IUserRepository userRepository, |
||||
IOrganizationUserRepository organizationUserRepository, |
||||
IOrganizationService organizationService, |
||||
IScimContext scimContext, |
||||
IOptions<ScimSettings> scimSettings, |
||||
ILogger<UsersController> logger) |
||||
{ |
||||
_userService = userService; |
||||
_userRepository = userRepository; |
||||
_organizationUserRepository = organizationUserRepository; |
||||
_organizationService = organizationService; |
||||
_scimContext = scimContext; |
||||
_scimSettings = scimSettings?.Value; |
||||
_logger = logger; |
||||
} |
||||
|
||||
[HttpGet("{id}")] |
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id) |
||||
{ |
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id); |
||||
if (orgUser == null || orgUser.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "User not found." |
||||
}); |
||||
} |
||||
return new ObjectResult(new ScimUserResponseModel(orgUser)); |
||||
} |
||||
|
||||
[HttpGet("")] |
||||
public async Task<IActionResult> Get( |
||||
Guid organizationId, |
||||
[FromQuery] string filter, |
||||
[FromQuery] int? count, |
||||
[FromQuery] int? startIndex) |
||||
{ |
||||
string emailFilter = null; |
||||
string usernameFilter = null; |
||||
string externalIdFilter = null; |
||||
if (!string.IsNullOrWhiteSpace(filter)) |
||||
{ |
||||
if (filter.StartsWith("userName eq ")) |
||||
{ |
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant(); |
||||
if (usernameFilter.Contains("@")) |
||||
{ |
||||
emailFilter = usernameFilter; |
||||
} |
||||
} |
||||
else if (filter.StartsWith("externalId eq ")) |
||||
{ |
||||
externalIdFilter = filter.Substring(14).Trim('"'); |
||||
} |
||||
} |
||||
|
||||
var userList = new List<ScimUserResponseModel> { }; |
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); |
||||
var totalResults = 0; |
||||
if (!string.IsNullOrWhiteSpace(emailFilter)) |
||||
{ |
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter); |
||||
if (orgUser != null) |
||||
{ |
||||
userList.Add(new ScimUserResponseModel(orgUser)); |
||||
} |
||||
totalResults = userList.Count; |
||||
} |
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter)) |
||||
{ |
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter); |
||||
if (orgUser != null) |
||||
{ |
||||
userList.Add(new ScimUserResponseModel(orgUser)); |
||||
} |
||||
totalResults = userList.Count; |
||||
} |
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) |
||||
{ |
||||
userList = orgUsers.OrderBy(ou => ou.Email) |
||||
.Skip(startIndex.Value - 1) |
||||
.Take(count.Value) |
||||
.Select(ou => new ScimUserResponseModel(ou)) |
||||
.ToList(); |
||||
totalResults = orgUsers.Count; |
||||
} |
||||
|
||||
var result = new ScimListResponseModel<ScimUserResponseModel> |
||||
{ |
||||
Resources = userList, |
||||
ItemsPerPage = count.GetValueOrDefault(userList.Count), |
||||
TotalResults = totalResults, |
||||
StartIndex = startIndex.GetValueOrDefault(1), |
||||
}; |
||||
return new ObjectResult(result); |
||||
} |
||||
|
||||
[HttpPost("")] |
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model) |
||||
{ |
||||
var email = model.PrimaryEmail?.ToLowerInvariant(); |
||||
if (string.IsNullOrWhiteSpace(email)) |
||||
{ |
||||
switch (_scimContext.RequestScimProvider) |
||||
{ |
||||
case ScimProviderType.AzureAd: |
||||
email = model.UserName?.ToLowerInvariant(); |
||||
break; |
||||
default: |
||||
break; |
||||
} |
||||
} |
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active) |
||||
{ |
||||
return new BadRequestResult(); |
||||
} |
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); |
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); |
||||
if (orgUserByEmail != null) |
||||
{ |
||||
return new ConflictResult(); |
||||
} |
||||
|
||||
string externalId = null; |
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId)) |
||||
{ |
||||
externalId = model.ExternalId; |
||||
} |
||||
else if (!string.IsNullOrWhiteSpace(model.UserName)) |
||||
{ |
||||
externalId = model.UserName; |
||||
} |
||||
else |
||||
{ |
||||
externalId = CoreHelpers.RandomString(15); |
||||
} |
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId); |
||||
if (orgUserByExternalId != null) |
||||
{ |
||||
return new ConflictResult(); |
||||
} |
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email, |
||||
OrganizationUserType.User, false, externalId, new List<SelectionReadOnly>()); |
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); |
||||
var response = new ScimUserResponseModel(orgUser); |
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), response); |
||||
} |
||||
|
||||
[HttpPut("{id}")] |
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model) |
||||
{ |
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id); |
||||
if (orgUser == null || orgUser.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "User not found." |
||||
}); |
||||
} |
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Deactivated) |
||||
{ |
||||
await _organizationService.ActivateUserAsync(orgUser, null); |
||||
} |
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Deactivated) |
||||
{ |
||||
await _organizationService.DeactivateUserAsync(orgUser, null); |
||||
} |
||||
|
||||
// Have to get full details object for response model |
||||
var orgUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); |
||||
return new ObjectResult(new ScimUserResponseModel(orgUserDetails)); |
||||
} |
||||
|
||||
[HttpPatch("{id}")] |
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model) |
||||
{ |
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id); |
||||
if (orgUser == null || orgUser.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "User not found." |
||||
}); |
||||
} |
||||
|
||||
var operationHandled = false; |
||||
|
||||
var replaceOp = model.Operations?.FirstOrDefault(o => o.Op == "replace"); |
||||
if (replaceOp != null) |
||||
{ |
||||
if (replaceOp.Value.TryGetProperty("active", out var activeProperty)) |
||||
{ |
||||
var active = activeProperty.GetBoolean(); |
||||
if (active && orgUser.Status == OrganizationUserStatusType.Deactivated) |
||||
{ |
||||
await _organizationService.ActivateUserAsync(orgUser, null); |
||||
operationHandled = true; |
||||
} |
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Deactivated) |
||||
{ |
||||
await _organizationService.DeactivateUserAsync(orgUser, null); |
||||
operationHandled = true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (!operationHandled) |
||||
{ |
||||
_logger.LogWarning("User patch operation not handled: {operation} : ", |
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}"))); |
||||
} |
||||
|
||||
return new NoContentResult(); |
||||
} |
||||
|
||||
[HttpDelete("{id}")] |
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model) |
||||
{ |
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id); |
||||
if (orgUser == null || orgUser.OrganizationId != organizationId) |
||||
{ |
||||
return new NotFoundObjectResult(new ScimErrorResponseModel |
||||
{ |
||||
Status = 404, |
||||
Detail = "User not found." |
||||
}); |
||||
} |
||||
await _organizationService.DeleteUserAsync(organizationId, id, null); |
||||
return new NoContentResult(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0 |
||||
|
||||
LABEL com.bitwarden.product="bitwarden" |
||||
|
||||
RUN apt-get update \ |
||||
&& apt-get install -y --no-install-recommends \ |
||||
gosu \ |
||||
curl \ |
||||
&& rm -rf /var/lib/apt/lists/* |
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000 |
||||
WORKDIR /app |
||||
EXPOSE 5000 |
||||
COPY obj/build-output/publish . |
||||
COPY entrypoint.sh / |
||||
RUN chmod +x /entrypoint.sh |
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 |
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"] |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
using Bit.Scim.Utilities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public abstract class BaseScimGroupModel : BaseScimModel |
||||
{ |
||||
public BaseScimGroupModel(bool initSchema = false) |
||||
{ |
||||
if (initSchema) |
||||
{ |
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup }; |
||||
} |
||||
} |
||||
|
||||
public string DisplayName { get; set; } |
||||
public string ExternalId { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public abstract class BaseScimModel |
||||
{ |
||||
public BaseScimModel() |
||||
{ } |
||||
|
||||
public BaseScimModel(string schema) |
||||
{ |
||||
Schemas = new List<string> { schema }; |
||||
} |
||||
|
||||
public List<string> Schemas { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
using Bit.Scim.Utilities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public abstract class BaseScimUserModel : BaseScimModel |
||||
{ |
||||
public BaseScimUserModel(bool initSchema = false) |
||||
{ |
||||
if (initSchema) |
||||
{ |
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }; |
||||
} |
||||
} |
||||
|
||||
public string UserName { get; set; } |
||||
public NameModel Name { get; set; } |
||||
public List<EmailModel> Emails { get; set; } |
||||
public string PrimaryEmail => Emails?.FirstOrDefault(e => e.Primary)?.Value; |
||||
public string DisplayName { get; set; } |
||||
public bool Active { get; set; } |
||||
public List<string> Groups { get; set; } |
||||
public string ExternalId { get; set; } |
||||
|
||||
public class NameModel |
||||
{ |
||||
public NameModel() { } |
||||
|
||||
public NameModel(string name) |
||||
{ |
||||
Formatted = name; |
||||
} |
||||
|
||||
public string Formatted { get; set; } |
||||
public string GivenName { get; set; } |
||||
public string MiddleName { get; set; } |
||||
public string FamilyName { get; set; } |
||||
} |
||||
|
||||
public class EmailModel |
||||
{ |
||||
public EmailModel() { } |
||||
|
||||
public EmailModel(string email) |
||||
{ |
||||
Primary = true; |
||||
Value = email; |
||||
Type = "work"; |
||||
} |
||||
|
||||
public bool Primary { get; set; } |
||||
public string Value { get; set; } |
||||
public string Type { get; set; } |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
using Bit.Scim.Utilities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimErrorResponseModel : BaseScimModel |
||||
{ |
||||
public ScimErrorResponseModel() |
||||
: base(ScimConstants.Scim2SchemaError) |
||||
{ } |
||||
|
||||
public string Detail { get; set; } |
||||
public int Status { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Utilities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimGroupRequestModel : BaseScimGroupModel |
||||
{ |
||||
public ScimGroupRequestModel() |
||||
: base(false) |
||||
{ } |
||||
|
||||
public Group ToGroup(Guid organizationId) |
||||
{ |
||||
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId; |
||||
return new Group |
||||
{ |
||||
Name = DisplayName, |
||||
ExternalId = externalId, |
||||
OrganizationId = organizationId |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using Bit.Core.Entities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimGroupResponseModel : BaseScimGroupModel |
||||
{ |
||||
public ScimGroupResponseModel() |
||||
: base(true) |
||||
{ |
||||
Meta = new ScimMetaModel("Group"); |
||||
} |
||||
|
||||
public ScimGroupResponseModel(Group group) |
||||
: this() |
||||
{ |
||||
Id = group.Id.ToString(); |
||||
DisplayName = group.Name; |
||||
ExternalId = group.ExternalId; |
||||
Meta.Created = group.CreationDate; |
||||
Meta.LastModified = group.RevisionDate; |
||||
} |
||||
|
||||
public string Id { get; set; } |
||||
public ScimMetaModel Meta { get; private set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
using Bit.Scim.Utilities; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimListResponseModel<T> : BaseScimModel |
||||
{ |
||||
public ScimListResponseModel() |
||||
: base(ScimConstants.Scim2SchemaListResponse) |
||||
{ } |
||||
|
||||
public int TotalResults { get; set; } |
||||
public int StartIndex { get; set; } |
||||
public int ItemsPerPage { get; set; } |
||||
public List<T> Resources { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimMetaModel |
||||
{ |
||||
public ScimMetaModel(string resourceType) |
||||
{ |
||||
ResourceType = resourceType; |
||||
} |
||||
|
||||
public string ResourceType { get; set; } |
||||
public DateTime? Created { get; set; } |
||||
public DateTime? LastModified { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimPatchModel : BaseScimModel |
||||
{ |
||||
public ScimPatchModel() |
||||
: base() { } |
||||
|
||||
public List<OperationModel> Operations { get; set; } |
||||
|
||||
public class OperationModel |
||||
{ |
||||
public string Op { get; set; } |
||||
public string Path { get; set; } |
||||
public JsonElement Value { get; set; } |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimUserRequestModel : BaseScimUserModel |
||||
{ |
||||
public ScimUserRequestModel() |
||||
: base(false) |
||||
{ } |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers; |
||||
|
||||
namespace Bit.Scim.Models |
||||
{ |
||||
public class ScimUserResponseModel : BaseScimUserModel |
||||
{ |
||||
public ScimUserResponseModel() |
||||
: base(true) |
||||
{ |
||||
Meta = new ScimMetaModel("User"); |
||||
Groups = new List<string>(); |
||||
} |
||||
|
||||
public ScimUserResponseModel(OrganizationUserUserDetails orgUser) |
||||
: this() |
||||
{ |
||||
Id = orgUser.Id.ToString(); |
||||
ExternalId = orgUser.ExternalId; |
||||
UserName = orgUser.Email; |
||||
DisplayName = orgUser.Name; |
||||
Emails = new List<EmailModel> { new EmailModel(orgUser.Email) }; |
||||
Name = new NameModel(orgUser.Name); |
||||
Active = orgUser.Status != Core.Enums.OrganizationUserStatusType.Deactivated; |
||||
} |
||||
|
||||
public string Id { get; set; } |
||||
public ScimMetaModel Meta { get; private set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
using Bit.Core.Utilities; |
||||
using Serilog.Events; |
||||
|
||||
namespace Bit.Scim |
||||
{ |
||||
public class Program |
||||
{ |
||||
public static void Main(string[] args) |
||||
{ |
||||
Host |
||||
.CreateDefaultBuilder(args) |
||||
.ConfigureWebHostDefaults(webBuilder => |
||||
{ |
||||
webBuilder.UseStartup<Startup>(); |
||||
webBuilder.ConfigureLogging((hostingContext, logging) => |
||||
logging.AddSerilog(hostingContext, e => |
||||
{ |
||||
var context = e.Properties["SourceContext"].ToString(); |
||||
|
||||
if (e.Properties.ContainsKey("RequestPath") && |
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && |
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
return e.Level >= LogEventLevel.Warning; |
||||
})); |
||||
}) |
||||
.Build() |
||||
.Run(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
{ |
||||
"iisSettings": { |
||||
"windowsAuthentication": false, |
||||
"anonymousAuthentication": true, |
||||
"iisExpress": { |
||||
"applicationUrl": "http://localhost:44558/", |
||||
"sslPort": 0 |
||||
} |
||||
}, |
||||
"profiles": { |
||||
"IIS Express": { |
||||
"commandName": "IISExpress", |
||||
"launchBrowser": false, |
||||
"launchUrl": "http://localhost:44558", |
||||
"environmentVariables": { |
||||
"ASPNETCORE_ENVIRONMENT": "Development" |
||||
} |
||||
}, |
||||
"Scim": { |
||||
"commandName": "Project", |
||||
"launchBrowser": false, |
||||
"launchUrl": "http://localhost:44558", |
||||
"environmentVariables": { |
||||
"ASPNETCORE_ENVIRONMENT": "Development" |
||||
}, |
||||
"applicationUrl": "http://localhost:44559" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web"> |
||||
|
||||
<PropertyGroup> |
||||
<UserSecretsId>bitwarden-Scim</UserSecretsId> |
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish> |
||||
</PropertyGroup> |
||||
|
||||
<ItemGroup> |
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" /> |
||||
</ItemGroup> |
||||
|
||||
<ItemGroup> |
||||
<ProjectReference Include="..\..\..\src\Core\Core.csproj" /> |
||||
<ProjectReference Include="..\..\..\src\SharedWeb\SharedWeb.csproj" /> |
||||
</ItemGroup> |
||||
|
||||
</Project> |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Scim |
||||
{ |
||||
public class ScimSettings |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
|
||||
using System.Globalization; |
||||
using Bit.Core.Context; |
||||
using Bit.Core.Settings; |
||||
using Bit.Core.Utilities; |
||||
using Bit.Scim.Context; |
||||
using Bit.Scim.Utilities; |
||||
using Bit.SharedWeb.Utilities; |
||||
using IdentityModel; |
||||
using Microsoft.Extensions.DependencyInjection.Extensions; |
||||
using Stripe; |
||||
|
||||
namespace Bit.Scim |
||||
{ |
||||
public class Startup |
||||
{ |
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration) |
||||
{ |
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); |
||||
Configuration = configuration; |
||||
Environment = env; |
||||
} |
||||
|
||||
public IConfiguration Configuration { get; } |
||||
public IWebHostEnvironment Environment { get; set; } |
||||
|
||||
public void ConfigureServices(IServiceCollection services) |
||||
{ |
||||
// Options |
||||
services.AddOptions(); |
||||
|
||||
// Settings |
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); |
||||
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings")); |
||||
|
||||
// Stripe Billing |
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey; |
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries; |
||||
|
||||
// Repositories |
||||
services.AddSqlServerRepositories(globalSettings); |
||||
|
||||
// Context |
||||
services.AddScoped<ICurrentContext, CurrentContext>(); |
||||
services.AddScoped<IScimContext, ScimContext>(); |
||||
|
||||
// Authentication |
||||
services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme) |
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>( |
||||
ApiKeyAuthenticationOptions.DefaultScheme, null); |
||||
|
||||
services.AddAuthorization(config => |
||||
{ |
||||
config.AddPolicy("Scim", policy => |
||||
{ |
||||
policy.RequireAuthenticatedUser(); |
||||
policy.RequireClaim(JwtClaimTypes.Scope, "api.scim"); |
||||
}); |
||||
}); |
||||
|
||||
// Identity |
||||
services.AddCustomIdentityServices(globalSettings); |
||||
|
||||
// Services |
||||
services.AddBaseServices(globalSettings); |
||||
services.AddDefaultServices(globalSettings); |
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); |
||||
|
||||
// Mvc |
||||
services.AddMvc(config => |
||||
{ |
||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute()); |
||||
}); |
||||
services.Configure<RouteOptions>(options => options.LowercaseUrls = true); |
||||
} |
||||
|
||||
public void Configure( |
||||
IApplicationBuilder app, |
||||
IWebHostEnvironment env, |
||||
IHostApplicationLifetime appLifetime, |
||||
GlobalSettings globalSettings) |
||||
{ |
||||
app.UseSerilog(env, appLifetime, globalSettings); |
||||
|
||||
// Add general security headers |
||||
app.UseMiddleware<SecurityHeadersMiddleware>(); |
||||
|
||||
if (env.IsDevelopment()) |
||||
{ |
||||
app.UseDeveloperExceptionPage(); |
||||
} |
||||
|
||||
// Default Middleware |
||||
app.UseDefaultMiddleware(env, globalSettings); |
||||
|
||||
// Add routing |
||||
app.UseRouting(); |
||||
|
||||
// Add Scim context |
||||
app.UseMiddleware<ScimContextMiddleware>(); |
||||
|
||||
// Add authentication and authorization to the request pipeline. |
||||
app.UseAuthentication(); |
||||
app.UseAuthorization(); |
||||
|
||||
// Add current context |
||||
app.UseMiddleware<CurrentContextMiddleware>(); |
||||
|
||||
// Add MVC to the request pipeline. |
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
|
||||
using System.Security.Claims; |
||||
using System.Text.Encodings.Web; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Scim.Context; |
||||
using IdentityModel; |
||||
using Microsoft.AspNetCore.Authentication; |
||||
using Microsoft.Extensions.Options; |
||||
|
||||
namespace Bit.Scim.Utilities |
||||
{ |
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions> |
||||
{ |
||||
private readonly IOrganizationRepository _organizationRepository; |
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; |
||||
private readonly IScimContext _scimContext; |
||||
|
||||
public ApiKeyAuthenticationHandler( |
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options, |
||||
ILoggerFactory logger, |
||||
UrlEncoder encoder, |
||||
ISystemClock clock, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationApiKeyRepository organizationApiKeyRepository, |
||||
IScimContext scimContext) : |
||||
base(options, logger, encoder, clock) |
||||
{ |
||||
_organizationRepository = organizationRepository; |
||||
_organizationApiKeyRepository = organizationApiKeyRepository; |
||||
_scimContext = scimContext; |
||||
} |
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() |
||||
{ |
||||
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null) |
||||
{ |
||||
Logger.LogWarning("No organization."); |
||||
return AuthenticateResult.Fail("Invalid parameters"); |
||||
} |
||||
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader) || authHeader.Count != 1) |
||||
{ |
||||
Logger.LogWarning("An API request was received without the Authorization header"); |
||||
return AuthenticateResult.Fail("Invalid parameters"); |
||||
} |
||||
var apiKey = authHeader.ToString(); |
||||
if (apiKey.StartsWith("Bearer ")) |
||||
{ |
||||
apiKey = apiKey.Substring(7); |
||||
} |
||||
|
||||
if (!_scimContext.Organization.Enabled || !_scimContext.Organization.UseScim || |
||||
_scimContext.ScimConfiguration == null || !_scimContext.ScimConfiguration.Enabled) |
||||
{ |
||||
Logger.LogInformation("Org {organizationId} not able to use Scim.", _scimContext.OrganizationId); |
||||
return AuthenticateResult.Fail("Invalid parameters"); |
||||
} |
||||
|
||||
var orgApiKey = (await _organizationApiKeyRepository |
||||
.GetManyByOrganizationIdTypeAsync(_scimContext.Organization.Id, OrganizationApiKeyType.Scim)) |
||||
.FirstOrDefault(); |
||||
if (orgApiKey?.ApiKey != apiKey) |
||||
{ |
||||
Logger.LogWarning("An API request was received with an invalid API key: {apiKey}", apiKey); |
||||
return AuthenticateResult.Fail("Invalid parameters"); |
||||
} |
||||
|
||||
Logger.LogInformation("Org {organizationId} authenticated", _scimContext.OrganizationId); |
||||
|
||||
var claims = new[] |
||||
{ |
||||
new Claim(JwtClaimTypes.ClientId, $"organization.{_scimContext.OrganizationId.Value}"), |
||||
new Claim("client_sub", _scimContext.OrganizationId.Value.ToString()), |
||||
new Claim(JwtClaimTypes.Scope, "api.scim"), |
||||
}; |
||||
var identity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler)); |
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), |
||||
ApiKeyAuthenticationOptions.DefaultScheme); |
||||
|
||||
return AuthenticateResult.Success(ticket); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication; |
||||
|
||||
namespace Bit.Scim.Utilities |
||||
{ |
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions |
||||
{ |
||||
public const string DefaultScheme = "ScimApiKey"; |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Scim.Utilities |
||||
{ |
||||
public static class ScimConstants |
||||
{ |
||||
public const string Scim2SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; |
||||
public const string Scim2SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error"; |
||||
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User"; |
||||
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group"; |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Settings; |
||||
using Bit.Scim.Context; |
||||
|
||||
namespace Bit.Scim.Utilities |
||||
{ |
||||
public class ScimContextMiddleware |
||||
{ |
||||
private readonly RequestDelegate _next; |
||||
|
||||
public ScimContextMiddleware(RequestDelegate next) |
||||
{ |
||||
_next = next; |
||||
} |
||||
|
||||
public async Task Invoke(HttpContext httpContext, IScimContext scimContext, GlobalSettings globalSettings, |
||||
IOrganizationRepository organizationRepository, IOrganizationConnectionRepository organizationConnectionRepository) |
||||
{ |
||||
await scimContext.BuildAsync(httpContext, globalSettings, organizationRepository, organizationConnectionRepository); |
||||
await _next.Invoke(httpContext); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
{ |
||||
"globalSettings": { |
||||
"baseServiceUri": { |
||||
"vault": "https://localhost:8080", |
||||
"api": "http://localhost:4000", |
||||
"identity": "http://localhost:33656", |
||||
"admin": "http://localhost:62911", |
||||
"notifications": "http://localhost:61840", |
||||
"sso": "http://localhost:51822", |
||||
"internalNotifications": "http://localhost:61840", |
||||
"internalAdmin": "http://localhost:62911", |
||||
"internalIdentity": "http://localhost:33656", |
||||
"internalApi": "http://localhost:4000", |
||||
"internalVault": "https://localhost:8080", |
||||
"internalSso": "http://localhost:51822", |
||||
"internalScim": "http://localhost:44559" |
||||
}, |
||||
"mail": { |
||||
"smtp": { |
||||
"host": "localhost", |
||||
"port": 10250 |
||||
} |
||||
}, |
||||
"attachment": { |
||||
"connectionString": "UseDevelopmentStorage=true", |
||||
"baseUrl": "http://localhost:4000/attachments/" |
||||
}, |
||||
"events": { |
||||
"connectionString": "UseDevelopmentStorage=true" |
||||
}, |
||||
"storage": { |
||||
"connectionString": "UseDevelopmentStorage=true" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
{ |
||||
"globalSettings": { |
||||
"baseServiceUri": { |
||||
"vault": "https://vault.bitwarden.com", |
||||
"api": "https://api.bitwarden.com", |
||||
"identity": "https://identity.bitwarden.com", |
||||
"admin": "https://admin.bitwarden.com", |
||||
"notifications": "https://notifications.bitwarden.com", |
||||
"sso": "https://sso.bitwarden.com", |
||||
"internalNotifications": "https://notifications.bitwarden.com", |
||||
"internalAdmin": "https://admin.bitwarden.com", |
||||
"internalIdentity": "https://identity.bitwarden.com", |
||||
"internalApi": "https://api.bitwarden.com", |
||||
"internalVault": "https://vault.bitwarden.com", |
||||
"internalSso": "https://sso.bitwarden.com", |
||||
"internalScim": "https://scim.bitwarden.com" |
||||
}, |
||||
"braintree": { |
||||
"production": true |
||||
}, |
||||
"bitPay": { |
||||
"production": true |
||||
} |
||||
}, |
||||
"Logging": { |
||||
"IncludeScopes": false, |
||||
"LogLevel": { |
||||
"Default": "Debug", |
||||
"System": "Information", |
||||
"Microsoft": "Information" |
||||
}, |
||||
"Console": { |
||||
"IncludeScopes": true, |
||||
"LogLevel": { |
||||
"Default": "Warning", |
||||
"System": "Warning", |
||||
"Microsoft": "Warning", |
||||
"Microsoft.Hosting.Lifetime": "Information" |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
{ |
||||
"globalSettings": { |
||||
"baseServiceUri": { |
||||
"vault": "https://vault.qa.bitwarden.pw", |
||||
"api": "https://api.qa.bitwarden.pw", |
||||
"identity": "https://identity.qa.bitwarden.pw", |
||||
"admin": "https://admin.qa.bitwarden.pw", |
||||
"notifications": "https://notifications.qa.bitwarden.pw", |
||||
"sso": "https://sso.qa.bitwarden.pw", |
||||
"internalNotifications": "https://notifications.qa.bitwarden.pw", |
||||
"internalAdmin": "https://admin.qa.bitwarden.pw", |
||||
"internalIdentity": "https://identity.qa.bitwarden.pw", |
||||
"internalApi": "https://api.qa.bitwarden.pw", |
||||
"internalVault": "https://vault.qa.bitwarden.pw", |
||||
"internalSso": "https://sso.qa.bitwarden.pw", |
||||
"internalScim": "https://scim.qa.bitwarden.pw" |
||||
}, |
||||
"braintree": { |
||||
"production": false |
||||
}, |
||||
"bitPay": { |
||||
"production": false |
||||
} |
||||
}, |
||||
"Logging": { |
||||
"IncludeScopes": false, |
||||
"LogLevel": { |
||||
"Default": "Debug", |
||||
"System": "Information", |
||||
"Microsoft": "Information" |
||||
}, |
||||
"Console": { |
||||
"IncludeScopes": true, |
||||
"LogLevel": { |
||||
"Default": "Debug", |
||||
"System": "Debug", |
||||
"Microsoft": "Debug", |
||||
"Microsoft.Hosting.Lifetime": "Information" |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
{ |
||||
"globalSettings": { |
||||
"selfHosted": false, |
||||
"siteName": "Bitwarden", |
||||
"projectName": "Scim", |
||||
"stripe": { |
||||
"apiKey": "SECRET" |
||||
}, |
||||
"sqlServer": { |
||||
"connectionString": "SECRET" |
||||
}, |
||||
"mail": { |
||||
"sendGridApiKey": "SECRET", |
||||
"amazonConfigSetName": "Email", |
||||
"replyToEmail": "no-reply@bitwarden.com" |
||||
}, |
||||
"identityServer": { |
||||
"certificateThumbprint": "SECRET" |
||||
}, |
||||
"dataProtection": { |
||||
"certificateThumbprint": "SECRET" |
||||
}, |
||||
"storage": { |
||||
"connectionString": "SECRET" |
||||
}, |
||||
"events": { |
||||
"connectionString": "SECRET" |
||||
}, |
||||
"serviceBus": { |
||||
"connectionString": "SECRET", |
||||
"applicationCacheTopicName": "SECRET" |
||||
}, |
||||
"documentDb": { |
||||
"uri": "SECRET", |
||||
"key": "SECRET" |
||||
}, |
||||
"sentry": { |
||||
"dsn": "SECRET" |
||||
}, |
||||
"notificationHub": { |
||||
"connectionString": "SECRET", |
||||
"hubName": "SECRET" |
||||
}, |
||||
"braintree": { |
||||
"production": false, |
||||
"merchantId": "SECRET", |
||||
"publicKey": "SECRET", |
||||
"privateKey": "SECRET" |
||||
}, |
||||
"bitPay": { |
||||
"production": false, |
||||
"token": "SECRET", |
||||
"notificationUrl": "https://bitwarden.com/SECRET" |
||||
}, |
||||
"amazon": { |
||||
"accessKeyId": "SECRET", |
||||
"accessKeySecret": "SECRET", |
||||
"region": "SECRET" |
||||
} |
||||
}, |
||||
"scimSettings": { |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path |
||||
|
||||
echo "`n## Building Scim" |
||||
|
||||
echo "`nBuilding app" |
||||
echo ".NET Core version $(dotnet --version)" |
||||
echo "Restore" |
||||
dotnet restore $dir\Scim.csproj |
||||
echo "Clean" |
||||
dotnet clean $dir\Scim.csproj -c "Release" -o $dir\obj\Azure\publish |
||||
echo "Publish" |
||||
dotnet publish $dir\Scim.csproj -c "Release" -o $dir\obj\Azure\publish |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash |
||||
set -e |
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && "pwd" )" |
||||
|
||||
echo -e "\n## Building SCIM" |
||||
|
||||
echo -e "\nBuilding app" |
||||
echo ".NET Core version $(dotnet --version)" |
||||
echo "Restore" |
||||
dotnet restore "$DIR/Scim.csproj" |
||||
echo "Clean" |
||||
dotnet clean "$DIR/Scim.csproj" -c "Release" -o "$DIR/obj/build-output/publish" |
||||
echo "Publish" |
||||
dotnet publish "$DIR/Scim.csproj" -c "Release" -o "$DIR/obj/build-output/publish" |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash |
||||
|
||||
# Setup |
||||
|
||||
GROUPNAME="bitwarden" |
||||
USERNAME="bitwarden" |
||||
|
||||
LUID=${LOCAL_UID:-0} |
||||
LGID=${LOCAL_GID:-0} |
||||
|
||||
# Step down from host root to well-known nobody/nogroup user |
||||
|
||||
if [ $LUID -eq 0 ] |
||||
then |
||||
LUID=65534 |
||||
fi |
||||
if [ $LGID -eq 0 ] |
||||
then |
||||
LGID=65534 |
||||
fi |
||||
|
||||
# Create user and group |
||||
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || |
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 |
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || |
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 |
||||
mkhomedir_helper $USERNAME |
||||
|
||||
# The rest... |
||||
|
||||
chown -R $USERNAME:$GROUPNAME /app |
||||
mkdir -p /etc/bitwarden/core |
||||
mkdir -p /etc/bitwarden/logs |
||||
mkdir -p /etc/bitwarden/ca-certificates |
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden |
||||
|
||||
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ |
||||
&& update-ca-certificates |
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
namespace Bit.Core.Enums |
||||
{ |
||||
public enum ScimProviderType : byte |
||||
{ |
||||
Default = 0, |
||||
AzureAd = 1, |
||||
Okta = 2, |
||||
OneLogin = 3, |
||||
JumpCloud = 4, |
||||
GoogleWorkspace = 5, |
||||
Rippling = 6, |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization; |
||||
using Bit.Core.Enums; |
||||
|
||||
namespace Bit.Core.Models.OrganizationConnectionConfigs |
||||
{ |
||||
public class ScimConfig |
||||
{ |
||||
public bool Enabled { get; set; } |
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] |
||||
public ScimProviderType? ScimProvider { get; set; } |
||||
} |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue