mirror of
https://github.com/bitwarden/server
synced 2026-01-06 18:43:36 +00:00
Revert filescoped (#2227)
* Revert "Add git blame entry (#2226)" This reverts commit239286737d. * Revert "Turn on file scoped namespaces (#2225)" This reverts commit34fb4cca2a.
This commit is contained in:
@@ -4,17 +4,18 @@ using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
public interface IScimContext
|
||||
namespace Bit.Scim.Context
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,60 +4,61 @@ using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
public class ScimContext : IScimContext
|
||||
namespace Bit.Scim.Context
|
||||
{
|
||||
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)
|
||||
public class ScimContext : IScimContext
|
||||
{
|
||||
if (_builtHttpContext)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private bool _builtHttpContext;
|
||||
|
||||
_builtHttpContext = true;
|
||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
||||
public ScimConfig ScimConfiguration { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public Organization Organization { get; set; }
|
||||
|
||||
string orgIdString = null;
|
||||
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
|
||||
public async virtual Task BuildAsync(
|
||||
HttpContext httpContext,
|
||||
GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository)
|
||||
{
|
||||
orgIdString = orgIdObject?.ToString();
|
||||
}
|
||||
|
||||
if (Guid.TryParse(orgIdString, out var orgId))
|
||||
{
|
||||
OrganizationId = orgId;
|
||||
Organization = await organizationRepository.GetByIdAsync(orgId);
|
||||
if (Organization != null)
|
||||
if (_builtHttpContext)
|
||||
{
|
||||
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id,
|
||||
OrganizationConnectionType.Scim);
|
||||
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent))
|
||||
{
|
||||
if (userAgent.ToString().StartsWith("Okta"))
|
||||
_builtHttpContext = true;
|
||||
|
||||
string orgIdString = null;
|
||||
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.Okta;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.AzureAd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Scim.Controllers;
|
||||
|
||||
[AllowAnonymous]
|
||||
public class InfoController : Controller
|
||||
namespace Bit.Scim.Controllers
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
[AllowAnonymous]
|
||||
public class InfoController : Controller
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,320 +8,321 @@ 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
|
||||
namespace Bit.Scim.Controllers.v2
|
||||
{
|
||||
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)
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/groups")]
|
||||
public class GroupsController : Controller
|
||||
{
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_scimContext = scimContext;
|
||||
_logger = logger;
|
||||
}
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ILogger<GroupsController> _logger;
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
IScimContext scimContext,
|
||||
ILogger<GroupsController> logger)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
_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)
|
||||
{
|
||||
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);
|
||||
await UpdateGroupMembersAsync(group, model, true);
|
||||
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);
|
||||
await UpdateGroupMembersAsync(group, model, false);
|
||||
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;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
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('"');
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
|
||||
var groupList = new List<ScimGroupResponseModel>();
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(nameFilter))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
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);
|
||||
await UpdateGroupMembersAsync(group, model, true);
|
||||
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);
|
||||
await UpdateGroupMembersAsync(group, model, false);
|
||||
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;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.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
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
orgUserIds.Add(addId.Value);
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
|
||||
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)
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value);
|
||||
operationHandled = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
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))
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model, bool skipIfEmpty)
|
||||
{
|
||||
_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
|
||||
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
await _groupService.DeleteAsync(group);
|
||||
return new NoContentResult();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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 (model.Members == null)
|
||||
{
|
||||
if (valueProperty.TryGetGuid(out var guid))
|
||||
return;
|
||||
}
|
||||
|
||||
var memberIds = new List<Guid>();
|
||||
foreach (var id in model.Members.Select(i => i.Value))
|
||||
{
|
||||
if (Guid.TryParse(id, out var guidId))
|
||||
{
|
||||
ids.Add(guid);
|
||||
memberIds.Add(guidId);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model, bool skipIfEmpty)
|
||||
{
|
||||
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.Members == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var memberIds = new List<Guid>();
|
||||
foreach (var id in model.Members.Select(i => i.Value))
|
||||
{
|
||||
if (Guid.TryParse(id, out var guidId))
|
||||
if (!memberIds.Any() && skipIfEmpty)
|
||||
{
|
||||
memberIds.Add(guidId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!memberIds.Any() && skipIfEmpty)
|
||||
{
|
||||
return;
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
|
||||
}
|
||||
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,286 +9,287 @@ 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
|
||||
namespace Bit.Scim.Controllers.v2
|
||||
{
|
||||
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)
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
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;
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IScimContext scimContext,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
ILogger<UsersController> logger)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
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))
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
if (filter.StartsWith("userName eq "))
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
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))
|
||||
{
|
||||
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:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
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.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(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;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Active from path
|
||||
if (operation.Path?.ToLowerInvariant() == "active")
|
||||
if (filter.StartsWith("userName eq "))
|
||||
{
|
||||
var active = operation.Value.ToString()?.ToLowerInvariant();
|
||||
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
|
||||
if (!operationHandled)
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
{
|
||||
operationHandled = handled;
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
// Active from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("active", out var activeProperty))
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
|
||||
if (!operationHandled)
|
||||
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:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
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.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(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;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Active from path
|
||||
if (operation.Path?.ToLowerInvariant() == "active")
|
||||
{
|
||||
operationHandled = handled;
|
||||
var active = operation.Value.ToString()?.ToLowerInvariant();
|
||||
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
// Active from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("active", out var activeProperty))
|
||||
{
|
||||
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!operationHandled)
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
await _organizationService.DeleteUserAsync(organizationId, id, null);
|
||||
return new NoContentResult();
|
||||
}
|
||||
_logger.LogWarning("User patch operation not handled: {operation} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
return true;
|
||||
return new NoContentResult();
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
return true;
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimGroupModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimGroupModel(bool initSchema = false)
|
||||
public abstract class BaseScimGroupModel : BaseScimModel
|
||||
{
|
||||
if (initSchema)
|
||||
public BaseScimGroupModel(bool initSchema = false)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
|
||||
if (initSchema)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string DisplayName { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimModel()
|
||||
{ }
|
||||
|
||||
public BaseScimModel(string schema)
|
||||
public abstract class BaseScimModel
|
||||
{
|
||||
Schemas = new List<string> { schema };
|
||||
}
|
||||
public BaseScimModel()
|
||||
{ }
|
||||
|
||||
public List<string> Schemas { get; set; }
|
||||
public BaseScimModel(string schema)
|
||||
{
|
||||
Schemas = new List<string> { schema };
|
||||
}
|
||||
|
||||
public List<string> Schemas { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimUserModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimUserModel(bool initSchema = false)
|
||||
public abstract class BaseScimUserModel : BaseScimModel
|
||||
{
|
||||
if (initSchema)
|
||||
public BaseScimUserModel(bool initSchema = false)
|
||||
{
|
||||
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 WorkEmail => Emails?.FirstOrDefault(e => e.Type == "work")?.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;
|
||||
if (initSchema)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser };
|
||||
}
|
||||
}
|
||||
|
||||
public string Formatted { get; set; }
|
||||
public string GivenName { get; set; }
|
||||
public string MiddleName { get; set; }
|
||||
public string FamilyName { get; set; }
|
||||
}
|
||||
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 WorkEmail => Emails?.FirstOrDefault(e => e.Type == "work")?.Value;
|
||||
public string DisplayName { get; set; }
|
||||
public bool Active { get; set; }
|
||||
public List<string> Groups { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
|
||||
public class EmailModel
|
||||
{
|
||||
public EmailModel() { }
|
||||
|
||||
public EmailModel(string email)
|
||||
public class NameModel
|
||||
{
|
||||
Primary = true;
|
||||
Value = email;
|
||||
Type = "work";
|
||||
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 bool Primary { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string Type { 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimErrorResponseModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimErrorResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaError)
|
||||
{ }
|
||||
public class ScimErrorResponseModel : BaseScimModel
|
||||
{
|
||||
public ScimErrorResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaError)
|
||||
{ }
|
||||
|
||||
public string Detail { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string Detail { get; set; }
|
||||
public int Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimGroupRequestModel : BaseScimGroupModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimGroupRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
|
||||
public Group ToGroup(Guid organizationId)
|
||||
public class ScimGroupRequestModel : BaseScimGroupModel
|
||||
{
|
||||
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
|
||||
return new Group
|
||||
public ScimGroupRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
|
||||
public Group ToGroup(Guid organizationId)
|
||||
{
|
||||
Name = DisplayName,
|
||||
ExternalId = externalId,
|
||||
OrganizationId = organizationId
|
||||
};
|
||||
}
|
||||
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
|
||||
return new Group
|
||||
{
|
||||
Name = DisplayName,
|
||||
ExternalId = externalId,
|
||||
OrganizationId = organizationId
|
||||
};
|
||||
}
|
||||
|
||||
public List<GroupMembersModel> Members { get; set; }
|
||||
public List<GroupMembersModel> Members { get; set; }
|
||||
|
||||
public class GroupMembersModel
|
||||
{
|
||||
public string Value { get; set; }
|
||||
public string Display { get; set; }
|
||||
public class GroupMembersModel
|
||||
{
|
||||
public string Value { get; set; }
|
||||
public string Display { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimGroupResponseModel : BaseScimGroupModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimGroupResponseModel()
|
||||
: base(true)
|
||||
public class ScimGroupResponseModel : BaseScimGroupModel
|
||||
{
|
||||
Meta = new ScimMetaModel("Group");
|
||||
}
|
||||
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 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; }
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimListResponseModel<T> : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimListResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaListResponse)
|
||||
{ }
|
||||
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; }
|
||||
public int TotalResults { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int ItemsPerPage { get; set; }
|
||||
public List<T> Resources { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimMetaModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimMetaModel(string resourceType)
|
||||
public class ScimMetaModel
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
}
|
||||
public ScimMetaModel(string resourceType)
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
}
|
||||
|
||||
public string ResourceType { get; set; }
|
||||
public DateTime? Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
public string ResourceType { get; set; }
|
||||
public DateTime? Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimPatchModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimPatchModel()
|
||||
: base() { }
|
||||
|
||||
public List<OperationModel> Operations { get; set; }
|
||||
|
||||
public class OperationModel
|
||||
public class ScimPatchModel : BaseScimModel
|
||||
{
|
||||
public string Op { get; set; }
|
||||
public string Path { get; set; }
|
||||
public JsonElement Value { get; set; }
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimUserResponseModel : BaseScimUserModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimUserResponseModel()
|
||||
: base(true)
|
||||
public class ScimUserResponseModel : BaseScimUserModel
|
||||
{
|
||||
Meta = new ScimMetaModel("User");
|
||||
Groups = new List<string>();
|
||||
}
|
||||
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.Revoked;
|
||||
}
|
||||
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.Revoked;
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class Program
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
public class Program
|
||||
{
|
||||
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")))
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, e =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
|
||||
return e.Level >= LogEventLevel.Warning;
|
||||
}));
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class ScimSettings
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public class ScimSettings
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,107 +9,108 @@ using IdentityModel;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class Startup
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
public class Startup
|
||||
{
|
||||
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"));
|
||||
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// 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 =>
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
{
|
||||
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();
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
// Default Middleware
|
||||
app.UseDefaultMiddleware(env, globalSettings);
|
||||
public IConfiguration Configuration { get; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Options
|
||||
services.AddOptions();
|
||||
|
||||
// Add Scim context
|
||||
app.UseMiddleware<ScimContextMiddleware>();
|
||||
// Settings
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings"));
|
||||
|
||||
// Add authentication and authorization to the request pipeline.
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
|
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
|
||||
|
||||
// Add MVC to the request pipeline.
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,82 +8,83 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
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)
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var endpoint = Context.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IScimContext scimContext) :
|
||||
base(options, logger, encoder, clock)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
|
||||
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null)
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
Logger.LogWarning("No organization.");
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
var endpoint = Context.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
public const string DefaultScheme = "ScimApiKey";
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public const string DefaultScheme = "ScimApiKey";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public static class ScimConstants
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
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";
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Scim.Context;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ScimContextMiddleware
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public ScimContextMiddleware(RequestDelegate next)
|
||||
public class ScimContextMiddleware
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
private readonly RequestDelegate _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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user